From c6c2e45c75186730724ce750838fe5d4b04f1a47 Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:45:06 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(certifications):=20Wave=20D=20atestats?= =?UTF-8?q?=20=E2=80=94=20leaderboard-derived,=20verifiable=20certificates?= =?UTF-8?q?=20+=20open-source=20decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining Wave D roadmap items: 1. certifications/attestations (the honest variant — 'a leaderboard-derived atestat'; also H-120 verifiable participation certificates): - New backend app: Certificate model (uuid verify id, WC-YYYY-NNNNNN serial, frozen bilingual achievement snapshot, revocation ledger) with three derivations — CONTEST_PARTICIPATION (official rated finisher, rank + post-rating), RATING_MILESTONE (1200..2000, peak semantics), SOLVE_MILESTONE (10..500, same derivation as the leaderboard). - Issuance hooks: the ratings commit (inside compute_contest_ratings) and the post-AC leaderboard recompute; rollback_contest_ratings now revokes the discarded contest's atestats and audits milestones the replayed ladder no longer supports. Deliberately NO create endpoint. - API: anonymous GET verify//, GET mine/ (paginated), public GET ?username= (LEG-2: minors/inactive excluded), admin POST revoke (reason mandatory, first reason sticks). - manage.py issue_certificates: idempotent backfill (--notify opt-in). - Frontend: /certificates holder list + /certificates/[uuid] anonymous verification page with printable atestat sheet (UXP-3 print CSS); navbar account link; certificate_issued notification rendering + deep link; 26 keys hand-translated across all 28 locale catalogs (+nav_certificates), public copies re-exported. - Tests: 30 backend tests (100% module coverage; suite total 100%), 12 vitest, 5 e2e (mock api) incl. honest REVOKED + not-found paths. 2. Open-source/self-host decision — docs/DECISION-OPEN-SOURCE.md: closed platform pre-traction (maintenance + security-audit cost), rolling component-level OSS (K-149), content portability as the community promise; explicit reopen path recorded. Also updates the trackers (BACKLOG.md, ROADMAP.md layers 0/4/5/6/8), API-MAP and RUNBOOK (Wave D operator notes). risk:low --- backend/certificates/__init__.py | 0 backend/certificates/admin.py | 40 + backend/certificates/apps.py | 8 + backend/certificates/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/issue_certificates.py | 36 + .../certificates/migrations/0001_initial.py | 148 ++++ backend/certificates/migrations/__init__.py | 0 backend/certificates/models.py | 119 +++ backend/certificates/serializers.py | 33 + backend/certificates/services.py | 278 ++++++ backend/certificates/test_certificates.py | 539 ++++++++++++ backend/certificates/urls.py | 20 + backend/certificates/views.py | 88 ++ backend/leaderboard/aggregation.py | 15 + backend/ratings/services.py | 25 + backend/webcoder_api/settings.py | 2 + backend/webcoder_api/test_webcoder_api.py | 2 +- backend/webcoder_api/urls.py | 4 + docs/API-MAP.md | 14 +- docs/BACKLOG.md | 12 +- docs/DECISION-OPEN-SOURCE.md | 85 ++ docs/ROADMAP.md | 14 +- docs/RUNBOOK.md | 17 + e2e/certificates.spec.ts | 72 ++ e2e/mock-api/server.mjs | 58 +- public/locales/ar.json | 29 +- public/locales/bg.json | 29 +- public/locales/ca.json | 29 +- public/locales/de.json | 29 +- public/locales/el.json | 29 +- public/locales/en.json | 29 +- public/locales/es.json | 29 +- public/locales/fa.json | 29 +- public/locales/fr.json | 29 +- public/locales/hi.json | 29 +- public/locales/hr.json | 29 +- public/locales/hu.json | 29 +- public/locales/id.json | 29 +- public/locales/it.json | 29 +- public/locales/ja.json | 29 +- public/locales/kk.json | 29 +- public/locales/ko.json | 29 +- public/locales/pl.json | 29 +- public/locales/pt.json | 29 +- public/locales/ro.json | 29 +- public/locales/ru.json | 29 +- public/locales/sr-Latn.json | 29 +- public/locales/th.json | 29 +- public/locales/tr.json | 29 +- public/locales/uk.json | 29 +- public/locales/vi.json | 29 +- public/locales/zh-Hans.json | 29 +- public/locales/zh-Hant.json | 29 +- scripts/add-certificate-keys.py | 805 ++++++++++++++++++ src/app/_lib/public-messages.ts | 25 + .../[uuid]/PrintCertificateButton.tsx | 24 + src/app/certificates/[uuid]/page.test.tsx | 87 ++ src/app/certificates/[uuid]/page.tsx | 197 +++++ .../_lib/certificates-api.test.ts | 71 ++ src/app/certificates/_lib/certificates-api.ts | 29 + src/app/certificates/page.test.tsx | 111 +++ src/app/certificates/page.tsx | 172 ++++ src/app/notifications/page.tsx | 18 +- src/components/layout/Navbar.tsx | 2 + src/locales/ar.json | 29 +- src/locales/bg.json | 29 +- src/locales/ca.json | 29 +- src/locales/de.json | 29 +- src/locales/el.json | 29 +- src/locales/en.json | 29 +- src/locales/es.json | 29 +- src/locales/fa.json | 29 +- src/locales/fr.json | 29 +- src/locales/hi.json | 29 +- src/locales/hr.json | 29 +- src/locales/hu.json | 29 +- src/locales/id.json | 29 +- src/locales/it.json | 29 +- src/locales/ja.json | 29 +- src/locales/kk.json | 29 +- src/locales/ko.json | 29 +- src/locales/pl.json | 29 +- src/locales/pt.json | 29 +- src/locales/ro.json | 29 +- src/locales/ru.json | 29 +- src/locales/sr-Latn.json | 29 +- src/locales/th.json | 29 +- src/locales/tr.json | 29 +- src/locales/uk.json | 29 +- src/locales/vi.json | 29 +- src/locales/zh-Hans.json | 29 +- src/locales/zh-Hant.json | 29 +- src/services/ApiService.ts | 9 + src/types/api.ts | 27 + src/types/index.ts | 1 + 96 files changed, 4759 insertions(+), 72 deletions(-) create mode 100644 backend/certificates/__init__.py create mode 100644 backend/certificates/admin.py create mode 100644 backend/certificates/apps.py create mode 100644 backend/certificates/management/__init__.py create mode 100644 backend/certificates/management/commands/__init__.py create mode 100644 backend/certificates/management/commands/issue_certificates.py create mode 100644 backend/certificates/migrations/0001_initial.py create mode 100644 backend/certificates/migrations/__init__.py create mode 100644 backend/certificates/models.py create mode 100644 backend/certificates/serializers.py create mode 100644 backend/certificates/services.py create mode 100644 backend/certificates/test_certificates.py create mode 100644 backend/certificates/urls.py create mode 100644 backend/certificates/views.py create mode 100644 docs/DECISION-OPEN-SOURCE.md create mode 100644 e2e/certificates.spec.ts create mode 100644 scripts/add-certificate-keys.py create mode 100644 src/app/certificates/[uuid]/PrintCertificateButton.tsx create mode 100644 src/app/certificates/[uuid]/page.test.tsx create mode 100644 src/app/certificates/[uuid]/page.tsx create mode 100644 src/app/certificates/_lib/certificates-api.test.ts create mode 100644 src/app/certificates/_lib/certificates-api.ts create mode 100644 src/app/certificates/page.test.tsx create mode 100644 src/app/certificates/page.tsx diff --git a/backend/certificates/__init__.py b/backend/certificates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/certificates/admin.py b/backend/certificates/admin.py new file mode 100644 index 000000000..3fea1bcf3 --- /dev/null +++ b/backend/certificates/admin.py @@ -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.") diff --git a/backend/certificates/apps.py b/backend/certificates/apps.py new file mode 100644 index 000000000..ddf1db3c0 --- /dev/null +++ b/backend/certificates/apps.py @@ -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" diff --git a/backend/certificates/management/__init__.py b/backend/certificates/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/certificates/management/commands/__init__.py b/backend/certificates/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/certificates/management/commands/issue_certificates.py b/backend/certificates/management/commands/issue_certificates.py new file mode 100644 index 000000000..2d640cba8 --- /dev/null +++ b/backend/certificates/management/commands/issue_certificates.py @@ -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") + ) diff --git a/backend/certificates/migrations/0001_initial.py b/backend/certificates/migrations/0001_initial.py new file mode 100644 index 000000000..edf31b137 --- /dev/null +++ b/backend/certificates/migrations/0001_initial.py @@ -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", + ), + ], + }, + ), + ] diff --git a/backend/certificates/migrations/__init__.py b/backend/certificates/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/certificates/models.py b/backend/certificates/models.py new file mode 100644 index 000000000..97d8e2a02 --- /dev/null +++ b/backend/certificates/models.py @@ -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" diff --git a/backend/certificates/serializers.py b/backend/certificates/serializers.py new file mode 100644 index 000000000..7763da5a0 --- /dev/null +++ b/backend/certificates/serializers.py @@ -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 diff --git a/backend/certificates/services.py b/backend/certificates/services.py new file mode 100644 index 000000000..4df4719ef --- /dev/null +++ b/backend/certificates/services.py @@ -0,0 +1,278 @@ +"""Certificate issuance services — pure derivations, idempotent by design. + +Every function here can run a thousand times and produce the same state: + +- ``issue_contest_certificates`` — one participation atestat per official + finisher of a rated contest (called from the ratings commit; the rank map + and post-contest ratings are exactly what Glicko just persisted); +- ``issue_rating_milestones`` / ``recompute_solve_milestones`` — peak + achievements (once crossed in rated competition / once solved, kept for + life — a later organic rating drop never un-earns a diploma); +- ``revoke_contest_certificates`` / ``audit_rating_milestones`` — the + integrity side: rollback/DQ tooling flips affected atestats to REVOKED + with a reason instead of silently deleting them (the verification page + keeps answering, honestly). + +Notifications fire only on the event-driven paths (a live contest +commit / a live solve), never from the backfill command — replaying +history must never spam. +""" + +import logging + +from django.db import IntegrityError, transaction +from django.utils import timezone + +from notifications.services import notify + +from .models import Certificate + +logger = logging.getLogger(__name__) + +#: Ladder thresholds (RAT-3 color bands start at 1200; 2000+ is "legend" +#: territory on a young ladder) — the honest "skill" atestat a small rated +#: community can actually stand behind. +RATING_THRESHOLDS: tuple[int, ...] = (1200, 1400, 1600, 1800, 2000) + +#: Distinct-APPROVED-problems-solved milestones — same derivation as the +#: leaderboard (D-B8), so the atestat cannot disagree with the standings. +SOLVE_THRESHOLDS: tuple[int, ...] = (10, 50, 100, 250, 500) + +_REVOKED_BELOW_THRESHOLD = "rating history rolled back below milestone" + + +def _issue( + user_id: int, + kind: str, + *, + contest=None, + threshold: int | None = None, + details: dict | None = None, + notify_user: bool = True, +) -> Certificate | None: + """Create the atestat if absent; None when it already exists. + + Never resurrects or edits an existing row (peak semantics + audit + trail). The IntegrityError path covers the rare double-delivery race + on the same user — the loser simply observes the winner's row. + """ + try: + with transaction.atomic(): + cert, created = Certificate.objects.get_or_create( + user_id=user_id, + kind=kind, + contest=contest, + threshold=threshold, + defaults={"details": details or {}}, + ) + if not created: + return None + cert.serial = f"WC-{timezone.now().year}-{cert.pk:06d}" + cert.save(update_fields=["serial"]) + except IntegrityError: + logger.info( + "certificate race lost for user=%s kind=%s threshold=%s", + user_id, + kind, + threshold, + ) + return None + if notify_user: + from users.models import User + + notify( + User.objects.filter(pk=user_id).first(), + "SYSTEM", + target=cert, + data={ + "kind": "certificate_issued", + "certificate_kind": cert.kind, + "serial": cert.serial, + "certificate_uuid": str(cert.uuid), + }, + ) + return cert + + +def issue_contest_certificates( + contest, + ranks: dict[int, int], + ratings_after: dict[int, int], + *, + notify_users: bool = True, +) -> list[Certificate]: + """Participation atestats for every official finisher of a rated contest. + + ``ranks`` / ``ratings_after`` are the exact values the ratings commit + just persisted — the snapshot is taken from the source of truth, then + frozen into ``details`` (the atestat must not change meaning if the + contest title is later edited). + """ + title = contest.title_i18n if isinstance(contest.title_i18n, dict) else {} + issued: list[Certificate] = [] + for user_id, rank in ranks.items(): + cert = _issue( + user_id, + Certificate.Kind.CONTEST_PARTICIPATION, + contest=contest, + details={ + "rank": rank, + "rating_after": ratings_after.get(user_id), + "contest_id": contest.id, + "contest_title_en": title.get("en", str(contest)), + "contest_title_ro": title.get("ro", title.get("en", str(contest))), + "contest_end": contest.end_at.date().isoformat(), + }, + notify_user=notify_users, + ) + if cert is not None: + issued.append(cert) + return issued + + +def issue_rating_milestones( + user_id: int, rating: int, *, notify_user: bool = True +) -> list[Certificate]: + """Atestats for every ladder threshold at or below ``rating`` (peak).""" + issued: list[Certificate] = [] + for threshold in RATING_THRESHOLDS: + if rating < threshold: + break # thresholds are ascending + cert = _issue( + user_id, + Certificate.Kind.RATING_MILESTONE, + threshold=threshold, + details={"rating": rating, "threshold": threshold}, + notify_user=notify_user, + ) + if cert is not None: + issued.append(cert) + return issued + + +def recompute_solve_milestones( + user_id: int, *, notify_user: bool = True +) -> list[Certificate]: + """Atestats for every solve threshold reached by the leaderboard row. + + Reads the same ``LeaderboardEntry`` the standings render, so a solve + certificate and the leaderboard can never disagree. + """ + from leaderboard.models import LeaderboardEntry + + entry = LeaderboardEntry.objects.filter(user_id=user_id).first() + solved = entry.solved_count if entry else 0 + issued: list[Certificate] = [] + for threshold in SOLVE_THRESHOLDS: + if solved < threshold: + break # thresholds are ascending + cert = _issue( + user_id, + Certificate.Kind.SOLVE_MILESTONE, + threshold=threshold, + details={"solved": solved, "threshold": threshold}, + notify_user=notify_user, + ) + if cert is not None: + issued.append(cert) + return issued + + +def revoke_contest_certificates(contest, reason: str) -> int: + """Flip every active atestat of one contest to REVOKED (rollback/DQ).""" + return Certificate.objects.filter(contest=contest, revoked_at__isnull=True).update( + revoked_at=timezone.now(), revoked_reason=reason[:200] + ) + + +def _revoke(cert: Certificate, reason: str) -> None: + cert.revoked_at = timezone.now() + cert.revoked_reason = reason[:200] + cert.save(update_fields=["revoked_at", "revoked_reason"]) + + +def audit_rating_milestones(reason: str = _REVOKED_BELOW_THRESHOLD) -> int: + """Post-rollback audit: revoke milestones the replayed ladder no longer + supports (a finisher whose only strong result was the discarded contest + keeps an honest REVOKED atestat, not a phantom 1600). + + Users whose rating history vanished entirely (every rated contest + discarded) lose every milestone the same way. + """ + from ratings.models import UserRating + + revoked = 0 + ratings = { + row["user_id"]: row["rating"] + for row in UserRating.objects.values("user_id", "rating") + } + stale = Certificate.objects.filter( + kind=Certificate.Kind.RATING_MILESTONE, revoked_at__isnull=True + ) + for cert in stale: + rating = ratings.get(cert.user_id) + if rating is None or (cert.threshold or 0) > rating: + _revoke(cert, reason) + revoked += 1 + return revoked + + +def backfill(*, notify_users: bool = False) -> dict[str, int]: + """Idempotent full derivation over existing history. + + Powers the ``issue_certificates`` management command (initial rollout + after this feature lands, and the recovery path after any manual data + surgery). Notifications stay off by default — replaying history must + never spam. + """ + from leaderboard.models import LeaderboardEntry + from ratings.models import RatingHistory, UserRating + + participation = 0 + milestones = 0 + solves = 0 + + history = RatingHistory.objects.order_by("contest_id", "user_id").values( + "user_id", + "contest_id", + "rank", + "rating", + "contest__title_i18n", + "contest__end_at", + ) + by_contest: dict[int, dict] = {} + for row in history: + info = by_contest.setdefault( + row["contest_id"], + { + "ranks": {}, + "ratings": {}, + }, + ) + info["ranks"][row["user_id"]] = row["rank"] + info["ratings"][row["user_id"]] = row["rating"] + + from contests.models import Contest + + for contest_id, info in by_contest.items(): + contest = Contest.objects.filter(pk=contest_id).first() + if contest is None: # pragma: no cover - FK guarantees the row + continue + participation += len( + issue_contest_certificates( + contest, info["ranks"], info["ratings"], notify_users=notify_users + ) + ) + + for row in UserRating.objects.values("user_id", "rating"): + milestones += len( + issue_rating_milestones(row["user_id"], row["rating"], notify_user=notify_users) + ) + for row in LeaderboardEntry.objects.values("user_id"): + solves += len(recompute_solve_milestones(row["user_id"], notify_user=notify_users)) + + return { + "participation": participation, + "rating_milestones": milestones, + "solve_milestones": solves, + } diff --git a/backend/certificates/test_certificates.py b/backend/certificates/test_certificates.py new file mode 100644 index 000000000..c6b512095 --- /dev/null +++ b/backend/certificates/test_certificates.py @@ -0,0 +1,539 @@ +"""Wave D atestats (certifications/attestations) — the honest variant. + +Unit + API + integration coverage: issuance idempotency, the frozen +achievement snapshot, public verification (H-120), revocation integrity +(rollback hooks), the backfill command, and the event hooks in the +ratings commit / leaderboard recompute. +""" + +from datetime import timedelta +from io import StringIO +from unittest import mock + +from django.core.management import call_command +from django.db import IntegrityError +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from certificates.models import Certificate +from certificates.services import ( + _issue, + audit_rating_milestones, + backfill, + issue_contest_certificates, + issue_rating_milestones, + recompute_solve_milestones, + revoke_contest_certificates, +) +from contests.models import Contest, ContestParticipation, ContestProblem, ContestScore +from leaderboard.aggregation import recompute_all_leaderboards, update_user_entry +from notifications.models import Notification +from problems.models import Problem +from problems.models import TestCase as ProblemTestCase +from ratings.models import RatingHistory, UserRating +from ratings.services import ( + compute_contest_ratings as compute_service, + rollback_contest_ratings, +) +from users.models import User + +NOW = timezone.now() + + +def _mk_problem(title: str) -> Problem: + return Problem.objects.create( + title_i18n={"en": title}, + statement_i18n={"en": "s"}, + status=Problem.ProblemStatus.APPROVED, + ) + + +class IssueServiceTests(TestCase): + def setUp(self): + self.alice = User.objects.create_user(username="alice", password="pw") + + def test_issue_creates_with_serial_and_notifies(self): + cert = _issue( + self.alice.pk, + Certificate.Kind.SOLVE_MILESTONE, + threshold=50, + details={"solved": 55}, + ) + self.assertIsNotNone(cert) + self.assertTrue(cert.serial.startswith(f"WC-{NOW.year}-")) + self.assertIn(f"{cert.pk:06d}", cert.serial) + self.assertTrue(Notification.objects.filter(data__kind="certificate_issued").exists()) + + def test_issue_existing_returns_none(self): + first = _issue(self.alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=50) + again = _issue( + self.alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=50, details={"x": 1} + ) + self.assertIsNotNone(first) + self.assertIsNone(again) + self.assertEqual(Certificate.objects.count(), 1) + # The original snapshot is never edited by a re-issue attempt. + self.assertNotIn("x", Certificate.objects.get().details) + + def test_issue_integrity_race_returns_none(self): + with mock.patch.object( + Certificate.objects, + "get_or_create", + side_effect=IntegrityError("duplicate key"), + ): + self.assertIsNone( + _issue(self.alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=10) + ) + + def test_issue_notify_off_creates_no_notification(self): + _issue( + self.alice.pk, + Certificate.Kind.SOLVE_MILESTONE, + threshold=10, + notify_user=False, + ) + self.assertFalse(Notification.objects.exists()) + + def test_model_str_status_and_revoked_flag(self): + cert = _issue(self.alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=10) + self.assertEqual(cert.status, "VALID") + self.assertFalse(cert.is_revoked) + self.assertIn(cert.serial, str(cert)) + self.assertIn("milestone", str(cert).lower()) + + +class ContestCertificateTests(TestCase): + def setUp(self): + self.organizer = User.objects.create_user(username="organizer", password="pw") + self.alice = User.objects.create_user(username="alice", password="pw") + self.bob = User.objects.create_user(username="bob", password="pw") + self.contest = Contest.objects.create( + title_i18n={"en": "Winter Cup", "ro": "Cupa de Iarnă"}, + created_by=self.organizer, + start_at=NOW - timedelta(hours=3), + end_at=NOW - timedelta(minutes=5), + format=Contest.Format.IOI, + is_rated=True, + ) + + def test_participation_snapshot_is_bilingual_and_frozen(self): + issued = issue_contest_certificates( + self.contest, + {self.alice.pk: 1, self.bob.pk: 2}, + {self.alice.pk: 1655, self.bob.pk: 1320}, + ) + self.assertEqual(len(issued), 2) + cert = Certificate.objects.get(user=self.alice, contest=self.contest) + self.assertEqual(cert.kind, Certificate.Kind.CONTEST_PARTICIPATION) + self.assertEqual(cert.details["rank"], 1) + self.assertEqual(cert.details["rating_after"], 1655) + self.assertEqual(cert.details["contest_title_en"], "Winter Cup") + self.assertEqual(cert.details["contest_title_ro"], "Cupa de Iarnă") + self.assertEqual(cert.details["contest_end"], self.contest.end_at.date().isoformat()) + # Idempotent: a replay issues nothing new. + again = issue_contest_certificates( + self.contest, {self.alice.pk: 1}, {self.alice.pk: 1655} + ) + self.assertEqual(again, []) + self.assertEqual(Certificate.objects.filter(contest=self.contest).count(), 2) + + def test_revoke_contest_certificates_flips_only_active(self): + issue_contest_certificates( + self.contest, {self.alice.pk: 1, self.bob.pk: 2}, {self.alice.pk: 1500, self.bob.pk: 1500} + ) + alice_cert = Certificate.objects.get(user=self.alice, contest=self.contest) + alice_cert.revoked_at = NOW + alice_cert.revoked_reason = "post-contest DQ" + alice_cert.save() + + flipped = revoke_contest_certificates(self.contest, "ratings discarded") + self.assertEqual(flipped, 1) + alice_cert.refresh_from_db() + self.assertEqual(alice_cert.revoked_reason, "post-contest DQ") + bob_cert = Certificate.objects.get(user=self.bob, contest=self.contest) + self.assertEqual(bob_cert.status, "REVOKED") + self.assertEqual(bob_cert.revoked_reason, "ratings discarded") + + +class MilestoneTests(TestCase): + def setUp(self): + self.alice = User.objects.create_user(username="alice", password="pw") + + def test_rating_milestones_issue_at_or_below(self): + issued = issue_rating_milestones(self.alice.pk, 1500) + self.assertEqual( + sorted(c.threshold for c in issued), [1200, 1400] + ) + # Peak semantics: a later organic drop re-issues nothing and revokes nothing. + issue_rating_milestones(self.alice.pk, 1350) + self.assertEqual( + Certificate.objects.filter(kind=Certificate.Kind.RATING_MILESTONE).count(), 2 + ) + + def test_rating_milestones_none_below_floor(self): + self.assertEqual(issue_rating_milestones(self.alice.pk, 900), []) + + def test_rating_milestones_all_thresholds(self): + issued = issue_rating_milestones(self.alice.pk, 2100) + self.assertEqual(len(issued), 5) + self.assertEqual(issued[-1].threshold, 2000) + + def test_audit_revokes_unsupported_milestones(self): + issue_rating_milestones(self.alice.pk, 1800) # 1200/1400/1600/1800 + user2 = User.objects.create_user(username="bob", password="pw") + issue_rating_milestones(user2.pk, 1300) # 1200 only + UserRating.objects.create(user=self.alice, rating=1450) + # bob gets NO UserRating — his history vanished in the rollback. + + revoked = audit_rating_milestones() + self.assertEqual(revoked, 3) # alice 1600+1800, bob 1200 + self.assertEqual( + Certificate.objects.filter( + kind=Certificate.Kind.RATING_MILESTONE, revoked_at__isnull=True + ).count(), + 2, # alice 1200 + 1400 (1450 still supports both) + ) + # A second audit is a no-op. + self.assertEqual(audit_rating_milestones(), 0) + + def test_solve_milestones_from_leaderboard_row(self): + from leaderboard.models import LeaderboardEntry + + LeaderboardEntry.objects.create( + user=self.alice, solved_count=55, total_points=550 + ) + issued = recompute_solve_milestones(self.alice.pk) + self.assertEqual(sorted(c.threshold for c in issued), [10, 50]) + + def test_solve_milestones_without_row(self): + self.assertEqual(recompute_solve_milestones(self.alice.pk), []) + + def test_solve_milestones_all_thresholds_and_rerun(self): + from leaderboard.models import LeaderboardEntry + from submissions.models import Submission + + problems = [ + Problem( + title_i18n={"en": f"B{i}"}, + statement_i18n={"en": "s"}, + status=Problem.ProblemStatus.APPROVED, + ) + for i in range(500) + ] + Problem.objects.bulk_create(problems) + Submission.objects.bulk_create( + [ + Submission( + user=self.alice, + problem=problem, + language="python3", + code="pass", + verdict=Submission.VerdictStatus.ACCEPTED, + ) + for problem in problems + ] + ) + LeaderboardEntry.objects.create(user=self.alice, solved_count=500) + + issued = recompute_solve_milestones(self.alice.pk) + self.assertEqual([c.threshold for c in issued], [10, 50, 100, 250, 500]) + # Re-run: existing rows return None inside the loop (idempotent). + self.assertEqual(recompute_solve_milestones(self.alice.pk), []) + + +class RatingsHookTests(TestCase): + """compute_contest_ratings issues atestats; rollback revokes them.""" + + def setUp(self): + self.organizer = User.objects.create_user(username="organizer", password="pw") + self.alice = User.objects.create_user(username="alice", password="pw") + self.bob = User.objects.create_user(username="bob", password="pw") + self.problem = _mk_problem("P") + ProblemTestCase.objects.create( + problem=self.problem, + input_data="1", + expected_output_data="2", + points=100, + order=0, + ) + self.client = APIClient() + + def mk_rated_contest(self, **kwargs): + defaults = dict( + title_i18n={"en": "Rated"}, + created_by=self.organizer, + start_at=NOW - timedelta(hours=3), + end_at=NOW - timedelta(minutes=5), + format=Contest.Format.IOI, + is_rated=True, + ) + defaults.update(kwargs) + contest = Contest.objects.create(**defaults) + ContestProblem.objects.create( + contest=contest, problem=self.problem, order=0, points=100 + ) + return contest + + def seed_scores(self, contest, scores): + for user, points in scores.items(): + part = ContestParticipation.objects.create(contest=contest, user=user) + cp = ContestProblem.objects.get(contest=contest) + ContestScore.objects.create( + participation=part, + contest_problem=cp, + best_score=points, + best_score_frozen=points, + ) + + def test_compute_issues_participation_and_milestones(self): + contest = self.mk_rated_contest() + self.seed_scores(contest, {self.alice: 100, self.bob: 40}) + compute_service(contest) + # Participation for both finishers. + self.assertEqual( + Certificate.objects.filter( + kind=Certificate.Kind.CONTEST_PARTICIPATION, contest=contest + ).count(), + 2, + ) + # Both start at 1500 → both cross 1200 and 1400. + self.assertEqual( + Certificate.objects.filter(kind=Certificate.Kind.RATING_MILESTONE).count(), 4 + ) + # The certificate_issued notification rides the notify_users flag. + self.assertTrue( + Notification.objects.filter(data__kind="certificate_issued").exists() + ) + # Recompute is a guarded no-op — no duplicate atestats. + compute_service(contest) + self.assertEqual(Certificate.objects.filter(contest=contest).count(), 2) + + def test_rollback_revokes_contest_and_unsupported_milestones(self): + contest = self.mk_rated_contest() + self.seed_scores(contest, {self.alice: 100, self.bob: 40}) + compute_service(contest) + self.assertEqual(Certificate.objects.filter(revoked_at__isnull=True).count(), 6) + + rollback_contest_ratings(contest) + # The discarded contest's participation atestats flipped to REVOKED. + self.assertEqual( + Certificate.objects.filter( + contest=contest, revoked_at__isnull=False + ).count(), + 2, + ) + # No rated history remains → every rating milestone is revoked too. + self.assertEqual( + Certificate.objects.filter( + kind=Certificate.Kind.RATING_MILESTONE, revoked_at__isnull=True + ).count(), + 0, + ) + + def test_rollback_of_never_rated_contest_is_clean(self): + contest = self.mk_rated_contest() + result = rollback_contest_ratings(contest) + self.assertIn("never rated", result) + + +class LeaderboardHookTests(TestCase): + def setUp(self): + self.alice = User.objects.create_user(username="alice", password="pw") + + def _seed_solves(self, count: int): + from submissions.models import Submission + + for i in range(count): + problem = _mk_problem(f"P{i}") + Submission.objects.create( + user=self.alice, + problem=problem, + language="python3", + code="pass", + verdict=Submission.VerdictStatus.ACCEPTED, + ) + + def test_update_user_entry_issues_at_threshold(self): + self._seed_solves(10) + update_user_entry(self.alice.pk) + self.assertTrue( + Certificate.objects.filter( + user=self.alice, + kind=Certificate.Kind.SOLVE_MILESTONE, + threshold=10, + ).exists() + ) + + def test_update_user_entry_below_threshold_issues_nothing(self): + self._seed_solves(9) + update_user_entry(self.alice.pk) + self.assertFalse(Certificate.objects.exists()) + + def test_full_recompute_issues_milestones(self): + self._seed_solves(10) + recompute_all_leaderboards() + self.assertEqual( + Certificate.objects.filter( + user=self.alice, kind=Certificate.Kind.SOLVE_MILESTONE + ).count(), + 1, + ) + + +class CertificateAPITests(TestCase): + def setUp(self): + self.alice = User.objects.create_user(username="alice", password="pw") + self.admin = User.objects.create_user( + username="admin", password="pw", role=User.Roles.ADMIN + ) + self.contest = Contest.objects.create( + title_i18n={"en": "C"}, + created_by=self.alice, + start_at=NOW - timedelta(hours=2), + end_at=NOW - timedelta(minutes=5), + format=Contest.Format.IOI, + is_rated=True, + ) + self.cert = issue_contest_certificates( + self.contest, {self.alice.pk: 1}, {self.alice.pk: 1500} + )[0] + self.client = APIClient() + + def test_verify_public_by_uuid(self): + response = self.client.get(f"/api/v1/certificates/verify/{self.cert.uuid}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["serial"], self.cert.serial) + self.assertEqual(response.data["status"], "VALID") + self.assertEqual(response.data["holder"], "alice") + self.assertEqual(response.data["details"]["rank"], 1) + self.assertEqual(response.data["kind"], "CONTEST_PARTICIPATION") + + def test_verify_unknown_uuid_404(self): + import uuid as uuid_mod + + response = self.client.get( + f"/api/v1/certificates/verify/{uuid_mod.uuid4()}/" + ) + self.assertEqual(response.status_code, 404) + + def test_mine_requires_auth(self): + response = self.client.get("/api/v1/certificates/mine/") + self.assertEqual(response.status_code, 401) + + def test_mine_lists_only_own(self): + bob = User.objects.create_user(username="bob", password="pw") + issue_contest_certificates(self.contest, {bob.pk: 2}, {bob.pk: 1400}) + self.client.force_authenticate(user=self.alice) + response = self.client.get("/api/v1/certificates/mine/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["holder"], "alice") + + def test_public_list_filters_username_and_hides_minors(self): + minor = User.objects.create_user(username="kid", password="pw", is_minor=True) + issue_contest_certificates(self.contest, {minor.pk: 3}, {minor.pk: 1300}) + response = self.client.get("/api/v1/certificates/?username=kid") + self.assertEqual(response.data["count"], 0) + response = self.client.get("/api/v1/certificates/?username=alice") + self.assertEqual(response.data["count"], 1) + # No username filter: every non-minor active holder. + response = self.client.get("/api/v1/certificates/") + self.assertEqual(response.data["count"], 1) + + def test_public_list_hides_inactive_holders(self): + self.alice.is_active = False + self.alice.save() + response = self.client.get("/api/v1/certificates/?username=alice") + self.assertEqual(response.data["count"], 0) + + def test_revoke_admin_only_and_reason_required(self): + url = f"/api/v1/certificates/{self.cert.pk}/revoke/" + self.client.force_authenticate(user=self.alice) + self.assertEqual(self.client.post(url, {"reason": "x"}).status_code, 403) + self.client.force_authenticate(user=self.admin) + self.assertEqual(self.client.post(url, {}).status_code, 400) + response = self.client.post(url, {"reason": "integrity review"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "REVOKED") + # Idempotent re-revoke keeps the first reason. + response = self.client.post(url, {"reason": "second reason"}) + self.assertEqual(response.data["revoked_reason"], "integrity review") + # And verification answers REVOKED honestly. + refreshed = self.client.get(f"/api/v1/certificates/verify/{self.cert.uuid}/") + self.assertEqual(refreshed.data["status"], "REVOKED") + + +class AdminActionTests(TestCase): + def test_revoke_action(self): + from certificates.admin import CertificateAdmin + from django.contrib.admin.sites import AdminSite + + alice = User.objects.create_user(username="alice", password="pw") + cert = _issue(alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=10) + model_admin = CertificateAdmin(Certificate, AdminSite()) + request = mock.Mock() + request.POST = {"apply": "1"} + model_admin.revoke_action(request, Certificate.objects.filter(pk=cert.pk)) + cert.refresh_from_db() + self.assertEqual(cert.status, "REVOKED") + + +class BackfillCommandTests(TestCase): + def setUp(self): + self.organizer = User.objects.create_user(username="organizer", password="pw") + self.alice = User.objects.create_user(username="alice", password="pw") + self.bob = User.objects.create_user(username="bob", password="pw") + self.problem = _mk_problem("P") + self.contest = Contest.objects.create( + title_i18n={"en": "C"}, + created_by=self.organizer, + start_at=NOW - timedelta(hours=3), + end_at=NOW - timedelta(minutes=5), + format=Contest.Format.IOI, + is_rated=True, + ) + ContestProblem.objects.create( + contest=self.contest, problem=self.problem, order=0, points=100 + ) + for user, score in ((self.alice, 100), (self.bob, 40)): + part = ContestParticipation.objects.create(contest=self.contest, user=user) + ContestScore.objects.create( + participation=part, + contest_problem=ContestProblem.objects.get(contest=self.contest), + best_score=score, + best_score_frozen=score, + ) + from leaderboard.models import LeaderboardEntry + + LeaderboardEntry.objects.create(user=self.alice, solved_count=12) + + def test_backfill_from_history_is_idempotent(self): + compute_service(self.contest, notify_users=False) + Certificate.objects.all().delete() + + counts = backfill() + self.assertEqual(counts["participation"], 2) + self.assertEqual(counts["rating_milestones"], 4) + self.assertEqual(counts["solve_milestones"], 1) + + again = backfill() + self.assertEqual(sum(again.values()), 0) + self.assertEqual(Certificate.objects.count(), 7) + + def test_command_output_and_notify_flag(self): + compute_service(self.contest, notify_users=False) + Certificate.objects.all().delete() + Notification.objects.all().delete() + + out = StringIO() + call_command("issue_certificates", stdout=out) + text = out.getvalue() + self.assertIn("participation: 2", text) + self.assertIn("rating_milestones: 4", text) + # Default: replaying history never notifies. + self.assertFalse(Notification.objects.filter(data__kind="certificate_issued").exists()) + + Certificate.objects.all().delete() + call_command("issue_certificates", "--notify", stdout=StringIO()) + self.assertTrue(Notification.objects.filter(data__kind="certificate_issued").exists()) diff --git a/backend/certificates/urls.py b/backend/certificates/urls.py new file mode 100644 index 000000000..11374161f --- /dev/null +++ b/backend/certificates/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from . import views + +app_name = "certificates" + +urlpatterns = [ + path( + "verify//", + views.CertificateVerifyView.as_view(), + name="verify", + ), + path("mine/", views.MyCertificatesView.as_view(), name="mine"), + path("", views.UserCertificatesView.as_view(), name="by-user"), + path( + "/revoke/", + views.RevokeCertificateView.as_view(), + name="revoke", + ), +] diff --git a/backend/certificates/views.py b/backend/certificates/views.py new file mode 100644 index 000000000..ed0f2a84b --- /dev/null +++ b/backend/certificates/views.py @@ -0,0 +1,88 @@ +"""Certificate API — public verification + holder lists + admin revoke. + +- ``GET /api/v1/certificates/verify//`` — ANONYMOUS: the employer / + teacher flow (H-120 verifiable participation certificates). Answers + VALID or REVOKED (with reason) — a revoked atestat stays answerable. +- ``GET /api/v1/certificates/mine/`` — the holder's list (paginated). +- ``GET /api/v1/certificates/?username=`` — public per-user list for + profiles; LEG-2: minors never appear. +- ``POST /api/v1/certificates//revoke/`` — admin integrity action + (DQ / rollback aftermath), audit-logged via the admin log. +""" + +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import permissions +from rest_framework.generics import ListAPIView +from rest_framework.response import Response +from rest_framework.views import APIView + +from users.permissions import IsAdminUser + +from .models import Certificate +from .serializers import CertificateSerializer + + +class CertificateVerifyView(APIView): + """Anonymous verification by the public UUID printed on the atestat.""" + + permission_classes = [permissions.AllowAny] + + def get(self, request, certificate_uuid): + certificate = get_object_or_404( + Certificate.objects.select_related("user"), uuid=certificate_uuid + ) + return Response(CertificateSerializer(certificate).data) + + +class MyCertificatesView(ListAPIView): + """The logged-in holder's atestats (newest first, paginated).""" + + permission_classes = [permissions.IsAuthenticated] + serializer_class = CertificateSerializer + + def get_queryset(self): + return ( + Certificate.objects.filter(user=self.request.user) + .select_related("user") + .order_by("-issued_at", "-id") + ) + + +class UserCertificatesView(ListAPIView): + """Public per-user list (``?username=``) for profile pages. + + Minors are excluded end-to-end (LEG-2): their atestats exist for the + holder and the direct verification URL, but never surface in public + listings. + """ + + permission_classes = [permissions.AllowAny] + serializer_class = CertificateSerializer + + def get_queryset(self): + queryset = ( + Certificate.objects.filter(user__is_minor=False, user__is_active=True) + .select_related("user") + .order_by("-issued_at", "-id") + ) + username = self.request.query_params.get("username") + if username: + queryset = queryset.filter(user__username=username) + return queryset + + +class RevokeCertificateView(APIView): + """Admin integrity action: revoke with a mandatory reason.""" + + permission_classes = [IsAdminUser] + + def post(self, request, pk): + certificate = get_object_or_404(Certificate, pk=pk) + reason = (request.data.get("reason") or "").strip() + if not reason: + return Response({"detail": "A revocation reason is required."}, status=400) + certificate.revoked_at = certificate.revoked_at or timezone.now() + certificate.revoked_reason = certificate.revoked_reason or reason[:200] + certificate.save(update_fields=["revoked_at", "revoked_reason"]) + return Response(CertificateSerializer(certificate).data) diff --git a/backend/leaderboard/aggregation.py b/backend/leaderboard/aggregation.py index fe8ece553..fa6527d08 100644 --- a/backend/leaderboard/aggregation.py +++ b/backend/leaderboard/aggregation.py @@ -95,6 +95,13 @@ def update_user_entry(user_id): "last_solved_at": last_solved_at, }, ) + # Wave D atestats (SOLVE_MILESTONE): this row IS the derivation the + # certificates stand on — re-derive idempotently, but only once the + # first threshold (10) is reachable so beginners' ACs stay query-free. + if solved_count >= 10: + from certificates.services import recompute_solve_milestones + + recompute_solve_milestones(user_id) return entry @@ -144,6 +151,14 @@ def recompute_all_leaderboards(): }, ) LeaderboardEntry.objects.exclude(user_id__in=per_user.keys()).delete() + # Wave D atestats: re-derive solve milestones from the freshly-written + # rows (idempotent — full recompute runs after imports/data surgery, + # exactly when the backfill is wanted). + from certificates.services import recompute_solve_milestones + + for user_id, acc in per_user.items(): + if acc["solved_count"] >= 10: + recompute_solve_milestones(user_id) return len(per_user) diff --git a/backend/ratings/services.py b/backend/ratings/services.py index 6a5fe50d8..9619bdad4 100644 --- a/backend/ratings/services.py +++ b/backend/ratings/services.py @@ -130,6 +130,21 @@ def compute_contest_ratings(contest: Contest, *, notify_users: bool = True) -> s "rating": result.rating, }, ) + + # Wave D atestats (certifications item): the rank map + post-contest + # ratings Glicko just persisted are the leaderboard-derived truth an + # atestat freezes — issue participation certificates and ladder + # milestones for every finisher (idempotent; notifications follow the + # caller's replay-suppression choice). + from certificates.services import ( + issue_contest_certificates, + issue_rating_milestones, + ) + + ratings_after = {result.user_id: result.rating for result in results} + issue_contest_certificates(contest, ranks, ratings_after, notify_users=notify_users) + for user_id, rating in ratings_after.items(): + issue_rating_milestones(user_id, rating, notify_user=notify_users) return f"contest {contest.id}: {len(results)} ratings updated" @@ -160,6 +175,16 @@ def rollback_contest_ratings(contest: Contest) -> str: result = compute_contest_ratings(other, notify_users=False) if "ratings updated" in result: replayed += 1 + # Wave D atestats: the discarded contest's participation certificates + # no longer stand (revoked with the reason, never deleted), and ladder + # milestones the replayed history no longer supports are revoked too. + from certificates.services import audit_rating_milestones, revoke_contest_certificates + + if had: + revoke_contest_certificates( + contest, reason=f"contest {contest.id} ratings discarded by organizer" + ) + audit_rating_milestones() if not had: return f"contest {contest.id} was never rated; nothing to roll back" return f"contest {contest.id} discarded (was rated); {replayed} contests replayed" diff --git a/backend/webcoder_api/settings.py b/backend/webcoder_api/settings.py index c9f6400e9..94d4dbc31 100644 --- a/backend/webcoder_api/settings.py +++ b/backend/webcoder_api/settings.py @@ -101,6 +101,8 @@ def _env_list(name: str, default: list[str] | None = None) -> list[str]: "scratch", "contests", "ratings", + # Wave D certifications/attestations: leaderboard-derived atestats. + "certificates", # Classroom (competitive roadmap Wave B: EDU-1/2/3). "classroom", "community_depth", diff --git a/backend/webcoder_api/test_webcoder_api.py b/backend/webcoder_api/test_webcoder_api.py index 025c406e7..d098a8d66 100644 --- a/backend/webcoder_api/test_webcoder_api.py +++ b/backend/webcoder_api/test_webcoder_api.py @@ -130,7 +130,7 @@ def test_urlpatterns_wiring(): from webcoder_api import urls as project_urls assert ( - len(project_urls.urlpatterns) == 29 + len(project_urls.urlpatterns) == 30 ) # + public API (OPS-1) + microsoft SSO lane (OPS-3) + leaderboard, notifications, forum, auth/csrf (SP1), healthz/readyz/metrics (X3), scratch (X7), contests (CON-1), ratings (RAT-1), classroom (EDU-1), community (COM-3/7, RAT-8/9/10), translations (i18n wave), messages (COM-5) assert reverse("token_obtain_pair") == "/api/v1/token/" # Assert the frontend-facing simplejwt ROUTES resolve (not reverse names): diff --git a/backend/webcoder_api/urls.py b/backend/webcoder_api/urls.py index d58cd36b3..d2202834f 100644 --- a/backend/webcoder_api/urls.py +++ b/backend/webcoder_api/urls.py @@ -53,6 +53,10 @@ path("api/v1/contests/", include("contests.urls", namespace="contests_api_v1")), # Ratings (competitive roadmap Wave B: RAT-1/2/3 + SAF-2 rollback). path("api/v1/ratings/", include("ratings.urls", namespace="ratings_api_v1")), + path( + "api/v1/certificates/", + include("certificates.urls", namespace="certificates_api_v1"), + ), path("api/v1/classroom/", include("classroom.urls", namespace="classroom_api_v1")), # Community depth (competitive roadmap Wave C: COM-3/7, RAT-8/9/10). path( diff --git a/docs/API-MAP.md b/docs/API-MAP.md index c88c6b880..da129f204 100644 --- a/docs/API-MAP.md +++ b/docs/API-MAP.md @@ -164,7 +164,19 @@ Endpoints from the installed package URLconf (`venv/.../dj_rest_auth/urls.py:11- Callbacks point at the CRA dev origin `http://localhost:3000/login/{google,github}/callback` (`users/api/oauth/google/views.py:7`, `github/views.py:7`) — will need updating for the Next.js frontend origin. -## 7. Non-JSON surfaces (for completeness) +## 7. Certificates app (Wave D atestats) — prefix `/api/v1/certificates/` +- `verify//` GET — **anonymous**; the public verification answer + (serial, kind, holder, frozen details, VALID/REVOKED + reason). H-120. +- `mine/` GET — the holder's list (paginated, newest first). +- `?username=` GET — public per-user list (paginated; LEG-2: minors and + inactive holders never surface). +- `/revoke/` POST — ADMIN-only integrity action `{reason}` (mandatory); + idempotent (the first reason sticks). Issuance itself is server-derived + only (ratings commit / leaderboard recompute hooks + the + `issue_certificates` management command) — there is deliberately no + create endpoint. + +## 8. Non-JSON surfaces (for completeness) - `/admin/` — Django admin site (`webcoder_api/urls.py:29`), session auth. - `/accounts/` — allauth browser views (login/signup/email/social flows; `webcoder_api/urls.py:42`). diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 78cc78d76..d2c2422b8 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -3,10 +3,10 @@ Item-level tracker of record. Statuses: `shipped (PR #N)` · `in-flight (wave X)` · `planned (wave E/F/G)` · `someday`. Wave agents MUST update their items' status in the same PR that ships them. Categories A–K; numbering is stable — never renumber, retire with `~`. ## A. Judge & problem mechanics — wave E -1. Subtask groups · 2. Interactive problems · 3. Output-only · 4. Rich/partial-credit checkers · 5. Tolerance modes · 6. Per-test time bars · 7. WA diff viewer · 8. Sample-run pre-submit · 9. Pre-submit lint · 10. Constraint-aware overflow warnings · 11. Accepted-solutions showcase · (post-solve AC browsing shipped Wave C — COM-7) 12. Stress-test helper · (JDG-11 COMPLETE Wave C #307: stress runner + validator runner + expected-output generation + Kattis/Polygon package importer, CNT-2-ledger-gated) · 13. Submission versioning/diff · 14. Compile-error explainers · 15. Verifier rejudge · 16. Testcase strength scoring · 17. Hidden-test preview for verifiers · 18. Language version pinning · 19. Memory profiling view · 20. Sandbox receipts page · 21. "Am I close?" bands · 22. Batch classroom import · (MCQ bank engine + 30-item pilot shipped Wave C — EDU-8, #307 audit-verified) 23. Drag-drop files · 24. Random problem button · 25. TLE time-travel analysis +1. Subtask groups · 2. Interactive problems — shipped (Wave D PR #239, JDG-2: interactor protocol over piped stdio, any registry language, own limits + pair wall-clock cap) · 3. Output-only · 4. Rich/partial-credit checkers · 5. Tolerance modes · 6. Per-test time bars · 7. WA diff viewer · 8. Sample-run pre-submit · 9. Pre-submit lint · 10. Constraint-aware overflow warnings · 11. Accepted-solutions showcase · (post-solve AC browsing shipped Wave C — COM-7) 12. Stress-test helper · (JDG-11 COMPLETE Wave C #307: stress runner + validator runner + expected-output generation + Kattis/Polygon package importer, CNT-2-ledger-gated) · 13. Submission versioning/diff · 14. Compile-error explainers · 15. Verifier rejudge · 16. Testcase strength scoring · 17. Hidden-test preview for verifiers · 18. Language version pinning · 19. Memory profiling view · 20. Sandbox receipts page · 21. "Am I close?" bands · 22. Batch classroom import · (MCQ bank engine + 30-item pilot shipped Wave C — EDU-8, #307 audit-verified) 23. Drag-drop files · 24. Random problem button · 25. TLE time-travel analysis ## B. Learning & curriculum — wave E -26. RO curriculum tree (grades 5–12) · 27. Admitere tracks · 28. Olympiad ladder · 29. Spaced-repeat review · 30. Skill graph w/ prerequisites · 31. Guided first-solve ladder · 32. Algorithm visualizers · 33. Theory cards · 34. Practice plan generator · 35. Mistake-pattern profile · 36. Read-the-code quizzes · 37. Fill-the-line micro-exercises · 38. Complexity drills · 39. Teacher collections · 40. Daily warmup katas · 41. Exam PDF export · (exam-mode site lockdown shipped Wave C — EDU-4) 42. Parent report cards · 43. Class effort leaderboards · 44. Class failure heatmaps · 45. Structured courses (Scratch→Python first) +26. RO curriculum tree (grades 5–10 shipped — EDU-9 Wave C grades 9–10 + EDU-10 Wave D #242 gimnaziu 5–8 with VERY_EASY band + beginner ladder; grades 11–12 remain planned) · 27. Admitere tracks · 28. Olympiad ladder · 29. Spaced-repeat review · 30. Skill graph w/ prerequisites · 31. Guided first-solve ladder · 32. Algorithm visualizers · 33. Theory cards · 34. Practice plan generator · 35. Mistake-pattern profile · 36. Read-the-code quizzes · 37. Fill-the-line micro-exercises · 38. Complexity drills · 39. Teacher collections · 40. Daily warmup katas · 41. Exam PDF export · (exam-mode site lockdown shipped Wave C — EDU-4) 42. Parent report cards · 43. Class effort leaderboards · 44. Class failure heatmaps · 45. Structured courses (Scratch→Python first) ## C. Contests & ratings — wave F 46. Virtual contests — shipped (Wave C PR, CON-5) · 47. Upsolving mode — shipped (Wave C PR, CON-5 archive gate) · 48. Hacks/counter-test phase · 49. Team contests · 50. School cups · 51. Rating graphs w/ annotations · 52. Percentiles (country/school) · 53. Divisions w/ promotion — first increment shipped (Wave C PR, CON-7 rating bands; promotion cutoffs still planned) · 54. Long contests · 55. Self-prediction calibration · 56. Contest commentaries · 57. Replay a user's contest · 58. Frozen-board drama page @@ -18,13 +18,13 @@ Item-level tracker of record. Statuses: `shipped (PR #N)` · `in-flight (wave X) 73. CodeMirror 6 editor · 74. Custom-test console · 75. Split-view layout · 76. Cross-device drafts · 77. KaTeX statements · 78. Statement annotations · 79. Full dark mode · 80. Ctrl-K palette · 81. Keyboard map · 82. Federated search · 83. Personal lists/pinning · 84. Notification digests · 85. .ics contest feed · 86. Localized sample comments · 87. Timezone support · 88. Onboarding checklist · 89. Guest try-it · 90. Minor-safe profiles · 91. Dyslexia-friendly/high-contrast mode · 92. Offline statement packs · 93. Print CSS · (sample-test bundle download shipped Wave C — UXP-3; print-CSS route still planned) · 94. Feature tour ## F. Mobile & access — wave G -95. Push notifications · 96. Offline mode (statements + drafts) · 97. Streak widget · 98. Tablet classroom mode · 99. QR class join · 100. Email/SMS fallback notices +(PWA manifest + service worker — offline problem browsing, install, notification deep links — shipped Wave D #241; install icons #289) 95. Push notifications · 96. Offline mode (statements + drafts) · 97. Streak widget · 98. Tablet classroom mode · 99. QR class join · 100. Email/SMS fallback notices ## G. Analytics & insights — wave G -101. Heat calendar · 102. Per-problem language stats · (weekly leaderboard + verdict feed shipped Wave C — RAT-9/COM-3) · 103. Live difficulty calibration · 104. Time-of-day charts · 105. Org dashboards · 106. Public API v1 · (read-only problems/leaderboard/feed/tags shipped Wave C — OPS-1; personal tokens pending OPS-2) · 107. Webhooks · 108. GDPR data export · 109. A/B framework · 110. Search analytics · 111. Funnel dashboards · 112. Season reports +101. Heat calendar · 102. Per-problem language stats · (weekly leaderboard + verdict feed shipped Wave C — RAT-9/COM-3) · 103. Live difficulty calibration · 104. Time-of-day charts · 105. Org dashboards · 106. Public API v1 · (read-only problems/leaderboard/feed/tags shipped Wave C — OPS-1; personal API tokens shipped Wave D #240 — OPS-2) · 107. Webhooks · 108. GDPR data export · 109. A/B framework · 110. Search analytics · 111. Funnel dashboards · 112. Season reports ## H. Integrity & safety — wave G -113. Plagiarism detection (MOSS/JPlag) · (vote trust-weighting shipped Wave C — RAT-10) 114. Boilerplate detection · 115. Multi-account heuristics · 116. AI-solution labeling/policy · 117. Abuse queue + SLA · 118. Moderation appeals + audit trail · 119. Bug bounty page · 120. Verifiable participation certificates (contest medals shipped as their first increment — Wave C CON-11) +113. Plagiarism detection (MOSS/JPlag) · (vote trust-weighting shipped Wave C — RAT-10) 114. Boilerplate detection · 115. Multi-account heuristics · 116. AI-solution labeling/policy · 117. Abuse queue + SLA · 118. Moderation appeals + audit trail · 119. Bug bounty page · 120. Verifiable participation certificates — shipped (Wave D, certifications/attestats: leaderboard-derived atestats — rated-contest participation, rating milestones, solve milestones — public UUID verification, print/PDF sheet, revocation ledger; contest medals were the first increment, Wave C CON-11) ## I. Authoring & content ops — wave F 121. Visual authoring wizard · 122. Testcase generator DSL · 123. Statement linter · 124. EN+translation sidecar authoring · 125. Problem versioning/changelog · (statement revision history shipped Wave C — UXP-4) · 126. Variant families · 127. Polygon-format import · 128. Statement attachments · 129. Advanced search w/ saved queries · 130. Source attribution + licensing field (UGC CC BY-SA + school DPA + ImportSource permission ledger + provenance fields shipped Wave C) · 131. Scheduled content drops · 132. Author analytics · 133. Testcase bounties · 134. Readability scoring @@ -33,7 +33,7 @@ Item-level tracker of record. Statuses: `shipped (PR #N)` · `in-flight (wave X) 135. Judge queue transparency page · 136. Autoscaling workers · 137. Contest priority lanes · 138. Load-test harness · 139. Status page · 140. Blue-green deploy · 141. Nightly prod-like e2e · 142. Restore drills · 143. Cost dashboard · 144. Edge protection · 145. Error tracking productized ## K. Sustainability & ecosystem — wave G -146. Supporter tier (no gameplay advantage) · 147. School partnerships program · 148. Teacher certification · 149. Open-source client components · 150. Annual transparency report +146. Supporter tier (no gameplay advantage) · 147. School partnerships program · 148. Teacher certification · 149. Open-source client components (posture DECIDED 2026-11-14 — docs/DECISION-OPEN-SOURCE.md: platform stays closed pre-traction, components open on a rolling basis; no self-host support) · 150. Annual transparency report --- diff --git a/docs/DECISION-OPEN-SOURCE.md b/docs/DECISION-OPEN-SOURCE.md new file mode 100644 index 000000000..4e263fc6b --- /dev/null +++ b/docs/DECISION-OPEN-SOURCE.md @@ -0,0 +1,85 @@ +# Decision: open-source / self-host posture + +Status: **DECIDED (2026-11-14, Wave D)** — recorded per the roadmap's Wave D +line "open-source/self-host decision (DMOJ/infoarena precedent vs +maintenance cost)". This document is the decision record; the roadmap and +backlog cite it rather than restating it. + +## The question + +Should WebCoder (a) open-source the platform, DMOJ-style, accepting +community self-hosting and contributions; (b) stay closed-source like +infoarena, a single-maintainer system; or (c) something in between? + +## Evidence from the two precedents the roadmap names + +- **DMOJ** (https://github.com/DMOJ/online-judge, AGPL-3.0): a thriving + open-source judge — dozens of independent installations (dmoj.ca is just + one), a public problem archive format other sites adopted, and years of + outside contributions. The costs are equally real: a public + vulnerability surface (security reports must be triaged against every + deployed instance), contribution-review overhead, a stable + plugin/migration contract for strangers' instances, and docs/support + obligations that consume maintainer hours the core site never sees. +- **infoarena** (https://www.infoarena.ro, closed): a personal project + serving Romania since ~2014 (roadmap §2.9) on a legacy stack. Zero + upstream maintenance burden, but also zero outside contributions, zero + institutional redundancy, and a bus-factor-of-one core that the roadmap + itself cites for its "dated UI / legacy toolchains" failure mode. + +## Constraints specific to us + +1. **Solo maintainer, pre-production** (roadmap: production deployment is + owner-deferred, LAST). Maintenance capacity is the binding resource. +2. **The competitive moat is content and community, not code** (roadmap §6 + big-bets table: the defensible assets are the bilingual archive, the + classroom workflows and the rated ladder — none of which live in the + repo). +3. **Trust story**: LEG-1/X5 (GDPR, DPA for schools) and SAF-1/SAF-2 + (moderation, contest integrity) are in-flight commitments. Publishing + the exact abuse-detection and anti-cheat heuristics would degrade them. +4. **Backlog K-149** already scopes the honest middle path: + "Open-source client components". + +## Decision + +**Stay closed-source as a platform; open-source well-scoped client +components on a rolling basis (K-149 begins the moment a component +stabilizes). No self-hosting support before production traction.** + +Concretely: + +1. **The Django backend and judge stay closed** until the platform has + production traction AND more than one maintainer. Reviewing outside + PRs against the 100%-coverage + zero-warnings gates costs more than + the contributions are worth at current scale, and publishing judge + internals before the security-audit series (docs/SECURITY-AUDIT-*.md) + has fully landed would hand attackers the sandbox design. +2. **Client components are the open-source surface.** The Blockly/Scratch + editors (#252/#258), the problem-statement renderer, the print/PDF + stylesheet and the certificates verification page are self-contained, + carry no trust heuristics, and are exactly the artifacts a teacher or + another judge would reuse. Extract under permissive licensing (MIT for + code we authored end-to-end) with a CONTRIBUTING note pinned to the + quality gates. First candidates, in order: the Scratch→Python + transpiler playground, the interactive-problem flush notice pattern, + the atestat verification page. +3. **No self-host docs, no installation support, no plugin contract** + while closed. If traction reverses this decision later, the reopen + path is: finish SECURITY-AUDIT-judge → AGPL-3.0 (DMOJ's license — + forced contribution-back fits a community judge) → a documented + docker-compose deployment profile → an explicit security-report + policy. That path is deliberately NOT started now. +4. **Institutional redundancy is solved operationally, not by + open-sourcing**: the backup/restore drills (OPR-1/SP6) plus the + archive import pipeline (CNT-2, RO-6) already give the community an + exit hatch — the problems and editorial corpus stay portable even + though the platform code is closed. Portability of CONTENT, not of + CODE, is the promise we can keep today. + +## Review trigger + +Revisit when any of: (a) a second recurring maintainer joins, (b) a +school/institution asks to self-host with an offer to maintain, (c) the +platform has been in production for two quarters. Until then this +decision is final and the repo's LICENSE (project code) stands. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 985def54d..c626cca73 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,9 +3,9 @@ Last updated: 2026-10-30 · This is the master plan. Layers are the platform's architecture; within each, items carry status: **SHIPPED** (with PR), **IN FLIGHT** (current program: waves A–D → parity audit → judge-fairness → i18n-10 → mobile → waves E/F/G), or **NEXT** (backlog item numbers from `BACKLOG.md`, e.g. "B-29"). Work top-down within dependencies; the tracker of record for item-level status is `BACKLOG.md`. ## Layer 0 — Core judge & execution -- SHIPPED: sandboxed docker-exec judge; 5 languages; per-test partial scoring; custom checkers (5 modes); per-problem time/mem limits; MLE/TLE genuine detection; verdict-forging backdoors removed; submit-time language validation (#181–#183). +- SHIPPED: sandboxed docker-exec judge; interactive problems — interactor protocol over piped stdio with own limits + pair wall-clock cap (JDG-2, Wave D #239); 5 languages; per-test partial scoring; custom checkers (5 modes); per-problem time/mem limits; MLE/TLE genuine detection; verdict-forging backdoors removed; submit-time language validation (#181–#183). - IN FLIGHT: per-language time+memory multipliers; language expansion (Pascal+8); per-problem library/feature policy (Boost bans, stdlib bans, string-bypass class); partial-code problems w/ locked regions; Scratch/block editor (judge-fairness wave). -- NEXT: subtask groups (A-1), interactive problems (A-2), output-only (A-3), rich checkers (A-4), diff viewer (A-7), sample-run pre-submit (A-8), stress-test helper (A-12), rejudge tooling (A-15/16/17), test strength analysis, time-travel TLE analysis (A-25). +- NEXT: subtask groups (A-1), output-only (A-3, shipped #246), rich checkers (A-4), diff viewer (A-7), sample-run pre-submit (A-8), stress-test helper (A-12), rejudge tooling (A-15/16/17), test strength analysis, time-travel TLE analysis (A-25). ## Layer 1 — Learning & curriculum - SHIPPED: difficulty/tags catalog, hints-free problem pages (hints = NEXT), skeleton onboarding pages. @@ -22,18 +22,19 @@ Last updated: 2026-10-30 · This is the master plan. Layers are the platform's a - NEXT: editorials system (D-59), per-problem discuss (D-60), solution sharing (D-61/11), blogs (D-62), wiki (D-63), WA-help queue (D-64), follow/feeds (D-65), reputation (D-66), problem-request board (D-69), embeds (D-72); authoring: wizard (I-121), generator DSL (I-122), statement linter (I-123), versioning (I-125), variants (I-126), Polygon import (I-127), attachments (I-128), advanced search (I-129), source attribution (I-130), author analytics (I-132). ## Layer 4 — Client experiences (web / PWA / mobile) -- SHIPPED: responsive+a11y gates, dark tokens base, RO/EN, viewport suites. -- IN FLIGHT: PWA install, Expo iOS+Android apps w/ store package (mobile wave); 10-language UI everywhere (i18n wave). +- SHIPPED: responsive+a11y gates, dark tokens base, RO/EN, viewport suites; PWA — manifest + service worker, offline problem browsing, install, notification deep links (OPS-6, Wave D #241; install icons #289). +- IN FLIGHT: Expo iOS+Android apps w/ store package (mobile wave; shipped #289); 10-language UI everywhere (i18n wave). - NEXT: CodeMirror 6 editor + console (E-73/74), split layout (E-75), drafts (E-76), KaTeX statements (E-77), annotations (E-78), full dark mode (E-79), palette (E-80), federated search (E-82), lists/pinning (E-83), digests (E-84), .ics (E-85), timezones (E-87), onboarding checklist (E-88), guest try-it (E-89), minor-safe profiles (E-90), a11y+ mode (E-91), offline packs (E-92/93), tour (E-94); mobile: push (F-95), offline mode (F-96), widget (F-97), tablet mode (F-98), QR join (F-99), fallbacks (F-100). ## Layer 5 — Data, analytics & insights -- IN FLIGHT: owner analytics instrumentation (wave D); leaderboard aggregation (shipped). +- SHIPPED: owner analytics instrumentation — activation funnel, D1/D7, weekly frozen snapshots + admin dashboard (ANA-1, wave D #244); leaderboard aggregation. - NEXT: heat calendar (G-101), per-problem language stats (G-102), difficulty calibration (G-103), time-of-day (G-104), org dashboards (G-105), API v1 expansion (G-106), webhooks (G-107), data export (G-108), A/B framework (G-109), search analytics (G-110), funnel dashboards (G-111), season reports (G-112). ## Layer 6 — Integrity, safety & trust - SHIPPED: sandbox receipts, throttles, moderation states, cookie-auth, CSRF. - IN FLIGHT: contest integrity guards + AI-cheating policy (wave B/C). -- NEXT: plagiarism detection MOSS/JPlag (H-113), boilerplate detection (H-114), multi-account heuristics (H-115), AI-solution labeling (H-116), abuse queue + SLA (H-117), moderation appeals + audit (H-118), bug bounty (H-119), verifiable participation certs (H-120). +- SHIPPED (wave D, certifications): leaderboard-derived atestats — rated-contest participation / rating milestones / solve milestones, public UUID verification page, print/PDF, revocation ledger (H-120; contest medals were the first increment, Wave C CON-11). +- NEXT: plagiarism detection MOSS/JPlag (H-113), boilerplate detection (H-114), multi-account heuristics (H-115), AI-solution labeling (H-116), abuse queue + SLA (H-117), moderation appeals + audit (H-118), bug bounty (H-119). ## Layer 7 — Platform ops & reliability - SHIPPED: compose stack + overrides, CI gates, prod settings profile, socket-proxy variant, backup strategy doc. @@ -41,6 +42,7 @@ Last updated: 2026-10-30 · This is the master plan. Layers are the platform's a - NEXT: judge queue transparency (J-135), autoscaling workers (J-136), priority lanes (J-137), load harness (J-138), status page (J-139), blue-green (J-140), nightly prod-like e2e (J-141), restore drills (J-142), cost dashboard (J-143), edge protection (J-144), error tracking productized (J-145). ## Layer 8 — Sustainability & ecosystem +- DECIDED (wave D): open-source/self-host posture — docs/DECISION-OPEN-SOURCE.md (closed platform pre-traction; component-level OSS rolling; K-149 remains the backlog item for the first extraction). - NEXT: supporter tier (K-146), school partnerships (K-147), teacher certification (K-148), open-source client components (K-149), transparency report (K-150). Events: community awards (D-67), AMAs (D-68), setter CVs (D-70), share cards (D-71). ## Sequencing rule diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index b0bc7b459..436632c3b 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -284,6 +284,23 @@ State at risk: the `pgdata` named volume (users, problems, testcases, submission - Public API (OPS-1): `/api/v1/public/` is unauthenticated read-only at `THROTTLE_PUBLIC_API_RATE` (default 60/min). +## Wave D operator notes + +- `manage.py issue_certificates` — idempotent derivation of atestats + (contest participation / rating milestones / solve milestones) from + existing history. Run once after deploying the certificates app + (before the first rated contest is unnecessary otherwise — the ratings + commit and the post-AC leaderboard recompute issue them live). + `--notify` also notifies holders; the default is silent (replaying + history never spams). +- OPS-2 progressive 2FA: `TWO_FACTOR_ENFORCE=true` (env) flips privileged + writes (problem authoring/approval) to refuse until TOTP is enrolled; + the default `false` is the "warn" phase (the `two_factor_required` + banner). Turn it on once every CREATOR/VERIFIER/ADMIN has enrolled. +- OPS-7 groundwork has no billing env vars by design — sponsor + attribution (`Contest.sponsor_name/logo_url`) and /support are + display-only rails. + ## §email — mail transport, weekly digest, deliverability (SP5/UXP-5b/OPR-5) Mail is env-driven with a **console-backend default** — no deploy sends diff --git a/e2e/certificates.spec.ts b/e2e/certificates.spec.ts new file mode 100644 index 000000000..827247502 --- /dev/null +++ b/e2e/certificates.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; + +// E2E: Wave D certifications/attestats — the honest variant. The holder +// list (/certificates, authed), the ANONYMOUS public verification page +// (/certificates/) with its printable atestat sheet, the honest +// REVOKED answer, and the not-found path. + +const VALID_UUID = 'e2e-cert-0001-valid-000000000001'; +const REVOKED_UUID = 'e2e-cert-0002-revoked-0000000002'; + +async function login(page: import('@playwright/test').Page) { + await page.goto('/login'); + await page.fill('#login-username', 'e2e_tester'); + await page.fill('#login-password', 'correct-horse-battery-staple'); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page.getByText('e2e_tester', { exact: true })).toBeVisible(); +} + +test.describe('certifications / atestats (mock api)', () => { + test('the holder list renders both certificates with honest badges', async ({ page }) => { + await login(page); + await page.goto('/certificates'); + + const list = page.getByTestId('certificates-list'); + await expect(list).toBeVisible(); + await expect(list).toContainText('WC-2026-000042'); + await expect(list).toContainText('Winter Cup'); + await expect(list).toContainText('WC-2026-000043'); + // One Valid badge, one Revoked badge — revoked rows stay listed. + await expect(list.getByText('Valid', { exact: true })).toBeVisible(); + await expect(list.getByText('Revoked', { exact: true })).toBeVisible(); + }); + + test('anonymous verification page answers VALID with the printable sheet', async ({ page }) => { + await page.goto(`/certificates/${VALID_UUID}`); + + const card = page.getByTestId('certificate-card'); + await expect(card).toBeVisible(); + await expect(card).toContainText('WC-2026-000042'); + await expect(card).toContainText('Winter Cup'); + await expect(card).toContainText('e2e_tester'); + await expect(page.getByTestId('certificate-verdict')).toContainText( + 'This certificate is valid.', + ); + // The print affordance (UXP-3 print stylesheet hides it on paper). + await expect(page.getByTestId('print-certificate')).toBeVisible(); + }); + + test('a revoked certificate keeps answering, with its reason', async ({ page }) => { + await page.goto(`/certificates/${REVOKED_UUID}`); + + await expect(page.getByTestId('certificate-card')).toBeVisible(); + const verdict = page.getByTestId('certificate-verdict'); + await expect(verdict).toContainText('This certificate has been revoked.'); + await expect(page.getByText(/ratings discarded by organizer/)).toBeVisible(); + }); + + test('unknown verification ids get the not-found copy', async ({ page }) => { + await page.goto('/certificates/00000000-dead-beef-0000-000000000000'); + await expect(page.getByRole('heading', { name: 'Certificate not found' })).toBeVisible(); + await expect(page.getByText(/does not match any certificate/i)).toBeVisible(); + }); + + test('the account menu links to /certificates', async ({ page }) => { + await login(page); + await page.goto('/'); + await page.getByRole('button', { name: 'Account menu' }).click(); + await expect( + page.getByRole('menuitem', { name: 'Certificates', exact: true }), + ).toHaveAttribute('href', '/certificates'); + }); +}); diff --git a/e2e/mock-api/server.mjs b/e2e/mock-api/server.mjs index d237e54f7..442dfa42e 100644 --- a/e2e/mock-api/server.mjs +++ b/e2e/mock-api/server.mjs @@ -2194,7 +2194,63 @@ const server = createServer((req, res) => { return; } - json(res, 404, { detail: `No mock for ${req.method} ${path}` }); + +// ---- Wave D atestats (certifications) -------------------------------- +// [Wave D certifications — the honest variant] Two fixtures: a VALID +// participation certificate and a REVOKED rating milestone, so the e2e +// lane covers both verification answers. +const MOCK_CERTIFICATES = [ + { + uuid: 'e2e-cert-0001-valid-000000000001', + serial: 'WC-2026-000042', + kind: 'CONTEST_PARTICIPATION', + status: 'VALID', + holder: 'e2e_tester', + details: { + rank: 3, + rating_after: 1662, + contest_id: 1, + contest_title_en: 'Winter Cup', + contest_title_ro: 'Cupa de Iarnă', + contest_end: '2026-11-01', + }, + issued_at: '2026-11-02T10:00:00Z', + revoked_at: null, + revoked_reason: '', + }, + { + uuid: 'e2e-cert-0002-revoked-0000000002', + serial: 'WC-2026-000043', + kind: 'RATING_MILESTONE', + status: 'REVOKED', + holder: 'e2e_tester', + details: { threshold: 1600, rating: 1610 }, + issued_at: '2026-11-02T10:00:01Z', + revoked_at: '2026-11-05T09:00:00Z', + revoked_reason: 'contest 1 ratings discarded by organizer', + }, +]; + +if (req.method === 'GET' && path === '/api/v1/certificates/mine/') { + if (!sessionUser(req)) { + json(res, 401, { detail: 'Authentication credentials were not provided.' }); + return; + } + json(res, 200, { count: MOCK_CERTIFICATES.length, next: null, previous: null, results: MOCK_CERTIFICATES }); + return; +} +if (req.method === 'GET' && path.startsWith('/api/v1/certificates/verify/')) { + const uuid = path.slice('/api/v1/certificates/verify/'.length).replace(/\/$/, ''); + const found = MOCK_CERTIFICATES.find((c) => c.uuid === uuid); + if (!found) { + json(res, 404, { detail: 'Not found.' }); + return; + } + json(res, 200, found); + return; +} + +json(res, 404, { detail: `No mock for ${req.method} ${path}` }); }); server.listen(PORT, HOST, () => { diff --git a/public/locales/ar.json b/public/locales/ar.json index bd011b5fc..f8a34bb19 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "أرشيف الأولمبياد", "archive_year_label": "سنة النسخة", "archive_grade_label": "مجموعة الصفوف", - "archive_all_groups": "كل المجموعات" + "archive_all_groups": "كل المجموعات", + "certificates_title": "شهاداتي", + "certificates_intro": "شهادات مُستمَدَّة من نشاط مُقيَّم على WebCoder: مشاركات رسمية في مسابقات مُصنَّفة، ومعالم تصنيف، ومعالم مسائل محلولة. يمكن التحقق من كل شهادة علنًا برقمها التسلسلي.", + "certificates_empty": "لا شهادات بعد — أكمل رسميًا مسابقة مُصنَّفة أو حل أول ١٠ مسائل لك.", + "certificates_load_error": "تعذّر تحميل الشهادات.", + "certificates_kind_CONTEST_PARTICIPATION": "مشاركة في مسابقة", + "certificates_kind_RATING_MILESTONE": "معلم تصنيف", + "certificates_kind_SOLVE_MILESTONE": "معلم مسائل محلولة", + "certificates_valid_badge": "صالح", + "certificates_revoked_badge": "ملغى", + "certificates_view_verify": "عرض / تحقق", + "certificates_rank_label": "الترتيب النهائي", + "certificates_rating_label": "التصنيف بعد المسابقة", + "certificates_solved_label": "المسائل المحلولة", + "certificates_issued_label": "تاريخ الإصدار", + "certificates_serial_label": "الرقم التسلسلي", + "certificates_holder_label": "مُنح إلى", + "certificates_verify_title": "التحقق من الشهادة", + "certificates_verify_valid": "هذه الشهادة صالحة.", + "certificates_verify_revoked": "تم إلغاء هذه الشهادة.", + "certificates_verify_revoked_reason_label": "السبب", + "certificates_verify_note": "يمكن لأي شخص التحقق من هذه الشهادة على", + "certificates_print_button": "طباعة / حفظ بصيغة PDF", + "certificates_not_found_title": "لم يتم العثور على الشهادة", + "certificates_not_found_body": "لا تطابق رابط التحقق هذا أي شهادة. تحقق من الرقم التسلسلي أو الرابط ثم حاول مجددًا.", + "notification_certificate_title": "تم إصدار الشهادة", + "notification_certificate_body": "شهادة جديدة ({{serial}}) تنتظرك في صفحة شهاداتك.", + "nav_certificates": "الشهادات" } diff --git a/public/locales/bg.json b/public/locales/bg.json index 5f217d084..d4df1f3bc 100644 --- a/public/locales/bg.json +++ b/public/locales/bg.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Архив на олимпиадите", "archive_year_label": "Година на изданието", "archive_grade_label": "Група класове", - "archive_all_groups": "Всички групи" + "archive_all_groups": "Всички групи", + "certificates_title": "Моите сертификати", + "certificates_intro": "Сертификати, спечелени в WebCoder, всеки изведен от оценена дейност: официални участия в рейтингови състезания, рейтингови етапи и етапи решени задачи. Всеки сертификат може да бъде проверен публично по серийния си номер.", + "certificates_empty": "Все още няма сертификати — завършете официално рейтингово състезание или решете първите си 10 задачи.", + "certificates_load_error": "Сертификатите не можаха да се заредят.", + "certificates_kind_CONTEST_PARTICIPATION": "Участие в състезание", + "certificates_kind_RATING_MILESTONE": "Рейтингов етап", + "certificates_kind_SOLVE_MILESTONE": "Етап решени задачи", + "certificates_valid_badge": "Действителен", + "certificates_revoked_badge": "Отменен", + "certificates_view_verify": "Преглед / проверка", + "certificates_rank_label": "Крайно класиране", + "certificates_rating_label": "Рейтинг след състезанието", + "certificates_solved_label": "Решени задачи", + "certificates_issued_label": "Издаден на", + "certificates_serial_label": "Сериен номер", + "certificates_holder_label": "Връчен на", + "certificates_verify_title": "Проверка на сертификат", + "certificates_verify_valid": "Този сертификат е действителен.", + "certificates_verify_revoked": "Този сертификат е отменен.", + "certificates_verify_revoked_reason_label": "Причина", + "certificates_verify_note": "Всеки може да провери този сертификат на", + "certificates_print_button": "Печат / запазване като PDF", + "certificates_not_found_title": "Сертификатът не е намерен", + "certificates_not_found_body": "Тази проверъчна връзка не съответства на никой сертификат. Проверете серийния номер или връзката и опитайте отново.", + "notification_certificate_title": "Издаден сертификат", + "notification_certificate_body": "Нов сертификат ({{serial}}) ви чака на страницата със сертификати.", + "nav_certificates": "Сертификати" } diff --git a/public/locales/ca.json b/public/locales/ca.json index 0df4f32ac..fafb8b9b4 100644 --- a/public/locales/ca.json +++ b/public/locales/ca.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arxiu d'olimpíades", "archive_year_label": "Any de l'edició", "archive_grade_label": "Grup de curs", - "archive_all_groups": "Tots els grups" + "archive_all_groups": "Tots els grups", + "certificates_title": "Els meus certificats", + "certificates_intro": "Certificats obtinguts a WebCoder, cadascun derivat d'activitat avaluada: participacions oficials en concursos puntuats, fites de rating i fites de problemes resolts. Cada certificat es pot verificar públicament pel número de sèrie.", + "certificates_empty": "Encara sense certificats: finalitza oficialment un concurs puntuat o resol els teus primers 10 problemes.", + "certificates_load_error": "No s'han pogut carregar els certificats.", + "certificates_kind_CONTEST_PARTICIPATION": "Participació en concurs", + "certificates_kind_RATING_MILESTONE": "Fita de rating", + "certificates_kind_SOLVE_MILESTONE": "Fita de problemes resolts", + "certificates_valid_badge": "Vàlid", + "certificates_revoked_badge": "Revocat", + "certificates_view_verify": "Veure / verificar", + "certificates_rank_label": "Posició final", + "certificates_rating_label": "Rating després del concurs", + "certificates_solved_label": "Problemes resolts", + "certificates_issued_label": "Emès", + "certificates_serial_label": "Número de sèrie", + "certificates_holder_label": "Atorgat a", + "certificates_verify_title": "Verificació de certificat", + "certificates_verify_valid": "Aquest certificat és vàlid.", + "certificates_verify_revoked": "Aquest certificat ha estat revocat.", + "certificates_verify_revoked_reason_label": "Motiu", + "certificates_verify_note": "Qualsevol pot verificar aquest certificat a", + "certificates_print_button": "Imprimeix / desa com a PDF", + "certificates_not_found_title": "Certificat no trobat", + "certificates_not_found_body": "Aquest enllaç de verificació no correspon a cap certificat. Comprova la sèrie o l'enllaç i torna-ho a provar.", + "notification_certificate_title": "Certificat emès", + "notification_certificate_body": "Un nou certificat ({{serial}}) t'espera a la teva pàgina de certificats.", + "nav_certificates": "Certificats" } diff --git a/public/locales/de.json b/public/locales/de.json index a7e03a4e2..152c768f3 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olympiade-Archiv", "archive_year_label": "Jahrgang", "archive_grade_label": "Klassenstufe", - "archive_all_groups": "Alle Stufen" + "archive_all_groups": "Alle Stufen", + "certificates_title": "Meine Zertifikate", + "certificates_intro": "Auf WebCoder erworbene Zertifikate, jedes abgeleitet aus bewerteter Aktivität: offizielle Teilnahmen an gewerteten Wettbewerben, Rating-Meilensteine und Meilensteine gelöster Aufgaben. Jedes Zertifikat ist über seine Seriennummer öffentlich überprüfbar.", + "certificates_empty": "Noch keine Zertifikate — beende offiziell einen gewerteten Wettbewerb oder löse deine ersten 10 Aufgaben.", + "certificates_load_error": "Zertifikate konnten nicht geladen werden.", + "certificates_kind_CONTEST_PARTICIPATION": "Wettbewerbs-Teilnahme", + "certificates_kind_RATING_MILESTONE": "Rating-Meilenstein", + "certificates_kind_SOLVE_MILESTONE": "Meilenstein gelöster Aufgaben", + "certificates_valid_badge": "Gültig", + "certificates_revoked_badge": "Widerrufen", + "certificates_view_verify": "Ansehen / prüfen", + "certificates_rank_label": "Endplatzierung", + "certificates_rating_label": "Rating nach dem Wettbewerb", + "certificates_solved_label": "Gelöste Aufgaben", + "certificates_issued_label": "Ausgestellt", + "certificates_serial_label": "Seriennummer", + "certificates_holder_label": "Verliehen an", + "certificates_verify_title": "Zertifikatsprüfung", + "certificates_verify_valid": "Dieses Zertifikat ist gültig.", + "certificates_verify_revoked": "Dieses Zertifikat wurde widerrufen.", + "certificates_verify_revoked_reason_label": "Grund", + "certificates_verify_note": "Jeder kann dieses Zertifikat prüfen unter", + "certificates_print_button": "Drucken / als PDF speichern", + "certificates_not_found_title": "Zertifikat nicht gefunden", + "certificates_not_found_body": "Dieser Prüfungslink passt zu keinem Zertifikat. Prüfe Seriennummer oder Link und versuche es erneut.", + "notification_certificate_title": "Zertifikat ausgestellt", + "notification_certificate_body": "Ein neues Zertifikat ({{serial}}) wartet auf deiner Zertifikatsseite.", + "nav_certificates": "Zertifikate" } diff --git a/public/locales/el.json b/public/locales/el.json index c046aff92..e82e27ed4 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Αρχείο Ολυμπιάδων", "archive_year_label": "Έτος διεξαγωγής", "archive_grade_label": "Ομάδα τάξεων", - "archive_all_groups": "Όλες οι ομάδες" + "archive_all_groups": "Όλες οι ομάδες", + "certificates_title": "Τα πιστοποιητικά μου", + "certificates_intro": "Πιστοποιητικά που αποκτήθηκαν στο WebCoder, καθένα προερχόμενο από βαθμολογημένη δραστηριότητα: επίσημες συμμετοχές σε βαθμολογημένους διαγωνισμούς, όρια rating και όρια λυμένων προβλημάτων. Κάθε πιστοποιητικό επαληθεύεται δημόσια με τον σειριακό του αριθμό.", + "certificates_empty": "Δεν υπάρχουν ακόμη πιστοποιητικά — ολοκληρώστε επίσημα έναν βαθμολογημένο διαγωνισμό ή λύστε τα πρώτα σας 10 προβλήματα.", + "certificates_load_error": "Αποτυχία φόρτωσης των πιστοποιητικών.", + "certificates_kind_CONTEST_PARTICIPATION": "Συμμετοχή σε διαγωνισμό", + "certificates_kind_RATING_MILESTONE": "Όριο rating", + "certificates_kind_SOLVE_MILESTONE": "Όριο λυμένων προβλημάτων", + "certificates_valid_badge": "Έγκυρο", + "certificates_revoked_badge": "Ανακληθέν", + "certificates_view_verify": "Προβολή / επαλήθευση", + "certificates_rank_label": "Τελική κατάταξη", + "certificates_rating_label": "Rating μετά τον διαγωνισμό", + "certificates_solved_label": "Λυμένα προβλήματα", + "certificates_issued_label": "Εκδόθηκε", + "certificates_serial_label": "Σειριακός αριθμός", + "certificates_holder_label": "Απονεμήθηκε σε", + "certificates_verify_title": "Επαλήθευση πιστοποιητικού", + "certificates_verify_valid": "Αυτό το πιστοποιητικό είναι έγκυρο.", + "certificates_verify_revoked": "Αυτό το πιστοποιητικό έχει ανακληθεί.", + "certificates_verify_revoked_reason_label": "Αιτία", + "certificates_verify_note": "Οποιοσδήποτε μπορεί να επαληθεύσει αυτό το πιστοποιητικό στο", + "certificates_print_button": "Εκτύπωση / αποθήκευση ως PDF", + "certificates_not_found_title": "Το πιστοποιητικό δεν βρέθηκε", + "certificates_not_found_body": "Αυτός ο σύνδεσμος επαλήθευσης δεν αντιστοιχεί σε κάποιο πιστοποιητικό. Ελέγξτε τον σειριακό αριθμό ή τον σύνδεσμο και δοκιμάστε ξανά.", + "notification_certificate_title": "Εκδόθηκε πιστοποιητικό", + "notification_certificate_body": "Ένα νέο πιστοποιητικό ({{serial}}) σας περιμένει στη σελίδα πιστοποιητικών σας.", + "nav_certificates": "Πιστοποιητικά" } diff --git a/public/locales/en.json b/public/locales/en.json index 0a8457235..eb2620580 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olympiad archive", "archive_year_label": "Edition year", "archive_grade_label": "Grade group", - "archive_all_groups": "All groups" + "archive_all_groups": "All groups", + "certificates_title": "My certificates", + "certificates_intro": "Certificates earned on WebCoder, each derived from judged activity: official rated-contest finishes, rating milestones and solved-problem milestones. Every certificate is publicly verifiable by its serial number.", + "certificates_empty": "No certificates yet — officially finish a rated contest or solve your first 10 problems.", + "certificates_load_error": "Failed to load certificates.", + "certificates_kind_CONTEST_PARTICIPATION": "Contest participation", + "certificates_kind_RATING_MILESTONE": "Rating milestone", + "certificates_kind_SOLVE_MILESTONE": "Solved-problems milestone", + "certificates_valid_badge": "Valid", + "certificates_revoked_badge": "Revoked", + "certificates_view_verify": "View / verify", + "certificates_rank_label": "Final rank", + "certificates_rating_label": "Rating after contest", + "certificates_solved_label": "Problems solved", + "certificates_issued_label": "Issued", + "certificates_serial_label": "Serial", + "certificates_holder_label": "Awarded to", + "certificates_verify_title": "Certificate verification", + "certificates_verify_valid": "This certificate is valid.", + "certificates_verify_revoked": "This certificate has been revoked.", + "certificates_verify_revoked_reason_label": "Reason", + "certificates_verify_note": "Anyone can verify this certificate at", + "certificates_print_button": "Print / save as PDF", + "certificates_not_found_title": "Certificate not found", + "certificates_not_found_body": "This verification link does not match any certificate. Check the serial or link and try again.", + "notification_certificate_title": "Certificate issued", + "notification_certificate_body": "A new certificate ({{serial}}) is waiting on your certificates page.", + "nav_certificates": "Certificates" } diff --git a/public/locales/es.json b/public/locales/es.json index 8fe10b835..cbe4e2ad0 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archivo de olimpiadas", "archive_year_label": "Año de la edición", "archive_grade_label": "Grupo de grado", - "archive_all_groups": "Todos los grupos" + "archive_all_groups": "Todos los grupos", + "certificates_title": "Mis certificados", + "certificates_intro": "Certificados obtenidos en WebCoder, cada uno derivado de actividad evaluada: participaciones oficiales en concursos puntuados, hitos de rating e hitos de problemas resueltos. Cada certificado se puede verificar públicamente por su número de serie.", + "certificates_empty": "Aún no hay certificados: termina oficialmente un concurso puntuado o resuelve tus primeros 10 problemas.", + "certificates_load_error": "No se pudieron cargar los certificados.", + "certificates_kind_CONTEST_PARTICIPATION": "Participación en concurso", + "certificates_kind_RATING_MILESTONE": "Hito de rating", + "certificates_kind_SOLVE_MILESTONE": "Hito de problemas resueltos", + "certificates_valid_badge": "Válido", + "certificates_revoked_badge": "Revocado", + "certificates_view_verify": "Ver / verificar", + "certificates_rank_label": "Puesto final", + "certificates_rating_label": "Rating tras el concurso", + "certificates_solved_label": "Problemas resueltos", + "certificates_issued_label": "Emitido", + "certificates_serial_label": "Serie", + "certificates_holder_label": "Otorgado a", + "certificates_verify_title": "Verificación de certificado", + "certificates_verify_valid": "Este certificado es válido.", + "certificates_verify_revoked": "Este certificado ha sido revocado.", + "certificates_verify_revoked_reason_label": "Motivo", + "certificates_verify_note": "Cualquiera puede verificar este certificado en", + "certificates_print_button": "Imprimir / guardar como PDF", + "certificates_not_found_title": "Certificado no encontrado", + "certificates_not_found_body": "Este enlace de verificación no corresponde a ningún certificado. Comprueba la serie o el enlace e inténtalo de nuevo.", + "notification_certificate_title": "Certificado emitido", + "notification_certificate_body": "Un nuevo certificado ({{serial}}) te espera en tu página de certificados.", + "nav_certificates": "Certificados" } diff --git a/public/locales/fa.json b/public/locales/fa.json index b8dfc691e..43bc45deb 100644 --- a/public/locales/fa.json +++ b/public/locales/fa.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "آرشیو المپیاد", "archive_year_label": "سال برگزاری", "archive_grade_label": "گروه پایه", - "archive_all_groups": "همه گروه‌ها" + "archive_all_groups": "همه گروه‌ها", + "certificates_title": "گواهی‌نامه‌های من", + "certificates_intro": "گواهی‌نامه‌های کسب‌شده در WebCoder، هر یک برگرفته از فعالیت ارزیابی‌شده: مشارکت رسمی در مسابقات ریت‌دار، نقاط عطف ریتینگ و نقاط عطف مسئله‌های حل‌شده. هر گواهی‌نامه را می‌توان با شماره سری آن به‌صورت عمومی راستی‌آزمایی کرد.", + "certificates_empty": "هنوز گواهی‌نامه‌ای نیست — یک مسابقه ریت‌دار را رسماً به پایان برسانید یا ۱۰ مسئله اول خود را حل کنید.", + "certificates_load_error": "بارگذاری گواهی‌نامه‌ها ناموفق بود.", + "certificates_kind_CONTEST_PARTICIPATION": "مشارکت در مسابقه", + "certificates_kind_RATING_MILESTONE": "نقطه عطف ریتینگ", + "certificates_kind_SOLVE_MILESTONE": "نقطه عطف مسئله‌های حل‌شده", + "certificates_valid_badge": "معتبر", + "certificates_revoked_badge": "ابطال‌شده", + "certificates_view_verify": "مشاهده / راستی‌آزمایی", + "certificates_rank_label": "رتبه نهایی", + "certificates_rating_label": "ریتینگ پس از مسابقه", + "certificates_solved_label": "مسئله‌های حل‌شده", + "certificates_issued_label": "تاریخ صدور", + "certificates_serial_label": "شماره سری", + "certificates_holder_label": "اعطا شده به", + "certificates_verify_title": "راستی‌آزمایی گواهی‌نامه", + "certificates_verify_valid": "این گواهی‌نامه معتبر است.", + "certificates_verify_revoked": "این گواهی‌نامه ابطال شده است.", + "certificates_verify_revoked_reason_label": "دلیل", + "certificates_verify_note": "هر کسی می‌تواند این گواهی‌نامه را در این نشانی راستی‌آزمایی کند:", + "certificates_print_button": "چاپ / ذخیره به‌صورت PDF", + "certificates_not_found_title": "گواهی‌نامه یافت نشد", + "certificates_not_found_body": "این پیوند راستی‌آزمایی با هیچ گواهی‌نامه‌ای مطابقت ندارد. شماره سری یا پیوند را بررسی کنید و دوباره تلاش کنید.", + "notification_certificate_title": "گواهی‌نامه صادر شد", + "notification_certificate_body": "یک گواهی‌نامه جدید ({{serial}}) در صفحه گواهی‌نامه‌های شما منتظر است.", + "nav_certificates": "گواهی‌نامه‌ها" } diff --git a/public/locales/fr.json b/public/locales/fr.json index 783bde398..d406d73a5 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archive des olympiades", "archive_year_label": "Année de l'édition", "archive_grade_label": "Groupe de niveau", - "archive_all_groups": "Tous les groupes" + "archive_all_groups": "Tous les groupes", + "certificates_title": "Mes certificats", + "certificates_intro": "Des certificats obtenus sur WebCoder, chacun dérivé d'une activité jugée : participations officielles à des concours notés, paliers de rating et paliers de problèmes résolus. Chaque certificat est vérifiable publiquement grâce à son numéro de série.", + "certificates_empty": "Aucun certificat pour l'instant — terminez officiellement un concours noté ou résolvez vos 10 premiers problèmes.", + "certificates_load_error": "Échec du chargement des certificats.", + "certificates_kind_CONTEST_PARTICIPATION": "Participation à un concours", + "certificates_kind_RATING_MILESTONE": "Palier de rating", + "certificates_kind_SOLVE_MILESTONE": "Palier de problèmes résolus", + "certificates_valid_badge": "Valide", + "certificates_revoked_badge": "Révoqué", + "certificates_view_verify": "Voir / vérifier", + "certificates_rank_label": "Rang final", + "certificates_rating_label": "Rating après le concours", + "certificates_solved_label": "Problèmes résolus", + "certificates_issued_label": "Délivré le", + "certificates_serial_label": "Numéro de série", + "certificates_holder_label": "Délivré à", + "certificates_verify_title": "Vérification de certificat", + "certificates_verify_valid": "Ce certificat est valide.", + "certificates_verify_revoked": "Ce certificat a été révoqué.", + "certificates_verify_revoked_reason_label": "Motif", + "certificates_verify_note": "N'importe qui peut vérifier ce certificat à l'adresse", + "certificates_print_button": "Imprimer / enregistrer en PDF", + "certificates_not_found_title": "Certificat introuvable", + "certificates_not_found_body": "Ce lien de vérification ne correspond à aucun certificat. Vérifiez le numéro de série ou le lien, puis réessayez.", + "notification_certificate_title": "Certificat délivré", + "notification_certificate_body": "Un nouveau certificat ({{serial}}) vous attend sur votre page de certificats.", + "nav_certificates": "Certificats" } diff --git a/public/locales/hi.json b/public/locales/hi.json index be3bb9a99..9dc9a7b1c 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "ओलंपियाड संग्रह", "archive_year_label": "संस्करण वर्ष", "archive_grade_label": "कक्षा समूह", - "archive_all_groups": "सभी समूह" + "archive_all_groups": "सभी समूह", + "certificates_title": "मेरे प्रमाणपत्र", + "certificates_intro": "WebCoder पर अर्जित प्रमाणपत्र, प्रत्येक निर्णायित गतिविधि से व्युत्पन्न: रेटेड प्रतियोगिताओं में आधिकारिक भागीदारी, रेटिंग मील के पत्थर और हल किए गए प्रॉब्लम मील के पत्थर। प्रत्येक प्रमाणपत्र सीरियल नंबर से सार्वजनिक रूप से सत्यापित किया जा सकता है।", + "certificates_empty": "अभी कोई प्रमाणपत्र नहीं — किसी रेटेड प्रतियोगिता को आधिकारिक रूप से पूरा करें या अपनी पहली 10 प्रॉब्लम हल करें।", + "certificates_load_error": "प्रमाणपत्र लोड करने में विफल।", + "certificates_kind_CONTEST_PARTICIPATION": "प्रतियोगिता भागीदारी", + "certificates_kind_RATING_MILESTONE": "रेटिंग मील का पत्थर", + "certificates_kind_SOLVE_MILESTONE": "हल किए गए प्रॉब्लम मील का पत्थर", + "certificates_valid_badge": "वैध", + "certificates_revoked_badge": "निरसित", + "certificates_view_verify": "देखें / सत्यापित करें", + "certificates_rank_label": "अंतिम स्थान", + "certificates_rating_label": "प्रतियोगिता के बाद रेटिंग", + "certificates_solved_label": "हल किए गए प्रॉब्लम", + "certificates_issued_label": "जारी", + "certificates_serial_label": "सीरियल नंबर", + "certificates_holder_label": "प्रदान किया गया", + "certificates_verify_title": "प्रमाणपत्र सत्यापन", + "certificates_verify_valid": "यह प्रमाणपत्र वैध है।", + "certificates_verify_revoked": "यह प्रमाणपत्र निरसित कर दिया गया है।", + "certificates_verify_revoked_reason_label": "कारण", + "certificates_verify_note": "कोई भी इस प्रमाणपत्र को इस पते पर सत्यापित कर सकता है:", + "certificates_print_button": "प्रिंट / PDF के रूप में सहेजें", + "certificates_not_found_title": "प्रमाणपत्र नहीं मिला", + "certificates_not_found_body": "यह सत्यापन लिंक किसी प्रमाणपत्र से मेल नहीं खाता। सीरियल नंबर या लिंक जाँचें और फिर प्रयास करें।", + "notification_certificate_title": "प्रमाणपत्र जारी हुआ", + "notification_certificate_body": "एक नया प्रमाणपत्र ({{serial}}) आपके प्रमाणपत्र पृष्ठ पर प्रतीक्षा कर रहा है।", + "nav_certificates": "प्रमाणपत्र" } diff --git a/public/locales/hr.json b/public/locales/hr.json index aad5eecc7..ff35554da 100644 --- a/public/locales/hr.json +++ b/public/locales/hr.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arhiva olimpijada", "archive_year_label": "Godina izdanja", "archive_grade_label": "Skupina razreda", - "archive_all_groups": "Sve skupine" + "archive_all_groups": "Sve skupine", + "certificates_title": "Moji certifikati", + "certificates_intro": "Certifikati stečeni na WebCoderu, svaki izveden iz ocijenjene aktivnosti: službena sudjelovanja na bodovnim natjecanjima, prekretnice ratinga i prekretnice riješenih zadataka. Svaki je certifikat javno provjerljiv po serijskom broju.", + "certificates_empty": "Još nema certifikata — službeno završi bodovno natjecanje ili riješi svojih prvih 10 zadataka.", + "certificates_load_error": "Učitavanje certifikata nije uspjelo.", + "certificates_kind_CONTEST_PARTICIPATION": "Sudjelovanje u natjecanju", + "certificates_kind_RATING_MILESTONE": "Prekretnica ratinga", + "certificates_kind_SOLVE_MILESTONE": "Prekretnica riješenih zadataka", + "certificates_valid_badge": "Valjan", + "certificates_revoked_badge": "Opozvan", + "certificates_view_verify": "Pogledaj / provjeri", + "certificates_rank_label": "Konačni plasman", + "certificates_rating_label": "Rating nakon natjecanja", + "certificates_solved_label": "Riješeni zadaci", + "certificates_issued_label": "Izdano", + "certificates_serial_label": "Serijski broj", + "certificates_holder_label": "Dodijeljeno", + "certificates_verify_title": "Provjera certifikata", + "certificates_verify_valid": "Ovaj certifikat je valjan.", + "certificates_verify_revoked": "Ovaj certifikat je opozvan.", + "certificates_verify_revoked_reason_label": "Razlog", + "certificates_verify_note": "Svatko može provjeriti ovaj certifikat na", + "certificates_print_button": "Ispiši / spremi kao PDF", + "certificates_not_found_title": "Certifikat nije pronađen", + "certificates_not_found_body": "Ova poveznica za provjeru ne odgovara nijednom certifikatu. Provjeri serijski broj ili poveznicu i pokušaj ponovno.", + "notification_certificate_title": "Certifikat izdan", + "notification_certificate_body": "Novi certifikat ({{serial}}) čeka te na tvojoj stranici certifikata.", + "nav_certificates": "Certifikati" } diff --git a/public/locales/hu.json b/public/locales/hu.json index 53d04acfc..ebef77135 100644 --- a/public/locales/hu.json +++ b/public/locales/hu.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olimpiai archívum", "archive_year_label": "Kiadás éve", "archive_grade_label": "Évfolyamcsoport", - "archive_all_groups": "Minden csoport" + "archive_all_groups": "Minden csoport", + "certificates_title": "Tanúsítványaim", + "certificates_intro": "A WebCoderön szerzett tanúsítványok, mindegyik értékelt tevékenységből származik: hivatalos részvétel értékelt versenyeken, rating-mérföldkövek és megoldott feladatok mérföldkövei. Minden tanúsítvány a sorozatszámával nyilvánosan ellenőrizhető.", + "certificates_empty": "Még nincs tanúsítvány — fejezz be hivatalosan egy értékelt versenyt, vagy oldj meg első 10 feladatod.", + "certificates_load_error": "A tanúsítványok betöltése nem sikerült.", + "certificates_kind_CONTEST_PARTICIPATION": "Verseny-részvétel", + "certificates_kind_RATING_MILESTONE": "Rating-mérföldkő", + "certificates_kind_SOLVE_MILESTONE": "Megoldott feladatok mérföldköve", + "certificates_valid_badge": "Érvényes", + "certificates_revoked_badge": "Visszavonva", + "certificates_view_verify": "Megtekintés / ellenőrzés", + "certificates_rank_label": "Végső helyezés", + "certificates_rating_label": "Rating a verseny után", + "certificates_solved_label": "Megoldott feladatok", + "certificates_issued_label": "Kibocsátva", + "certificates_serial_label": "Sorozatszám", + "certificates_holder_label": "Tulajdonos", + "certificates_verify_title": "Tanúsítvány-ellenőrzés", + "certificates_verify_valid": "Ez a tanúsítvány érvényes.", + "certificates_verify_revoked": "Ez a tanúsítvány visszavonva.", + "certificates_verify_revoked_reason_label": "Indok", + "certificates_verify_note": "Ez a tanúsítvány bárki által ellenőrizhető itt:", + "certificates_print_button": "Nyomtatás / mentés PDF-ként", + "certificates_not_found_title": "A tanúsítvány nem található", + "certificates_not_found_body": "Ez az ellenőrző hivatkozás egyik tanúsítványhoz sem illik. Ellenőrizd a sorozatszámot vagy a hivatkozást, és próbáld újra.", + "notification_certificate_title": "Tanúsítvány kiadva", + "notification_certificate_body": "Egy új tanúsítvány ({{serial}}) vár a tanúsítványoldaladon.", + "nav_certificates": "Tanúsítványok" } diff --git a/public/locales/id.json b/public/locales/id.json index 3db598a3d..1dba043e5 100644 --- a/public/locales/id.json +++ b/public/locales/id.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arsip olimpiade", "archive_year_label": "Tahun penyelenggaraan", "archive_grade_label": "Kelompok kelas", - "archive_all_groups": "Semua kelompok" + "archive_all_groups": "Semua kelompok", + "certificates_title": "Sertifikat saya", + "certificates_intro": "Sertifikat yang diperoleh di WebCoder, masing-masing diturunkan dari aktivitas yang dinilai: partisipasi resmi dalam kontes berating, tonggak rating, dan tonggak masalah yang diselesaikan. Setiap sertifikat dapat diverifikasi secara publik melalui nomor serinya.", + "certificates_empty": "Belum ada sertifikat — selesaikan secara resmi sebuah kontes berating atau selesaikan 10 masalah pertamamu.", + "certificates_load_error": "Gagal memuat sertifikat.", + "certificates_kind_CONTEST_PARTICIPATION": "Partisipasi kontes", + "certificates_kind_RATING_MILESTONE": "Tonggak rating", + "certificates_kind_SOLVE_MILESTONE": "Tonggak masalah terpecahkan", + "certificates_valid_badge": "Valid", + "certificates_revoked_badge": "Dicabut", + "certificates_view_verify": "Lihat / verifikasi", + "certificates_rank_label": "Peringkat akhir", + "certificates_rating_label": "Rating setelah kontes", + "certificates_solved_label": "Masalah terpecahkan", + "certificates_issued_label": "Diterbitkan", + "certificates_serial_label": "Nomor seri", + "certificates_holder_label": "Diberikan kepada", + "certificates_verify_title": "Verifikasi sertifikat", + "certificates_verify_valid": "Sertifikat ini valid.", + "certificates_verify_revoked": "Sertifikat ini telah dicabut.", + "certificates_verify_revoked_reason_label": "Alasan", + "certificates_verify_note": "Siapa pun dapat memverifikasi sertifikat ini di", + "certificates_print_button": "Cetak / simpan sebagai PDF", + "certificates_not_found_title": "Sertifikat tidak ditemukan", + "certificates_not_found_body": "Tautan verifikasi ini tidak cocok dengan sertifikat apa pun. Periksa nomor seri atau tautannya lalu coba lagi.", + "notification_certificate_title": "Sertifikat diterbitkan", + "notification_certificate_body": "Sertifikat baru ({{serial}}) menunggumu di halaman sertifikatmu.", + "nav_certificates": "Sertifikat" } diff --git a/public/locales/it.json b/public/locales/it.json index 9ca65d0cb..2431c5fef 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archivio delle olimpiadi", "archive_year_label": "Anno dell'edizione", "archive_grade_label": "Gruppo di classe", - "archive_all_groups": "Tutti i gruppi" + "archive_all_groups": "Tutti i gruppi", + "certificates_title": "I miei certificati", + "certificates_intro": "Certificati conseguiti su WebCoder, ciascuno derivato da attività valutata: partecipazioni ufficiali a gare con rating, traguardi di rating e traguardi di problemi risolti. Ogni certificato è verificabile pubblicamente tramite il numero di serie.", + "certificates_empty": "Ancora nessun certificato — concludi ufficialmente una gara con rating o risolvi i tuoi primi 10 problemi.", + "certificates_load_error": "Impossibile caricare i certificati.", + "certificates_kind_CONTEST_PARTICIPATION": "Partecipazione a una gara", + "certificates_kind_RATING_MILESTONE": "Traguardo di rating", + "certificates_kind_SOLVE_MILESTONE": "Traguardo di problemi risolti", + "certificates_valid_badge": "Valido", + "certificates_revoked_badge": "Revocato", + "certificates_view_verify": "Vedi / verifica", + "certificates_rank_label": "Posizione finale", + "certificates_rating_label": "Rating dopo la gara", + "certificates_solved_label": "Problemi risolti", + "certificates_issued_label": "Rilasciato", + "certificates_serial_label": "Matricola", + "certificates_holder_label": "Assegnato a", + "certificates_verify_title": "Verifica del certificato", + "certificates_verify_valid": "Questo certificato è valido.", + "certificates_verify_revoked": "Questo certificato è stato revocato.", + "certificates_verify_revoked_reason_label": "Motivo", + "certificates_verify_note": "Chiunque può verificare questo certificato su", + "certificates_print_button": "Stampa / salva come PDF", + "certificates_not_found_title": "Certificato non trovato", + "certificates_not_found_body": "Questo collegamento di verifica non corrisponde ad alcun certificato. Controlla la matricola o il collegamento e riprova.", + "notification_certificate_title": "Certificato rilasciato", + "notification_certificate_body": "Un nuovo certificato ({{serial}}) ti aspetta nella tua pagina dei certificati.", + "nav_certificates": "Certificati" } diff --git a/public/locales/ja.json b/public/locales/ja.json index 206ffb62d..274e53384 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "オリンピアードアーカイブ", "archive_year_label": "開催年", "archive_grade_label": "学年グループ", - "archive_all_groups": "すべてのグループ" + "archive_all_groups": "すべてのグループ", + "certificates_title": "マイ証明書", + "certificates_intro": "WebCoder で獲得した証明書。いずれも採点済みの活動(レーテッドコンテストの公式参加、レーティングマイルストーン、問題解答マイルストーン)に基づいています。各証明書はシリアル番号で公開検証できます。", + "certificates_empty": "まだ証明書はありません。レーテッドコンテストを公式に完走するか、最初の 10 問を解いてみましょう。", + "certificates_load_error": "証明書の読み込みに失敗しました。", + "certificates_kind_CONTEST_PARTICIPATION": "コンテスト参加", + "certificates_kind_RATING_MILESTONE": "レーティングマイルストーン", + "certificates_kind_SOLVE_MILESTONE": "解答マイルストーン", + "certificates_valid_badge": "有効", + "certificates_revoked_badge": "失効", + "certificates_view_verify": "表示 / 検証", + "certificates_rank_label": "最終順位", + "certificates_rating_label": "コンテスト後のレーティング", + "certificates_solved_label": "解答済み問題数", + "certificates_issued_label": "発行日", + "certificates_serial_label": "シリアル番号", + "certificates_holder_label": "授与者", + "certificates_verify_title": "証明書の検証", + "certificates_verify_valid": "この証明書は有効です。", + "certificates_verify_revoked": "この証明書は失効しています。", + "certificates_verify_revoked_reason_label": "理由", + "certificates_verify_note": "この証明書は誰でも次の URL で検証できます:", + "certificates_print_button": "印刷 / PDF として保存", + "certificates_not_found_title": "証明書が見つかりません", + "certificates_not_found_body": "この検証リンクはどの証明書にも一致しません。シリアル番号かリンクを確認して、もう一度お試しください。", + "notification_certificate_title": "証明書が発行されました", + "notification_certificate_body": "新しい証明書({{serial}})が証明書ページで待っています。", + "nav_certificates": "証明書" } diff --git a/public/locales/kk.json b/public/locales/kk.json index 26ab397d9..bcde4862e 100644 --- a/public/locales/kk.json +++ b/public/locales/kk.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Олимпиада мұрағаты", "archive_year_label": "Өткізілген жылы", "archive_grade_label": "Сынып тобы", - "archive_all_groups": "Барлық топтар" + "archive_all_groups": "Барлық топтар", + "certificates_title": "Менің сертификаттарым", + "certificates_intro": "WebCoder-де алынған сертификаттар, әрқайсысы бағаланған әрекеттен алынған: рейтингілі жарыстарға ресми қатысу, рейтинг шектері және шешілген есептер шектері. Әр сертификат сериялық нөмірі бойынша жария түрде тексеруге болады.", + "certificates_empty": "Әзірге сертификат жоқ — рейтингілі жарысты ресми аяқтаңыз немесе алғашқы 10 есебін шешіңіз.", + "certificates_load_error": "Сертификаттарды жүктеу сәтсіз аяқталды.", + "certificates_kind_CONTEST_PARTICIPATION": "Жарысқа қатысу", + "certificates_kind_RATING_MILESTONE": "Рейтинг шегі", + "certificates_kind_SOLVE_MILESTONE": "Шешілген есептер шегі", + "certificates_valid_badge": "Жарамды", + "certificates_revoked_badge": "Қайтарылды", + "certificates_view_verify": "Көру / тексеру", + "certificates_rank_label": "Қорытынды орын", + "certificates_rating_label": "Жарыстан кейінгі рейтинг", + "certificates_solved_label": "Шешілген есептер", + "certificates_issued_label": "Шығарылды", + "certificates_serial_label": "Сериялық нөмір", + "certificates_holder_label": "Табысталды", + "certificates_verify_title": "Сертификатты тексеру", + "certificates_verify_valid": "Бұл сертификат жарамды.", + "certificates_verify_revoked": "Бұл сертификат қайтарылды.", + "certificates_verify_revoked_reason_label": "Себеп", + "certificates_verify_note": "Бұл сертификатты кез келген адам мына мекенжайда тексере алады:", + "certificates_print_button": "Басып шығару / PDF ретінде сақтау", + "certificates_not_found_title": "Сертификат табылмады", + "certificates_not_found_body": "Бұл тексеру сілтемесі ешбір сертификатқа сәйкес келмейді. Сериялық нөмірді немесе сілтемені тексеріп, қайта байқап көріңіз.", + "notification_certificate_title": "Сертификат шығарылды", + "notification_certificate_body": "Жаңа сертификат ({{serial}}) сертификаттар парағында сені күтіп тұр.", + "nav_certificates": "Сертификаттар" } diff --git a/public/locales/ko.json b/public/locales/ko.json index fdbebc43c..0641addc7 100644 --- a/public/locales/ko.json +++ b/public/locales/ko.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "올림피아드 아카이브", "archive_year_label": "개최 연도", "archive_grade_label": "학년 그룹", - "archive_all_groups": "모든 그룹" + "archive_all_groups": "모든 그룹", + "certificates_title": "내 인증서", + "certificates_intro": "WebCoder에서 획득한 인증서입니다. 각 인증서는 평가된 활동(공식 레이팅 대회 참가, 레이팅 마일스톤, 문제 해결 마일스톤)에서 도출됩니다. 모든 인증서는 일련번호로 공개 검증할 수 있습니다.", + "certificates_empty": "아직 인증서가 없습니다. 공식 레이팅 대회를 완주하거나 첫 10문제를 해결해 보세요.", + "certificates_load_error": "인증서를 불러오지 못했습니다.", + "certificates_kind_CONTEST_PARTICIPATION": "대회 참가", + "certificates_kind_RATING_MILESTONE": "레이팅 마일스톤", + "certificates_kind_SOLVE_MILESTONE": "문제 해결 마일스톤", + "certificates_valid_badge": "유효", + "certificates_revoked_badge": "취소됨", + "certificates_view_verify": "보기 / 검증", + "certificates_rank_label": "최종 순위", + "certificates_rating_label": "대회 후 레이팅", + "certificates_solved_label": "해결한 문제 수", + "certificates_issued_label": "발급일", + "certificates_serial_label": "일련번호", + "certificates_holder_label": "수여자", + "certificates_verify_title": "인증서 검증", + "certificates_verify_valid": "이 인증서는 유효합니다.", + "certificates_verify_revoked": "이 인증서는 취소되었습니다.", + "certificates_verify_revoked_reason_label": "사유", + "certificates_verify_note": "누구나 다음 주소에서 이 인증서를 검증할 수 있습니다:", + "certificates_print_button": "인쇄 / PDF로 저장", + "certificates_not_found_title": "인증서를 찾을 수 없습니다", + "certificates_not_found_body": "이 검증 링크는 어떤 인증서와도 일치하지 않습니다. 일련번호나 링크를 확인하고 다시 시도하세요.", + "notification_certificate_title": "인증서가 발급되었습니다", + "notification_certificate_body": "새 인증서({{serial}})가 인증서 페이지에서 기다리고 있습니다.", + "nav_certificates": "인증서" } diff --git a/public/locales/pl.json b/public/locales/pl.json index 2c6c7dda4..42b868c6e 100644 --- a/public/locales/pl.json +++ b/public/locales/pl.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archiwum olimpiad", "archive_year_label": "Rocznik", "archive_grade_label": "Grupa klas", - "archive_all_groups": "Wszystkie grupy" + "archive_all_groups": "Wszystkie grupy", + "certificates_title": "Moje certyfikaty", + "certificates_intro": "Certyfikaty zdobyte na WebCoderze, każdy uzyskany z ocenianej aktywności: oficjalne udziały w notowanych konkursach, progi ratingowe i progi rozwiązanych zadań. Każdy certyfikat można publicznie zweryfikować po numerze seryjnym.", + "certificates_empty": "Brak certyfikatów — zakończ oficjalnie notowany konkurs albo rozwiąż swoje pierwsze 10 zadań.", + "certificates_load_error": "Nie udało się wczytać certyfikatów.", + "certificates_kind_CONTEST_PARTICIPATION": "Udział w konkursie", + "certificates_kind_RATING_MILESTONE": "Próg ratingowy", + "certificates_kind_SOLVE_MILESTONE": "Próg rozwiązanych zadań", + "certificates_valid_badge": "Ważny", + "certificates_revoked_badge": "Unieważniony", + "certificates_view_verify": "Zobacz / zweryfikuj", + "certificates_rank_label": "Miejsce końcowe", + "certificates_rating_label": "Rating po konkursie", + "certificates_solved_label": "Rozwiązane zadania", + "certificates_issued_label": "Wydano", + "certificates_serial_label": "Numer seryjny", + "certificates_holder_label": "Przyznany dla", + "certificates_verify_title": "Weryfikacja certyfikatu", + "certificates_verify_valid": "Ten certyfikat jest ważny.", + "certificates_verify_revoked": "Ten certyfikat został unieważniony.", + "certificates_verify_revoked_reason_label": "Powód", + "certificates_verify_note": "Każdy może zweryfikować ten certyfikat pod adresem", + "certificates_print_button": "Drukuj / zapisz jako PDF", + "certificates_not_found_title": "Nie znaleziono certyfikatu", + "certificates_not_found_body": "Ten link weryfikacyjny nie pasuje do żadnego certyfikatu. Sprawdź numer seryjny lub link i spróbuj ponownie.", + "notification_certificate_title": "Wydano certyfikat", + "notification_certificate_body": "Nowy certyfikat ({{serial}}) czeka na Twojej stronie certyfikatów.", + "nav_certificates": "Certyfikaty" } diff --git a/public/locales/pt.json b/public/locales/pt.json index fd862e9b7..77a5c4967 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arquivo de olimpíadas", "archive_year_label": "Ano da edição", "archive_grade_label": "Grupo de série", - "archive_all_groups": "Todos os grupos" + "archive_all_groups": "Todos os grupos", + "certificates_title": "Meus certificados", + "certificates_intro": "Certificados obtidos no WebCoder, cada um derivado de atividade avaliada: participações oficiais em concursos pontuados, marcos de rating e marcos de problemas resolvidos. Cada certificado pode ser verificado publicamente pelo número de série.", + "certificates_empty": "Ainda sem certificados — conclua oficialmente um concurso pontuado ou resolva seus primeiros 10 problemas.", + "certificates_load_error": "Falha ao carregar os certificados.", + "certificates_kind_CONTEST_PARTICIPATION": "Participação em concurso", + "certificates_kind_RATING_MILESTONE": "Marco de rating", + "certificates_kind_SOLVE_MILESTONE": "Marco de problemas resolvidos", + "certificates_valid_badge": "Válido", + "certificates_revoked_badge": "Revogado", + "certificates_view_verify": "Ver / verificar", + "certificates_rank_label": "Classificação final", + "certificates_rating_label": "Rating após o concurso", + "certificates_solved_label": "Problemas resolvidos", + "certificates_issued_label": "Emitido", + "certificates_serial_label": "Número de série", + "certificates_holder_label": "Atribuído a", + "certificates_verify_title": "Verificação de certificado", + "certificates_verify_valid": "Este certificado é válido.", + "certificates_verify_revoked": "Este certificado foi revogado.", + "certificates_verify_revoked_reason_label": "Motivo", + "certificates_verify_note": "Qualquer pessoa pode verificar este certificado em", + "certificates_print_button": "Imprimir / salvar como PDF", + "certificates_not_found_title": "Certificado não encontrado", + "certificates_not_found_body": "Este link de verificação não corresponde a nenhum certificado. Confira o número de série ou o link e tente novamente.", + "notification_certificate_title": "Certificado emitido", + "notification_certificate_body": "Um novo certificado ({{serial}}) espera por você na sua página de certificados.", + "nav_certificates": "Certificados" } diff --git a/public/locales/ro.json b/public/locales/ro.json index 1c46f6302..88ff7a267 100644 --- a/public/locales/ro.json +++ b/public/locales/ro.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arhiva olimpiadelor", "archive_year_label": "Anul ediției", "archive_grade_label": "Grupa de clase", - "archive_all_groups": "Toate grupele" + "archive_all_groups": "Toate grupele", + "certificates_title": "Atestatele mele", + "certificates_intro": "Atestate obținute pe WebCoder, fiecare derivat din activitate evaluată: participări oficiale la concursuri notate, praguri de rating și praguri de probleme rezolvate. Fiecare atestat poate fi verificat public după numărul de serie.", + "certificates_empty": "Încă niciun atestat — termină oficial un concurs notat sau rezolvă primele 10 probleme.", + "certificates_load_error": "Încărcarea atestatelor a eșuat.", + "certificates_kind_CONTEST_PARTICIPATION": "Participare la concurs", + "certificates_kind_RATING_MILESTONE": "Prag de rating", + "certificates_kind_SOLVE_MILESTONE": "Prag de probleme rezolvate", + "certificates_valid_badge": "Valid", + "certificates_revoked_badge": "Revocat", + "certificates_view_verify": "Vezi / verifică", + "certificates_rank_label": "Loc final", + "certificates_rating_label": "Rating după concurs", + "certificates_solved_label": "Probleme rezolvate", + "certificates_issued_label": "Eliberat", + "certificates_serial_label": "Serie", + "certificates_holder_label": "Acordat lui", + "certificates_verify_title": "Verificare atestat", + "certificates_verify_valid": "Acest atestat este valid.", + "certificates_verify_revoked": "Acest atestat a fost revocat.", + "certificates_verify_revoked_reason_label": "Motiv", + "certificates_verify_note": "Oricine poate verifica acest atestat la", + "certificates_print_button": "Tipărește / salvează ca PDF", + "certificates_not_found_title": "Atestat inexistent", + "certificates_not_found_body": "Această legătură de verificare nu corespunde niciunui atestat. Verifică seria sau legătura și încearcă din nou.", + "notification_certificate_title": "Atestat eliberat", + "notification_certificate_body": "Un nou atestat ({{serial}}) te așteaptă pe pagina atestatelor tale.", + "nav_certificates": "Atestate" } diff --git a/public/locales/ru.json b/public/locales/ru.json index 956344137..fa535ef79 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Архив олимпиад", "archive_year_label": "Год проведения", "archive_grade_label": "Группа классов", - "archive_all_groups": "Все группы" + "archive_all_groups": "Все группы", + "certificates_title": "Мои сертификаты", + "certificates_intro": "Сертификаты, полученные на WebCoder, каждый выведен из проверенной активности: официальные участия в рейтинговых contests, рубежи рейтинга и рубежи решённых задач. Каждый сертификат можно публично проверить по серийному номеру.", + "certificates_empty": "Сертификатов пока нет — официально завершите рейтинговый contest или решите первые 10 задач.", + "certificates_load_error": "Не удалось загрузить сертификаты.", + "certificates_kind_CONTEST_PARTICIPATION": "Участие в contest", + "certificates_kind_RATING_MILESTONE": "Рубеж рейтинга", + "certificates_kind_SOLVE_MILESTONE": "Рубеж решённых задач", + "certificates_valid_badge": "Действителен", + "certificates_revoked_badge": "Отозван", + "certificates_view_verify": "Открыть / проверить", + "certificates_rank_label": "Итоговое место", + "certificates_rating_label": "Рейтинг после contest", + "certificates_solved_label": "Решённые задачи", + "certificates_issued_label": "Выдан", + "certificates_serial_label": "Серийный номер", + "certificates_holder_label": "Вручён", + "certificates_verify_title": "Проверка сертификата", + "certificates_verify_valid": "Этот сертификат действителен.", + "certificates_verify_revoked": "Этот сертификат отозван.", + "certificates_verify_revoked_reason_label": "Причина", + "certificates_verify_note": "Проверить этот сертификат может любой по адресу", + "certificates_print_button": "Печать / сохранить в PDF", + "certificates_not_found_title": "Сертификат не найден", + "certificates_not_found_body": "Эта ссылка не соответствует ни одному сертификату. Проверьте серийный номер или ссылку и попробуйте снова.", + "notification_certificate_title": "Выдан сертификат", + "notification_certificate_body": "Новый сертификат ({{serial}}) ждёт вас на странице сертификатов.", + "nav_certificates": "Сертификаты" } diff --git a/public/locales/sr-Latn.json b/public/locales/sr-Latn.json index 3e47d84a7..32368c296 100644 --- a/public/locales/sr-Latn.json +++ b/public/locales/sr-Latn.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arhiva olimpijada", "archive_year_label": "Godina izdanja", "archive_grade_label": "Grupa razreda", - "archive_all_groups": "Sve grupe" + "archive_all_groups": "Sve grupe", + "certificates_title": "Moji sertifikati", + "certificates_intro": "Sertifikati stečeni na WebCoderu, svaki izveden iz ocenjene aktivnosti: zvanična učešća na rangiranim takmičenjima, prekretnice rejtinga i prekretnice rešenih zadataka. Svaki sertifikat je javno proverljiv po serijskom broju.", + "certificates_empty": "Još nema sertifikata — zvanično završi rangirano takmičenje ili reši svojih prvih 10 zadataka.", + "certificates_load_error": "Učitavanje sertifikata nije uspelo.", + "certificates_kind_CONTEST_PARTICIPATION": "Učešće na takmičenju", + "certificates_kind_RATING_MILESTONE": "Prekretnica rejtinga", + "certificates_kind_SOLVE_MILESTONE": "Prekretnica rešenih zadataka", + "certificates_valid_badge": "Važeći", + "certificates_revoked_badge": "Opozvan", + "certificates_view_verify": "Pogledaj / proveri", + "certificates_rank_label": "Konačno mesto", + "certificates_rating_label": "Rejting nakon takmičenja", + "certificates_solved_label": "Rešeni zadaci", + "certificates_issued_label": "Izdato", + "certificates_serial_label": "Serijski broj", + "certificates_holder_label": "Dodeljeno", + "certificates_verify_title": "Provera sertifikata", + "certificates_verify_valid": "Ovaj sertifikat je važeći.", + "certificates_verify_revoked": "Ovaj sertifikat je opozvan.", + "certificates_verify_revoked_reason_label": "Razlog", + "certificates_verify_note": "Svako može proveriti ovaj sertifikat na", + "certificates_print_button": "Štampaj / sačuvaj kao PDF", + "certificates_not_found_title": "Sertifikat nije pronađen", + "certificates_not_found_body": "Ovaj link za proveru ne odgovara nijednom sertifikatu. Proveri serijski broj ili link i pokušaj ponovo.", + "notification_certificate_title": "Sertifikat izdat", + "notification_certificate_body": "Novi sertifikat ({{serial}}) čeka te na tvojoj stranici sertifikata.", + "nav_certificates": "Sertifikati" } diff --git a/public/locales/th.json b/public/locales/th.json index 2043327b1..5f261c93a 100644 --- a/public/locales/th.json +++ b/public/locales/th.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "คลังข้อสอบโอลิมปิก", "archive_year_label": "ปีที่จัด", "archive_grade_label": "กลุ่มชั้นเรียน", - "archive_all_groups": "ทุกกลุ่ม" + "archive_all_groups": "ทุกกลุ่ม", + "certificates_title": "ใบรับรองของฉัน", + "certificates_intro": "ใบรับรองที่ได้รับบน WebCoder ซึ่งแต่ละใบได้จากกิจกรรมที่ถูกตัดสิน: การเข้าร่วมการแข่งขันแบบมีเรตติ้งอย่างเป็นทางการ ไมล์สโตนเรตติ้ง และไมล์สโตนจำนวนโจทย์ที่แก้ได้ ทุกใบรับรองตรวจสอบได้สาธารณะด้วยหมายเลขซีเรียล", + "certificates_empty": "ยังไม่มีใบรับรอง — จบการแข่งขันแบบมีเรตติ้งอย่างเป็นทางการ หรือแก้โจทย์ 10 ข้อแรกของคุณ", + "certificates_load_error": "โหลดใบรับรองไม่สำเร็จ", + "certificates_kind_CONTEST_PARTICIPATION": "การเข้าร่วมการแข่งขัน", + "certificates_kind_RATING_MILESTONE": "ไมล์สโตนเรตติ้ง", + "certificates_kind_SOLVE_MILESTONE": "ไมล์สโตนโจทย์ที่แก้ได้", + "certificates_valid_badge": "ใช้ได้", + "certificates_revoked_badge": "ถูกเพิกถอน", + "certificates_view_verify": "ดู / ตรวจสอบ", + "certificates_rank_label": "อันดับสุดท้าย", + "certificates_rating_label": "เรตติ้งหลังการแข่งขัน", + "certificates_solved_label": "โจทย์ที่แก้ได้", + "certificates_issued_label": "วันออกใบรับรอง", + "certificates_serial_label": "หมายเลขซีเรียล", + "certificates_holder_label": "มอบให้แก่", + "certificates_verify_title": "การตรวจสอบใบรับรอง", + "certificates_verify_valid": "ใบรับรองนี้ใช้ได้", + "certificates_verify_revoked": "ใบรับรองนี้ถูกเพิกถอนแล้ว", + "certificates_verify_revoked_reason_label": "เหตุผล", + "certificates_verify_note": "ทุกคนสามารถตรวจสอบใบรับรองนี้ได้ที่", + "certificates_print_button": "พิมพ์ / บันทึกเป็น PDF", + "certificates_not_found_title": "ไม่พบใบรับรอง", + "certificates_not_found_body": "ลิงก์ยืนยันนี้ไม่ตรงกับใบรับรองใด กรุณาตรวจสอบหมายเลขซีเรียลหรือลิงก์แล้วลองอีกครั้ง", + "notification_certificate_title": "ออกใบรับรองแล้ว", + "notification_certificate_body": "ใบรับรองใหม่ ({{serial}}) กำลังรอคุณอยู่ที่หน้าใบรับรองของคุณ", + "nav_certificates": "ใบรับรอง" } diff --git a/public/locales/tr.json b/public/locales/tr.json index e2bd6a438..0d27577fa 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olimpiyat arşivi", "archive_year_label": "Yıl", "archive_grade_label": "Sınıf grubu", - "archive_all_groups": "Tüm gruplar" + "archive_all_groups": "Tüm gruplar", + "certificates_title": "Sertifikalarım", + "certificates_intro": "WebCoder'da kazanılan sertifikalar; her biri değerlendirilmiş etkinlikten türetilmiştir: resmî reytingli yarışma katılımları, reyting kilometre taşları ve çözülen problems kilometre taşları. Her sertifika seri numarasıyla herkes tarafından doğrulanabilir.", + "certificates_empty": "Henüz sertifika yok — resmî olarak bir reytingli yarışma tamamlayın veya ilk 10 problemi çözün.", + "certificates_load_error": "Sertifikalar yüklenemedi.", + "certificates_kind_CONTEST_PARTICIPATION": "Yarışma katılımı", + "certificates_kind_RATING_MILESTONE": "Reyting kilometre taşı", + "certificates_kind_SOLVE_MILESTONE": "Çözülen problems kilometre taşı", + "certificates_valid_badge": "Geçerli", + "certificates_revoked_badge": "İptal edildi", + "certificates_view_verify": "Görüntüle / doğrula", + "certificates_rank_label": "Final sıralaması", + "certificates_rating_label": "Yarışma sonrası reyting", + "certificates_solved_label": "Çözülen problemler", + "certificates_issued_label": "Veriliş", + "certificates_serial_label": "Seri numarası", + "certificates_holder_label": "Verilen kişi", + "certificates_verify_title": "Sertifika doğrulaması", + "certificates_verify_valid": "Bu sertifika geçerlidir.", + "certificates_verify_revoked": "Bu sertifika iptal edildi.", + "certificates_verify_revoked_reason_label": "Gerekçe", + "certificates_verify_note": "Herkes bu sertifikayı şu adresten doğrulayabilir:", + "certificates_print_button": "Yazdır / PDF olarak kaydet", + "certificates_not_found_title": "Sertifika bulunamadı", + "certificates_not_found_body": "Bu doğrulama bağlantısı hiçbir sertifikayla eşleşmiyor. Seri numarasını veya bağlantıyı kontrol edip tekrar deneyin.", + "notification_certificate_title": "Sertifika verildi", + "notification_certificate_body": "Yeni bir sertifika ({{serial}}) sertifikalar sayfanda seni bekliyor.", + "nav_certificates": "Sertifikalar" } diff --git a/public/locales/uk.json b/public/locales/uk.json index a8d39e650..aaf7e0516 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Архів олімпіад", "archive_year_label": "Рік проведення", "archive_grade_label": "Група класів", - "archive_all_groups": "Усі групи" + "archive_all_groups": "Усі групи", + "certificates_title": "Мої сертифікати", + "certificates_intro": "Сертифікати, здобуті на WebCoder, кожен виведений з оціненої активності: офіційні участі в рейтингових змаганнях, рубежі рейтингу та рубежі розв'язаних задач. Кожен сертифікат можна публічно перевірити за серійним номером.", + "certificates_empty": "Сертифікатів ще немає — офіційно завершіть рейтингове змагання або розв'яжіть перші 10 задач.", + "certificates_load_error": "Не вдалося завантажити сертифікати.", + "certificates_kind_CONTEST_PARTICIPATION": "Участь у змаганні", + "certificates_kind_RATING_MILESTONE": "Рубіж рейтингу", + "certificates_kind_SOLVE_MILESTONE": "Рубіж розв'язаних задач", + "certificates_valid_badge": "Чинний", + "certificates_revoked_badge": "Анульований", + "certificates_view_verify": "Переглянути / перевірити", + "certificates_rank_label": "Підсумкове місце", + "certificates_rating_label": "Рейтинг після змагання", + "certificates_solved_label": "Розв'язані задачі", + "certificates_issued_label": "Видано", + "certificates_serial_label": "Серійний номер", + "certificates_holder_label": "Вручається", + "certificates_verify_title": "Перевірка сертифіката", + "certificates_verify_valid": "Цей сертифікат чинний.", + "certificates_verify_revoked": "Цей сертифікат анульовано.", + "certificates_verify_revoked_reason_label": "Причина", + "certificates_verify_note": "Перевірити цей сертифікат може будь-хто за адресою", + "certificates_print_button": "Друк / зберегти як PDF", + "certificates_not_found_title": "Сертифікат не знайдено", + "certificates_not_found_body": "Це посилання не відповідає жодному сертифікату. Перевірте серійний номер або посилання та спробуйте ще раз.", + "notification_certificate_title": "Видано сертифікат", + "notification_certificate_body": "Новий сертифікат ({{serial}}) чекає на вас на сторінці сертифікатів.", + "nav_certificates": "Сертифікати" } diff --git a/public/locales/vi.json b/public/locales/vi.json index 63d5b66b3..ff7478174 100644 --- a/public/locales/vi.json +++ b/public/locales/vi.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Kho đề Olympic", "archive_year_label": "Năm tổ chức", "archive_grade_label": "Nhóm lớp", - "archive_all_groups": "Tất cả các nhóm" + "archive_all_groups": "Tất cả các nhóm", + "certificates_title": "Chứng chỉ của tôi", + "certificates_intro": "Chứng chỉ đạt được trên WebCoder, mỗi chứng chỉ đều bắt nguồn từ hoạt động đã được chấm: tham gia chính thức các kỳ thi có rating, cột mốc rating và cột mốc số bài đã giải. Mỗi chứng chỉ đều có thể xác minh công khai qua số sê-ri.", + "certificates_empty": "Chưa có chứng chỉ nào — hãy hoàn tất chính thức một kỳ thi có rating hoặc giải 10 bài đầu tiên của bạn.", + "certificates_load_error": "Không thể tải chứng chỉ.", + "certificates_kind_CONTEST_PARTICIPATION": "Tham gia kỳ thi", + "certificates_kind_RATING_MILESTONE": "Cột mốc rating", + "certificates_kind_SOLVE_MILESTONE": "Cột mốc số bài đã giải", + "certificates_valid_badge": "Hợp lệ", + "certificates_revoked_badge": "Đã thu hồi", + "certificates_view_verify": "Xem / xác minh", + "certificates_rank_label": "Hạng cuối", + "certificates_rating_label": "Rating sau kỳ thi", + "certificates_solved_label": "Số bài đã giải", + "certificates_issued_label": "Cấp ngày", + "certificates_serial_label": "Số sê-ri", + "certificates_holder_label": "Trao cho", + "certificates_verify_title": "Xác minh chứng chỉ", + "certificates_verify_valid": "Chứng chỉ này hợp lệ.", + "certificates_verify_revoked": "Chứng chỉ này đã bị thu hồi.", + "certificates_verify_revoked_reason_label": "Lý do", + "certificates_verify_note": "Ai cũng có thể xác minh chứng chỉ này tại", + "certificates_print_button": "In / lưu dưới dạng PDF", + "certificates_not_found_title": "Không tìm thấy chứng chỉ", + "certificates_not_found_body": "Liên kết xác minh này không khớp với chứng chỉ nào. Hãy kiểm tra số sê-ri hoặc liên kết rồi thử lại.", + "notification_certificate_title": "Đã cấp chứng chỉ", + "notification_certificate_body": "Một chứng chỉ mới ({{serial}}) đang chờ bạn trên trang chứng chỉ của mình.", + "nav_certificates": "Chứng chỉ" } diff --git a/public/locales/zh-Hans.json b/public/locales/zh-Hans.json index 12c387785..fbc5fc48f 100644 --- a/public/locales/zh-Hans.json +++ b/public/locales/zh-Hans.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "奥赛题库", "archive_year_label": "届次年份", "archive_grade_label": "年级组", - "archive_all_groups": "全部组别" + "archive_all_groups": "全部组别", + "certificates_title": "我的证书", + "certificates_intro": "在 WebCoder 上获得的证书,每一项均来自经评测的真实活动:正式计分比赛的完赛、评分里程碑和解题里程碑。每张证书均可通过序列号公开验证。", + "certificates_empty": "还没有证书——正式完赛一场计分比赛,或解开前 10 道题。", + "certificates_load_error": "证书加载失败。", + "certificates_kind_CONTEST_PARTICIPATION": "比赛参与", + "certificates_kind_RATING_MILESTONE": "评分里程碑", + "certificates_kind_SOLVE_MILESTONE": "解题里程碑", + "certificates_valid_badge": "有效", + "certificates_revoked_badge": "已撤销", + "certificates_view_verify": "查看 / 验证", + "certificates_rank_label": "最终排名", + "certificates_rating_label": "赛后评分", + "certificates_solved_label": "已解决问题数", + "certificates_issued_label": "颁发于", + "certificates_serial_label": "序列号", + "certificates_holder_label": "授予", + "certificates_verify_title": "证书验证", + "certificates_verify_valid": "该证书有效。", + "certificates_verify_revoked": "该证书已被撤销。", + "certificates_verify_revoked_reason_label": "原因", + "certificates_verify_note": "任何人都可以在以下链接验证该证书:", + "certificates_print_button": "打印 / 保存为 PDF", + "certificates_not_found_title": "未找到证书", + "certificates_not_found_body": "该验证链接不匹配任何证书。请检查序列号或链接后重试。", + "notification_certificate_title": "证书已颁发", + "notification_certificate_body": "一张新证书({{serial}})正在你的证书页等你。", + "nav_certificates": "证书" } diff --git a/public/locales/zh-Hant.json b/public/locales/zh-Hant.json index ff6a61582..17ba583ea 100644 --- a/public/locales/zh-Hant.json +++ b/public/locales/zh-Hant.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "奧賽題庫", "archive_year_label": "屆次年份", "archive_grade_label": "年級組", - "archive_all_groups": "全部組別" + "archive_all_groups": "全部組別", + "certificates_title": "我的證書", + "certificates_intro": "在 WebCoder 上獲得的證書,每一項均來自經評測的真實活動:正式計分比賽的完賽、評分里程碑與解題里程碑。每張證書均可透過序號公開驗證。", + "certificates_empty": "還沒有證書——正式完賽一場計分比賽,或解開前 10 道題。", + "certificates_load_error": "證書載入失敗。", + "certificates_kind_CONTEST_PARTICIPATION": "比賽參與", + "certificates_kind_RATING_MILESTONE": "評分里程碑", + "certificates_kind_SOLVE_MILESTONE": "解題里程碑", + "certificates_valid_badge": "有效", + "certificates_revoked_badge": "已撤銷", + "certificates_view_verify": "檢視 / 驗證", + "certificates_rank_label": "最終排名", + "certificates_rating_label": "賽後評分", + "certificates_solved_label": "已解決問題數", + "certificates_issued_label": "頒發於", + "certificates_serial_label": "序號", + "certificates_holder_label": "授予", + "certificates_verify_title": "證書驗證", + "certificates_verify_valid": "該證書有效。", + "certificates_verify_revoked": "該證書已被撤銷。", + "certificates_verify_revoked_reason_label": "原因", + "certificates_verify_note": "任何人都可以在以下連結驗證該證書:", + "certificates_print_button": "列印 / 儲存為 PDF", + "certificates_not_found_title": "未找到證書", + "certificates_not_found_body": "該驗證連結不符合任何證書。請檢查序號或連結後重試。", + "notification_certificate_title": "證書已頒發", + "notification_certificate_body": "一張新證書({{serial}})正在你的證書頁等你。", + "nav_certificates": "證書" } diff --git a/scripts/add-certificate-keys.py b/scripts/add-certificate-keys.py new file mode 100644 index 000000000..d4750e091 --- /dev/null +++ b/scripts/add-certificate-keys.py @@ -0,0 +1,805 @@ +# One-shot: add the Wave-D certificates key family to every locale catalog. +# Translations are hand-written per language (no machine rot); placeholders +# like {{serial}} are preserved verbatim. +import json +import pathlib +import collections + +ROOT = pathlib.Path(__file__).resolve().parent.parent +LOCALES = ROOT / "src" / "locales" + +K = { + "certificates_title": { + "en": "My certificates", + "ro": "Atestatele mele", + "es": "Mis certificados", + "fr": "Mes certificats", + "de": "Meine Zertifikate", + "pt": "Meus certificados", + "it": "I miei certificati", + "ca": "Els meus certificats", + "pl": "Moje certyfikaty", + "hu": "Tanúsítványaim", + "hr": "Moji certifikati", + "sr-Latn": "Moji sertifikati", + "ru": "Мои сертификаты", + "uk": "Мої сертифікати", + "bg": "Моите сертификати", + "el": "Τα πιστοποιητικά μου", + "zh-Hans": "我的证书", + "zh-Hant": "我的證書", + "ja": "マイ証明書", + "ko": "내 인증서", + "hi": "मेरे प्रमाणपत्र", + "vi": "Chứng chỉ của tôi", + "th": "ใบรับรองของฉัน", + "id": "Sertifikat saya", + "tr": "Sertifikalarım", + "kk": "Менің сертификаттарым", + "ar": "شهاداتي", + "fa": "گواهی‌نامه‌های من", + }, + "certificates_intro": { + "en": "Certificates earned on WebCoder, each derived from judged activity: official rated-contest finishes, rating milestones and solved-problem milestones. Every certificate is publicly verifiable by its serial number.", + "ro": "Atestate obținute pe WebCoder, fiecare derivat din activitate evaluată: participări oficiale la concursuri notate, praguri de rating și praguri de probleme rezolvate. Fiecare atestat poate fi verificat public după numărul de serie.", + "es": "Certificados obtenidos en WebCoder, cada uno derivado de actividad evaluada: participaciones oficiales en concursos puntuados, hitos de rating e hitos de problemas resueltos. Cada certificado se puede verificar públicamente por su número de serie.", + "fr": "Des certificats obtenus sur WebCoder, chacun dérivé d'une activité jugée : participations officielles à des concours notés, paliers de rating et paliers de problèmes résolus. Chaque certificat est vérifiable publiquement grâce à son numéro de série.", + "de": "Auf WebCoder erworbene Zertifikate, jedes abgeleitet aus bewerteter Aktivität: offizielle Teilnahmen an gewerteten Wettbewerben, Rating-Meilensteine und Meilensteine gelöster Aufgaben. Jedes Zertifikat ist über seine Seriennummer öffentlich überprüfbar.", + "pt": "Certificados obtidos no WebCoder, cada um derivado de atividade avaliada: participações oficiais em concursos pontuados, marcos de rating e marcos de problemas resolvidos. Cada certificado pode ser verificado publicamente pelo número de série.", + "it": "Certificati conseguiti su WebCoder, ciascuno derivato da attività valutata: partecipazioni ufficiali a gare con rating, traguardi di rating e traguardi di problemi risolti. Ogni certificato è verificabile pubblicamente tramite il numero di serie.", + "ca": "Certificats obtinguts a WebCoder, cadascun derivat d'activitat avaluada: participacions oficials en concursos puntuats, fites de rating i fites de problemes resolts. Cada certificat es pot verificar públicament pel número de sèrie.", + "pl": "Certyfikaty zdobyte na WebCoderze, każdy uzyskany z ocenianej aktywności: oficjalne udziały w notowanych konkursach, progi ratingowe i progi rozwiązanych zadań. Każdy certyfikat można publicznie zweryfikować po numerze seryjnym.", + "hu": "A WebCoderön szerzett tanúsítványok, mindegyik értékelt tevékenységből származik: hivatalos részvétel értékelt versenyeken, rating-mérföldkövek és megoldott feladatok mérföldkövei. Minden tanúsítvány a sorozatszámával nyilvánosan ellenőrizhető.", + "hr": "Certifikati stečeni na WebCoderu, svaki izveden iz ocijenjene aktivnosti: službena sudjelovanja na bodovnim natjecanjima, prekretnice ratinga i prekretnice riješenih zadataka. Svaki je certifikat javno provjerljiv po serijskom broju.", + "sr-Latn": "Sertifikati stečeni na WebCoderu, svaki izveden iz ocenjene aktivnosti: zvanična učešća na rangiranim takmičenjima, prekretnice rejtinga i prekretnice rešenih zadataka. Svaki sertifikat je javno proverljiv po serijskom broju.", + "ru": "Сертификаты, полученные на WebCoder, каждый выведен из проверенной активности: официальные участия в рейтинговых contests, рубежи рейтинга и рубежи решённых задач. Каждый сертификат можно публично проверить по серийному номеру.", + "uk": "Сертифікати, здобуті на WebCoder, кожен виведений з оціненої активності: офіційні участі в рейтингових змаганнях, рубежі рейтингу та рубежі розв'язаних задач. Кожен сертифікат можна публічно перевірити за серійним номером.", + "bg": "Сертификати, спечелени в WebCoder, всеки изведен от оценена дейност: официални участия в рейтингови състезания, рейтингови етапи и етапи решени задачи. Всеки сертификат може да бъде проверен публично по серийния си номер.", + "el": "Πιστοποιητικά που αποκτήθηκαν στο WebCoder, καθένα προερχόμενο από βαθμολογημένη δραστηριότητα: επίσημες συμμετοχές σε βαθμολογημένους διαγωνισμούς, όρια rating και όρια λυμένων προβλημάτων. Κάθε πιστοποιητικό επαληθεύεται δημόσια με τον σειριακό του αριθμό.", + "zh-Hans": "在 WebCoder 上获得的证书,每一项均来自经评测的真实活动:正式计分比赛的完赛、评分里程碑和解题里程碑。每张证书均可通过序列号公开验证。", + "zh-Hant": "在 WebCoder 上獲得的證書,每一項均來自經評測的真實活動:正式計分比賽的完賽、評分里程碑與解題里程碑。每張證書均可透過序號公開驗證。", + "ja": "WebCoder で獲得した証明書。いずれも採点済みの活動(レーテッドコンテストの公式参加、レーティングマイルストーン、問題解答マイルストーン)に基づいています。各証明書はシリアル番号で公開検証できます。", + "ko": "WebCoder에서 획득한 인증서입니다. 각 인증서는 평가된 활동(공식 레이팅 대회 참가, 레이팅 마일스톤, 문제 해결 마일스톤)에서 도출됩니다. 모든 인증서는 일련번호로 공개 검증할 수 있습니다.", + "hi": "WebCoder पर अर्जित प्रमाणपत्र, प्रत्येक निर्णायित गतिविधि से व्युत्पन्न: रेटेड प्रतियोगिताओं में आधिकारिक भागीदारी, रेटिंग मील के पत्थर और हल किए गए प्रॉब्लम मील के पत्थर। प्रत्येक प्रमाणपत्र सीरियल नंबर से सार्वजनिक रूप से सत्यापित किया जा सकता है।", + "vi": "Chứng chỉ đạt được trên WebCoder, mỗi chứng chỉ đều bắt nguồn từ hoạt động đã được chấm: tham gia chính thức các kỳ thi có rating, cột mốc rating và cột mốc số bài đã giải. Mỗi chứng chỉ đều có thể xác minh công khai qua số sê-ri.", + "th": "ใบรับรองที่ได้รับบน WebCoder ซึ่งแต่ละใบได้จากกิจกรรมที่ถูกตัดสิน: การเข้าร่วมการแข่งขันแบบมีเรตติ้งอย่างเป็นทางการ ไมล์สโตนเรตติ้ง และไมล์สโตนจำนวนโจทย์ที่แก้ได้ ทุกใบรับรองตรวจสอบได้สาธารณะด้วยหมายเลขซีเรียล", + "id": "Sertifikat yang diperoleh di WebCoder, masing-masing diturunkan dari aktivitas yang dinilai: partisipasi resmi dalam kontes berating, tonggak rating, dan tonggak masalah yang diselesaikan. Setiap sertifikat dapat diverifikasi secara publik melalui nomor serinya.", + "tr": "WebCoder'da kazanılan sertifikalar; her biri değerlendirilmiş etkinlikten türetilmiştir: resmî reytingli yarışma katılımları, reyting kilometre taşları ve çözülen problems kilometre taşları. Her sertifika seri numarasıyla herkes tarafından doğrulanabilir.", + "kk": "WebCoder-де алынған сертификаттар, әрқайсысы бағаланған әрекеттен алынған: рейтингілі жарыстарға ресми қатысу, рейтинг шектері және шешілген есептер шектері. Әр сертификат сериялық нөмірі бойынша жария түрде тексеруге болады.", + "ar": "شهادات مُستمَدَّة من نشاط مُقيَّم على WebCoder: مشاركات رسمية في مسابقات مُصنَّفة، ومعالم تصنيف، ومعالم مسائل محلولة. يمكن التحقق من كل شهادة علنًا برقمها التسلسلي.", + "fa": "گواهی‌نامه‌های کسب‌شده در WebCoder، هر یک برگرفته از فعالیت ارزیابی‌شده: مشارکت رسمی در مسابقات ریت‌دار، نقاط عطف ریتینگ و نقاط عطف مسئله‌های حل‌شده. هر گواهی‌نامه را می‌توان با شماره سری آن به‌صورت عمومی راستی‌آزمایی کرد.", + }, + "certificates_empty": { + "en": "No certificates yet — officially finish a rated contest or solve your first 10 problems.", + "ro": "Încă niciun atestat — termină oficial un concurs notat sau rezolvă primele 10 probleme.", + "es": "Aún no hay certificados: termina oficialmente un concurso puntuado o resuelve tus primeros 10 problemas.", + "fr": "Aucun certificat pour l'instant — terminez officiellement un concours noté ou résolvez vos 10 premiers problèmes.", + "de": "Noch keine Zertifikate — beende offiziell einen gewerteten Wettbewerb oder löse deine ersten 10 Aufgaben.", + "pt": "Ainda sem certificados — conclua oficialmente um concurso pontuado ou resolva seus primeiros 10 problemas.", + "it": "Ancora nessun certificato — concludi ufficialmente una gara con rating o risolvi i tuoi primi 10 problemi.", + "ca": "Encara sense certificats: finalitza oficialment un concurs puntuat o resol els teus primers 10 problemes.", + "pl": "Brak certyfikatów — zakończ oficjalnie notowany konkurs albo rozwiąż swoje pierwsze 10 zadań.", + "hu": "Még nincs tanúsítvány — fejezz be hivatalosan egy értékelt versenyt, vagy oldj meg első 10 feladatod.", + "hr": "Još nema certifikata — službeno završi bodovno natjecanje ili riješi svojih prvih 10 zadataka.", + "sr-Latn": "Još nema sertifikata — zvanično završi rangirano takmičenje ili reši svojih prvih 10 zadataka.", + "ru": "Сертификатов пока нет — официально завершите рейтинговый contest или решите первые 10 задач.", + "uk": "Сертифікатів ще немає — офіційно завершіть рейтингове змагання або розв'яжіть перші 10 задач.", + "bg": "Все още няма сертификати — завършете официално рейтингово състезание или решете първите си 10 задачи.", + "el": "Δεν υπάρχουν ακόμη πιστοποιητικά — ολοκληρώστε επίσημα έναν βαθμολογημένο διαγωνισμό ή λύστε τα πρώτα σας 10 προβλήματα.", + "zh-Hans": "还没有证书——正式完赛一场计分比赛,或解开前 10 道题。", + "zh-Hant": "還沒有證書——正式完賽一場計分比賽,或解開前 10 道題。", + "ja": "まだ証明書はありません。レーテッドコンテストを公式に完走するか、最初の 10 問を解いてみましょう。", + "ko": "아직 인증서가 없습니다. 공식 레이팅 대회를 완주하거나 첫 10문제를 해결해 보세요.", + "hi": "अभी कोई प्रमाणपत्र नहीं — किसी रेटेड प्रतियोगिता को आधिकारिक रूप से पूरा करें या अपनी पहली 10 प्रॉब्लम हल करें।", + "vi": "Chưa có chứng chỉ nào — hãy hoàn tất chính thức một kỳ thi có rating hoặc giải 10 bài đầu tiên của bạn.", + "th": "ยังไม่มีใบรับรอง — จบการแข่งขันแบบมีเรตติ้งอย่างเป็นทางการ หรือแก้โจทย์ 10 ข้อแรกของคุณ", + "id": "Belum ada sertifikat — selesaikan secara resmi sebuah kontes berating atau selesaikan 10 masalah pertamamu.", + "tr": "Henüz sertifika yok — resmî olarak bir reytingli yarışma tamamlayın veya ilk 10 problemi çözün.", + "kk": "Әзірге сертификат жоқ — рейтингілі жарысты ресми аяқтаңыз немесе алғашқы 10 есебін шешіңіз.", + "ar": "لا شهادات بعد — أكمل رسميًا مسابقة مُصنَّفة أو حل أول ١٠ مسائل لك.", + "fa": "هنوز گواهی‌نامه‌ای نیست — یک مسابقه ریت‌دار را رسماً به پایان برسانید یا ۱۰ مسئله اول خود را حل کنید.", + }, + "certificates_load_error": { + "en": "Failed to load certificates.", + "ro": "Încărcarea atestatelor a eșuat.", + "es": "No se pudieron cargar los certificados.", + "fr": "Échec du chargement des certificats.", + "de": "Zertifikate konnten nicht geladen werden.", + "pt": "Falha ao carregar os certificados.", + "it": "Impossibile caricare i certificati.", + "ca": "No s'han pogut carregar els certificats.", + "pl": "Nie udało się wczytać certyfikatów.", + "hu": "A tanúsítványok betöltése nem sikerült.", + "hr": "Učitavanje certifikata nije uspjelo.", + "sr-Latn": "Učitavanje sertifikata nije uspelo.", + "ru": "Не удалось загрузить сертификаты.", + "uk": "Не вдалося завантажити сертифікати.", + "bg": "Сертификатите не можаха да се заредят.", + "el": "Αποτυχία φόρτωσης των πιστοποιητικών.", + "zh-Hans": "证书加载失败。", + "zh-Hant": "證書載入失敗。", + "ja": "証明書の読み込みに失敗しました。", + "ko": "인증서를 불러오지 못했습니다.", + "hi": "प्रमाणपत्र लोड करने में विफल।", + "vi": "Không thể tải chứng chỉ.", + "th": "โหลดใบรับรองไม่สำเร็จ", + "id": "Gagal memuat sertifikat.", + "tr": "Sertifikalar yüklenemedi.", + "kk": "Сертификаттарды жүктеу сәтсіз аяқталды.", + "ar": "تعذّر تحميل الشهادات.", + "fa": "بارگذاری گواهی‌نامه‌ها ناموفق بود.", + }, + "certificates_kind_CONTEST_PARTICIPATION": { + "en": "Contest participation", + "ro": "Participare la concurs", + "es": "Participación en concurso", + "fr": "Participation à un concours", + "de": "Wettbewerbs-Teilnahme", + "pt": "Participação em concurso", + "it": "Partecipazione a una gara", + "ca": "Participació en concurs", + "pl": "Udział w konkursie", + "hu": "Verseny-részvétel", + "hr": "Sudjelovanje u natjecanju", + "sr-Latn": "Učešće na takmičenju", + "ru": "Участие в contest", + "uk": "Участь у змаганні", + "bg": "Участие в състезание", + "el": "Συμμετοχή σε διαγωνισμό", + "zh-Hans": "比赛参与", + "zh-Hant": "比賽參與", + "ja": "コンテスト参加", + "ko": "대회 참가", + "hi": "प्रतियोगिता भागीदारी", + "vi": "Tham gia kỳ thi", + "th": "การเข้าร่วมการแข่งขัน", + "id": "Partisipasi kontes", + "tr": "Yarışma katılımı", + "kk": "Жарысқа қатысу", + "ar": "مشاركة في مسابقة", + "fa": "مشارکت در مسابقه", + }, + "certificates_kind_RATING_MILESTONE": { + "en": "Rating milestone", + "ro": "Prag de rating", + "es": "Hito de rating", + "fr": "Palier de rating", + "de": "Rating-Meilenstein", + "pt": "Marco de rating", + "it": "Traguardo di rating", + "ca": "Fita de rating", + "pl": "Próg ratingowy", + "hu": "Rating-mérföldkő", + "hr": "Prekretnica ratinga", + "sr-Latn": "Prekretnica rejtinga", + "ru": "Рубеж рейтинга", + "uk": "Рубіж рейтингу", + "bg": "Рейтингов етап", + "el": "Όριο rating", + "zh-Hans": "评分里程碑", + "zh-Hant": "評分里程碑", + "ja": "レーティングマイルストーン", + "ko": "레이팅 마일스톤", + "hi": "रेटिंग मील का पत्थर", + "vi": "Cột mốc rating", + "th": "ไมล์สโตนเรตติ้ง", + "id": "Tonggak rating", + "tr": "Reyting kilometre taşı", + "kk": "Рейтинг шегі", + "ar": "معلم تصنيف", + "fa": "نقطه عطف ریتینگ", + }, + "certificates_kind_SOLVE_MILESTONE": { + "en": "Solved-problems milestone", + "ro": "Prag de probleme rezolvate", + "es": "Hito de problemas resueltos", + "fr": "Palier de problèmes résolus", + "de": "Meilenstein gelöster Aufgaben", + "pt": "Marco de problemas resolvidos", + "it": "Traguardo di problemi risolti", + "ca": "Fita de problemes resolts", + "pl": "Próg rozwiązanych zadań", + "hu": "Megoldott feladatok mérföldköve", + "hr": "Prekretnica riješenih zadataka", + "sr-Latn": "Prekretnica rešenih zadataka", + "ru": "Рубеж решённых задач", + "uk": "Рубіж розв'язаних задач", + "bg": "Етап решени задачи", + "el": "Όριο λυμένων προβλημάτων", + "zh-Hans": "解题里程碑", + "zh-Hant": "解題里程碑", + "ja": "解答マイルストーン", + "ko": "문제 해결 마일스톤", + "hi": "हल किए गए प्रॉब्लम मील का पत्थर", + "vi": "Cột mốc số bài đã giải", + "th": "ไมล์สโตนโจทย์ที่แก้ได้", + "id": "Tonggak masalah terpecahkan", + "tr": "Çözülen problems kilometre taşı", + "kk": "Шешілген есептер шегі", + "ar": "معلم مسائل محلولة", + "fa": "نقطه عطف مسئله‌های حل‌شده", + }, + "certificates_valid_badge": { + "en": "Valid", + "ro": "Valid", + "es": "Válido", + "fr": "Valide", + "de": "Gültig", + "pt": "Válido", + "it": "Valido", + "ca": "Vàlid", + "pl": "Ważny", + "hu": "Érvényes", + "hr": "Valjan", + "sr-Latn": "Važeći", + "ru": "Действителен", + "uk": "Чинний", + "bg": "Действителен", + "el": "Έγκυρο", + "zh-Hans": "有效", + "zh-Hant": "有效", + "ja": "有効", + "ko": "유효", + "hi": "वैध", + "vi": "Hợp lệ", + "th": "ใช้ได้", + "id": "Valid", + "tr": "Geçerli", + "kk": "Жарамды", + "ar": "صالح", + "fa": "معتبر", + }, + "certificates_revoked_badge": { + "en": "Revoked", + "ro": "Revocat", + "es": "Revocado", + "fr": "Révoqué", + "de": "Widerrufen", + "pt": "Revogado", + "it": "Revocato", + "ca": "Revocat", + "pl": "Unieważniony", + "hu": "Visszavonva", + "hr": "Opozvan", + "sr-Latn": "Opozvan", + "ru": "Отозван", + "uk": "Анульований", + "bg": "Отменен", + "el": "Ανακληθέν", + "zh-Hans": "已撤销", + "zh-Hant": "已撤銷", + "ja": "失効", + "ko": "취소됨", + "hi": "निरसित", + "vi": "Đã thu hồi", + "th": "ถูกเพิกถอน", + "id": "Dicabut", + "tr": "İptal edildi", + "kk": "Қайтарылды", + "ar": "ملغى", + "fa": "ابطال‌شده", + }, + "certificates_view_verify": { + "en": "View / verify", + "ro": "Vezi / verifică", + "es": "Ver / verificar", + "fr": "Voir / vérifier", + "de": "Ansehen / prüfen", + "pt": "Ver / verificar", + "it": "Vedi / verifica", + "ca": "Veure / verificar", + "pl": "Zobacz / zweryfikuj", + "hu": "Megtekintés / ellenőrzés", + "hr": "Pogledaj / provjeri", + "sr-Latn": "Pogledaj / proveri", + "ru": "Открыть / проверить", + "uk": "Переглянути / перевірити", + "bg": "Преглед / проверка", + "el": "Προβολή / επαλήθευση", + "zh-Hans": "查看 / 验证", + "zh-Hant": "檢視 / 驗證", + "ja": "表示 / 検証", + "ko": "보기 / 검증", + "hi": "देखें / सत्यापित करें", + "vi": "Xem / xác minh", + "th": "ดู / ตรวจสอบ", + "id": "Lihat / verifikasi", + "tr": "Görüntüle / doğrula", + "kk": "Көру / тексеру", + "ar": "عرض / تحقق", + "fa": "مشاهده / راستی‌آزمایی", + }, + "certificates_rank_label": { + "en": "Final rank", + "ro": "Loc final", + "es": "Puesto final", + "fr": "Rang final", + "de": "Endplatzierung", + "pt": "Classificação final", + "it": "Posizione finale", + "ca": "Posició final", + "pl": "Miejsce końcowe", + "hu": "Végső helyezés", + "hr": "Konačni plasman", + "sr-Latn": "Konačno mesto", + "ru": "Итоговое место", + "uk": "Підсумкове місце", + "bg": "Крайно класиране", + "el": "Τελική κατάταξη", + "zh-Hans": "最终排名", + "zh-Hant": "最終排名", + "ja": "最終順位", + "ko": "최종 순위", + "hi": "अंतिम स्थान", + "vi": "Hạng cuối", + "th": "อันดับสุดท้าย", + "id": "Peringkat akhir", + "tr": "Final sıralaması", + "kk": "Қорытынды орын", + "ar": "الترتيب النهائي", + "fa": "رتبه نهایی", + }, + "certificates_rating_label": { + "en": "Rating after contest", + "ro": "Rating după concurs", + "es": "Rating tras el concurso", + "fr": "Rating après le concours", + "de": "Rating nach dem Wettbewerb", + "pt": "Rating após o concurso", + "it": "Rating dopo la gara", + "ca": "Rating després del concurs", + "pl": "Rating po konkursie", + "hu": "Rating a verseny után", + "hr": "Rating nakon natjecanja", + "sr-Latn": "Rejting nakon takmičenja", + "ru": "Рейтинг после contest", + "uk": "Рейтинг після змагання", + "bg": "Рейтинг след състезанието", + "el": "Rating μετά τον διαγωνισμό", + "zh-Hans": "赛后评分", + "zh-Hant": "賽後評分", + "ja": "コンテスト後のレーティング", + "ko": "대회 후 레이팅", + "hi": "प्रतियोगिता के बाद रेटिंग", + "vi": "Rating sau kỳ thi", + "th": "เรตติ้งหลังการแข่งขัน", + "id": "Rating setelah kontes", + "tr": "Yarışma sonrası reyting", + "kk": "Жарыстан кейінгі рейтинг", + "ar": "التصنيف بعد المسابقة", + "fa": "ریتینگ پس از مسابقه", + }, + "certificates_solved_label": { + "en": "Problems solved", + "ro": "Probleme rezolvate", + "es": "Problemas resueltos", + "fr": "Problèmes résolus", + "de": "Gelöste Aufgaben", + "pt": "Problemas resolvidos", + "it": "Problemi risolti", + "ca": "Problemes resolts", + "pl": "Rozwiązane zadania", + "hu": "Megoldott feladatok", + "hr": "Riješeni zadaci", + "sr-Latn": "Rešeni zadaci", + "ru": "Решённые задачи", + "uk": "Розв'язані задачі", + "bg": "Решени задачи", + "el": "Λυμένα προβλήματα", + "zh-Hans": "已解决问题数", + "zh-Hant": "已解決問題數", + "ja": "解答済み問題数", + "ko": "해결한 문제 수", + "hi": "हल किए गए प्रॉब्लम", + "vi": "Số bài đã giải", + "th": "โจทย์ที่แก้ได้", + "id": "Masalah terpecahkan", + "tr": "Çözülen problemler", + "kk": "Шешілген есептер", + "ar": "المسائل المحلولة", + "fa": "مسئله‌های حل‌شده", + }, + "certificates_issued_label": { + "en": "Issued", + "ro": "Eliberat", + "es": "Emitido", + "fr": "Délivré le", + "de": "Ausgestellt", + "pt": "Emitido", + "it": "Rilasciato", + "ca": "Emès", + "pl": "Wydano", + "hu": "Kibocsátva", + "hr": "Izdano", + "sr-Latn": "Izdato", + "ru": "Выдан", + "uk": "Видано", + "bg": "Издаден на", + "el": "Εκδόθηκε", + "zh-Hans": "颁发于", + "zh-Hant": "頒發於", + "ja": "発行日", + "ko": "발급일", + "hi": "जारी", + "vi": "Cấp ngày", + "th": "วันออกใบรับรอง", + "id": "Diterbitkan", + "tr": "Veriliş", + "kk": "Шығарылды", + "ar": "تاريخ الإصدار", + "fa": "تاریخ صدور", + }, + "certificates_serial_label": { + "en": "Serial", + "ro": "Serie", + "es": "Serie", + "fr": "Numéro de série", + "de": "Seriennummer", + "pt": "Número de série", + "it": "Matricola", + "ca": "Número de sèrie", + "pl": "Numer seryjny", + "hu": "Sorozatszám", + "hr": "Serijski broj", + "sr-Latn": "Serijski broj", + "ru": "Серийный номер", + "uk": "Серійний номер", + "bg": "Сериен номер", + "el": "Σειριακός αριθμός", + "zh-Hans": "序列号", + "zh-Hant": "序號", + "ja": "シリアル番号", + "ko": "일련번호", + "hi": "सीरियल नंबर", + "vi": "Số sê-ri", + "th": "หมายเลขซีเรียล", + "id": "Nomor seri", + "tr": "Seri numarası", + "kk": "Сериялық нөмір", + "ar": "الرقم التسلسلي", + "fa": "شماره سری", + }, + "certificates_holder_label": { + "en": "Awarded to", + "ro": "Acordat lui", + "es": "Otorgado a", + "fr": "Délivré à", + "de": "Verliehen an", + "pt": "Atribuído a", + "it": "Assegnato a", + "ca": "Atorgat a", + "pl": "Przyznany dla", + "hu": "Tulajdonos", + "hr": "Dodijeljeno", + "sr-Latn": "Dodeljeno", + "ru": "Вручён", + "uk": "Вручається", + "bg": "Връчен на", + "el": "Απονεμήθηκε σε", + "zh-Hans": "授予", + "zh-Hant": "授予", + "ja": "授与者", + "ko": "수여자", + "hi": "प्रदान किया गया", + "vi": "Trao cho", + "th": "มอบให้แก่", + "id": "Diberikan kepada", + "tr": "Verilen kişi", + "kk": "Табысталды", + "ar": "مُنح إلى", + "fa": "اعطا شده به", + }, + "certificates_verify_title": { + "en": "Certificate verification", + "ro": "Verificare atestat", + "es": "Verificación de certificado", + "fr": "Vérification de certificat", + "de": "Zertifikatsprüfung", + "pt": "Verificação de certificado", + "it": "Verifica del certificato", + "ca": "Verificació de certificat", + "pl": "Weryfikacja certyfikatu", + "hu": "Tanúsítvány-ellenőrzés", + "hr": "Provjera certifikata", + "sr-Latn": "Provera sertifikata", + "ru": "Проверка сертификата", + "uk": "Перевірка сертифіката", + "bg": "Проверка на сертификат", + "el": "Επαλήθευση πιστοποιητικού", + "zh-Hans": "证书验证", + "zh-Hant": "證書驗證", + "ja": "証明書の検証", + "ko": "인증서 검증", + "hi": "प्रमाणपत्र सत्यापन", + "vi": "Xác minh chứng chỉ", + "th": "การตรวจสอบใบรับรอง", + "id": "Verifikasi sertifikat", + "tr": "Sertifika doğrulaması", + "kk": "Сертификатты тексеру", + "ar": "التحقق من الشهادة", + "fa": "راستی‌آزمایی گواهی‌نامه", + }, + "certificates_verify_valid": { + "en": "This certificate is valid.", + "ro": "Acest atestat este valid.", + "es": "Este certificado es válido.", + "fr": "Ce certificat est valide.", + "de": "Dieses Zertifikat ist gültig.", + "pt": "Este certificado é válido.", + "it": "Questo certificato è valido.", + "ca": "Aquest certificat és vàlid.", + "pl": "Ten certyfikat jest ważny.", + "hu": "Ez a tanúsítvány érvényes.", + "hr": "Ovaj certifikat je valjan.", + "sr-Latn": "Ovaj sertifikat je važeći.", + "ru": "Этот сертификат действителен.", + "uk": "Цей сертифікат чинний.", + "bg": "Този сертификат е действителен.", + "el": "Αυτό το πιστοποιητικό είναι έγκυρο.", + "zh-Hans": "该证书有效。", + "zh-Hant": "該證書有效。", + "ja": "この証明書は有効です。", + "ko": "이 인증서는 유효합니다.", + "hi": "यह प्रमाणपत्र वैध है।", + "vi": "Chứng chỉ này hợp lệ.", + "th": "ใบรับรองนี้ใช้ได้", + "id": "Sertifikat ini valid.", + "tr": "Bu sertifika geçerlidir.", + "kk": "Бұл сертификат жарамды.", + "ar": "هذه الشهادة صالحة.", + "fa": "این گواهی‌نامه معتبر است.", + }, + "certificates_verify_revoked": { + "en": "This certificate has been revoked.", + "ro": "Acest atestat a fost revocat.", + "es": "Este certificado ha sido revocado.", + "fr": "Ce certificat a été révoqué.", + "de": "Dieses Zertifikat wurde widerrufen.", + "pt": "Este certificado foi revogado.", + "it": "Questo certificato è stato revocato.", + "ca": "Aquest certificat ha estat revocat.", + "pl": "Ten certyfikat został unieważniony.", + "hu": "Ez a tanúsítvány visszavonva.", + "hr": "Ovaj certifikat je opozvan.", + "sr-Latn": "Ovaj sertifikat je opozvan.", + "ru": "Этот сертификат отозван.", + "uk": "Цей сертифікат анульовано.", + "bg": "Този сертификат е отменен.", + "el": "Αυτό το πιστοποιητικό έχει ανακληθεί.", + "zh-Hans": "该证书已被撤销。", + "zh-Hant": "該證書已被撤銷。", + "ja": "この証明書は失効しています。", + "ko": "이 인증서는 취소되었습니다.", + "hi": "यह प्रमाणपत्र निरसित कर दिया गया है।", + "vi": "Chứng chỉ này đã bị thu hồi.", + "th": "ใบรับรองนี้ถูกเพิกถอนแล้ว", + "id": "Sertifikat ini telah dicabut.", + "tr": "Bu sertifika iptal edildi.", + "kk": "Бұл сертификат қайтарылды.", + "ar": "تم إلغاء هذه الشهادة.", + "fa": "این گواهی‌نامه ابطال شده است.", + }, + "certificates_verify_revoked_reason_label": { + "en": "Reason", + "ro": "Motiv", + "es": "Motivo", + "fr": "Motif", + "de": "Grund", + "pt": "Motivo", + "it": "Motivo", + "ca": "Motiu", + "pl": "Powód", + "hu": "Indok", + "hr": "Razlog", + "sr-Latn": "Razlog", + "ru": "Причина", + "uk": "Причина", + "bg": "Причина", + "el": "Αιτία", + "zh-Hans": "原因", + "zh-Hant": "原因", + "ja": "理由", + "ko": "사유", + "hi": "कारण", + "vi": "Lý do", + "th": "เหตุผล", + "id": "Alasan", + "tr": "Gerekçe", + "kk": "Себеп", + "ar": "السبب", + "fa": "دلیل", + }, + "certificates_verify_note": { + "en": "Anyone can verify this certificate at", + "ro": "Oricine poate verifica acest atestat la", + "es": "Cualquiera puede verificar este certificado en", + "fr": "N'importe qui peut vérifier ce certificat à l'adresse", + "de": "Jeder kann dieses Zertifikat prüfen unter", + "pt": "Qualquer pessoa pode verificar este certificado em", + "it": "Chiunque può verificare questo certificato su", + "ca": "Qualsevol pot verificar aquest certificat a", + "pl": "Każdy może zweryfikować ten certyfikat pod adresem", + "hu": "Ez a tanúsítvány bárki által ellenőrizhető itt:", + "hr": "Svatko može provjeriti ovaj certifikat na", + "sr-Latn": "Svako može proveriti ovaj sertifikat na", + "ru": "Проверить этот сертификат может любой по адресу", + "uk": "Перевірити цей сертифікат може будь-хто за адресою", + "bg": "Всеки може да провери този сертификат на", + "el": "Οποιοσδήποτε μπορεί να επαληθεύσει αυτό το πιστοποιητικό στο", + "zh-Hans": "任何人都可以在以下链接验证该证书:", + "zh-Hant": "任何人都可以在以下連結驗證該證書:", + "ja": "この証明書は誰でも次の URL で検証できます:", + "ko": "누구나 다음 주소에서 이 인증서를 검증할 수 있습니다:", + "hi": "कोई भी इस प्रमाणपत्र को इस पते पर सत्यापित कर सकता है:", + "vi": "Ai cũng có thể xác minh chứng chỉ này tại", + "th": "ทุกคนสามารถตรวจสอบใบรับรองนี้ได้ที่", + "id": "Siapa pun dapat memverifikasi sertifikat ini di", + "tr": "Herkes bu sertifikayı şu adresten doğrulayabilir:", + "kk": "Бұл сертификатты кез келген адам мына мекенжайда тексере алады:", + "ar": "يمكن لأي شخص التحقق من هذه الشهادة على", + "fa": "هر کسی می‌تواند این گواهی‌نامه را در این نشانی راستی‌آزمایی کند:", + }, + "certificates_print_button": { + "en": "Print / save as PDF", + "ro": "Tipărește / salvează ca PDF", + "es": "Imprimir / guardar como PDF", + "fr": "Imprimer / enregistrer en PDF", + "de": "Drucken / als PDF speichern", + "pt": "Imprimir / salvar como PDF", + "it": "Stampa / salva come PDF", + "ca": "Imprimeix / desa com a PDF", + "pl": "Drukuj / zapisz jako PDF", + "hu": "Nyomtatás / mentés PDF-ként", + "hr": "Ispiši / spremi kao PDF", + "sr-Latn": "Štampaj / sačuvaj kao PDF", + "ru": "Печать / сохранить в PDF", + "uk": "Друк / зберегти як PDF", + "bg": "Печат / запазване като PDF", + "el": "Εκτύπωση / αποθήκευση ως PDF", + "zh-Hans": "打印 / 保存为 PDF", + "zh-Hant": "列印 / 儲存為 PDF", + "ja": "印刷 / PDF として保存", + "ko": "인쇄 / PDF로 저장", + "hi": "प्रिंट / PDF के रूप में सहेजें", + "vi": "In / lưu dưới dạng PDF", + "th": "พิมพ์ / บันทึกเป็น PDF", + "id": "Cetak / simpan sebagai PDF", + "tr": "Yazdır / PDF olarak kaydet", + "kk": "Басып шығару / PDF ретінде сақтау", + "ar": "طباعة / حفظ بصيغة PDF", + "fa": "چاپ / ذخیره به‌صورت PDF", + }, + "certificates_not_found_title": { + "en": "Certificate not found", + "ro": "Atestat inexistent", + "es": "Certificado no encontrado", + "fr": "Certificat introuvable", + "de": "Zertifikat nicht gefunden", + "pt": "Certificado não encontrado", + "it": "Certificato non trovato", + "ca": "Certificat no trobat", + "pl": "Nie znaleziono certyfikatu", + "hu": "A tanúsítvány nem található", + "hr": "Certifikat nije pronađen", + "sr-Latn": "Sertifikat nije pronađen", + "ru": "Сертификат не найден", + "uk": "Сертифікат не знайдено", + "bg": "Сертификатът не е намерен", + "el": "Το πιστοποιητικό δεν βρέθηκε", + "zh-Hans": "未找到证书", + "zh-Hant": "未找到證書", + "ja": "証明書が見つかりません", + "ko": "인증서를 찾을 수 없습니다", + "hi": "प्रमाणपत्र नहीं मिला", + "vi": "Không tìm thấy chứng chỉ", + "th": "ไม่พบใบรับรอง", + "id": "Sertifikat tidak ditemukan", + "tr": "Sertifika bulunamadı", + "kk": "Сертификат табылмады", + "ar": "لم يتم العثور على الشهادة", + "fa": "گواهی‌نامه یافت نشد", + }, + "certificates_not_found_body": { + "en": "This verification link does not match any certificate. Check the serial or link and try again.", + "ro": "Această legătură de verificare nu corespunde niciunui atestat. Verifică seria sau legătura și încearcă din nou.", + "es": "Este enlace de verificación no corresponde a ningún certificado. Comprueba la serie o el enlace e inténtalo de nuevo.", + "fr": "Ce lien de vérification ne correspond à aucun certificat. Vérifiez le numéro de série ou le lien, puis réessayez.", + "de": "Dieser Prüfungslink passt zu keinem Zertifikat. Prüfe Seriennummer oder Link und versuche es erneut.", + "pt": "Este link de verificação não corresponde a nenhum certificado. Confira o número de série ou o link e tente novamente.", + "it": "Questo collegamento di verifica non corrisponde ad alcun certificato. Controlla la matricola o il collegamento e riprova.", + "ca": "Aquest enllaç de verificació no correspon a cap certificat. Comprova la sèrie o l'enllaç i torna-ho a provar.", + "pl": "Ten link weryfikacyjny nie pasuje do żadnego certyfikatu. Sprawdź numer seryjny lub link i spróbuj ponownie.", + "hu": "Ez az ellenőrző hivatkozás egyik tanúsítványhoz sem illik. Ellenőrizd a sorozatszámot vagy a hivatkozást, és próbáld újra.", + "hr": "Ova poveznica za provjeru ne odgovara nijednom certifikatu. Provjeri serijski broj ili poveznicu i pokušaj ponovno.", + "sr-Latn": "Ovaj link za proveru ne odgovara nijednom sertifikatu. Proveri serijski broj ili link i pokušaj ponovo.", + "ru": "Эта ссылка не соответствует ни одному сертификату. Проверьте серийный номер или ссылку и попробуйте снова.", + "uk": "Це посилання не відповідає жодному сертифікату. Перевірте серійний номер або посилання та спробуйте ще раз.", + "bg": "Тази проверъчна връзка не съответства на никой сертификат. Проверете серийния номер или връзката и опитайте отново.", + "el": "Αυτός ο σύνδεσμος επαλήθευσης δεν αντιστοιχεί σε κάποιο πιστοποιητικό. Ελέγξτε τον σειριακό αριθμό ή τον σύνδεσμο και δοκιμάστε ξανά.", + "zh-Hans": "该验证链接不匹配任何证书。请检查序列号或链接后重试。", + "zh-Hant": "該驗證連結不符合任何證書。請檢查序號或連結後重試。", + "ja": "この検証リンクはどの証明書にも一致しません。シリアル番号かリンクを確認して、もう一度お試しください。", + "ko": "이 검증 링크는 어떤 인증서와도 일치하지 않습니다. 일련번호나 링크를 확인하고 다시 시도하세요.", + "hi": "यह सत्यापन लिंक किसी प्रमाणपत्र से मेल नहीं खाता। सीरियल नंबर या लिंक जाँचें और फिर प्रयास करें।", + "vi": "Liên kết xác minh này không khớp với chứng chỉ nào. Hãy kiểm tra số sê-ri hoặc liên kết rồi thử lại.", + "th": "ลิงก์ยืนยันนี้ไม่ตรงกับใบรับรองใด กรุณาตรวจสอบหมายเลขซีเรียลหรือลิงก์แล้วลองอีกครั้ง", + "id": "Tautan verifikasi ini tidak cocok dengan sertifikat apa pun. Periksa nomor seri atau tautannya lalu coba lagi.", + "tr": "Bu doğrulama bağlantısı hiçbir sertifikayla eşleşmiyor. Seri numarasını veya bağlantıyı kontrol edip tekrar deneyin.", + "kk": "Бұл тексеру сілтемесі ешбір сертификатқа сәйкес келмейді. Сериялық нөмірді немесе сілтемені тексеріп, қайта байқап көріңіз.", + "ar": "لا تطابق رابط التحقق هذا أي شهادة. تحقق من الرقم التسلسلي أو الرابط ثم حاول مجددًا.", + "fa": "این پیوند راستی‌آزمایی با هیچ گواهی‌نامه‌ای مطابقت ندارد. شماره سری یا پیوند را بررسی کنید و دوباره تلاش کنید.", + }, + "notification_certificate_title": { + "en": "Certificate issued", + "ro": "Atestat eliberat", + "es": "Certificado emitido", + "fr": "Certificat délivré", + "de": "Zertifikat ausgestellt", + "pt": "Certificado emitido", + "it": "Certificato rilasciato", + "ca": "Certificat emès", + "pl": "Wydano certyfikat", + "hu": "Tanúsítvány kiadva", + "hr": "Certifikat izdan", + "sr-Latn": "Sertifikat izdat", + "ru": "Выдан сертификат", + "uk": "Видано сертифікат", + "bg": "Издаден сертификат", + "el": "Εκδόθηκε πιστοποιητικό", + "zh-Hans": "证书已颁发", + "zh-Hant": "證書已頒發", + "ja": "証明書が発行されました", + "ko": "인증서가 발급되었습니다", + "hi": "प्रमाणपत्र जारी हुआ", + "vi": "Đã cấp chứng chỉ", + "th": "ออกใบรับรองแล้ว", + "id": "Sertifikat diterbitkan", + "tr": "Sertifika verildi", + "kk": "Сертификат шығарылды", + "ar": "تم إصدار الشهادة", + "fa": "گواهی‌نامه صادر شد", + }, + "notification_certificate_body": { + "en": "A new certificate ({{serial}}) is waiting on your certificates page.", + "ro": "Un nou atestat ({{serial}}) te așteaptă pe pagina atestatelor tale.", + "es": "Un nuevo certificado ({{serial}}) te espera en tu página de certificados.", + "fr": "Un nouveau certificat ({{serial}}) vous attend sur votre page de certificats.", + "de": "Ein neues Zertifikat ({{serial}}) wartet auf deiner Zertifikatsseite.", + "pt": "Um novo certificado ({{serial}}) espera por você na sua página de certificados.", + "it": "Un nuovo certificato ({{serial}}) ti aspetta nella tua pagina dei certificati.", + "ca": "Un nou certificat ({{serial}}) t'espera a la teva pàgina de certificats.", + "pl": "Nowy certyfikat ({{serial}}) czeka na Twojej stronie certyfikatów.", + "hu": "Egy új tanúsítvány ({{serial}}) vár a tanúsítványoldaladon.", + "hr": "Novi certifikat ({{serial}}) čeka te na tvojoj stranici certifikata.", + "sr-Latn": "Novi sertifikat ({{serial}}) čeka te na tvojoj stranici sertifikata.", + "ru": "Новый сертификат ({{serial}}) ждёт вас на странице сертификатов.", + "uk": "Новий сертифікат ({{serial}}) чекає на вас на сторінці сертифікатів.", + "bg": "Нов сертификат ({{serial}}) ви чака на страницата със сертификати.", + "el": "Ένα νέο πιστοποιητικό ({{serial}}) σας περιμένει στη σελίδα πιστοποιητικών σας.", + "zh-Hans": "一张新证书({{serial}})正在你的证书页等你。", + "zh-Hant": "一張新證書({{serial}})正在你的證書頁等你。", + "ja": "新しい証明書({{serial}})が証明書ページで待っています。", + "ko": "새 인증서({{serial}})가 인증서 페이지에서 기다리고 있습니다.", + "hi": "एक नया प्रमाणपत्र ({{serial}}) आपके प्रमाणपत्र पृष्ठ पर प्रतीक्षा कर रहा है।", + "vi": "Một chứng chỉ mới ({{serial}}) đang chờ bạn trên trang chứng chỉ của mình.", + "th": "ใบรับรองใหม่ ({{serial}}) กำลังรอคุณอยู่ที่หน้าใบรับรองของคุณ", + "id": "Sertifikat baru ({{serial}}) menunggumu di halaman sertifikatmu.", + "tr": "Yeni bir sertifika ({{serial}}) sertifikalar sayfanda seni bekliyor.", + "kk": "Жаңа сертификат ({{serial}}) сертификаттар парағында сені күтіп тұр.", + "ar": "شهادة جديدة ({{serial}}) تنتظرك في صفحة شهاداتك.", + "fa": "یک گواهی‌نامه جدید ({{serial}}) در صفحه گواهی‌نامه‌های شما منتظر است.", + }, +} + +for path in sorted(LOCALES.glob("*.json")): + locale = path.stem + data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=collections.OrderedDict) + missing = [k for k in K if locale not in K[k]] + if missing: + raise SystemExit(f"{locale}: missing translations for {missing}") + for key, per_locale in K.items(): + data[key] = per_locale[locale] + path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(f"{locale}: +{len(K)} keys -> {len(data)}") diff --git a/src/app/_lib/public-messages.ts b/src/app/_lib/public-messages.ts index fb676c770..9b620eca4 100644 --- a/src/app/_lib/public-messages.ts +++ b/src/app/_lib/public-messages.ts @@ -276,6 +276,31 @@ export const PUBLIC_KEYS: string[] = [ 'faq_a3', 'faq_q4', 'faq_a4', + // Wave D atestats (certifications) — the anonymous verification page. + 'certificates_title', + 'certificates_intro', + 'certificates_empty', + 'certificates_load_error', + 'certificates_kind_CONTEST_PARTICIPATION', + 'certificates_kind_RATING_MILESTONE', + 'certificates_kind_SOLVE_MILESTONE', + 'certificates_valid_badge', + 'certificates_revoked_badge', + 'certificates_view_verify', + 'certificates_rank_label', + 'certificates_rating_label', + 'certificates_solved_label', + 'certificates_issued_label', + 'certificates_serial_label', + 'certificates_holder_label', + 'certificates_verify_title', + 'certificates_verify_valid', + 'certificates_verify_revoked', + 'certificates_verify_revoked_reason_label', + 'certificates_verify_note', + 'certificates_print_button', + 'certificates_not_found_title', + 'certificates_not_found_body', ]; function pick(catalog: Record): Record { diff --git a/src/app/certificates/[uuid]/PrintCertificateButton.tsx b/src/app/certificates/[uuid]/PrintCertificateButton.tsx new file mode 100644 index 000000000..76e1bf574 --- /dev/null +++ b/src/app/certificates/[uuid]/PrintCertificateButton.tsx @@ -0,0 +1,24 @@ +'use client'; + +import React from 'react'; + +/** + * [Wave D atestats] The print button for the public verification page. + * The global @media print stylesheet (globals.css, UXP-3) hides buttons + * and chrome, so window.print() yields a clean certificate sheet. + * + * The label arrives as a prop (serialized from the server component's + * translator) — this island stays free of locale state. + */ +export default function PrintCertificateButton({ label }: { label: string }) { + return ( + + ); +} diff --git a/src/app/certificates/[uuid]/page.test.tsx b/src/app/certificates/[uuid]/page.test.tsx new file mode 100644 index 000000000..c5ad71205 --- /dev/null +++ b/src/app/certificates/[uuid]/page.test.tsx @@ -0,0 +1,87 @@ +// [Wave D atestats] The public /certificates/[uuid] verification page: +// the frozen achievement renders for anonymous visitors, revoked +// certificates answer honestly (with reason), unknown UUIDs get the +// not-found copy, and the RO locale picks the Romanian contest title. + +import { describe, expect, it, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; + +const cookie = vi.hoisted(() => ({ value: 'en' })); +const certRow = vi.hoisted(() => ({ value: null as null | object })); + +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: (name: string) => (name === 'i18nextLng' ? { value: cookie.value } : undefined), + }), +})); + +vi.mock('../_lib/certificates-api', () => ({ + getCertificate: async () => certRow.value, +})); + +import VerifyPage from './page'; + +const VALID_CERT = { + uuid: '11111111-2222-3333-4444-555555555555', + serial: 'WC-2026-000004', + kind: 'CONTEST_PARTICIPATION', + status: 'VALID', + holder: 'alice', + details: { + rank: 2, + rating_after: 1662, + contest_id: 7, + contest_title_en: 'Winter Cup', + contest_title_ro: 'Cupa de Iarnă', + }, + issued_at: '2026-11-01T12:00:00Z', + revoked_at: null, + revoked_reason: '', +}; + +async function renderPage() { + const jsx = (await VerifyPage({ params: Promise.resolve({ uuid: 'x' }) })) as React.ReactElement; + return renderToStaticMarkup(jsx); +} + +describe('CertificateVerifyPage [Wave D]', () => { + it('renders the frozen achievement + serial for a valid certificate', async () => { + cookie.value = 'en'; + certRow.value = VALID_CERT; + const html = await renderPage(); + expect(html).toContain('WC-2026-000004'); + expect(html).toContain('Winter Cup'); + expect(html).toContain('This certificate is valid.'); + expect(html).toContain('Final rank'); + expect(html).toContain('Print / save as PDF'); + }); + + it('answers REVOKED with the reason instead of vanishing', async () => { + cookie.value = 'en'; + certRow.value = { + ...VALID_CERT, + status: 'REVOKED', + revoked_reason: 'contest 7 ratings discarded by organizer', + }; + const html = await renderPage(); + expect(html).toContain('This certificate has been revoked.'); + expect(html).toContain('ratings discarded'); + expect(html).not.toContain('This certificate is valid.'); + }); + + it('renders the not-found copy for an unknown UUID', async () => { + cookie.value = 'en'; + certRow.value = null; + const html = await renderPage(); + expect(html).toContain('Certificate not found'); + expect(html).toContain('does not match any certificate'); + }); + + it('prefers the Romanian contest title on the ro locale', async () => { + cookie.value = 'ro'; + certRow.value = VALID_CERT; + const html = await renderPage(); + expect(html).toContain('Cupa de Iarnă'); + expect(html).toContain('Acest atestat este valid.'); + }); +}); diff --git a/src/app/certificates/[uuid]/page.tsx b/src/app/certificates/[uuid]/page.tsx new file mode 100644 index 000000000..6c144e995 --- /dev/null +++ b/src/app/certificates/[uuid]/page.tsx @@ -0,0 +1,197 @@ +// [Wave D certifications — H-120 verifiable participation certificates] +// The PUBLIC atestat page: anyone (employer, teacher, admission board) +// opens /certificates/ — no account — and sees the frozen +// achievement, the serial, and an honest VALID / REVOKED banner. +// A revoked certificate keeps answering (with its reason) instead of +// vanishing — that is the point of a verification ledger. +// +// Server-rendered + 60s revalidated (certificates-api.ts); the print +// button island triggers the browser's print/save-as-PDF dialog over the +// global UXP-3 print stylesheet. + +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { createTranslator, getLocale } from '../../_lib/public-i18n'; +import { getCertificate } from '../_lib/certificates-api'; +import type { CertificateType } from '@/types/api'; +import PrintCertificateButton from './PrintCertificateButton'; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ uuid: string }>; +}): Promise { + const { uuid } = await params; + const t = createTranslator(await getLocale()); + let serial = ''; + try { + const cert = await getCertificate(uuid); + if (cert) serial = ` — ${cert.serial}`; + } catch { + // Metadata is best-effort; the page body handles fetch failures. + } + return { title: `${t('certificates_verify_title', 'Certificate verification')}${serial}`, robots: { index: false } }; +} + +function achievementRows( + cert: CertificateType, + t: ReturnType, + locale: string, +): { label: string; value: string }[] { + const rows: { label: string; value: string }[] = []; + if (cert.kind === 'CONTEST_PARTICIPATION') { + const title = + locale === 'ro' && cert.details.contest_title_ro + ? cert.details.contest_title_ro + : cert.details.contest_title_en ?? `#${cert.details.contest_id ?? ''}`; + rows.push({ label: t('certificates_kind_CONTEST_PARTICIPATION', 'Contest participation'), value: title }); + if (cert.details.rank !== undefined) { + rows.push({ + label: t('certificates_rank_label', 'Final rank'), + value: String(cert.details.rank), + }); + } + if (cert.details.rating_after !== undefined) { + rows.push({ + label: t('certificates_rating_label', 'Rating after contest'), + value: String(cert.details.rating_after), + }); + } + } else if (cert.kind === 'RATING_MILESTONE') { + rows.push({ + label: t('certificates_kind_RATING_MILESTONE', 'Rating milestone'), + value: `≥ ${cert.details.threshold ?? ''}`, + }); + } else { + rows.push({ + label: t('certificates_solved_label', 'Problems solved'), + value: String(cert.details.solved ?? cert.details.threshold ?? ''), + }); + } + return rows; +} + +export default async function CertificateVerifyPage({ + params, +}: { + params: Promise<{ uuid: string }>; +}) { + const { uuid } = await params; + const locale = await getLocale(); + const t = createTranslator(locale); + const cert = await getCertificate(uuid); + + if (!cert) { + return ( +
+

+ {t('certificates_not_found_title', 'Certificate not found')} +

+

+ {t( + 'certificates_not_found_body', + 'This verification link does not match any certificate. Check the serial or link and try again.', + )} +

+
+ ); + } + + const revoked = cert.status === 'REVOKED'; + const verifyUrl = `/certificates/${cert.uuid}`; + + return ( +
+

+ {t('certificates_verify_title', 'Certificate verification')} +

+ + {/* The printable atestat sheet. */} +
+
+ WebCoder + + {t('certificates_serial_label', 'Serial')}: {cert.serial} + +
+ +

+ {cert.kind === 'CONTEST_PARTICIPATION' + ? t('certificates_kind_CONTEST_PARTICIPATION', 'Contest participation') + : cert.kind === 'RATING_MILESTONE' + ? t('certificates_kind_RATING_MILESTONE', 'Rating milestone') + : t('certificates_kind_SOLVE_MILESTONE', 'Solved-problems milestone')} +

+ +

+ {t('certificates_holder_label', 'Awarded to')}:{' '} + {cert.holder} +

+ +
+ {achievementRows(cert, t, locale).map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
{t('certificates_issued_label', 'Issued')}
+
{cert.issued_at.slice(0, 10)}
+
+
+ +

+ {revoked + ? t('certificates_verify_revoked', 'This certificate has been revoked.') + : t('certificates_verify_valid', 'This certificate is valid.')} +

+ {revoked && cert.revoked_reason ? ( +

+ {t('certificates_verify_revoked_reason_label', 'Reason')}: {cert.revoked_reason} +

+ ) : null} +
+ + {/* Screen-only controls (the print stylesheet hides them). */} +
+ +

+ {t('certificates_verify_note', 'Anyone can verify this certificate at')}{' '} + {verifyUrl} +

+
+

+ + ← {t('certificates_title', 'My certificates')} + +

+
+ ); +} diff --git a/src/app/certificates/_lib/certificates-api.test.ts b/src/app/certificates/_lib/certificates-api.test.ts new file mode 100644 index 000000000..0ef72b840 --- /dev/null +++ b/src/app/certificates/_lib/certificates-api.test.ts @@ -0,0 +1,71 @@ +// [Wave D certifications] Server-side verification fetch: 200 → row, +// 404 → null (honest not-found page), 5xx → thrown (never a phantom +// "valid"), and the real-backend e2e bypass of the ISR cache. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCertificate } from './certificates-api'; + +const CERT = { + uuid: 'b0b0b0b0-1111-2222-3333-444444444444', + serial: 'WC-2026-000001', + kind: 'CONTEST_PARTICIPATION', + status: 'VALID', + holder: 'alice', + details: { rank: 1, rating_after: 1662 }, + issued_at: '2026-11-14T00:00:00Z', + revoked_at: null, + revoked_reason: '', +}; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }); +} + +beforeEach(() => { + delete process.env.E2E_API_BASE; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.E2E_API_BASE; +}); + +describe('certificates-api [Wave D]', () => { + it('returns the certificate on 200 and revalidates by default', async () => { + const fetchMock = vi.fn(async () => jsonResponse(CERT)); + vi.stubGlobal('fetch', fetchMock); + const cert = await getCertificate(CERT.uuid); + expect(cert?.serial).toBe('WC-2026-000001'); + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect((init as { next?: { revalidate: number } }).next?.revalidate).toBe(60); + }); + + it('maps 404 to null (the not-found page path)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{"detail":"Not found."}', { status: 404 })), + ); + expect(await getCertificate('00000000-0000-0000-0000-000000000000')).toBeNull(); + }); + + it('throws on a non-404 failure instead of faking an answer', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('boom', { status: 500, statusText: 'Internal Server Error' })), + ); + await expect(getCertificate(CERT.uuid)).rejects.toThrow('Internal Server Error'); + }); + + it('bypasses the cache in real-backend e2e mode', async () => { + process.env.E2E_API_BASE = 'http://127.0.0.1:8010'; + const fetchMock = vi.fn(async () => jsonResponse(CERT)); + vi.stubGlobal('fetch', fetchMock); + await getCertificate(CERT.uuid); + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect((init as { cache?: string }).cache).toBe('no-store'); + }); +}); diff --git a/src/app/certificates/_lib/certificates-api.ts b/src/app/certificates/_lib/certificates-api.ts new file mode 100644 index 000000000..7754cdf59 --- /dev/null +++ b/src/app/certificates/_lib/certificates-api.ts @@ -0,0 +1,29 @@ +// Server-side data access for the PUBLIC certificate verification page +// (Wave D atestats). Mirrors problems-api.ts: centralized base URL, no +// silent catch — a down backend surfaces as the page's not-found/error +// state rather than a phantom "valid" answer. +// +// Verification answers are 60s-revalidated (ISR): a revocation lands +// within a minute; the printed serial+UUID stay stable. + +import { CertificateType } from '@/types/api'; +import { apiUrl } from '@/lib/api-config'; + +export async function getCertificate( + uuid: string, +): Promise { + const cacheOpts = process.env.E2E_API_BASE + ? ({ cache: 'no-store' } as RequestInit) + : ({ next: { revalidate: 60 } } as RequestInit); + const response = await fetch( + apiUrl(`/certificates/verify/${uuid}/`), + cacheOpts, + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(response.statusText); + } + return (await response.json()) as CertificateType; +} diff --git a/src/app/certificates/page.test.tsx b/src/app/certificates/page.test.tsx new file mode 100644 index 000000000..52199d42e --- /dev/null +++ b/src/app/certificates/page.test.tsx @@ -0,0 +1,111 @@ +// [Wave D atestats] The /certificates holder list: cards render from the +// paginated mine endpoint, revoked rows badge honestly, the empty state +// names the two honest ways to earn one, and API failures surface as an +// error (never as a fake empty list). + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import type { CertificateType } from '@/types/api'; +import CertificatesPage from './page'; + +const mocks = vi.hoisted(() => ({ + auth: { + isAuthenticated: true, + token: 'test-access-token' as string | null, + user: { id: 1, username: 'tester', email: 't@example.com', role: 'BASIC_USER' } as object | null, + login: vi.fn(), + logout: vi.fn(), + }, + getMyCertificates: vi.fn(), + replace: vi.fn(), + push: vi.fn(), + prefetch: vi.fn(), + path: '/', +})); + +vi.mock('@/context/AuthContext', () => ({ useAuth: () => mocks.auth })); + +vi.mock('@/services/ApiService', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + collectAllPages: async (fetchPage: (page: number) => Promise<{ results: unknown[] }>) => { + const response = await fetchPage(1); + return response.results; + }, + CertificateService: { getMyCertificates: mocks.getMyCertificates }, + }; +}); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mocks.replace, push: mocks.push, prefetch: mocks.prefetch }), + usePathname: () => mocks.path, +})); + +const CERT: CertificateType = { + uuid: 'aaaaaaaa-0000-0000-0000-000000000001', + serial: 'WC-2026-000007', + kind: 'CONTEST_PARTICIPATION', + status: 'VALID', + holder: 'tester', + details: { rank: 3, rating_after: 1500, contest_title_en: 'Winter Cup' }, + issued_at: '2026-11-01T12:00:00Z', + revoked_at: null, + revoked_reason: '', +}; + +const REVOKED: CertificateType = { + ...CERT, + uuid: 'aaaaaaaa-0000-0000-0000-000000000002', + serial: 'WC-2026-000008', + status: 'REVOKED', + revoked_reason: 'ratings discarded', +}; + +const pageOf = (results: CertificateType[]) => ({ + count: results.length, + next: null, + previous: null, + results, +}); + +beforeEach(() => { + mocks.getMyCertificates.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('CertificatesPage [Wave D]', () => { + it('lists the holder certificates with kind, achievement and serial', async () => { + mocks.getMyCertificates.mockResolvedValue(pageOf([CERT])); + render(); + expect(await screen.findByTestId('certificates-list')).toBeTruthy(); + expect(screen.getByTestId('certificate-serial-aaaaaaaa-0000-0000-0000-000000000001')).toBeTruthy(); + expect(screen.getByText('Contest participation')).toBeTruthy(); + expect(screen.getByText(/Winter Cup/)).toBeTruthy(); + expect(screen.getByRole('link', { name: 'View / verify' })).toBeTruthy(); + }); + + it('badges revoked rows while still listing them', async () => { + mocks.getMyCertificates.mockResolvedValue(pageOf([REVOKED])); + render(); + expect(await screen.findByText('Revoked')).toBeTruthy(); + expect(screen.getByTestId('certificate-serial-aaaaaaaa-0000-0000-0000-000000000002')).toBeTruthy(); + }); + + it('shows the honest empty state when nothing is earned yet', async () => { + mocks.getMyCertificates.mockResolvedValue(pageOf([])); + render(); + expect(await screen.findByTestId('certificates-empty')).toBeTruthy(); + expect(screen.getByText(/first 10 problems/)).toBeTruthy(); + }); + + it('surfaces API failures as an error, never as an empty list', async () => { + mocks.getMyCertificates.mockRejectedValue(new Error('network down')); + render(); + expect(await screen.findByTestId('certificates-error')).toBeTruthy(); + expect(screen.getByText('Failed to load certificates.')).toBeTruthy(); + }); +}); diff --git a/src/app/certificates/page.tsx b/src/app/certificates/page.tsx new file mode 100644 index 000000000..1aae351f5 --- /dev/null +++ b/src/app/certificates/page.tsx @@ -0,0 +1,172 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useTranslation } from 'react-i18next'; +import { CertificateService, collectAllPages } from '@/services/ApiService'; +import { CertificateType } from '@/types/api'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; +import ProtectedRoute from '@/components/common/ProtectedRoute'; + +/** + * [Wave D certifications — the honest variant] The holder's atestat list. + * + * Every certificate here is DERIVED from judged activity (rated-contest + * finishes, rating milestones, solve milestones — never self-paced + * unproctored "skill tests"), so the list is short and bounded: + * collectAllPages is the right fetch strategy (a handful of pages at most). + * + * The public counterpart — anonymous verification by UUID + print/PDF — + * lives at /certificates/[uuid]. + */ + +function achievementLine(cert: CertificateType, t: ReturnType['t']): string { + if (cert.kind === 'CONTEST_PARTICIPATION') { + const title = cert.details.contest_title_en ?? `#${cert.details.contest_id ?? ''}`; + const parts = [title]; + if (cert.details.rank !== undefined) { + parts.push(`${t('certificates_rank_label', 'Final rank')}: ${cert.details.rank}`); + } + if (cert.details.rating_after !== undefined) { + parts.push( + `${t('certificates_rating_label', 'Rating after contest')}: ${cert.details.rating_after}`, + ); + } + return parts.join(' · '); + } + if (cert.kind === 'RATING_MILESTONE') { + return `≥ ${cert.details.threshold ?? ''}${ + cert.details.rating !== undefined ? ` (${cert.details.rating})` : '' + }`; + } + return `${t('certificates_solved_label', 'Problems solved')}: ${ + cert.details.solved ?? cert.details.threshold ?? '' + }`; +} + +function kindLabel(kind: CertificateType['kind'], t: ReturnType['t']): string { + switch (kind) { + case 'CONTEST_PARTICIPATION': + return t('certificates_kind_CONTEST_PARTICIPATION', 'Contest participation'); + case 'RATING_MILESTONE': + return t('certificates_kind_RATING_MILESTONE', 'Rating milestone'); + default: + return t('certificates_kind_SOLVE_MILESTONE', 'Solved-problems milestone'); + } +} + +function CertificatesPageContent() { + const { t } = useTranslation(); + const [certificates, setCertificates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const rows = await collectAllPages(CertificateService.getMyCertificates); + setCertificates(rows); + } catch { + setError(t('certificates_load_error', 'Failed to load certificates.')); + } finally { + setLoading(false); + } + // The error string is itself localized; reload on language change. + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+

+ {t('certificates_title', 'My certificates')} +

+

+ {t( + 'certificates_intro', + 'Certificates earned on WebCoder, each derived from judged activity: official rated-contest finishes, rating milestones and solved-problem milestones. Every certificate is publicly verifiable by its serial number.', + )} +

+ + {loading ? ( + + ) : error ? ( +

+ {error} +

+ ) : certificates.length === 0 ? ( +

+ {t( + 'certificates_empty', + 'No certificates yet — officially finish a rated contest or solve your first 10 problems.', + )} +

+ ) : ( +
    + {certificates.map((cert) => ( +
  • +
    + {kindLabel(cert.kind, t)} +
    + {achievementLine(cert, t)} +
    +
    + {t('certificates_serial_label', 'Serial')}:{' '} + + {cert.serial} + {' '} + · {t('certificates_issued_label', 'Issued')}: {cert.issued_at.slice(0, 10)} +
    +
    +
    + + {cert.status === 'REVOKED' + ? t('certificates_revoked_badge', 'Revoked') + : t('certificates_valid_badge', 'Valid')} + + + {t('certificates_view_verify', 'View / verify')} + +
    +
  • + ))} +
+ )} +
+ ); +} + +export default function CertificatesPage() { + return ( + + + + ); +} diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index bd9c8ee26..ff231ecbe 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -104,6 +104,10 @@ function targetHref(n: ApiNotification): string | null { const conversationId = num(n, 'conversation_id'); return conversationId !== undefined ? `/messages/${conversationId}` : null; } + const certUuid = str(n, 'certificate_uuid'); + if (n.verb === 'SYSTEM' && str(n, 'kind') === 'certificate_issued' && certUuid) { + return `/certificates/${certUuid}`; + } if (n.target_type === 'submission' && n.target_id !== null) return `/submissions/${n.target_id}`; if (n.target_type === 'thread' && n.target_id !== null) return `/forum/${n.target_id}`; if (n.target_type === 'problem' && (num(n, 'problem_id') ?? n.target_id) !== null) { @@ -176,11 +180,23 @@ function describe( }; } case 'SYSTEM': - default: + default: { + if (str(n, 'kind') === 'certificate_issued') { + const serial = str(n, 'serial') ?? ''; + return { + title: t('notification_certificate_title', 'Certificate issued'), + body: t( + 'notification_certificate_body', + 'A new certificate ({{serial}}) is waiting on your certificates page.', + { serial }, + ), + }; + } return { title: t('notification_system_title', 'System notification'), body: str(n, 'message') ?? '', }; + } } } diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 5d1c20efd..acc8b2418 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -98,6 +98,8 @@ const Navbar: React.FC = () => { { href: '/messages', label: t('nav_messages', 'Messages') }, { href: '/profile', label: t('nav_profile', 'Profile') }, { href: '/my-submissions', label: t('nav_my_submissions', 'My Submissions') }, + // Wave D atestats (certifications): the holder's certificates. + { href: '/certificates', label: t('nav_certificates', 'Certificates') }, { href: '/submission-history', label: t('nav_submission_history', 'History') }, ]; const roleLinks: NavLinkItem[] = [ diff --git a/src/locales/ar.json b/src/locales/ar.json index bd011b5fc..f8a34bb19 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "أرشيف الأولمبياد", "archive_year_label": "سنة النسخة", "archive_grade_label": "مجموعة الصفوف", - "archive_all_groups": "كل المجموعات" + "archive_all_groups": "كل المجموعات", + "certificates_title": "شهاداتي", + "certificates_intro": "شهادات مُستمَدَّة من نشاط مُقيَّم على WebCoder: مشاركات رسمية في مسابقات مُصنَّفة، ومعالم تصنيف، ومعالم مسائل محلولة. يمكن التحقق من كل شهادة علنًا برقمها التسلسلي.", + "certificates_empty": "لا شهادات بعد — أكمل رسميًا مسابقة مُصنَّفة أو حل أول ١٠ مسائل لك.", + "certificates_load_error": "تعذّر تحميل الشهادات.", + "certificates_kind_CONTEST_PARTICIPATION": "مشاركة في مسابقة", + "certificates_kind_RATING_MILESTONE": "معلم تصنيف", + "certificates_kind_SOLVE_MILESTONE": "معلم مسائل محلولة", + "certificates_valid_badge": "صالح", + "certificates_revoked_badge": "ملغى", + "certificates_view_verify": "عرض / تحقق", + "certificates_rank_label": "الترتيب النهائي", + "certificates_rating_label": "التصنيف بعد المسابقة", + "certificates_solved_label": "المسائل المحلولة", + "certificates_issued_label": "تاريخ الإصدار", + "certificates_serial_label": "الرقم التسلسلي", + "certificates_holder_label": "مُنح إلى", + "certificates_verify_title": "التحقق من الشهادة", + "certificates_verify_valid": "هذه الشهادة صالحة.", + "certificates_verify_revoked": "تم إلغاء هذه الشهادة.", + "certificates_verify_revoked_reason_label": "السبب", + "certificates_verify_note": "يمكن لأي شخص التحقق من هذه الشهادة على", + "certificates_print_button": "طباعة / حفظ بصيغة PDF", + "certificates_not_found_title": "لم يتم العثور على الشهادة", + "certificates_not_found_body": "لا تطابق رابط التحقق هذا أي شهادة. تحقق من الرقم التسلسلي أو الرابط ثم حاول مجددًا.", + "notification_certificate_title": "تم إصدار الشهادة", + "notification_certificate_body": "شهادة جديدة ({{serial}}) تنتظرك في صفحة شهاداتك.", + "nav_certificates": "الشهادات" } diff --git a/src/locales/bg.json b/src/locales/bg.json index 5f217d084..d4df1f3bc 100644 --- a/src/locales/bg.json +++ b/src/locales/bg.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Архив на олимпиадите", "archive_year_label": "Година на изданието", "archive_grade_label": "Група класове", - "archive_all_groups": "Всички групи" + "archive_all_groups": "Всички групи", + "certificates_title": "Моите сертификати", + "certificates_intro": "Сертификати, спечелени в WebCoder, всеки изведен от оценена дейност: официални участия в рейтингови състезания, рейтингови етапи и етапи решени задачи. Всеки сертификат може да бъде проверен публично по серийния си номер.", + "certificates_empty": "Все още няма сертификати — завършете официално рейтингово състезание или решете първите си 10 задачи.", + "certificates_load_error": "Сертификатите не можаха да се заредят.", + "certificates_kind_CONTEST_PARTICIPATION": "Участие в състезание", + "certificates_kind_RATING_MILESTONE": "Рейтингов етап", + "certificates_kind_SOLVE_MILESTONE": "Етап решени задачи", + "certificates_valid_badge": "Действителен", + "certificates_revoked_badge": "Отменен", + "certificates_view_verify": "Преглед / проверка", + "certificates_rank_label": "Крайно класиране", + "certificates_rating_label": "Рейтинг след състезанието", + "certificates_solved_label": "Решени задачи", + "certificates_issued_label": "Издаден на", + "certificates_serial_label": "Сериен номер", + "certificates_holder_label": "Връчен на", + "certificates_verify_title": "Проверка на сертификат", + "certificates_verify_valid": "Този сертификат е действителен.", + "certificates_verify_revoked": "Този сертификат е отменен.", + "certificates_verify_revoked_reason_label": "Причина", + "certificates_verify_note": "Всеки може да провери този сертификат на", + "certificates_print_button": "Печат / запазване като PDF", + "certificates_not_found_title": "Сертификатът не е намерен", + "certificates_not_found_body": "Тази проверъчна връзка не съответства на никой сертификат. Проверете серийния номер или връзката и опитайте отново.", + "notification_certificate_title": "Издаден сертификат", + "notification_certificate_body": "Нов сертификат ({{serial}}) ви чака на страницата със сертификати.", + "nav_certificates": "Сертификати" } diff --git a/src/locales/ca.json b/src/locales/ca.json index 0df4f32ac..fafb8b9b4 100644 --- a/src/locales/ca.json +++ b/src/locales/ca.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arxiu d'olimpíades", "archive_year_label": "Any de l'edició", "archive_grade_label": "Grup de curs", - "archive_all_groups": "Tots els grups" + "archive_all_groups": "Tots els grups", + "certificates_title": "Els meus certificats", + "certificates_intro": "Certificats obtinguts a WebCoder, cadascun derivat d'activitat avaluada: participacions oficials en concursos puntuats, fites de rating i fites de problemes resolts. Cada certificat es pot verificar públicament pel número de sèrie.", + "certificates_empty": "Encara sense certificats: finalitza oficialment un concurs puntuat o resol els teus primers 10 problemes.", + "certificates_load_error": "No s'han pogut carregar els certificats.", + "certificates_kind_CONTEST_PARTICIPATION": "Participació en concurs", + "certificates_kind_RATING_MILESTONE": "Fita de rating", + "certificates_kind_SOLVE_MILESTONE": "Fita de problemes resolts", + "certificates_valid_badge": "Vàlid", + "certificates_revoked_badge": "Revocat", + "certificates_view_verify": "Veure / verificar", + "certificates_rank_label": "Posició final", + "certificates_rating_label": "Rating després del concurs", + "certificates_solved_label": "Problemes resolts", + "certificates_issued_label": "Emès", + "certificates_serial_label": "Número de sèrie", + "certificates_holder_label": "Atorgat a", + "certificates_verify_title": "Verificació de certificat", + "certificates_verify_valid": "Aquest certificat és vàlid.", + "certificates_verify_revoked": "Aquest certificat ha estat revocat.", + "certificates_verify_revoked_reason_label": "Motiu", + "certificates_verify_note": "Qualsevol pot verificar aquest certificat a", + "certificates_print_button": "Imprimeix / desa com a PDF", + "certificates_not_found_title": "Certificat no trobat", + "certificates_not_found_body": "Aquest enllaç de verificació no correspon a cap certificat. Comprova la sèrie o l'enllaç i torna-ho a provar.", + "notification_certificate_title": "Certificat emès", + "notification_certificate_body": "Un nou certificat ({{serial}}) t'espera a la teva pàgina de certificats.", + "nav_certificates": "Certificats" } diff --git a/src/locales/de.json b/src/locales/de.json index a7e03a4e2..152c768f3 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olympiade-Archiv", "archive_year_label": "Jahrgang", "archive_grade_label": "Klassenstufe", - "archive_all_groups": "Alle Stufen" + "archive_all_groups": "Alle Stufen", + "certificates_title": "Meine Zertifikate", + "certificates_intro": "Auf WebCoder erworbene Zertifikate, jedes abgeleitet aus bewerteter Aktivität: offizielle Teilnahmen an gewerteten Wettbewerben, Rating-Meilensteine und Meilensteine gelöster Aufgaben. Jedes Zertifikat ist über seine Seriennummer öffentlich überprüfbar.", + "certificates_empty": "Noch keine Zertifikate — beende offiziell einen gewerteten Wettbewerb oder löse deine ersten 10 Aufgaben.", + "certificates_load_error": "Zertifikate konnten nicht geladen werden.", + "certificates_kind_CONTEST_PARTICIPATION": "Wettbewerbs-Teilnahme", + "certificates_kind_RATING_MILESTONE": "Rating-Meilenstein", + "certificates_kind_SOLVE_MILESTONE": "Meilenstein gelöster Aufgaben", + "certificates_valid_badge": "Gültig", + "certificates_revoked_badge": "Widerrufen", + "certificates_view_verify": "Ansehen / prüfen", + "certificates_rank_label": "Endplatzierung", + "certificates_rating_label": "Rating nach dem Wettbewerb", + "certificates_solved_label": "Gelöste Aufgaben", + "certificates_issued_label": "Ausgestellt", + "certificates_serial_label": "Seriennummer", + "certificates_holder_label": "Verliehen an", + "certificates_verify_title": "Zertifikatsprüfung", + "certificates_verify_valid": "Dieses Zertifikat ist gültig.", + "certificates_verify_revoked": "Dieses Zertifikat wurde widerrufen.", + "certificates_verify_revoked_reason_label": "Grund", + "certificates_verify_note": "Jeder kann dieses Zertifikat prüfen unter", + "certificates_print_button": "Drucken / als PDF speichern", + "certificates_not_found_title": "Zertifikat nicht gefunden", + "certificates_not_found_body": "Dieser Prüfungslink passt zu keinem Zertifikat. Prüfe Seriennummer oder Link und versuche es erneut.", + "notification_certificate_title": "Zertifikat ausgestellt", + "notification_certificate_body": "Ein neues Zertifikat ({{serial}}) wartet auf deiner Zertifikatsseite.", + "nav_certificates": "Zertifikate" } diff --git a/src/locales/el.json b/src/locales/el.json index c046aff92..e82e27ed4 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Αρχείο Ολυμπιάδων", "archive_year_label": "Έτος διεξαγωγής", "archive_grade_label": "Ομάδα τάξεων", - "archive_all_groups": "Όλες οι ομάδες" + "archive_all_groups": "Όλες οι ομάδες", + "certificates_title": "Τα πιστοποιητικά μου", + "certificates_intro": "Πιστοποιητικά που αποκτήθηκαν στο WebCoder, καθένα προερχόμενο από βαθμολογημένη δραστηριότητα: επίσημες συμμετοχές σε βαθμολογημένους διαγωνισμούς, όρια rating και όρια λυμένων προβλημάτων. Κάθε πιστοποιητικό επαληθεύεται δημόσια με τον σειριακό του αριθμό.", + "certificates_empty": "Δεν υπάρχουν ακόμη πιστοποιητικά — ολοκληρώστε επίσημα έναν βαθμολογημένο διαγωνισμό ή λύστε τα πρώτα σας 10 προβλήματα.", + "certificates_load_error": "Αποτυχία φόρτωσης των πιστοποιητικών.", + "certificates_kind_CONTEST_PARTICIPATION": "Συμμετοχή σε διαγωνισμό", + "certificates_kind_RATING_MILESTONE": "Όριο rating", + "certificates_kind_SOLVE_MILESTONE": "Όριο λυμένων προβλημάτων", + "certificates_valid_badge": "Έγκυρο", + "certificates_revoked_badge": "Ανακληθέν", + "certificates_view_verify": "Προβολή / επαλήθευση", + "certificates_rank_label": "Τελική κατάταξη", + "certificates_rating_label": "Rating μετά τον διαγωνισμό", + "certificates_solved_label": "Λυμένα προβλήματα", + "certificates_issued_label": "Εκδόθηκε", + "certificates_serial_label": "Σειριακός αριθμός", + "certificates_holder_label": "Απονεμήθηκε σε", + "certificates_verify_title": "Επαλήθευση πιστοποιητικού", + "certificates_verify_valid": "Αυτό το πιστοποιητικό είναι έγκυρο.", + "certificates_verify_revoked": "Αυτό το πιστοποιητικό έχει ανακληθεί.", + "certificates_verify_revoked_reason_label": "Αιτία", + "certificates_verify_note": "Οποιοσδήποτε μπορεί να επαληθεύσει αυτό το πιστοποιητικό στο", + "certificates_print_button": "Εκτύπωση / αποθήκευση ως PDF", + "certificates_not_found_title": "Το πιστοποιητικό δεν βρέθηκε", + "certificates_not_found_body": "Αυτός ο σύνδεσμος επαλήθευσης δεν αντιστοιχεί σε κάποιο πιστοποιητικό. Ελέγξτε τον σειριακό αριθμό ή τον σύνδεσμο και δοκιμάστε ξανά.", + "notification_certificate_title": "Εκδόθηκε πιστοποιητικό", + "notification_certificate_body": "Ένα νέο πιστοποιητικό ({{serial}}) σας περιμένει στη σελίδα πιστοποιητικών σας.", + "nav_certificates": "Πιστοποιητικά" } diff --git a/src/locales/en.json b/src/locales/en.json index 0a8457235..eb2620580 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olympiad archive", "archive_year_label": "Edition year", "archive_grade_label": "Grade group", - "archive_all_groups": "All groups" + "archive_all_groups": "All groups", + "certificates_title": "My certificates", + "certificates_intro": "Certificates earned on WebCoder, each derived from judged activity: official rated-contest finishes, rating milestones and solved-problem milestones. Every certificate is publicly verifiable by its serial number.", + "certificates_empty": "No certificates yet — officially finish a rated contest or solve your first 10 problems.", + "certificates_load_error": "Failed to load certificates.", + "certificates_kind_CONTEST_PARTICIPATION": "Contest participation", + "certificates_kind_RATING_MILESTONE": "Rating milestone", + "certificates_kind_SOLVE_MILESTONE": "Solved-problems milestone", + "certificates_valid_badge": "Valid", + "certificates_revoked_badge": "Revoked", + "certificates_view_verify": "View / verify", + "certificates_rank_label": "Final rank", + "certificates_rating_label": "Rating after contest", + "certificates_solved_label": "Problems solved", + "certificates_issued_label": "Issued", + "certificates_serial_label": "Serial", + "certificates_holder_label": "Awarded to", + "certificates_verify_title": "Certificate verification", + "certificates_verify_valid": "This certificate is valid.", + "certificates_verify_revoked": "This certificate has been revoked.", + "certificates_verify_revoked_reason_label": "Reason", + "certificates_verify_note": "Anyone can verify this certificate at", + "certificates_print_button": "Print / save as PDF", + "certificates_not_found_title": "Certificate not found", + "certificates_not_found_body": "This verification link does not match any certificate. Check the serial or link and try again.", + "notification_certificate_title": "Certificate issued", + "notification_certificate_body": "A new certificate ({{serial}}) is waiting on your certificates page.", + "nav_certificates": "Certificates" } diff --git a/src/locales/es.json b/src/locales/es.json index 8fe10b835..cbe4e2ad0 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archivo de olimpiadas", "archive_year_label": "Año de la edición", "archive_grade_label": "Grupo de grado", - "archive_all_groups": "Todos los grupos" + "archive_all_groups": "Todos los grupos", + "certificates_title": "Mis certificados", + "certificates_intro": "Certificados obtenidos en WebCoder, cada uno derivado de actividad evaluada: participaciones oficiales en concursos puntuados, hitos de rating e hitos de problemas resueltos. Cada certificado se puede verificar públicamente por su número de serie.", + "certificates_empty": "Aún no hay certificados: termina oficialmente un concurso puntuado o resuelve tus primeros 10 problemas.", + "certificates_load_error": "No se pudieron cargar los certificados.", + "certificates_kind_CONTEST_PARTICIPATION": "Participación en concurso", + "certificates_kind_RATING_MILESTONE": "Hito de rating", + "certificates_kind_SOLVE_MILESTONE": "Hito de problemas resueltos", + "certificates_valid_badge": "Válido", + "certificates_revoked_badge": "Revocado", + "certificates_view_verify": "Ver / verificar", + "certificates_rank_label": "Puesto final", + "certificates_rating_label": "Rating tras el concurso", + "certificates_solved_label": "Problemas resueltos", + "certificates_issued_label": "Emitido", + "certificates_serial_label": "Serie", + "certificates_holder_label": "Otorgado a", + "certificates_verify_title": "Verificación de certificado", + "certificates_verify_valid": "Este certificado es válido.", + "certificates_verify_revoked": "Este certificado ha sido revocado.", + "certificates_verify_revoked_reason_label": "Motivo", + "certificates_verify_note": "Cualquiera puede verificar este certificado en", + "certificates_print_button": "Imprimir / guardar como PDF", + "certificates_not_found_title": "Certificado no encontrado", + "certificates_not_found_body": "Este enlace de verificación no corresponde a ningún certificado. Comprueba la serie o el enlace e inténtalo de nuevo.", + "notification_certificate_title": "Certificado emitido", + "notification_certificate_body": "Un nuevo certificado ({{serial}}) te espera en tu página de certificados.", + "nav_certificates": "Certificados" } diff --git a/src/locales/fa.json b/src/locales/fa.json index b8dfc691e..43bc45deb 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "آرشیو المپیاد", "archive_year_label": "سال برگزاری", "archive_grade_label": "گروه پایه", - "archive_all_groups": "همه گروه‌ها" + "archive_all_groups": "همه گروه‌ها", + "certificates_title": "گواهی‌نامه‌های من", + "certificates_intro": "گواهی‌نامه‌های کسب‌شده در WebCoder، هر یک برگرفته از فعالیت ارزیابی‌شده: مشارکت رسمی در مسابقات ریت‌دار، نقاط عطف ریتینگ و نقاط عطف مسئله‌های حل‌شده. هر گواهی‌نامه را می‌توان با شماره سری آن به‌صورت عمومی راستی‌آزمایی کرد.", + "certificates_empty": "هنوز گواهی‌نامه‌ای نیست — یک مسابقه ریت‌دار را رسماً به پایان برسانید یا ۱۰ مسئله اول خود را حل کنید.", + "certificates_load_error": "بارگذاری گواهی‌نامه‌ها ناموفق بود.", + "certificates_kind_CONTEST_PARTICIPATION": "مشارکت در مسابقه", + "certificates_kind_RATING_MILESTONE": "نقطه عطف ریتینگ", + "certificates_kind_SOLVE_MILESTONE": "نقطه عطف مسئله‌های حل‌شده", + "certificates_valid_badge": "معتبر", + "certificates_revoked_badge": "ابطال‌شده", + "certificates_view_verify": "مشاهده / راستی‌آزمایی", + "certificates_rank_label": "رتبه نهایی", + "certificates_rating_label": "ریتینگ پس از مسابقه", + "certificates_solved_label": "مسئله‌های حل‌شده", + "certificates_issued_label": "تاریخ صدور", + "certificates_serial_label": "شماره سری", + "certificates_holder_label": "اعطا شده به", + "certificates_verify_title": "راستی‌آزمایی گواهی‌نامه", + "certificates_verify_valid": "این گواهی‌نامه معتبر است.", + "certificates_verify_revoked": "این گواهی‌نامه ابطال شده است.", + "certificates_verify_revoked_reason_label": "دلیل", + "certificates_verify_note": "هر کسی می‌تواند این گواهی‌نامه را در این نشانی راستی‌آزمایی کند:", + "certificates_print_button": "چاپ / ذخیره به‌صورت PDF", + "certificates_not_found_title": "گواهی‌نامه یافت نشد", + "certificates_not_found_body": "این پیوند راستی‌آزمایی با هیچ گواهی‌نامه‌ای مطابقت ندارد. شماره سری یا پیوند را بررسی کنید و دوباره تلاش کنید.", + "notification_certificate_title": "گواهی‌نامه صادر شد", + "notification_certificate_body": "یک گواهی‌نامه جدید ({{serial}}) در صفحه گواهی‌نامه‌های شما منتظر است.", + "nav_certificates": "گواهی‌نامه‌ها" } diff --git a/src/locales/fr.json b/src/locales/fr.json index 783bde398..d406d73a5 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archive des olympiades", "archive_year_label": "Année de l'édition", "archive_grade_label": "Groupe de niveau", - "archive_all_groups": "Tous les groupes" + "archive_all_groups": "Tous les groupes", + "certificates_title": "Mes certificats", + "certificates_intro": "Des certificats obtenus sur WebCoder, chacun dérivé d'une activité jugée : participations officielles à des concours notés, paliers de rating et paliers de problèmes résolus. Chaque certificat est vérifiable publiquement grâce à son numéro de série.", + "certificates_empty": "Aucun certificat pour l'instant — terminez officiellement un concours noté ou résolvez vos 10 premiers problèmes.", + "certificates_load_error": "Échec du chargement des certificats.", + "certificates_kind_CONTEST_PARTICIPATION": "Participation à un concours", + "certificates_kind_RATING_MILESTONE": "Palier de rating", + "certificates_kind_SOLVE_MILESTONE": "Palier de problèmes résolus", + "certificates_valid_badge": "Valide", + "certificates_revoked_badge": "Révoqué", + "certificates_view_verify": "Voir / vérifier", + "certificates_rank_label": "Rang final", + "certificates_rating_label": "Rating après le concours", + "certificates_solved_label": "Problèmes résolus", + "certificates_issued_label": "Délivré le", + "certificates_serial_label": "Numéro de série", + "certificates_holder_label": "Délivré à", + "certificates_verify_title": "Vérification de certificat", + "certificates_verify_valid": "Ce certificat est valide.", + "certificates_verify_revoked": "Ce certificat a été révoqué.", + "certificates_verify_revoked_reason_label": "Motif", + "certificates_verify_note": "N'importe qui peut vérifier ce certificat à l'adresse", + "certificates_print_button": "Imprimer / enregistrer en PDF", + "certificates_not_found_title": "Certificat introuvable", + "certificates_not_found_body": "Ce lien de vérification ne correspond à aucun certificat. Vérifiez le numéro de série ou le lien, puis réessayez.", + "notification_certificate_title": "Certificat délivré", + "notification_certificate_body": "Un nouveau certificat ({{serial}}) vous attend sur votre page de certificats.", + "nav_certificates": "Certificats" } diff --git a/src/locales/hi.json b/src/locales/hi.json index be3bb9a99..9dc9a7b1c 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "ओलंपियाड संग्रह", "archive_year_label": "संस्करण वर्ष", "archive_grade_label": "कक्षा समूह", - "archive_all_groups": "सभी समूह" + "archive_all_groups": "सभी समूह", + "certificates_title": "मेरे प्रमाणपत्र", + "certificates_intro": "WebCoder पर अर्जित प्रमाणपत्र, प्रत्येक निर्णायित गतिविधि से व्युत्पन्न: रेटेड प्रतियोगिताओं में आधिकारिक भागीदारी, रेटिंग मील के पत्थर और हल किए गए प्रॉब्लम मील के पत्थर। प्रत्येक प्रमाणपत्र सीरियल नंबर से सार्वजनिक रूप से सत्यापित किया जा सकता है।", + "certificates_empty": "अभी कोई प्रमाणपत्र नहीं — किसी रेटेड प्रतियोगिता को आधिकारिक रूप से पूरा करें या अपनी पहली 10 प्रॉब्लम हल करें।", + "certificates_load_error": "प्रमाणपत्र लोड करने में विफल।", + "certificates_kind_CONTEST_PARTICIPATION": "प्रतियोगिता भागीदारी", + "certificates_kind_RATING_MILESTONE": "रेटिंग मील का पत्थर", + "certificates_kind_SOLVE_MILESTONE": "हल किए गए प्रॉब्लम मील का पत्थर", + "certificates_valid_badge": "वैध", + "certificates_revoked_badge": "निरसित", + "certificates_view_verify": "देखें / सत्यापित करें", + "certificates_rank_label": "अंतिम स्थान", + "certificates_rating_label": "प्रतियोगिता के बाद रेटिंग", + "certificates_solved_label": "हल किए गए प्रॉब्लम", + "certificates_issued_label": "जारी", + "certificates_serial_label": "सीरियल नंबर", + "certificates_holder_label": "प्रदान किया गया", + "certificates_verify_title": "प्रमाणपत्र सत्यापन", + "certificates_verify_valid": "यह प्रमाणपत्र वैध है।", + "certificates_verify_revoked": "यह प्रमाणपत्र निरसित कर दिया गया है।", + "certificates_verify_revoked_reason_label": "कारण", + "certificates_verify_note": "कोई भी इस प्रमाणपत्र को इस पते पर सत्यापित कर सकता है:", + "certificates_print_button": "प्रिंट / PDF के रूप में सहेजें", + "certificates_not_found_title": "प्रमाणपत्र नहीं मिला", + "certificates_not_found_body": "यह सत्यापन लिंक किसी प्रमाणपत्र से मेल नहीं खाता। सीरियल नंबर या लिंक जाँचें और फिर प्रयास करें।", + "notification_certificate_title": "प्रमाणपत्र जारी हुआ", + "notification_certificate_body": "एक नया प्रमाणपत्र ({{serial}}) आपके प्रमाणपत्र पृष्ठ पर प्रतीक्षा कर रहा है।", + "nav_certificates": "प्रमाणपत्र" } diff --git a/src/locales/hr.json b/src/locales/hr.json index aad5eecc7..ff35554da 100644 --- a/src/locales/hr.json +++ b/src/locales/hr.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arhiva olimpijada", "archive_year_label": "Godina izdanja", "archive_grade_label": "Skupina razreda", - "archive_all_groups": "Sve skupine" + "archive_all_groups": "Sve skupine", + "certificates_title": "Moji certifikati", + "certificates_intro": "Certifikati stečeni na WebCoderu, svaki izveden iz ocijenjene aktivnosti: službena sudjelovanja na bodovnim natjecanjima, prekretnice ratinga i prekretnice riješenih zadataka. Svaki je certifikat javno provjerljiv po serijskom broju.", + "certificates_empty": "Još nema certifikata — službeno završi bodovno natjecanje ili riješi svojih prvih 10 zadataka.", + "certificates_load_error": "Učitavanje certifikata nije uspjelo.", + "certificates_kind_CONTEST_PARTICIPATION": "Sudjelovanje u natjecanju", + "certificates_kind_RATING_MILESTONE": "Prekretnica ratinga", + "certificates_kind_SOLVE_MILESTONE": "Prekretnica riješenih zadataka", + "certificates_valid_badge": "Valjan", + "certificates_revoked_badge": "Opozvan", + "certificates_view_verify": "Pogledaj / provjeri", + "certificates_rank_label": "Konačni plasman", + "certificates_rating_label": "Rating nakon natjecanja", + "certificates_solved_label": "Riješeni zadaci", + "certificates_issued_label": "Izdano", + "certificates_serial_label": "Serijski broj", + "certificates_holder_label": "Dodijeljeno", + "certificates_verify_title": "Provjera certifikata", + "certificates_verify_valid": "Ovaj certifikat je valjan.", + "certificates_verify_revoked": "Ovaj certifikat je opozvan.", + "certificates_verify_revoked_reason_label": "Razlog", + "certificates_verify_note": "Svatko može provjeriti ovaj certifikat na", + "certificates_print_button": "Ispiši / spremi kao PDF", + "certificates_not_found_title": "Certifikat nije pronađen", + "certificates_not_found_body": "Ova poveznica za provjeru ne odgovara nijednom certifikatu. Provjeri serijski broj ili poveznicu i pokušaj ponovno.", + "notification_certificate_title": "Certifikat izdan", + "notification_certificate_body": "Novi certifikat ({{serial}}) čeka te na tvojoj stranici certifikata.", + "nav_certificates": "Certifikati" } diff --git a/src/locales/hu.json b/src/locales/hu.json index 53d04acfc..ebef77135 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olimpiai archívum", "archive_year_label": "Kiadás éve", "archive_grade_label": "Évfolyamcsoport", - "archive_all_groups": "Minden csoport" + "archive_all_groups": "Minden csoport", + "certificates_title": "Tanúsítványaim", + "certificates_intro": "A WebCoderön szerzett tanúsítványok, mindegyik értékelt tevékenységből származik: hivatalos részvétel értékelt versenyeken, rating-mérföldkövek és megoldott feladatok mérföldkövei. Minden tanúsítvány a sorozatszámával nyilvánosan ellenőrizhető.", + "certificates_empty": "Még nincs tanúsítvány — fejezz be hivatalosan egy értékelt versenyt, vagy oldj meg első 10 feladatod.", + "certificates_load_error": "A tanúsítványok betöltése nem sikerült.", + "certificates_kind_CONTEST_PARTICIPATION": "Verseny-részvétel", + "certificates_kind_RATING_MILESTONE": "Rating-mérföldkő", + "certificates_kind_SOLVE_MILESTONE": "Megoldott feladatok mérföldköve", + "certificates_valid_badge": "Érvényes", + "certificates_revoked_badge": "Visszavonva", + "certificates_view_verify": "Megtekintés / ellenőrzés", + "certificates_rank_label": "Végső helyezés", + "certificates_rating_label": "Rating a verseny után", + "certificates_solved_label": "Megoldott feladatok", + "certificates_issued_label": "Kibocsátva", + "certificates_serial_label": "Sorozatszám", + "certificates_holder_label": "Tulajdonos", + "certificates_verify_title": "Tanúsítvány-ellenőrzés", + "certificates_verify_valid": "Ez a tanúsítvány érvényes.", + "certificates_verify_revoked": "Ez a tanúsítvány visszavonva.", + "certificates_verify_revoked_reason_label": "Indok", + "certificates_verify_note": "Ez a tanúsítvány bárki által ellenőrizhető itt:", + "certificates_print_button": "Nyomtatás / mentés PDF-ként", + "certificates_not_found_title": "A tanúsítvány nem található", + "certificates_not_found_body": "Ez az ellenőrző hivatkozás egyik tanúsítványhoz sem illik. Ellenőrizd a sorozatszámot vagy a hivatkozást, és próbáld újra.", + "notification_certificate_title": "Tanúsítvány kiadva", + "notification_certificate_body": "Egy új tanúsítvány ({{serial}}) vár a tanúsítványoldaladon.", + "nav_certificates": "Tanúsítványok" } diff --git a/src/locales/id.json b/src/locales/id.json index 3db598a3d..1dba043e5 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arsip olimpiade", "archive_year_label": "Tahun penyelenggaraan", "archive_grade_label": "Kelompok kelas", - "archive_all_groups": "Semua kelompok" + "archive_all_groups": "Semua kelompok", + "certificates_title": "Sertifikat saya", + "certificates_intro": "Sertifikat yang diperoleh di WebCoder, masing-masing diturunkan dari aktivitas yang dinilai: partisipasi resmi dalam kontes berating, tonggak rating, dan tonggak masalah yang diselesaikan. Setiap sertifikat dapat diverifikasi secara publik melalui nomor serinya.", + "certificates_empty": "Belum ada sertifikat — selesaikan secara resmi sebuah kontes berating atau selesaikan 10 masalah pertamamu.", + "certificates_load_error": "Gagal memuat sertifikat.", + "certificates_kind_CONTEST_PARTICIPATION": "Partisipasi kontes", + "certificates_kind_RATING_MILESTONE": "Tonggak rating", + "certificates_kind_SOLVE_MILESTONE": "Tonggak masalah terpecahkan", + "certificates_valid_badge": "Valid", + "certificates_revoked_badge": "Dicabut", + "certificates_view_verify": "Lihat / verifikasi", + "certificates_rank_label": "Peringkat akhir", + "certificates_rating_label": "Rating setelah kontes", + "certificates_solved_label": "Masalah terpecahkan", + "certificates_issued_label": "Diterbitkan", + "certificates_serial_label": "Nomor seri", + "certificates_holder_label": "Diberikan kepada", + "certificates_verify_title": "Verifikasi sertifikat", + "certificates_verify_valid": "Sertifikat ini valid.", + "certificates_verify_revoked": "Sertifikat ini telah dicabut.", + "certificates_verify_revoked_reason_label": "Alasan", + "certificates_verify_note": "Siapa pun dapat memverifikasi sertifikat ini di", + "certificates_print_button": "Cetak / simpan sebagai PDF", + "certificates_not_found_title": "Sertifikat tidak ditemukan", + "certificates_not_found_body": "Tautan verifikasi ini tidak cocok dengan sertifikat apa pun. Periksa nomor seri atau tautannya lalu coba lagi.", + "notification_certificate_title": "Sertifikat diterbitkan", + "notification_certificate_body": "Sertifikat baru ({{serial}}) menunggumu di halaman sertifikatmu.", + "nav_certificates": "Sertifikat" } diff --git a/src/locales/it.json b/src/locales/it.json index 9ca65d0cb..2431c5fef 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archivio delle olimpiadi", "archive_year_label": "Anno dell'edizione", "archive_grade_label": "Gruppo di classe", - "archive_all_groups": "Tutti i gruppi" + "archive_all_groups": "Tutti i gruppi", + "certificates_title": "I miei certificati", + "certificates_intro": "Certificati conseguiti su WebCoder, ciascuno derivato da attività valutata: partecipazioni ufficiali a gare con rating, traguardi di rating e traguardi di problemi risolti. Ogni certificato è verificabile pubblicamente tramite il numero di serie.", + "certificates_empty": "Ancora nessun certificato — concludi ufficialmente una gara con rating o risolvi i tuoi primi 10 problemi.", + "certificates_load_error": "Impossibile caricare i certificati.", + "certificates_kind_CONTEST_PARTICIPATION": "Partecipazione a una gara", + "certificates_kind_RATING_MILESTONE": "Traguardo di rating", + "certificates_kind_SOLVE_MILESTONE": "Traguardo di problemi risolti", + "certificates_valid_badge": "Valido", + "certificates_revoked_badge": "Revocato", + "certificates_view_verify": "Vedi / verifica", + "certificates_rank_label": "Posizione finale", + "certificates_rating_label": "Rating dopo la gara", + "certificates_solved_label": "Problemi risolti", + "certificates_issued_label": "Rilasciato", + "certificates_serial_label": "Matricola", + "certificates_holder_label": "Assegnato a", + "certificates_verify_title": "Verifica del certificato", + "certificates_verify_valid": "Questo certificato è valido.", + "certificates_verify_revoked": "Questo certificato è stato revocato.", + "certificates_verify_revoked_reason_label": "Motivo", + "certificates_verify_note": "Chiunque può verificare questo certificato su", + "certificates_print_button": "Stampa / salva come PDF", + "certificates_not_found_title": "Certificato non trovato", + "certificates_not_found_body": "Questo collegamento di verifica non corrisponde ad alcun certificato. Controlla la matricola o il collegamento e riprova.", + "notification_certificate_title": "Certificato rilasciato", + "notification_certificate_body": "Un nuovo certificato ({{serial}}) ti aspetta nella tua pagina dei certificati.", + "nav_certificates": "Certificati" } diff --git a/src/locales/ja.json b/src/locales/ja.json index 206ffb62d..274e53384 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "オリンピアードアーカイブ", "archive_year_label": "開催年", "archive_grade_label": "学年グループ", - "archive_all_groups": "すべてのグループ" + "archive_all_groups": "すべてのグループ", + "certificates_title": "マイ証明書", + "certificates_intro": "WebCoder で獲得した証明書。いずれも採点済みの活動(レーテッドコンテストの公式参加、レーティングマイルストーン、問題解答マイルストーン)に基づいています。各証明書はシリアル番号で公開検証できます。", + "certificates_empty": "まだ証明書はありません。レーテッドコンテストを公式に完走するか、最初の 10 問を解いてみましょう。", + "certificates_load_error": "証明書の読み込みに失敗しました。", + "certificates_kind_CONTEST_PARTICIPATION": "コンテスト参加", + "certificates_kind_RATING_MILESTONE": "レーティングマイルストーン", + "certificates_kind_SOLVE_MILESTONE": "解答マイルストーン", + "certificates_valid_badge": "有効", + "certificates_revoked_badge": "失効", + "certificates_view_verify": "表示 / 検証", + "certificates_rank_label": "最終順位", + "certificates_rating_label": "コンテスト後のレーティング", + "certificates_solved_label": "解答済み問題数", + "certificates_issued_label": "発行日", + "certificates_serial_label": "シリアル番号", + "certificates_holder_label": "授与者", + "certificates_verify_title": "証明書の検証", + "certificates_verify_valid": "この証明書は有効です。", + "certificates_verify_revoked": "この証明書は失効しています。", + "certificates_verify_revoked_reason_label": "理由", + "certificates_verify_note": "この証明書は誰でも次の URL で検証できます:", + "certificates_print_button": "印刷 / PDF として保存", + "certificates_not_found_title": "証明書が見つかりません", + "certificates_not_found_body": "この検証リンクはどの証明書にも一致しません。シリアル番号かリンクを確認して、もう一度お試しください。", + "notification_certificate_title": "証明書が発行されました", + "notification_certificate_body": "新しい証明書({{serial}})が証明書ページで待っています。", + "nav_certificates": "証明書" } diff --git a/src/locales/kk.json b/src/locales/kk.json index 26ab397d9..bcde4862e 100644 --- a/src/locales/kk.json +++ b/src/locales/kk.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Олимпиада мұрағаты", "archive_year_label": "Өткізілген жылы", "archive_grade_label": "Сынып тобы", - "archive_all_groups": "Барлық топтар" + "archive_all_groups": "Барлық топтар", + "certificates_title": "Менің сертификаттарым", + "certificates_intro": "WebCoder-де алынған сертификаттар, әрқайсысы бағаланған әрекеттен алынған: рейтингілі жарыстарға ресми қатысу, рейтинг шектері және шешілген есептер шектері. Әр сертификат сериялық нөмірі бойынша жария түрде тексеруге болады.", + "certificates_empty": "Әзірге сертификат жоқ — рейтингілі жарысты ресми аяқтаңыз немесе алғашқы 10 есебін шешіңіз.", + "certificates_load_error": "Сертификаттарды жүктеу сәтсіз аяқталды.", + "certificates_kind_CONTEST_PARTICIPATION": "Жарысқа қатысу", + "certificates_kind_RATING_MILESTONE": "Рейтинг шегі", + "certificates_kind_SOLVE_MILESTONE": "Шешілген есептер шегі", + "certificates_valid_badge": "Жарамды", + "certificates_revoked_badge": "Қайтарылды", + "certificates_view_verify": "Көру / тексеру", + "certificates_rank_label": "Қорытынды орын", + "certificates_rating_label": "Жарыстан кейінгі рейтинг", + "certificates_solved_label": "Шешілген есептер", + "certificates_issued_label": "Шығарылды", + "certificates_serial_label": "Сериялық нөмір", + "certificates_holder_label": "Табысталды", + "certificates_verify_title": "Сертификатты тексеру", + "certificates_verify_valid": "Бұл сертификат жарамды.", + "certificates_verify_revoked": "Бұл сертификат қайтарылды.", + "certificates_verify_revoked_reason_label": "Себеп", + "certificates_verify_note": "Бұл сертификатты кез келген адам мына мекенжайда тексере алады:", + "certificates_print_button": "Басып шығару / PDF ретінде сақтау", + "certificates_not_found_title": "Сертификат табылмады", + "certificates_not_found_body": "Бұл тексеру сілтемесі ешбір сертификатқа сәйкес келмейді. Сериялық нөмірді немесе сілтемені тексеріп, қайта байқап көріңіз.", + "notification_certificate_title": "Сертификат шығарылды", + "notification_certificate_body": "Жаңа сертификат ({{serial}}) сертификаттар парағында сені күтіп тұр.", + "nav_certificates": "Сертификаттар" } diff --git a/src/locales/ko.json b/src/locales/ko.json index fdbebc43c..0641addc7 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "올림피아드 아카이브", "archive_year_label": "개최 연도", "archive_grade_label": "학년 그룹", - "archive_all_groups": "모든 그룹" + "archive_all_groups": "모든 그룹", + "certificates_title": "내 인증서", + "certificates_intro": "WebCoder에서 획득한 인증서입니다. 각 인증서는 평가된 활동(공식 레이팅 대회 참가, 레이팅 마일스톤, 문제 해결 마일스톤)에서 도출됩니다. 모든 인증서는 일련번호로 공개 검증할 수 있습니다.", + "certificates_empty": "아직 인증서가 없습니다. 공식 레이팅 대회를 완주하거나 첫 10문제를 해결해 보세요.", + "certificates_load_error": "인증서를 불러오지 못했습니다.", + "certificates_kind_CONTEST_PARTICIPATION": "대회 참가", + "certificates_kind_RATING_MILESTONE": "레이팅 마일스톤", + "certificates_kind_SOLVE_MILESTONE": "문제 해결 마일스톤", + "certificates_valid_badge": "유효", + "certificates_revoked_badge": "취소됨", + "certificates_view_verify": "보기 / 검증", + "certificates_rank_label": "최종 순위", + "certificates_rating_label": "대회 후 레이팅", + "certificates_solved_label": "해결한 문제 수", + "certificates_issued_label": "발급일", + "certificates_serial_label": "일련번호", + "certificates_holder_label": "수여자", + "certificates_verify_title": "인증서 검증", + "certificates_verify_valid": "이 인증서는 유효합니다.", + "certificates_verify_revoked": "이 인증서는 취소되었습니다.", + "certificates_verify_revoked_reason_label": "사유", + "certificates_verify_note": "누구나 다음 주소에서 이 인증서를 검증할 수 있습니다:", + "certificates_print_button": "인쇄 / PDF로 저장", + "certificates_not_found_title": "인증서를 찾을 수 없습니다", + "certificates_not_found_body": "이 검증 링크는 어떤 인증서와도 일치하지 않습니다. 일련번호나 링크를 확인하고 다시 시도하세요.", + "notification_certificate_title": "인증서가 발급되었습니다", + "notification_certificate_body": "새 인증서({{serial}})가 인증서 페이지에서 기다리고 있습니다.", + "nav_certificates": "인증서" } diff --git a/src/locales/pl.json b/src/locales/pl.json index 2c6c7dda4..42b868c6e 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Archiwum olimpiad", "archive_year_label": "Rocznik", "archive_grade_label": "Grupa klas", - "archive_all_groups": "Wszystkie grupy" + "archive_all_groups": "Wszystkie grupy", + "certificates_title": "Moje certyfikaty", + "certificates_intro": "Certyfikaty zdobyte na WebCoderze, każdy uzyskany z ocenianej aktywności: oficjalne udziały w notowanych konkursach, progi ratingowe i progi rozwiązanych zadań. Każdy certyfikat można publicznie zweryfikować po numerze seryjnym.", + "certificates_empty": "Brak certyfikatów — zakończ oficjalnie notowany konkurs albo rozwiąż swoje pierwsze 10 zadań.", + "certificates_load_error": "Nie udało się wczytać certyfikatów.", + "certificates_kind_CONTEST_PARTICIPATION": "Udział w konkursie", + "certificates_kind_RATING_MILESTONE": "Próg ratingowy", + "certificates_kind_SOLVE_MILESTONE": "Próg rozwiązanych zadań", + "certificates_valid_badge": "Ważny", + "certificates_revoked_badge": "Unieważniony", + "certificates_view_verify": "Zobacz / zweryfikuj", + "certificates_rank_label": "Miejsce końcowe", + "certificates_rating_label": "Rating po konkursie", + "certificates_solved_label": "Rozwiązane zadania", + "certificates_issued_label": "Wydano", + "certificates_serial_label": "Numer seryjny", + "certificates_holder_label": "Przyznany dla", + "certificates_verify_title": "Weryfikacja certyfikatu", + "certificates_verify_valid": "Ten certyfikat jest ważny.", + "certificates_verify_revoked": "Ten certyfikat został unieważniony.", + "certificates_verify_revoked_reason_label": "Powód", + "certificates_verify_note": "Każdy może zweryfikować ten certyfikat pod adresem", + "certificates_print_button": "Drukuj / zapisz jako PDF", + "certificates_not_found_title": "Nie znaleziono certyfikatu", + "certificates_not_found_body": "Ten link weryfikacyjny nie pasuje do żadnego certyfikatu. Sprawdź numer seryjny lub link i spróbuj ponownie.", + "notification_certificate_title": "Wydano certyfikat", + "notification_certificate_body": "Nowy certyfikat ({{serial}}) czeka na Twojej stronie certyfikatów.", + "nav_certificates": "Certyfikaty" } diff --git a/src/locales/pt.json b/src/locales/pt.json index fd862e9b7..77a5c4967 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arquivo de olimpíadas", "archive_year_label": "Ano da edição", "archive_grade_label": "Grupo de série", - "archive_all_groups": "Todos os grupos" + "archive_all_groups": "Todos os grupos", + "certificates_title": "Meus certificados", + "certificates_intro": "Certificados obtidos no WebCoder, cada um derivado de atividade avaliada: participações oficiais em concursos pontuados, marcos de rating e marcos de problemas resolvidos. Cada certificado pode ser verificado publicamente pelo número de série.", + "certificates_empty": "Ainda sem certificados — conclua oficialmente um concurso pontuado ou resolva seus primeiros 10 problemas.", + "certificates_load_error": "Falha ao carregar os certificados.", + "certificates_kind_CONTEST_PARTICIPATION": "Participação em concurso", + "certificates_kind_RATING_MILESTONE": "Marco de rating", + "certificates_kind_SOLVE_MILESTONE": "Marco de problemas resolvidos", + "certificates_valid_badge": "Válido", + "certificates_revoked_badge": "Revogado", + "certificates_view_verify": "Ver / verificar", + "certificates_rank_label": "Classificação final", + "certificates_rating_label": "Rating após o concurso", + "certificates_solved_label": "Problemas resolvidos", + "certificates_issued_label": "Emitido", + "certificates_serial_label": "Número de série", + "certificates_holder_label": "Atribuído a", + "certificates_verify_title": "Verificação de certificado", + "certificates_verify_valid": "Este certificado é válido.", + "certificates_verify_revoked": "Este certificado foi revogado.", + "certificates_verify_revoked_reason_label": "Motivo", + "certificates_verify_note": "Qualquer pessoa pode verificar este certificado em", + "certificates_print_button": "Imprimir / salvar como PDF", + "certificates_not_found_title": "Certificado não encontrado", + "certificates_not_found_body": "Este link de verificação não corresponde a nenhum certificado. Confira o número de série ou o link e tente novamente.", + "notification_certificate_title": "Certificado emitido", + "notification_certificate_body": "Um novo certificado ({{serial}}) espera por você na sua página de certificados.", + "nav_certificates": "Certificados" } diff --git a/src/locales/ro.json b/src/locales/ro.json index 1c46f6302..88ff7a267 100644 --- a/src/locales/ro.json +++ b/src/locales/ro.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arhiva olimpiadelor", "archive_year_label": "Anul ediției", "archive_grade_label": "Grupa de clase", - "archive_all_groups": "Toate grupele" + "archive_all_groups": "Toate grupele", + "certificates_title": "Atestatele mele", + "certificates_intro": "Atestate obținute pe WebCoder, fiecare derivat din activitate evaluată: participări oficiale la concursuri notate, praguri de rating și praguri de probleme rezolvate. Fiecare atestat poate fi verificat public după numărul de serie.", + "certificates_empty": "Încă niciun atestat — termină oficial un concurs notat sau rezolvă primele 10 probleme.", + "certificates_load_error": "Încărcarea atestatelor a eșuat.", + "certificates_kind_CONTEST_PARTICIPATION": "Participare la concurs", + "certificates_kind_RATING_MILESTONE": "Prag de rating", + "certificates_kind_SOLVE_MILESTONE": "Prag de probleme rezolvate", + "certificates_valid_badge": "Valid", + "certificates_revoked_badge": "Revocat", + "certificates_view_verify": "Vezi / verifică", + "certificates_rank_label": "Loc final", + "certificates_rating_label": "Rating după concurs", + "certificates_solved_label": "Probleme rezolvate", + "certificates_issued_label": "Eliberat", + "certificates_serial_label": "Serie", + "certificates_holder_label": "Acordat lui", + "certificates_verify_title": "Verificare atestat", + "certificates_verify_valid": "Acest atestat este valid.", + "certificates_verify_revoked": "Acest atestat a fost revocat.", + "certificates_verify_revoked_reason_label": "Motiv", + "certificates_verify_note": "Oricine poate verifica acest atestat la", + "certificates_print_button": "Tipărește / salvează ca PDF", + "certificates_not_found_title": "Atestat inexistent", + "certificates_not_found_body": "Această legătură de verificare nu corespunde niciunui atestat. Verifică seria sau legătura și încearcă din nou.", + "notification_certificate_title": "Atestat eliberat", + "notification_certificate_body": "Un nou atestat ({{serial}}) te așteaptă pe pagina atestatelor tale.", + "nav_certificates": "Atestate" } diff --git a/src/locales/ru.json b/src/locales/ru.json index 956344137..fa535ef79 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Архив олимпиад", "archive_year_label": "Год проведения", "archive_grade_label": "Группа классов", - "archive_all_groups": "Все группы" + "archive_all_groups": "Все группы", + "certificates_title": "Мои сертификаты", + "certificates_intro": "Сертификаты, полученные на WebCoder, каждый выведен из проверенной активности: официальные участия в рейтинговых contests, рубежи рейтинга и рубежи решённых задач. Каждый сертификат можно публично проверить по серийному номеру.", + "certificates_empty": "Сертификатов пока нет — официально завершите рейтинговый contest или решите первые 10 задач.", + "certificates_load_error": "Не удалось загрузить сертификаты.", + "certificates_kind_CONTEST_PARTICIPATION": "Участие в contest", + "certificates_kind_RATING_MILESTONE": "Рубеж рейтинга", + "certificates_kind_SOLVE_MILESTONE": "Рубеж решённых задач", + "certificates_valid_badge": "Действителен", + "certificates_revoked_badge": "Отозван", + "certificates_view_verify": "Открыть / проверить", + "certificates_rank_label": "Итоговое место", + "certificates_rating_label": "Рейтинг после contest", + "certificates_solved_label": "Решённые задачи", + "certificates_issued_label": "Выдан", + "certificates_serial_label": "Серийный номер", + "certificates_holder_label": "Вручён", + "certificates_verify_title": "Проверка сертификата", + "certificates_verify_valid": "Этот сертификат действителен.", + "certificates_verify_revoked": "Этот сертификат отозван.", + "certificates_verify_revoked_reason_label": "Причина", + "certificates_verify_note": "Проверить этот сертификат может любой по адресу", + "certificates_print_button": "Печать / сохранить в PDF", + "certificates_not_found_title": "Сертификат не найден", + "certificates_not_found_body": "Эта ссылка не соответствует ни одному сертификату. Проверьте серийный номер или ссылку и попробуйте снова.", + "notification_certificate_title": "Выдан сертификат", + "notification_certificate_body": "Новый сертификат ({{serial}}) ждёт вас на странице сертификатов.", + "nav_certificates": "Сертификаты" } diff --git a/src/locales/sr-Latn.json b/src/locales/sr-Latn.json index 3e47d84a7..32368c296 100644 --- a/src/locales/sr-Latn.json +++ b/src/locales/sr-Latn.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Arhiva olimpijada", "archive_year_label": "Godina izdanja", "archive_grade_label": "Grupa razreda", - "archive_all_groups": "Sve grupe" + "archive_all_groups": "Sve grupe", + "certificates_title": "Moji sertifikati", + "certificates_intro": "Sertifikati stečeni na WebCoderu, svaki izveden iz ocenjene aktivnosti: zvanična učešća na rangiranim takmičenjima, prekretnice rejtinga i prekretnice rešenih zadataka. Svaki sertifikat je javno proverljiv po serijskom broju.", + "certificates_empty": "Još nema sertifikata — zvanično završi rangirano takmičenje ili reši svojih prvih 10 zadataka.", + "certificates_load_error": "Učitavanje sertifikata nije uspelo.", + "certificates_kind_CONTEST_PARTICIPATION": "Učešće na takmičenju", + "certificates_kind_RATING_MILESTONE": "Prekretnica rejtinga", + "certificates_kind_SOLVE_MILESTONE": "Prekretnica rešenih zadataka", + "certificates_valid_badge": "Važeći", + "certificates_revoked_badge": "Opozvan", + "certificates_view_verify": "Pogledaj / proveri", + "certificates_rank_label": "Konačno mesto", + "certificates_rating_label": "Rejting nakon takmičenja", + "certificates_solved_label": "Rešeni zadaci", + "certificates_issued_label": "Izdato", + "certificates_serial_label": "Serijski broj", + "certificates_holder_label": "Dodeljeno", + "certificates_verify_title": "Provera sertifikata", + "certificates_verify_valid": "Ovaj sertifikat je važeći.", + "certificates_verify_revoked": "Ovaj sertifikat je opozvan.", + "certificates_verify_revoked_reason_label": "Razlog", + "certificates_verify_note": "Svako može proveriti ovaj sertifikat na", + "certificates_print_button": "Štampaj / sačuvaj kao PDF", + "certificates_not_found_title": "Sertifikat nije pronađen", + "certificates_not_found_body": "Ovaj link za proveru ne odgovara nijednom sertifikatu. Proveri serijski broj ili link i pokušaj ponovo.", + "notification_certificate_title": "Sertifikat izdat", + "notification_certificate_body": "Novi sertifikat ({{serial}}) čeka te na tvojoj stranici sertifikata.", + "nav_certificates": "Sertifikati" } diff --git a/src/locales/th.json b/src/locales/th.json index 2043327b1..5f261c93a 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "คลังข้อสอบโอลิมปิก", "archive_year_label": "ปีที่จัด", "archive_grade_label": "กลุ่มชั้นเรียน", - "archive_all_groups": "ทุกกลุ่ม" + "archive_all_groups": "ทุกกลุ่ม", + "certificates_title": "ใบรับรองของฉัน", + "certificates_intro": "ใบรับรองที่ได้รับบน WebCoder ซึ่งแต่ละใบได้จากกิจกรรมที่ถูกตัดสิน: การเข้าร่วมการแข่งขันแบบมีเรตติ้งอย่างเป็นทางการ ไมล์สโตนเรตติ้ง และไมล์สโตนจำนวนโจทย์ที่แก้ได้ ทุกใบรับรองตรวจสอบได้สาธารณะด้วยหมายเลขซีเรียล", + "certificates_empty": "ยังไม่มีใบรับรอง — จบการแข่งขันแบบมีเรตติ้งอย่างเป็นทางการ หรือแก้โจทย์ 10 ข้อแรกของคุณ", + "certificates_load_error": "โหลดใบรับรองไม่สำเร็จ", + "certificates_kind_CONTEST_PARTICIPATION": "การเข้าร่วมการแข่งขัน", + "certificates_kind_RATING_MILESTONE": "ไมล์สโตนเรตติ้ง", + "certificates_kind_SOLVE_MILESTONE": "ไมล์สโตนโจทย์ที่แก้ได้", + "certificates_valid_badge": "ใช้ได้", + "certificates_revoked_badge": "ถูกเพิกถอน", + "certificates_view_verify": "ดู / ตรวจสอบ", + "certificates_rank_label": "อันดับสุดท้าย", + "certificates_rating_label": "เรตติ้งหลังการแข่งขัน", + "certificates_solved_label": "โจทย์ที่แก้ได้", + "certificates_issued_label": "วันออกใบรับรอง", + "certificates_serial_label": "หมายเลขซีเรียล", + "certificates_holder_label": "มอบให้แก่", + "certificates_verify_title": "การตรวจสอบใบรับรอง", + "certificates_verify_valid": "ใบรับรองนี้ใช้ได้", + "certificates_verify_revoked": "ใบรับรองนี้ถูกเพิกถอนแล้ว", + "certificates_verify_revoked_reason_label": "เหตุผล", + "certificates_verify_note": "ทุกคนสามารถตรวจสอบใบรับรองนี้ได้ที่", + "certificates_print_button": "พิมพ์ / บันทึกเป็น PDF", + "certificates_not_found_title": "ไม่พบใบรับรอง", + "certificates_not_found_body": "ลิงก์ยืนยันนี้ไม่ตรงกับใบรับรองใด กรุณาตรวจสอบหมายเลขซีเรียลหรือลิงก์แล้วลองอีกครั้ง", + "notification_certificate_title": "ออกใบรับรองแล้ว", + "notification_certificate_body": "ใบรับรองใหม่ ({{serial}}) กำลังรอคุณอยู่ที่หน้าใบรับรองของคุณ", + "nav_certificates": "ใบรับรอง" } diff --git a/src/locales/tr.json b/src/locales/tr.json index e2bd6a438..0d27577fa 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Olimpiyat arşivi", "archive_year_label": "Yıl", "archive_grade_label": "Sınıf grubu", - "archive_all_groups": "Tüm gruplar" + "archive_all_groups": "Tüm gruplar", + "certificates_title": "Sertifikalarım", + "certificates_intro": "WebCoder'da kazanılan sertifikalar; her biri değerlendirilmiş etkinlikten türetilmiştir: resmî reytingli yarışma katılımları, reyting kilometre taşları ve çözülen problems kilometre taşları. Her sertifika seri numarasıyla herkes tarafından doğrulanabilir.", + "certificates_empty": "Henüz sertifika yok — resmî olarak bir reytingli yarışma tamamlayın veya ilk 10 problemi çözün.", + "certificates_load_error": "Sertifikalar yüklenemedi.", + "certificates_kind_CONTEST_PARTICIPATION": "Yarışma katılımı", + "certificates_kind_RATING_MILESTONE": "Reyting kilometre taşı", + "certificates_kind_SOLVE_MILESTONE": "Çözülen problems kilometre taşı", + "certificates_valid_badge": "Geçerli", + "certificates_revoked_badge": "İptal edildi", + "certificates_view_verify": "Görüntüle / doğrula", + "certificates_rank_label": "Final sıralaması", + "certificates_rating_label": "Yarışma sonrası reyting", + "certificates_solved_label": "Çözülen problemler", + "certificates_issued_label": "Veriliş", + "certificates_serial_label": "Seri numarası", + "certificates_holder_label": "Verilen kişi", + "certificates_verify_title": "Sertifika doğrulaması", + "certificates_verify_valid": "Bu sertifika geçerlidir.", + "certificates_verify_revoked": "Bu sertifika iptal edildi.", + "certificates_verify_revoked_reason_label": "Gerekçe", + "certificates_verify_note": "Herkes bu sertifikayı şu adresten doğrulayabilir:", + "certificates_print_button": "Yazdır / PDF olarak kaydet", + "certificates_not_found_title": "Sertifika bulunamadı", + "certificates_not_found_body": "Bu doğrulama bağlantısı hiçbir sertifikayla eşleşmiyor. Seri numarasını veya bağlantıyı kontrol edip tekrar deneyin.", + "notification_certificate_title": "Sertifika verildi", + "notification_certificate_body": "Yeni bir sertifika ({{serial}}) sertifikalar sayfanda seni bekliyor.", + "nav_certificates": "Sertifikalar" } diff --git a/src/locales/uk.json b/src/locales/uk.json index a8d39e650..aaf7e0516 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Архів олімпіад", "archive_year_label": "Рік проведення", "archive_grade_label": "Група класів", - "archive_all_groups": "Усі групи" + "archive_all_groups": "Усі групи", + "certificates_title": "Мої сертифікати", + "certificates_intro": "Сертифікати, здобуті на WebCoder, кожен виведений з оціненої активності: офіційні участі в рейтингових змаганнях, рубежі рейтингу та рубежі розв'язаних задач. Кожен сертифікат можна публічно перевірити за серійним номером.", + "certificates_empty": "Сертифікатів ще немає — офіційно завершіть рейтингове змагання або розв'яжіть перші 10 задач.", + "certificates_load_error": "Не вдалося завантажити сертифікати.", + "certificates_kind_CONTEST_PARTICIPATION": "Участь у змаганні", + "certificates_kind_RATING_MILESTONE": "Рубіж рейтингу", + "certificates_kind_SOLVE_MILESTONE": "Рубіж розв'язаних задач", + "certificates_valid_badge": "Чинний", + "certificates_revoked_badge": "Анульований", + "certificates_view_verify": "Переглянути / перевірити", + "certificates_rank_label": "Підсумкове місце", + "certificates_rating_label": "Рейтинг після змагання", + "certificates_solved_label": "Розв'язані задачі", + "certificates_issued_label": "Видано", + "certificates_serial_label": "Серійний номер", + "certificates_holder_label": "Вручається", + "certificates_verify_title": "Перевірка сертифіката", + "certificates_verify_valid": "Цей сертифікат чинний.", + "certificates_verify_revoked": "Цей сертифікат анульовано.", + "certificates_verify_revoked_reason_label": "Причина", + "certificates_verify_note": "Перевірити цей сертифікат може будь-хто за адресою", + "certificates_print_button": "Друк / зберегти як PDF", + "certificates_not_found_title": "Сертифікат не знайдено", + "certificates_not_found_body": "Це посилання не відповідає жодному сертифікату. Перевірте серійний номер або посилання та спробуйте ще раз.", + "notification_certificate_title": "Видано сертифікат", + "notification_certificate_body": "Новий сертифікат ({{serial}}) чекає на вас на сторінці сертифікатів.", + "nav_certificates": "Сертифікати" } diff --git a/src/locales/vi.json b/src/locales/vi.json index 63d5b66b3..ff7478174 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "Kho đề Olympic", "archive_year_label": "Năm tổ chức", "archive_grade_label": "Nhóm lớp", - "archive_all_groups": "Tất cả các nhóm" + "archive_all_groups": "Tất cả các nhóm", + "certificates_title": "Chứng chỉ của tôi", + "certificates_intro": "Chứng chỉ đạt được trên WebCoder, mỗi chứng chỉ đều bắt nguồn từ hoạt động đã được chấm: tham gia chính thức các kỳ thi có rating, cột mốc rating và cột mốc số bài đã giải. Mỗi chứng chỉ đều có thể xác minh công khai qua số sê-ri.", + "certificates_empty": "Chưa có chứng chỉ nào — hãy hoàn tất chính thức một kỳ thi có rating hoặc giải 10 bài đầu tiên của bạn.", + "certificates_load_error": "Không thể tải chứng chỉ.", + "certificates_kind_CONTEST_PARTICIPATION": "Tham gia kỳ thi", + "certificates_kind_RATING_MILESTONE": "Cột mốc rating", + "certificates_kind_SOLVE_MILESTONE": "Cột mốc số bài đã giải", + "certificates_valid_badge": "Hợp lệ", + "certificates_revoked_badge": "Đã thu hồi", + "certificates_view_verify": "Xem / xác minh", + "certificates_rank_label": "Hạng cuối", + "certificates_rating_label": "Rating sau kỳ thi", + "certificates_solved_label": "Số bài đã giải", + "certificates_issued_label": "Cấp ngày", + "certificates_serial_label": "Số sê-ri", + "certificates_holder_label": "Trao cho", + "certificates_verify_title": "Xác minh chứng chỉ", + "certificates_verify_valid": "Chứng chỉ này hợp lệ.", + "certificates_verify_revoked": "Chứng chỉ này đã bị thu hồi.", + "certificates_verify_revoked_reason_label": "Lý do", + "certificates_verify_note": "Ai cũng có thể xác minh chứng chỉ này tại", + "certificates_print_button": "In / lưu dưới dạng PDF", + "certificates_not_found_title": "Không tìm thấy chứng chỉ", + "certificates_not_found_body": "Liên kết xác minh này không khớp với chứng chỉ nào. Hãy kiểm tra số sê-ri hoặc liên kết rồi thử lại.", + "notification_certificate_title": "Đã cấp chứng chỉ", + "notification_certificate_body": "Một chứng chỉ mới ({{serial}}) đang chờ bạn trên trang chứng chỉ của mình.", + "nav_certificates": "Chứng chỉ" } diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 12c387785..fbc5fc48f 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "奥赛题库", "archive_year_label": "届次年份", "archive_grade_label": "年级组", - "archive_all_groups": "全部组别" + "archive_all_groups": "全部组别", + "certificates_title": "我的证书", + "certificates_intro": "在 WebCoder 上获得的证书,每一项均来自经评测的真实活动:正式计分比赛的完赛、评分里程碑和解题里程碑。每张证书均可通过序列号公开验证。", + "certificates_empty": "还没有证书——正式完赛一场计分比赛,或解开前 10 道题。", + "certificates_load_error": "证书加载失败。", + "certificates_kind_CONTEST_PARTICIPATION": "比赛参与", + "certificates_kind_RATING_MILESTONE": "评分里程碑", + "certificates_kind_SOLVE_MILESTONE": "解题里程碑", + "certificates_valid_badge": "有效", + "certificates_revoked_badge": "已撤销", + "certificates_view_verify": "查看 / 验证", + "certificates_rank_label": "最终排名", + "certificates_rating_label": "赛后评分", + "certificates_solved_label": "已解决问题数", + "certificates_issued_label": "颁发于", + "certificates_serial_label": "序列号", + "certificates_holder_label": "授予", + "certificates_verify_title": "证书验证", + "certificates_verify_valid": "该证书有效。", + "certificates_verify_revoked": "该证书已被撤销。", + "certificates_verify_revoked_reason_label": "原因", + "certificates_verify_note": "任何人都可以在以下链接验证该证书:", + "certificates_print_button": "打印 / 保存为 PDF", + "certificates_not_found_title": "未找到证书", + "certificates_not_found_body": "该验证链接不匹配任何证书。请检查序列号或链接后重试。", + "notification_certificate_title": "证书已颁发", + "notification_certificate_body": "一张新证书({{serial}})正在你的证书页等你。", + "nav_certificates": "证书" } diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json index ff6a61582..17ba583ea 100644 --- a/src/locales/zh-Hant.json +++ b/src/locales/zh-Hant.json @@ -1182,5 +1182,32 @@ "archive_filter_toggle": "奧賽題庫", "archive_year_label": "屆次年份", "archive_grade_label": "年級組", - "archive_all_groups": "全部組別" + "archive_all_groups": "全部組別", + "certificates_title": "我的證書", + "certificates_intro": "在 WebCoder 上獲得的證書,每一項均來自經評測的真實活動:正式計分比賽的完賽、評分里程碑與解題里程碑。每張證書均可透過序號公開驗證。", + "certificates_empty": "還沒有證書——正式完賽一場計分比賽,或解開前 10 道題。", + "certificates_load_error": "證書載入失敗。", + "certificates_kind_CONTEST_PARTICIPATION": "比賽參與", + "certificates_kind_RATING_MILESTONE": "評分里程碑", + "certificates_kind_SOLVE_MILESTONE": "解題里程碑", + "certificates_valid_badge": "有效", + "certificates_revoked_badge": "已撤銷", + "certificates_view_verify": "檢視 / 驗證", + "certificates_rank_label": "最終排名", + "certificates_rating_label": "賽後評分", + "certificates_solved_label": "已解決問題數", + "certificates_issued_label": "頒發於", + "certificates_serial_label": "序號", + "certificates_holder_label": "授予", + "certificates_verify_title": "證書驗證", + "certificates_verify_valid": "該證書有效。", + "certificates_verify_revoked": "該證書已被撤銷。", + "certificates_verify_revoked_reason_label": "原因", + "certificates_verify_note": "任何人都可以在以下連結驗證該證書:", + "certificates_print_button": "列印 / 儲存為 PDF", + "certificates_not_found_title": "未找到證書", + "certificates_not_found_body": "該驗證連結不符合任何證書。請檢查序號或連結後重試。", + "notification_certificate_title": "證書已頒發", + "notification_certificate_body": "一張新證書({{serial}})正在你的證書頁等你。", + "nav_certificates": "證書" } diff --git a/src/services/ApiService.ts b/src/services/ApiService.ts index f696570c7..23cd076d0 100644 --- a/src/services/ApiService.ts +++ b/src/services/ApiService.ts @@ -17,6 +17,7 @@ import { AdminStatsResponse, ApiNotification, AssignmentGrid, + CertificateType, Clarification, ClassMembershipRow, ClassRoom, @@ -390,6 +391,14 @@ export const AuthService = { revokeApiToken: () => apiFetch<{ detail: string }>('/users/me/tokens/', { method: 'DELETE' }), }; +// [Wave D certifications] The holder's atestats (paginated; newest first). +export const CertificateService = { + getMyCertificates: (page = 1) => + apiFetch>( + `/certificates/mine/?page=${page}`, + ), +}; + export const ToolingService = { getStatementRevisions: (problemId: number | string) => apiFetch<{ diff --git a/src/types/api.ts b/src/types/api.ts index 72997046a..8102d95d5 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -773,3 +773,30 @@ export interface TranslationAttribution { contributor_username: string; reviewed_at: string | null; } + +/** [Wave D certifications] One atestat row — see backend + * certificates/models.py. `details` is the frozen achievement snapshot + * (rank / rating_after / solved / contest titles by kind). */ +export interface CertificateType { + uuid: string; + serial: string; + kind: 'CONTEST_PARTICIPATION' | 'RATING_MILESTONE' | 'SOLVE_MILESTONE'; + status: 'VALID' | 'REVOKED'; + holder: string; + details: { + rank?: number; + rating_after?: number; + solved?: number; + threshold?: number; + rating?: number; + contest_id?: number; + contest_title_en?: string; + contest_title_ro?: string; + contest_end?: string; + }; + issued_at: string; + revoked_at: string | null; + revoked_reason: string; +} + +export type GetMyCertificatesResponse = PaginatedResponse; diff --git a/src/types/index.ts b/src/types/index.ts index 46f4e876e..c79e0594d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -162,3 +162,4 @@ export interface AdminUserType extends User { first_name?: string; last_name?: string; } + From f5ef4cb082eb1d26868cfc97ccfe7dae2e7e6592 Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:57:45 +0200 Subject: [PATCH 2/5] style: biome-format the certificates files (biome 2.5.0, CI-pinned version) --- e2e/certificates.spec.ts | 7 +- e2e/mock-api/server.mjs | 104 +++++++++--------- src/app/certificates/[uuid]/page.tsx | 32 +++++- src/app/certificates/_lib/certificates-api.ts | 9 +- src/app/certificates/page.test.tsx | 12 +- src/app/certificates/page.tsx | 37 ++++++- src/services/ApiService.ts | 4 +- src/types/index.ts | 1 - 8 files changed, 128 insertions(+), 78 deletions(-) diff --git a/e2e/certificates.spec.ts b/e2e/certificates.spec.ts index 827247502..38b3fad15 100644 --- a/e2e/certificates.spec.ts +++ b/e2e/certificates.spec.ts @@ -65,8 +65,9 @@ test.describe('certifications / atestats (mock api)', () => { await login(page); await page.goto('/'); await page.getByRole('button', { name: 'Account menu' }).click(); - await expect( - page.getByRole('menuitem', { name: 'Certificates', exact: true }), - ).toHaveAttribute('href', '/certificates'); + await expect(page.getByRole('menuitem', { name: 'Certificates', exact: true })).toHaveAttribute( + 'href', + '/certificates', + ); }); }); diff --git a/e2e/mock-api/server.mjs b/e2e/mock-api/server.mjs index 442dfa42e..9bd343165 100644 --- a/e2e/mock-api/server.mjs +++ b/e2e/mock-api/server.mjs @@ -2194,63 +2194,67 @@ const server = createServer((req, res) => { return; } - -// ---- Wave D atestats (certifications) -------------------------------- -// [Wave D certifications — the honest variant] Two fixtures: a VALID -// participation certificate and a REVOKED rating milestone, so the e2e -// lane covers both verification answers. -const MOCK_CERTIFICATES = [ - { - uuid: 'e2e-cert-0001-valid-000000000001', - serial: 'WC-2026-000042', - kind: 'CONTEST_PARTICIPATION', - status: 'VALID', - holder: 'e2e_tester', - details: { - rank: 3, - rating_after: 1662, - contest_id: 1, - contest_title_en: 'Winter Cup', - contest_title_ro: 'Cupa de Iarnă', - contest_end: '2026-11-01', + // ---- Wave D atestats (certifications) -------------------------------- + // [Wave D certifications — the honest variant] Two fixtures: a VALID + // participation certificate and a REVOKED rating milestone, so the e2e + // lane covers both verification answers. + const MOCK_CERTIFICATES = [ + { + uuid: 'e2e-cert-0001-valid-000000000001', + serial: 'WC-2026-000042', + kind: 'CONTEST_PARTICIPATION', + status: 'VALID', + holder: 'e2e_tester', + details: { + rank: 3, + rating_after: 1662, + contest_id: 1, + contest_title_en: 'Winter Cup', + contest_title_ro: 'Cupa de Iarnă', + contest_end: '2026-11-01', + }, + issued_at: '2026-11-02T10:00:00Z', + revoked_at: null, + revoked_reason: '', }, - issued_at: '2026-11-02T10:00:00Z', - revoked_at: null, - revoked_reason: '', - }, - { - uuid: 'e2e-cert-0002-revoked-0000000002', - serial: 'WC-2026-000043', - kind: 'RATING_MILESTONE', - status: 'REVOKED', - holder: 'e2e_tester', - details: { threshold: 1600, rating: 1610 }, - issued_at: '2026-11-02T10:00:01Z', - revoked_at: '2026-11-05T09:00:00Z', - revoked_reason: 'contest 1 ratings discarded by organizer', - }, -]; + { + uuid: 'e2e-cert-0002-revoked-0000000002', + serial: 'WC-2026-000043', + kind: 'RATING_MILESTONE', + status: 'REVOKED', + holder: 'e2e_tester', + details: { threshold: 1600, rating: 1610 }, + issued_at: '2026-11-02T10:00:01Z', + revoked_at: '2026-11-05T09:00:00Z', + revoked_reason: 'contest 1 ratings discarded by organizer', + }, + ]; -if (req.method === 'GET' && path === '/api/v1/certificates/mine/') { - if (!sessionUser(req)) { - json(res, 401, { detail: 'Authentication credentials were not provided.' }); + if (req.method === 'GET' && path === '/api/v1/certificates/mine/') { + if (!sessionUser(req)) { + json(res, 401, { detail: 'Authentication credentials were not provided.' }); + return; + } + json(res, 200, { + count: MOCK_CERTIFICATES.length, + next: null, + previous: null, + results: MOCK_CERTIFICATES, + }); return; } - json(res, 200, { count: MOCK_CERTIFICATES.length, next: null, previous: null, results: MOCK_CERTIFICATES }); - return; -} -if (req.method === 'GET' && path.startsWith('/api/v1/certificates/verify/')) { - const uuid = path.slice('/api/v1/certificates/verify/'.length).replace(/\/$/, ''); - const found = MOCK_CERTIFICATES.find((c) => c.uuid === uuid); - if (!found) { - json(res, 404, { detail: 'Not found.' }); + if (req.method === 'GET' && path.startsWith('/api/v1/certificates/verify/')) { + const uuid = path.slice('/api/v1/certificates/verify/'.length).replace(/\/$/, ''); + const found = MOCK_CERTIFICATES.find((c) => c.uuid === uuid); + if (!found) { + json(res, 404, { detail: 'Not found.' }); + return; + } + json(res, 200, found); return; } - json(res, 200, found); - return; -} -json(res, 404, { detail: `No mock for ${req.method} ${path}` }); + json(res, 404, { detail: `No mock for ${req.method} ${path}` }); }); server.listen(PORT, HOST, () => { diff --git a/src/app/certificates/[uuid]/page.tsx b/src/app/certificates/[uuid]/page.tsx index 6c144e995..8f343d3b5 100644 --- a/src/app/certificates/[uuid]/page.tsx +++ b/src/app/certificates/[uuid]/page.tsx @@ -30,7 +30,10 @@ export async function generateMetadata({ } catch { // Metadata is best-effort; the page body handles fetch failures. } - return { title: `${t('certificates_verify_title', 'Certificate verification')}${serial}`, robots: { index: false } }; + return { + title: `${t('certificates_verify_title', 'Certificate verification')}${serial}`, + robots: { index: false }, + }; } function achievementRows( @@ -43,8 +46,11 @@ function achievementRows( const title = locale === 'ro' && cert.details.contest_title_ro ? cert.details.contest_title_ro - : cert.details.contest_title_en ?? `#${cert.details.contest_id ?? ''}`; - rows.push({ label: t('certificates_kind_CONTEST_PARTICIPATION', 'Contest participation'), value: title }); + : (cert.details.contest_title_en ?? `#${cert.details.contest_id ?? ''}`); + rows.push({ + label: t('certificates_kind_CONTEST_PARTICIPATION', 'Contest participation'), + value: title, + }); if (cert.details.rank !== undefined) { rows.push({ label: t('certificates_rank_label', 'Final rank'), @@ -116,7 +122,15 @@ export default async function CertificateVerifyPage({ background: 'var(--card, #fff)', }} > -
+
WebCoder {t('certificates_serial_label', 'Serial')}: {cert.serial} @@ -145,7 +159,10 @@ export default async function CertificateVerifyPage({
{achievementRows(cert, t, locale).map((row) => ( -
+
{row.label}
{row.value}
@@ -180,7 +197,10 @@ export default async function CertificateVerifyPage({ {/* Screen-only controls (the print stylesheet hides them). */} -
+

{t('certificates_verify_note', 'Anyone can verify this certificate at')}{' '} diff --git a/src/app/certificates/_lib/certificates-api.ts b/src/app/certificates/_lib/certificates-api.ts index 7754cdf59..269700b0b 100644 --- a/src/app/certificates/_lib/certificates-api.ts +++ b/src/app/certificates/_lib/certificates-api.ts @@ -9,16 +9,11 @@ import { CertificateType } from '@/types/api'; import { apiUrl } from '@/lib/api-config'; -export async function getCertificate( - uuid: string, -): Promise { +export async function getCertificate(uuid: string): Promise { const cacheOpts = process.env.E2E_API_BASE ? ({ cache: 'no-store' } as RequestInit) : ({ next: { revalidate: 60 } } as RequestInit); - const response = await fetch( - apiUrl(`/certificates/verify/${uuid}/`), - cacheOpts, - ); + const response = await fetch(apiUrl(`/certificates/verify/${uuid}/`), cacheOpts); if (response.status === 404) { return null; } diff --git a/src/app/certificates/page.test.tsx b/src/app/certificates/page.test.tsx index 52199d42e..b2714e768 100644 --- a/src/app/certificates/page.test.tsx +++ b/src/app/certificates/page.test.tsx @@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => ({ auth: { isAuthenticated: true, token: 'test-access-token' as string | null, - user: { id: 1, username: 'tester', email: 't@example.com', role: 'BASIC_USER' } as object | null, + user: { id: 1, username: 'tester', email: 't@example.com', role: 'BASIC_USER' } as + | object + | null, login: vi.fn(), logout: vi.fn(), }, @@ -82,7 +84,9 @@ describe('CertificatesPage [Wave D]', () => { mocks.getMyCertificates.mockResolvedValue(pageOf([CERT])); render(); expect(await screen.findByTestId('certificates-list')).toBeTruthy(); - expect(screen.getByTestId('certificate-serial-aaaaaaaa-0000-0000-0000-000000000001')).toBeTruthy(); + expect( + screen.getByTestId('certificate-serial-aaaaaaaa-0000-0000-0000-000000000001'), + ).toBeTruthy(); expect(screen.getByText('Contest participation')).toBeTruthy(); expect(screen.getByText(/Winter Cup/)).toBeTruthy(); expect(screen.getByRole('link', { name: 'View / verify' })).toBeTruthy(); @@ -92,7 +96,9 @@ describe('CertificatesPage [Wave D]', () => { mocks.getMyCertificates.mockResolvedValue(pageOf([REVOKED])); render(); expect(await screen.findByText('Revoked')).toBeTruthy(); - expect(screen.getByTestId('certificate-serial-aaaaaaaa-0000-0000-0000-000000000002')).toBeTruthy(); + expect( + screen.getByTestId('certificate-serial-aaaaaaaa-0000-0000-0000-000000000002'), + ).toBeTruthy(); }); it('shows the honest empty state when nothing is earned yet', async () => { diff --git a/src/app/certificates/page.tsx b/src/app/certificates/page.tsx index 1aae351f5..2ffd7238f 100644 --- a/src/app/certificates/page.tsx +++ b/src/app/certificates/page.tsx @@ -44,7 +44,10 @@ function achievementLine(cert: CertificateType, t: ReturnType['t']): string { +function kindLabel( + kind: CertificateType['kind'], + t: ReturnType['t'], +): string { switch (kind) { case 'CONTEST_PARTICIPATION': return t('certificates_kind_CONTEST_PARTICIPATION', 'Contest participation'); @@ -84,7 +87,14 @@ function CertificatesPageContent() {

{t('certificates_title', 'My certificates')}

-

+

{t( 'certificates_intro', 'Certificates earned on WebCoder, each derived from judged activity: official rated-contest finishes, rating milestones and solved-problem milestones. Every certificate is publicly verifiable by its serial number.', @@ -105,7 +115,10 @@ function CertificatesPageContent() { )}

) : ( -
    +
      {certificates.map((cert) => (
    • {t('certificates_serial_label', 'Serial')}:{' '} - + {cert.serial} {' '} · {t('certificates_issued_label', 'Issued')}: {cert.issued_at.slice(0, 10)} @@ -165,7 +181,18 @@ function CertificatesPageContent() { export default function CertificatesPage() { return ( - + ); diff --git a/src/services/ApiService.ts b/src/services/ApiService.ts index 23cd076d0..8ac86a2c7 100644 --- a/src/services/ApiService.ts +++ b/src/services/ApiService.ts @@ -394,9 +394,7 @@ export const AuthService = { // [Wave D certifications] The holder's atestats (paginated; newest first). export const CertificateService = { getMyCertificates: (page = 1) => - apiFetch>( - `/certificates/mine/?page=${page}`, - ), + apiFetch>(`/certificates/mine/?page=${page}`), }; export const ToolingService = { diff --git a/src/types/index.ts b/src/types/index.ts index c79e0594d..46f4e876e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -162,4 +162,3 @@ export interface AdminUserType extends User { first_name?: string; last_name?: string; } - From 41530a0f5b35ebbca3f0816013b80a22286bca72 Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:06:28 +0200 Subject: [PATCH 3/5] style: ruff format + drop an unused import (CI quality gate) --- backend/certificates/admin.py | 10 ++++- .../management/commands/issue_certificates.py | 4 +- .../certificates/migrations/0001_initial.py | 1 - backend/certificates/models.py | 4 +- backend/certificates/services.py | 8 +++- backend/certificates/test_certificates.py | 41 ++++++++++++------- backend/ratings/services.py | 5 ++- scripts/add-certificate-keys.py | 4 +- 8 files changed, 54 insertions(+), 23 deletions(-) diff --git a/backend/certificates/admin.py b/backend/certificates/admin.py index 3fea1bcf3..797ccbb8c 100644 --- a/backend/certificates/admin.py +++ b/backend/certificates/admin.py @@ -13,7 +13,15 @@ class CertificateAdmin(admin.ModelAdmin): the "leaderboard-derived" honesty guarantee. """ - list_display = ("serial", "kind", "user", "contest", "threshold", "status", "issued_at") + 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 = ( diff --git a/backend/certificates/management/commands/issue_certificates.py b/backend/certificates/management/commands/issue_certificates.py index 2d640cba8..228b463de 100644 --- a/backend/certificates/management/commands/issue_certificates.py +++ b/backend/certificates/management/commands/issue_certificates.py @@ -32,5 +32,7 @@ def handle(self, *args, **options): 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") + self.style.SUCCESS( + f"done — {total} new certificate(s); existing rows untouched" + ) ) diff --git a/backend/certificates/migrations/0001_initial.py b/backend/certificates/migrations/0001_initial.py index edf31b137..862eae694 100644 --- a/backend/certificates/migrations/0001_initial.py +++ b/backend/certificates/migrations/0001_initial.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/certificates/models.py b/backend/certificates/models.py index 97d8e2a02..3ea813a18 100644 --- a/backend/certificates/models.py +++ b/backend/certificates/models.py @@ -78,7 +78,9 @@ class Kind(models.TextChoices): #: 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) + revoked_reason = models.CharField( + _("revocation reason"), max_length=200, blank=True + ) class Meta: verbose_name = _("certificate") diff --git a/backend/certificates/services.py b/backend/certificates/services.py index 4df4719ef..be303ea95 100644 --- a/backend/certificates/services.py +++ b/backend/certificates/services.py @@ -266,10 +266,14 @@ def backfill(*, notify_users: bool = False) -> dict[str, int]: for row in UserRating.objects.values("user_id", "rating"): milestones += len( - issue_rating_milestones(row["user_id"], row["rating"], notify_user=notify_users) + issue_rating_milestones( + row["user_id"], row["rating"], notify_user=notify_users + ) ) for row in LeaderboardEntry.objects.values("user_id"): - solves += len(recompute_solve_milestones(row["user_id"], notify_user=notify_users)) + solves += len( + recompute_solve_milestones(row["user_id"], notify_user=notify_users) + ) return { "participation": participation, diff --git a/backend/certificates/test_certificates.py b/backend/certificates/test_certificates.py index c6b512095..6a90108c3 100644 --- a/backend/certificates/test_certificates.py +++ b/backend/certificates/test_certificates.py @@ -31,7 +31,7 @@ from notifications.models import Notification from problems.models import Problem from problems.models import TestCase as ProblemTestCase -from ratings.models import RatingHistory, UserRating +from ratings.models import UserRating from ratings.services import ( compute_contest_ratings as compute_service, rollback_contest_ratings, @@ -63,12 +63,17 @@ def test_issue_creates_with_serial_and_notifies(self): self.assertIsNotNone(cert) self.assertTrue(cert.serial.startswith(f"WC-{NOW.year}-")) self.assertIn(f"{cert.pk:06d}", cert.serial) - self.assertTrue(Notification.objects.filter(data__kind="certificate_issued").exists()) + self.assertTrue( + Notification.objects.filter(data__kind="certificate_issued").exists() + ) def test_issue_existing_returns_none(self): first = _issue(self.alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=50) again = _issue( - self.alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=50, details={"x": 1} + self.alice.pk, + Certificate.Kind.SOLVE_MILESTONE, + threshold=50, + details={"x": 1}, ) self.assertIsNotNone(first) self.assertIsNone(again) @@ -130,7 +135,9 @@ def test_participation_snapshot_is_bilingual_and_frozen(self): self.assertEqual(cert.details["rating_after"], 1655) self.assertEqual(cert.details["contest_title_en"], "Winter Cup") self.assertEqual(cert.details["contest_title_ro"], "Cupa de Iarnă") - self.assertEqual(cert.details["contest_end"], self.contest.end_at.date().isoformat()) + self.assertEqual( + cert.details["contest_end"], self.contest.end_at.date().isoformat() + ) # Idempotent: a replay issues nothing new. again = issue_contest_certificates( self.contest, {self.alice.pk: 1}, {self.alice.pk: 1655} @@ -140,7 +147,9 @@ def test_participation_snapshot_is_bilingual_and_frozen(self): def test_revoke_contest_certificates_flips_only_active(self): issue_contest_certificates( - self.contest, {self.alice.pk: 1, self.bob.pk: 2}, {self.alice.pk: 1500, self.bob.pk: 1500} + self.contest, + {self.alice.pk: 1, self.bob.pk: 2}, + {self.alice.pk: 1500, self.bob.pk: 1500}, ) alice_cert = Certificate.objects.get(user=self.alice, contest=self.contest) alice_cert.revoked_at = NOW @@ -162,13 +171,12 @@ def setUp(self): def test_rating_milestones_issue_at_or_below(self): issued = issue_rating_milestones(self.alice.pk, 1500) - self.assertEqual( - sorted(c.threshold for c in issued), [1200, 1400] - ) + self.assertEqual(sorted(c.threshold for c in issued), [1200, 1400]) # Peak semantics: a later organic drop re-issues nothing and revokes nothing. issue_rating_milestones(self.alice.pk, 1350) self.assertEqual( - Certificate.objects.filter(kind=Certificate.Kind.RATING_MILESTONE).count(), 2 + Certificate.objects.filter(kind=Certificate.Kind.RATING_MILESTONE).count(), + 2, ) def test_rating_milestones_none_below_floor(self): @@ -299,7 +307,8 @@ def test_compute_issues_participation_and_milestones(self): ) # Both start at 1500 → both cross 1200 and 1400. self.assertEqual( - Certificate.objects.filter(kind=Certificate.Kind.RATING_MILESTONE).count(), 4 + Certificate.objects.filter(kind=Certificate.Kind.RATING_MILESTONE).count(), + 4, ) # The certificate_issued notification rides the notify_users flag. self.assertTrue( @@ -412,9 +421,7 @@ def test_verify_public_by_uuid(self): def test_verify_unknown_uuid_404(self): import uuid as uuid_mod - response = self.client.get( - f"/api/v1/certificates/verify/{uuid_mod.uuid4()}/" - ) + response = self.client.get(f"/api/v1/certificates/verify/{uuid_mod.uuid4()}/") self.assertEqual(response.status_code, 404) def test_mine_requires_auth(self): @@ -532,8 +539,12 @@ def test_command_output_and_notify_flag(self): self.assertIn("participation: 2", text) self.assertIn("rating_milestones: 4", text) # Default: replaying history never notifies. - self.assertFalse(Notification.objects.filter(data__kind="certificate_issued").exists()) + self.assertFalse( + Notification.objects.filter(data__kind="certificate_issued").exists() + ) Certificate.objects.all().delete() call_command("issue_certificates", "--notify", stdout=StringIO()) - self.assertTrue(Notification.objects.filter(data__kind="certificate_issued").exists()) + self.assertTrue( + Notification.objects.filter(data__kind="certificate_issued").exists() + ) diff --git a/backend/ratings/services.py b/backend/ratings/services.py index 9619bdad4..33c9f15d8 100644 --- a/backend/ratings/services.py +++ b/backend/ratings/services.py @@ -178,7 +178,10 @@ def rollback_contest_ratings(contest: Contest) -> str: # Wave D atestats: the discarded contest's participation certificates # no longer stand (revoked with the reason, never deleted), and ladder # milestones the replayed history no longer supports are revoked too. - from certificates.services import audit_rating_milestones, revoke_contest_certificates + from certificates.services import ( + audit_rating_milestones, + revoke_contest_certificates, + ) if had: revoke_contest_certificates( diff --git a/scripts/add-certificate-keys.py b/scripts/add-certificate-keys.py index d4750e091..fc56dd9c5 100644 --- a/scripts/add-certificate-keys.py +++ b/scripts/add-certificate-keys.py @@ -793,7 +793,9 @@ for path in sorted(LOCALES.glob("*.json")): locale = path.stem - data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=collections.OrderedDict) + data = json.loads( + path.read_text(encoding="utf-8"), object_pairs_hook=collections.OrderedDict + ) missing = [k for k in K if locale not in K[k]] if missing: raise SystemExit(f"{locale}: missing translations for {missing}") From cdb082f61632f43de5b6932b0185ec26a691efcd Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:23:59 +0200 Subject: [PATCH 4/5] fix(types): single-string TextChoices members + Optional narrowing (basedpyright gate) --- backend/certificates/test_certificates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/certificates/test_certificates.py b/backend/certificates/test_certificates.py index 6a90108c3..367b0bb96 100644 --- a/backend/certificates/test_certificates.py +++ b/backend/certificates/test_certificates.py @@ -60,7 +60,7 @@ def test_issue_creates_with_serial_and_notifies(self): threshold=50, details={"solved": 55}, ) - self.assertIsNotNone(cert) + assert cert is not None # narrow the Optional for the type gate self.assertTrue(cert.serial.startswith(f"WC-{NOW.year}-")) self.assertIn(f"{cert.pk:06d}", cert.serial) self.assertTrue( @@ -102,6 +102,7 @@ def test_issue_notify_off_creates_no_notification(self): def test_model_str_status_and_revoked_flag(self): cert = _issue(self.alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=10) + assert cert is not None # narrow the Optional for the type gate self.assertEqual(cert.status, "VALID") self.assertFalse(cert.is_revoked) self.assertIn(cert.serial, str(cert)) @@ -478,6 +479,7 @@ def test_revoke_action(self): alice = User.objects.create_user(username="alice", password="pw") cert = _issue(alice.pk, Certificate.Kind.SOLVE_MILESTONE, threshold=10) + assert cert is not None # narrow the Optional for the type gate model_admin = CertificateAdmin(Certificate, AdminSite()) request = mock.Mock() request.POST = {"apply": "1"} From d36114e4dfd2802424353604ee850fd0a0ce7914 Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:34:33 +0200 Subject: [PATCH 5/5] fix(types): single-string TextChoices members (the real edit) + choices migration --- .../migrations/0002_alter_certificate_kind.py | 25 +++++++++++++++++++ backend/certificates/models.py | 10 +++++--- 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 backend/certificates/migrations/0002_alter_certificate_kind.py diff --git a/backend/certificates/migrations/0002_alter_certificate_kind.py b/backend/certificates/migrations/0002_alter_certificate_kind.py new file mode 100644 index 000000000..cd7e29936 --- /dev/null +++ b/backend/certificates/migrations/0002_alter_certificate_kind.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.17 on 2026-11-14 04:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("certificates", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="certificate", + name="kind", + field=models.CharField( + choices=[ + ("CONTEST_PARTICIPATION", "Contest Participation"), + ("RATING_MILESTONE", "Rating Milestone"), + ("SOLVE_MILESTONE", "Solve Milestone"), + ], + max_length=24, + verbose_name="kind", + ), + ), + ] diff --git a/backend/certificates/models.py b/backend/certificates/models.py index 3ea813a18..8cad3f214 100644 --- a/backend/certificates/models.py +++ b/backend/certificates/models.py @@ -31,10 +31,14 @@ class Certificate(models.Model): problems solved (10…500), the same derivation the leaderboard uses. """ + # NB: single-string member form — under the repo's no-django-stubs + # typecheck the tuple form's label half infers as a tuple, breaking + # every use of a member as a ``str``. Human labels live in the + # frontend catalogs. class Kind(models.TextChoices): - CONTEST_PARTICIPATION = "CONTEST_PARTICIPATION", _("Contest participation") - RATING_MILESTONE = "RATING_MILESTONE", _("Rating milestone") - SOLVE_MILESTONE = "SOLVE_MILESTONE", _("Problems-solved milestone") + CONTEST_PARTICIPATION = "CONTEST_PARTICIPATION" + RATING_MILESTONE = "RATING_MILESTONE" + SOLVE_MILESTONE = "SOLVE_MILESTONE" #: Public verification id — printed on the atestat; the verify endpoint #: is anonymous so employers/teachers can check it without an account.