Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
65 changes: 59 additions & 6 deletions src/scripts/validate_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)

Comment on lines +53 to +65

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
89 changes: 89 additions & 0 deletions test/unit/test_validate_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading