From ca4005e9c301f781e8f57366087fbd3feacd204f Mon Sep 17 00:00:00 2001 From: rryoung98 Date: Tue, 22 Sep 2026 06:51:05 -0500 Subject: [PATCH 1/5] feat: forward certificateSettings so a course.json can pick its certificate template The validate step rewrites the deploy payload through the Course model, and pydantic drops undeclared fields, so certificateSettings in course.json never reached the API. An author could not enable certificates or choose a design at deploy time; only the qBook admin UI could. Course gains an optional certificateSettings (enabled, criteria, templateId) mirroring qbraid-api's validator: templateId is one of qbraid, quera, qct; enabled is a strict boolean. Course.to_payload omits the object when absent (and criteria when absent inside it), because the API refuses an explicit null there, unlike durationWeeks. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + src/scripts/validate_course.py | 50 ++++++++++++++++++++++++--- test/unit/test_validate_course.py | 57 +++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d2919..30aea2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ uses: qBraid/upload-course-action@v0.1.0-beta ### Added - Unit tests for JSend response handling in course creation and polling +- Forward the optional `certificateSettings` object (`enabled`, `criteria`, `templateId`) from `course.json` to the deploy payload, so an author can enable certificates and pick the certificate design (`qbraid`, `quera` or `qct`) at deploy time. Previously the validate step's model round-trip silently dropped it. An unknown template or a non-boolean `enabled` fails validation with a field-level message; an absent object is omitted from the payload rather than sent as `null`, which the qBraid API refuses - Forward the optional `durationWeeks` field (author-declared course length in weeks, integer 1-52) from `course.json` to the deploy payload; previously the validate step's model round-trip silently dropped it. An out-of-range or non-integer value (including a numeric string such as `"3"`) now fails validation with a clear message, and an absent field serializes as `null`, which the qBraid API treats as not declared - Unit tests for shared deploy payload/response handling in `deploy_common.py` - Action inputs to configure polling limits (`max-poll-attempts`, `poll-interval-seconds`, `max-consecutive-errors`) diff --git a/src/scripts/validate_course.py b/src/scripts/validate_course.py index f03f336..e5886fd 100644 --- a/src/scripts/validate_course.py +++ b/src/scripts/validate_course.py @@ -5,7 +5,7 @@ import sys import urllib.request from pathlib import Path -from typing import List, Optional, Set +from typing import Any, Dict, List, Literal, Optional, Set from common import Config, setup_logging from pydantic import BaseModel, Field, ValidationError, field_validator @@ -50,6 +50,28 @@ class ImageLink(BaseModel): lightLogo: str +class CertificateCriteria(BaseModel): + """When a learner earns the certificate: a completion percentage or a + points total, both of which the qBraid API accepts as `value >= 0`.""" + + type: Literal["completion", "points"] + value: float = Field(..., ge=0) + + +class CertificateSettings(BaseModel): + """Per-course certificate settings, forwarded verbatim to the qBraid API. + + `templateId` picks the certificate design. The API's enum is the source + of truth; this list mirrors it so a typo fails here with a field-level + message instead of a 400 at deploy time. Omitted, the API keeps the + template already stored for the course, or derives one from the deploy + domain for a new course.""" + + enabled: bool = Field(..., strict=True) + criteria: Optional[CertificateCriteria] = None + templateId: Optional[Literal["qbraid", "quera", "qct"]] = None + + class Section(BaseModel): """Model for course sections.""" @@ -117,6 +139,26 @@ class Course(BaseModel): # it keep the platform's chapter-count estimate. strict, because pydantic # would otherwise coerce "3" to 3 and forward a value the API rejects. durationWeeks: Optional[int] = Field(None, ge=1, le=52, strict=True) + # Optional certificate settings, including the template. The API rejects + # an explicit null here, so `to_payload` drops the key when absent. + certificateSettings: Optional[CertificateSettings] = None + + def to_payload(self) -> Dict[str, Any]: + """The deploy payload the API receives. + + `model_dump` alone would serialize an absent `certificateSettings` (and + an absent `criteria` inside it) as null, which the API's validator + refuses; those keys are dropped instead. `durationWeeks` stays as + null on purpose: the API treats it as not declared. + """ + payload = self.model_dump(mode="json") + if self.certificateSettings is None: + payload.pop("certificateSettings") + else: + payload["certificateSettings"] = self.certificateSettings.model_dump( + mode="json", exclude_none=True + ) + return payload @field_validator("deployedTo") @classmethod @@ -197,12 +239,10 @@ def validate(self) -> None: logger.info("✅ course.json structure and file sizes are valid") - # Save course data for next steps. A course.json without - # durationWeeks serializes it as null; the qBraid API treats null - # the same as an absent field. + # Save course data for next steps. try: with open(Config.COURSE_DATA_FILE_NAME, "w") as f: - json.dump(course.model_dump(mode="json"), f) + json.dump(course.to_payload(), f) except IOError as e: logger.error(f"Failed to write {Config.COURSE_DATA_FILE_NAME}: {e}") sys.exit(1) diff --git a/test/unit/test_validate_course.py b/test/unit/test_validate_course.py index f83bfc8..c9e9f52 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -208,6 +208,63 @@ def test_out_of_range_duration_weeks_fails_validation(self, bad_value): with pytest.raises(ValidationError): Course(**self._duration_course_data(durationWeeks=bad_value)) + def test_certificate_settings_with_template_are_forwarded(self): + """An author can pick the certificate design in course.json.""" + course = Course( + **self._duration_course_data( + certificateSettings={ + "enabled": True, + "criteria": {"type": "completion", "value": 80}, + "templateId": "qct", + } + ) + ) + + payload = course.to_payload() + + assert payload["certificateSettings"] == { + "enabled": True, + "criteria": {"type": "completion", "value": 80.0}, + "templateId": "qct", + } + + def test_absent_certificate_settings_are_omitted_not_null(self): + """The API refuses certificateSettings: null, so the key must go.""" + course = Course(**self._duration_course_data()) + + payload = course.to_payload() + + assert "certificateSettings" not in payload + # durationWeeks keeps its documented null. + assert payload["durationWeeks"] is None + + def test_certificate_settings_without_criteria_omit_the_nested_key(self): + course = Course( + **self._duration_course_data( + certificateSettings={"enabled": False, "templateId": "quera"} + ) + ) + + assert course.to_payload()["certificateSettings"] == { + "enabled": False, + "templateId": "quera", + } + + @pytest.mark.parametrize( + "bad_settings", + [ + {"enabled": True, "templateId": "QCT"}, + {"enabled": True, "templateId": "thailand"}, + {"templateId": "qct"}, + {"enabled": "yes"}, + {"enabled": True, "criteria": {"type": "grade", "value": 1}}, + {"enabled": True, "criteria": {"type": "points", "value": -1}}, + ], + ) + def test_bad_certificate_settings_fail_validation(self, bad_settings): + with pytest.raises(ValidationError): + Course(**self._duration_course_data(certificateSettings=bad_settings)) + @mock.patch("builtins.open", new_callable=mock.mock_open) @mock.patch("json.load") @mock.patch("validate_course.Path.exists") From 50d8d31486af980133309b4a91bd1a5bc334ee3e Mon Sep 17 00:00:00 2001 From: rryoung98 Date: Sat, 26 Sep 2026 12:40:24 -0500 Subject: [PATCH 2/5] docs: document certificateSettings and the template ids in the README An author had no way to learn the valid templateId values short of a failed validation. Lists the fields, the three designs, and what an omitted field does on redeploy, including that an omitted criteria resets to 100%. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30aea2e..df7604b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ uses: qBraid/upload-course-action@v0.1.0-beta ### Added - Unit tests for JSend response handling in course creation and polling - Forward the optional `certificateSettings` object (`enabled`, `criteria`, `templateId`) from `course.json` to the deploy payload, so an author can enable certificates and pick the certificate design (`qbraid`, `quera` or `qct`) at deploy time. Previously the validate step's model round-trip silently dropped it. An unknown template or a non-boolean `enabled` fails validation with a field-level message; an absent object is omitted from the payload rather than sent as `null`, which the qBraid API refuses +- README section documenting `certificateSettings`: the fields, the available `templateId` values (`qbraid`, `quera`, `qct`), and what an omitted field does on redeploy - Forward the optional `durationWeeks` field (author-declared course length in weeks, integer 1-52) from `course.json` to the deploy payload; previously the validate step's model round-trip silently dropped it. An out-of-range or non-integer value (including a numeric string such as `"3"`) now fails validation with a clear message, and an absent field serializes as `null`, which the qBraid API treats as not declared - Unit tests for shared deploy payload/response handling in `deploy_common.py` - Action inputs to configure polling limits (`max-poll-attempts`, `poll-interval-seconds`, `max-consecutive-errors`) diff --git a/README.md b/README.md index 02d793a..727fa8c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,46 @@ jobs: | `course-custom-id` | Custom ID of the deployed course | | `qbook_url` | URL of the deployed course | +## Certificate Settings + +A `course.json` can turn on certificates and pick their design with an optional `certificateSettings` object: + +```json +{ + "courseName": "Intro to Quantum Computing", + "certificateSettings": { + "enabled": true, + "criteria": { "type": "completion", "value": 80 }, + "templateId": "qct" + } +} +``` + +| Field | Required | Values | +| :--- | :--- | :--- | +| `enabled` | **Yes** | `true` or `false` | +| `criteria.type` | No | `completion` (percent of the course completed) or `points` (points earned) | +| `criteria.value` | No | A number `>= 0`: a percentage for `completion`, a point total for `points` | +| `templateId` | No | One of the template ids below | + +### Certificate templates + +| `templateId` | Design | +| :--- | :--- | +| `qbraid` | The standard qBraid certificate of completion | +| `quera` | QuEra-branded certificate on a dark background | +| `qct` | Quantum Ready Thailand Certification Program, with partner logos | + +Ids are lowercase. Any other value fails validation before the deploy starts. + +### What happens when a field is omitted + +- **No `certificateSettings` object**: nothing is sent, and the course keeps whatever certificate settings it already has. +- **No `templateId`**: an existing course keeps its stored design. A new course gets `quera` if it deploys to `quera.com`, otherwise `qbraid`. +- **No `criteria`**: the course's criteria reset to 100% completion, even if an admin set different criteria in qBook. If you include `certificateSettings`, include `criteria` too. + +Changing the design only affects certificates that have not been delivered yet. Certificates already sent keep their design. + ## How it Works This action validates your course structure and creates it via the qBraid API: From 51958d5ba086d7a5644d7d19324b7c9df673e339 Mon Sep 17 00:00:00 2001 From: rryoung98 Date: Sat, 26 Sep 2026 15:16:31 -0500 Subject: [PATCH 3/5] docs: say which organizations may use each certificate template quera and qct are reserved for their partner organizations by the qBraid API, and a deploy naming one the course's org cannot use fails with 403 CERTIFICATE_TEMPLATE_NOT_ALLOWED. Documents that, and that a course keeps a design it already has. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 727fa8c..d78d8df 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,16 @@ A `course.json` can turn on certificates and pick their design with an optional ### Certificate templates -| `templateId` | Design | -| :--- | :--- | -| `qbraid` | The standard qBraid certificate of completion | -| `quera` | QuEra-branded certificate on a dark background | -| `qct` | Quantum Ready Thailand Certification Program, with partner logos | +| `templateId` | Design | Available to | +| :--- | :--- | :--- | +| `qbraid` | The standard qBraid certificate of completion | Every organization | +| `quera` | QuEra-branded certificate on a dark background | QuEra organizations, and any course deployed to `quera.com` | +| `qct` | Quantum Ready Thailand Certification Program, with partner logos | Quantum Club Thailand | Ids are lowercase. Any other value fails validation before the deploy starts. +Partner designs are reserved for their partner's organization. Naming one your course's organization cannot use fails the deploy with `403 CERTIFICATE_TEMPLATE_NOT_ALLOWED`, and the course is left unchanged. A course can always keep the design it already has. To use a partner design for another organization, contact qBraid. + ### What happens when a field is omitted - **No `certificateSettings` object**: nothing is sent, and the course keeps whatever certificate settings it already has. From 9d038c1fb68565b0fb65f21585211d1864500894 Mon Sep 17 00:00:00 2001 From: rryoung98 Date: Wed, 30 Sep 2026 19:33:03 -0500 Subject: [PATCH 4/5] fix: refuse unknown certificate keys and lax criteria values CertificateSettings and CertificateCriteria used pydantic's default extra="ignore", so a typo such as templateID passed validation and the course deployed with the default design. Both models now forbid extra keys, and the error location names the unknown key. criteria.value is strict and finite: "80", true, inf and NaN are refused, not coerced. The README and changelog said quera was open to any course deployed to quera.com. The qBraid API grants nothing by deploy domain: a partner design needs a grant on the organization, set by qBraid staff, and the API otherwise answers 403 CERTIFICATE_TEMPLATE_NOT_ALLOWED. A new course with no templateId gets quera only on quera.com with that grant. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++-- README.md | 10 +++++----- src/scripts/validate_course.py | 23 +++++++++++++++++----- test/unit/test_validate_course.py | 32 +++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df7604b..8f64225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,8 +38,8 @@ uses: qBraid/upload-course-action@v0.1.0-beta ### Added - Unit tests for JSend response handling in course creation and polling -- Forward the optional `certificateSettings` object (`enabled`, `criteria`, `templateId`) from `course.json` to the deploy payload, so an author can enable certificates and pick the certificate design (`qbraid`, `quera` or `qct`) at deploy time. Previously the validate step's model round-trip silently dropped it. An unknown template or a non-boolean `enabled` fails validation with a field-level message; an absent object is omitted from the payload rather than sent as `null`, which the qBraid API refuses -- README section documenting `certificateSettings`: the fields, the available `templateId` values (`qbraid`, `quera`, `qct`), and what an omitted field does on redeploy +- Forward the optional `certificateSettings` object (`enabled`, `criteria`, `templateId`) from `course.json` to the deploy payload, so an author can enable certificates and pick the certificate design (`qbraid`, `quera` or `qct`) at deploy time. Previously the validate step's model round-trip silently dropped it. An unknown template, an unknown key (such as `templateID`), a non-boolean `enabled`, or a `criteria.value` that is a string, a boolean, infinite or negative fails validation with a field-level message naming the field; an absent object is omitted from the payload rather than sent as `null`, which the qBraid API refuses. The partner designs `quera` and `qct` need a grant on the course's organization, set by qBraid staff; without it the API answers `403 CERTIFICATE_TEMPLATE_NOT_ALLOWED` and the deploy fails +- README section documenting `certificateSettings`: the fields, the available `templateId` values (`qbraid`, `quera`, `qct`) and which organizations may use each, and what an omitted field does on redeploy - Forward the optional `durationWeeks` field (author-declared course length in weeks, integer 1-52) from `course.json` to the deploy payload; previously the validate step's model round-trip silently dropped it. An out-of-range or non-integer value (including a numeric string such as `"3"`) now fails validation with a clear message, and an absent field serializes as `null`, which the qBraid API treats as not declared - Unit tests for shared deploy payload/response handling in `deploy_common.py` - Action inputs to configure polling limits (`max-poll-attempts`, `poll-interval-seconds`, `max-consecutive-errors`) diff --git a/README.md b/README.md index d78d8df..99e7044 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,17 @@ A `course.json` can turn on certificates and pick their design with an optional | `templateId` | Design | Available to | | :--- | :--- | :--- | | `qbraid` | The standard qBraid certificate of completion | Every organization | -| `quera` | QuEra-branded certificate on a dark background | QuEra organizations, and any course deployed to `quera.com` | -| `qct` | Quantum Ready Thailand Certification Program, with partner logos | Quantum Club Thailand | +| `quera` | QuEra-branded certificate on a dark background | Organizations that qBraid has granted the `quera` design | +| `qct` | Quantum Ready Thailand Certification Program, with partner logos | Organizations that qBraid has granted the `qct` design | -Ids are lowercase. Any other value fails validation before the deploy starts. +Ids are lowercase. Any other value fails validation before the deploy starts, and so does an unknown key inside `certificateSettings` or `criteria` (for example `templateID`); the error names the key. `criteria.value` must be a JSON number: `"80"` or `true` is refused, not converted. -Partner designs are reserved for their partner's organization. Naming one your course's organization cannot use fails the deploy with `403 CERTIFICATE_TEMPLATE_NOT_ALLOWED`, and the course is left unchanged. A course can always keep the design it already has. To use a partner design for another organization, contact qBraid. +Partner designs (`quera`, `qct`) need a grant on your course's organization, which qBraid staff set. The deploy domain grants nothing: deploying to `quera.com` does not by itself allow `quera`. Naming a partner design your organization has not been granted fails the deploy: the qBraid API answers `403 CERTIFICATE_TEMPLATE_NOT_ALLOWED`, the action fails and shows that response, and the course is left unchanged. A course can always keep the design it already has. To get a partner design granted to your organization, contact qBraid. ### What happens when a field is omitted - **No `certificateSettings` object**: nothing is sent, and the course keeps whatever certificate settings it already has. -- **No `templateId`**: an existing course keeps its stored design. A new course gets `quera` if it deploys to `quera.com`, otherwise `qbraid`. +- **No `templateId`**: an existing course keeps its stored design. A new course gets `quera` only if it deploys to `quera.com` and its organization holds the `quera` grant. Every other new course gets `qbraid`. - **No `criteria`**: the course's criteria reset to 100% completion, even if an admin set different criteria in qBook. If you include `certificateSettings`, include `criteria` too. Changing the design only affects certificates that have not been delivered yet. Certificates already sent keep their design. diff --git a/src/scripts/validate_course.py b/src/scripts/validate_course.py index e5886fd..7ed170d 100644 --- a/src/scripts/validate_course.py +++ b/src/scripts/validate_course.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Literal, Optional, Set from common import Config, setup_logging -from pydantic import BaseModel, Field, ValidationError, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator logger = setup_logging(__name__) @@ -52,10 +52,16 @@ class ImageLink(BaseModel): class CertificateCriteria(BaseModel): """When a learner earns the certificate: a completion percentage or a - points total, both of which the qBraid API accepts as `value >= 0`.""" + points total, both of which the qBraid API accepts as `value >= 0`. + + Unknown keys are refused so a misspelled key fails validation instead of + being dropped. `value` is strict: a string or a boolean is refused + rather than coerced, and so are infinity and NaN.""" + + model_config = ConfigDict(extra="forbid") type: Literal["completion", "points"] - value: float = Field(..., ge=0) + value: float = Field(..., ge=0, strict=True, allow_inf_nan=False) class CertificateSettings(BaseModel): @@ -64,8 +70,15 @@ class CertificateSettings(BaseModel): `templateId` picks the certificate design. The API's enum is the source of truth; this list mirrors it so a typo fails here with a field-level message instead of a 400 at deploy time. Omitted, the API keeps the - template already stored for the course, or derives one from the deploy - domain for a new course.""" + template already stored for the course. A new course gets `quera` when + it deploys to quera.com and its organization holds the quera grant, + otherwise `qbraid`. + + Unknown keys are refused: `templateID` would otherwise be ignored and + the course deployed with the default design. The validation error names + the unknown key.""" + + model_config = ConfigDict(extra="forbid") enabled: bool = Field(..., strict=True) criteria: Optional[CertificateCriteria] = None diff --git a/test/unit/test_validate_course.py b/test/unit/test_validate_course.py index c9e9f52..346322b 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -259,12 +259,44 @@ def test_certificate_settings_without_criteria_omit_the_nested_key(self): {"enabled": "yes"}, {"enabled": True, "criteria": {"type": "grade", "value": 1}}, {"enabled": True, "criteria": {"type": "points", "value": -1}}, + {"enabled": True, "criteria": {"type": "completion", "value": "80"}}, + {"enabled": True, "criteria": {"type": "completion", "value": True}}, + {"enabled": True, "criteria": {"type": "points", "value": float("inf")}}, + {"enabled": True, "criteria": {"type": "points", "value": float("nan")}}, ], ) def test_bad_certificate_settings_fail_validation(self, bad_settings): with pytest.raises(ValidationError): Course(**self._duration_course_data(certificateSettings=bad_settings)) + @pytest.mark.parametrize( + "bad_settings, bad_loc", + [ + ( + {"enabled": True, "templateID": "qct"}, + ("certificateSettings", "templateID"), + ), + ( + { + "enabled": True, + "criteria": {"type": "completion", "value": 80, "valu": 90}, + }, + ("certificateSettings", "criteria", "valu"), + ), + ], + ) + def test_unknown_certificate_keys_fail_and_name_the_key( + self, bad_settings, bad_loc + ): + """A misspelled key is refused, not silently dropped, and the error + location names it so the author can find the typo.""" + with pytest.raises(ValidationError) as exc_info: + Course(**self._duration_course_data(certificateSettings=bad_settings)) + + errors = exc_info.value.errors() + assert [error["loc"] for error in errors] == [bad_loc] + assert errors[0]["type"] == "extra_forbidden" + @mock.patch("builtins.open", new_callable=mock.mock_open) @mock.patch("json.load") @mock.patch("validate_course.Path.exists") From 69fb4ecabe1a6f1a873ae9cd744c35334ff5f157 Mon Sep 17 00:00:00 2001 From: rryoung98 Date: Wed, 30 Sep 2026 20:05:27 -0500 Subject: [PATCH 5/5] test(validate): use a non-typo unknown key in the strict-settings test The spell checker flagged the deliberate misspelling. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/unit/test_validate_course.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/test_validate_course.py b/test/unit/test_validate_course.py index 346322b..be399bd 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -279,9 +279,9 @@ def test_bad_certificate_settings_fail_validation(self, bad_settings): ( { "enabled": True, - "criteria": {"type": "completion", "value": 80, "valu": 90}, + "criteria": {"type": "completion", "value": 80, "threshold": 90}, }, - ("certificateSettings", "criteria", "valu"), + ("certificateSettings", "criteria", "threshold"), ), ], )