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
29 changes: 29 additions & 0 deletions src/ol_analytics_api/tenants/b2b_learner_records/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
30 changes: 30 additions & 0 deletions src/ol_analytics_api/tenants/b2b_learner_records/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,))
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
59 changes: 57 additions & 2 deletions tests/test_learner_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)