diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/models.py b/src/ol_analytics_api/tenants/b2b_learner_records/models.py index b7c42c6..3690aac 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/models.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/models.py @@ -86,6 +86,35 @@ class LearnerRecordsResponse[RowT: BaseModel](BaseModel): data: list[RowT] = Field(description="The requested page of records.") +class CourseRun(BaseModel): + """One course run covered by one of the organization's contracts. No personal data, + so no consent gate.""" + + organization_id: uuid.UUID = Field(description="The organization's Keycloak organization UUID.") + organization_name: str = Field(description="Display name of the organization.") + contract_id: int = Field( + description="Numeric identifier of the B2B contract.", + json_schema_extra={"format": "int64"}, + ) + contract_name: str = Field(description="Name of the B2B contract.") + contract_is_active: bool = Field(description="Whether the contract is currently active.") + contract_start_date: datetime.date | None = Field( + description="Null where the contract records no start date." + ) + contract_end_date: datetime.date | None = Field( + description="Null where the contract records no end date." + ) + seat_limit: int | None = Field(ge=0, description="Null means uncapped, not zero.") + courserun_id: str = Field( + description="Readable course-run identifier, e.g. `course-v1:MITxT+14.310x+2T2026`." + ) + courserun_title: str = Field(description="Mutable display title — key on courserun_id.") + courserun_start_on: UtcDatetime | None = Field( + description="Start date/time of the course run, or null if unscheduled." + ) + courserun_end_on: UtcDatetime | None = Field(description="Null for self-paced runs.") + + class Learner(BaseModel): """One learner's association with the organization.""" diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/queries.py b/src/ol_analytics_api/tenants/b2b_learner_records/queries.py index 4e8ea1c..7b3bcff 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/queries.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/queries.py @@ -36,6 +36,7 @@ LEARNER_MV = "mv_b2b_learner" ENROLLMENT_MV = "mv_b2b_learner_enrollment" +CONTRACT_COURSERUN_MV = "mv_b2b_contract_courserun" def _outcomes_shared() -> str: @@ -366,3 +367,32 @@ def _recomputed_learners(schema: str, filters: RecordFilters) -> tuple[str, list f" {join} ({enrollment_rollup}) e ON l.user_pk = e.user_pk" ) return records, params + + +def courses(schema: str, filters: RecordFilters) -> RecordQuery: + """The organization's contracts and their course runs. + + Built without ``_assemble``: these rows carry no personal data, so there is + no consent projection, and ``outcomes_withheld_count`` is always 0. + """ + table = f"{validate_sql_identifier(schema)}.{CONTRACT_COURSERUN_MV}" + scope = ["sso_organization_id = %s"] + params: list[Any] = [str(filters.organization_id)] + if filters.contract_id is not None: + scope.append("contract_id = %s") + params.append(filters.contract_id) + where = " AND ".join(scope) + page = ( + "SELECT sso_organization_id AS organization_id, organization_name, contract_id," # noqa: S608 + " b2b_contract_name AS contract_name, b2b_contract_is_active AS contract_is_active," + " b2b_contract_start_date AS contract_start_date," + " b2b_contract_end_date AS contract_end_date, seat_limit," + " courserun_readable_id AS courserun_id, courserun_title, courserun_start_on," + f" courserun_end_on FROM {table} WHERE {where}" + " ORDER BY contract_id, courserun_id LIMIT %s OFFSET %s" + ) + count = ( + "SELECT COUNT(*) AS total_count, 0 AS outcomes_withheld_count" # noqa: S608 + f" FROM {table} WHERE {where}" + ) + return RecordQuery(page, count, tuple(params), (CONTRACT_COURSERUN_MV,)) diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py b/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py index febe4ec..590d18b 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py @@ -19,6 +19,7 @@ from ol_analytics_api.tenants.b2b_learner_records.auth import require_organization_grant from ol_analytics_api.tenants.b2b_learner_records.config import settings from ol_analytics_api.tenants.b2b_learner_records.models import ( + CourseRun, Enrollment, Learner, LearnerRecordsResponse, @@ -148,3 +149,22 @@ async def list_enrollments( # noqa: PLR0913 return await _respond( queries.enrollments(settings.starrocks_schema, filters), organization_id, page, Enrollment ) + + +@router.get( + "/courses", + operation_id="listCourses", + tags=["catalog"], + response_model=LearnerRecordsResponse[CourseRun], + summary="Contracts and course runs covered by the organization's licence", +) +async def list_courses( + *, + organization_id: uuid.UUID, + page: PageParams, + contract_id: int | None = None, +) -> LearnerRecordsResponse[BaseModel]: + filters = queries.RecordFilters(organization_id=organization_id, contract_id=contract_id) + return await _respond( + queries.courses(settings.starrocks_schema, filters), organization_id, page, CourseRun + ) diff --git a/tests/test_learner_records.py b/tests/test_learner_records.py index 1530731..6690d33 100644 --- a/tests/test_learner_records.py +++ b/tests/test_learner_records.py @@ -20,7 +20,7 @@ from ol_analytics_api.tenants.b2b_learner_records import queries from ol_analytics_api.tenants.b2b_learner_records.auth import NO_GRANT_DETAIL from ol_analytics_api.tenants.b2b_learner_records.config import settings -from ol_analytics_api.tenants.b2b_learner_records.models import Enrollment, Learner +from ol_analytics_api.tenants.b2b_learner_records.models import CourseRun, Enrollment, Learner BASE = "/api/v1/learner-records" ORG_ID = "8f14e45f-ceea-467a-9c1b-2f4b9c0a3d21" @@ -99,7 +99,7 @@ def _enrollment_row(**overrides): } -@pytest.mark.parametrize("name", ["Learner", "Enrollment"]) +@pytest.mark.parametrize("name", ["Learner", "Enrollment", "CourseRun"]) def test_every_record_field_is_required_in_the_generated_schema(name): # The contract lists pending fields as required and nullable. A defaulted # field would generate as optional, and a client would treat it as absent. @@ -464,3 +464,58 @@ async def test_as_of_is_read_before_the_records(app, monkeypatch): ] assert kinds.index("as_of") < kinds.index("page") assert kinds.index("as_of") < kinds.index("count") + + +def _course_row(**overrides): + return { + "organization_id": ORG_ID, + "organization_name": "Contoso Manufacturing", + "contract_id": 42, + "contract_name": "Contoso 2026 Site Licence", + "contract_is_active": 1, + "contract_start_date": None, + "contract_end_date": datetime.date(2026, 12, 31), + "seat_limit": None, + "courserun_id": "course-v1:MITxT+14.310x+2T2026", + "courserun_title": "Data Analysis for Social Scientists", + "courserun_start_on": "2026-02-01T00:00:00.000", + "courserun_end_on": None, + **overrides, + } + + +async def test_courses_read_the_contract_courserun_view(app, monkeypatch): + pool = _FakePool(rows=[_course_row()], total_count=1) + response = await _get( + app, f"/organizations/{ORG_ID}/courses", _partner_header(ORG_ID), pool, monkeypatch + ) + + assert response.status_code == 200 + body = response.json() + assert body["outcomes_withheld_count"] == 0 + [course] = body["data"] + assert set(course) == set(CourseRun.model_fields) + assert course["contract_is_active"] is True + assert course["contract_end_date"] == "2026-12-31" + assert course["seat_limit"] is None + assert course["courserun_start_on"] == "2026-02-01T00:00:00Z" + query, params = pool.page_call() + assert f"FROM b2b_learner_records.{queries.CONTRACT_COURSERUN_MV} WHERE" in query + assert "outcomes_shared" not in query + assert query.endswith("ORDER BY contract_id, courserun_id LIMIT %s OFFSET %s") + assert params == (ORG_ID, 100, 0) + + +async def test_courses_contract_filter_is_bound(app, monkeypatch): + pool = _FakePool() + await _get( + app, + f"/organizations/{ORG_ID}/courses?contract_id=42", + _partner_header(ORG_ID), + pool, + monkeypatch, + ) + query, params = pool.page_call() + assert "sso_organization_id = %s AND contract_id = %s" in query + assert params == (ORG_ID, 42, 100, 0) + assert pool.count_call()[1] == (ORG_ID, 42)