diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d2919..8f64225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +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, 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 02d793a..99e7044 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,48 @@ 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 | Available to | +| :--- | :--- | :--- | +| `qbraid` | The standard qBraid certificate of completion | Every organization | +| `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, 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 (`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` 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. + ## How it Works This action validates your course structure and creates it via the qBraid API: diff --git a/src/scripts/validate_course.py b/src/scripts/validate_course.py index f03f336..7ed170d 100644 --- a/src/scripts/validate_course.py +++ b/src/scripts/validate_course.py @@ -5,10 +5,10 @@ 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 +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator logger = setup_logging(__name__) @@ -50,6 +50,41 @@ 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`. + + 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, strict=True, allow_inf_nan=False) + + +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. 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 + templateId: Optional[Literal["qbraid", "quera", "qct"]] = None + + class Section(BaseModel): """Model for course sections.""" @@ -117,6 +152,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 +252,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..be399bd 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -208,6 +208,95 @@ 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}}, + {"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, "threshold": 90}, + }, + ("certificateSettings", "criteria", "threshold"), + ), + ], + ) + 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")