Skip to content
Merged
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
127 changes: 127 additions & 0 deletions openapi/specs/b2b_dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,17 @@ paths:
description: Repeat for several. `unknown` selects rows with withheld outcomes.
title: Completion Status
description: Repeat for several. `unknown` selects rows with withheld outcomes.
- name: courserun_readable_id
in: query
required: false
schema:
anyOf:
- type: string
Comment thread
Copilot marked this conversation as resolved.
minLength: 1
- type: 'null'
description: Exact match. Narrows to one course run, e.g. the module filter.
title: Courserun Readable Id
description: Exact match. Narrows to one course run, e.g. the module filter.
- name: include_inactive
in: query
required: false
Expand Down Expand Up @@ -561,6 +572,55 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/course-runs:
get:
tags:
- learners
summary: Course runs under the contract, for the learner-progress module filter
operationId: learners_course_runs_retrieve
parameters:
- name: organization_id
in: path
required: true
schema:
type: string
title: Organization Id
- name: contract_id
in: path
required: true
schema:
type: integer
title: Contract Id
- name: limit
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
default: 100
title: Limit
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
title: Offset
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/CourseRunsResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/analytics/admin/contract-health:
get:
tags:
Expand Down Expand Up @@ -1339,6 +1399,73 @@ components:
``seats_consumed`` over ``seat_limit``, null when the limit is zero or null

(the view divides by ``nullif(seat_limit, 0)``).'
CourseRun:
properties:
courserun_id:
type: string
title: Courserun Id
description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026.
courserun_title:
type: string
title: Courserun Title
description: The course's title.
courserun_start_on:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Courserun Start On
description: When the course run starts. Empty if no start date is set.
courserun_end_on:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Courserun End On
description: When the course run ends. Empty for self-paced courses.
type: object
required:
- courserun_id
- courserun_title
- courserun_start_on
- courserun_end_on
title: CourseRun
description: One course run under the contract, for the learner-progress module
filter.
CourseRunsResponse:
properties:
organization_id:
type: string
title: Organization Id
description: The organization's ID.
as_of:
anyOf:
- type: string
format: date-time
- type: 'null'
title: As Of
description: When the data was last updated. Empty before the first update.
total_count:
type: integer
title: Total Count
description: Course runs under the contract, across all pages.
data:
items:
$ref: '#/components/schemas/CourseRun'
type: array
title: Data
description: This page of course runs.
type: object
required:
- organization_id
- as_of
- total_count
- data
title: CourseRunsResponse
description: 'The org envelope (``organization_id``, ``as_of``, ``total_count``,
``data``),

matching ``LearnerProgressResponse``''s shape.'
EnrollmentCompletionFunnel:
properties:
organization_key:
Expand Down
27 changes: 27 additions & 0 deletions src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,33 @@ class CompletionStatusCounts(BaseModel):
)


class CourseRun(BaseModel):
"""One course run under the contract, for the learner-progress module filter."""

courserun_id: str = Field(
description="The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026."
)
courserun_title: str = Field(description="The course's title.")
courserun_start_on: UtcDatetime | None = Field(
description="When the course run starts. Empty if no start date is set."
)
courserun_end_on: UtcDatetime | None = Field(
description="When the course run ends. Empty for self-paced courses."
)


class CourseRunsResponse(BaseModel):
"""The org envelope (``organization_id``, ``as_of``, ``total_count``, ``data``),
matching ``LearnerProgressResponse``'s shape."""

organization_id: str = Field(description="The organization's ID.")
as_of: UtcDatetime | None = Field(
description="When the data was last updated. Empty before the first update."
)
total_count: int = Field(description="Course runs under the contract, across all pages.")
data: list[CourseRun] = Field(description="This page of course runs.")


class LearnerProgressResponse(BaseModel):
"""The org envelope (``organization_id``, ``as_of``, ``total_count``,
``data``) plus ``outcomes_withheld_count``, so a client can show how many
Expand Down
39 changes: 39 additions & 0 deletions src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from ol_analytics_api.tenants.b2b_dashboard.config import settings

ENROLLMENT_MV = "mv_b2b_learner_enrollment"
CONTRACT_COURSERUN_MV = "mv_b2b_contract_courserun"

# Matches the b2b_learner_records tenant. An unrevoked certificate is certified
# without requiring is_passing, since production has unrevoked certificates with
Expand Down Expand Up @@ -100,6 +101,7 @@ class ProgressFilters:
contract_id: int
search: str | None = None
completion_statuses: tuple[str, ...] = ()
courserun_readable_id: str | None = None
include_inactive: bool = False
sort: SortKey = SortKey.FULL_NAME
descending: bool = False
Expand Down Expand Up @@ -149,6 +151,9 @@ def learner_progress(filters: ProgressFilters) -> ProgressQuery:
pattern = _contains_pattern(filters.search)
predicates.append("(LOWER(email) LIKE %s OR LOWER(full_name) LIKE %s)")
params.extend([pattern, pattern])
if filters.courserun_readable_id:
Comment thread
daniellefrappier18 marked this conversation as resolved.
predicates.append("courserun_readable_id = %s")
params.append(filters.courserun_readable_id)
if filters.completion_statuses:
# Status values match only rows whose outcomes are shared; `unknown`
# selects the withheld ones. Otherwise a status filter would reveal the
Expand Down Expand Up @@ -195,3 +200,37 @@ def learner_progress(filters: ProgressFilters) -> ProgressQuery:
f" FROM ({records}) records{where}"
)
return ProgressQuery(page, count, tuple(params))


@dataclass(frozen=True)
class CourseRunsQuery:
"""``params`` binds ``count``; ``page`` takes ``params`` plus LIMIT and OFFSET."""

page: str
count: str
params: tuple[Any, ...]


def course_runs(organization_id: str, contract_id: int) -> CourseRunsQuery:
"""The contract's course runs, for ``learner_progress``'s module filter.

Catalog metadata, not learner rows: unlike ``learner_progress``, there is
no consent gating and no anonymization floor, matching
``b2b_learner_records.queries.courses()`` for the same reason.
"""
table = f"{validate_sql_identifier(settings.learner_records_schema)}.{CONTRACT_COURSERUN_MV}"
where = "sso_organization_id = %s AND contract_id = %s"
params: tuple[Any, ...] = (organization_id, contract_id)
# Nulls last (self-paced runs have no start date), then title for a
# human-friendly order, then the readable id as a unique tie-break so
# LIMIT/OFFSET paging is deterministic even when runs share a title.
page = (
"SELECT courserun_readable_id AS courserun_id, courserun_title," # noqa: S608
" courserun_start_on, courserun_end_on"
f" FROM {table} WHERE {where}"
" ORDER BY courserun_start_on IS NULL, courserun_start_on, courserun_title,"
" courserun_readable_id"
" LIMIT %s OFFSET %s"
)
count = f"SELECT COUNT(*) AS total_count FROM {table} WHERE {where}" # noqa: S608
return CourseRunsQuery(page, count, params)
37 changes: 37 additions & 0 deletions src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
from ol_analytics_api.tenants.b2b_dashboard.config import settings
from ol_analytics_api.tenants.b2b_dashboard.learner_models import (
CompletionStatusCounts,
CourseRun,
CourseRunsResponse,
LearnerProgress,
LearnerProgressResponse,
)
Expand Down Expand Up @@ -74,6 +76,13 @@ async def learner_progress( # noqa: PLR0913
default_factory=list,
),
],
courserun_readable_id: Annotated[
str | None,
Query(
min_length=1,
description="Exact match. Narrows to one course run, e.g. the module filter.",
),
] = None,
include_inactive: Annotated[
bool, Query(description="Include deactivated enrollments (unenrolled, refunded).")
] = False,
Expand All @@ -86,6 +95,7 @@ async def learner_progress( # noqa: PLR0913
contract_id=contract_id,
search=search,
completion_statuses=tuple(status.value for status in completion_status or ()),
courserun_readable_id=courserun_readable_id,
include_inactive=include_inactive,
sort=sort,
descending=descending,
Expand Down Expand Up @@ -113,3 +123,30 @@ async def learner_progress( # noqa: PLR0913
needs_attention_count=int(counts["needs_attention_count"] or 0),
data=[LearnerProgress(**row) for row in rows],
)


@router.get(
"/course-runs",
response_model=CourseRunsResponse,
name="course_runs",
operation_id="learners_course_runs_retrieve",
summary="Course runs under the contract, for the learner-progress module filter",
)
async def course_runs(
*,
organization_id: str,
contract_id: int,
page: Annotated[Pagination, Depends(pagination)],
) -> CourseRunsResponse:
query = learner_queries.course_runs(organization_id, contract_id)
as_of = await latest_refresh_timestamp(
settings.learner_records_schema, learner_queries.CONTRACT_COURSERUN_MV
)
rows = await starrocks_pool.fetch_all(query.page, (*query.params, page.limit, page.offset))
counts = (await starrocks_pool.fetch_all(query.count, query.params))[0]
return CourseRunsResponse(
organization_id=organization_id,
as_of=as_of,
total_count=int(counts["total_count"]),
data=[CourseRun(**row) for row in rows],
)
Loading
Loading