Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a4e18ef
feat(viewer): viewer service module + LP auto-enrolment tracking
aimansharief Jul 31, 2026
49e4826
chore(viewer): keep migration cql local-only
aimansharief Jul 31, 2026
d06688d
refactor(viewer): remove eager enrol fan-out; trim unused helpers
aimansharief Jul 31, 2026
a0ba0fb
test(activity): consolidate HierarchyRelationsUtil trackable + cache …
aimansharief Jul 31, 2026
3e1ec7c
docs(viewer): drop stale ref to removed ProgressionPolicy.computeAchi…
aimansharief Jul 31, 2026
dfda98c
ci(viewer): build + deploy standalone viewer-service image
aimansharief Jul 31, 2026
b2cc2ab
Merge pull request #49 from aimansharief/feat/viewer-service
aimansharief Jul 31, 2026
b40552a
fix(enrolment): address course_batch/user_enrolments by collectionid/…
aimansharief Aug 3, 2026
674a7b7
feat(coursebatch): use collectionId/contextId for the courseBatch ES …
aimansharief Aug 4, 2026
4e06a65
feat(coursebatch): gate collectionId/contextId ES fields on viewer_en…
aimansharief Aug 4, 2026
4af39c6
feat(enrolment): Map viewer docs to courseId/batchId
aimansharief Aug 4, 2026
727121a
fix: map renamed collectionid/contextid columns to collectionId/conte…
aimansharief Aug 4, 2026
59f032d
fix: restore courseId/batchId on enrolment rows for /user/courses/list
aimansharief Aug 4, 2026
2d4acca
fix: guard enrolment-row remap behind null/empty check
aimansharief Aug 4, 2026
a27b9ed
chore: add trace logging across enrolment list flow
aimansharief Aug 4, 2026
2c2b346
fix: remap lowercase collectionid/contextid on enrolment rows too
aimansharief Aug 4, 2026
f6c7d6b
fix: unify all courseBatch ES writers on collectionId/contextId
aimansharief Aug 4, 2026
c5c5576
refactor: use courseId/batchId everywhere; drop collectionId/contextI…
aimansharief Aug 5, 2026
24a9bc7
fix: viewer enrolment rollup + lastcontentaccesstime
aimansharief Aug 5, 2026
def10c9
fix: summary CSV columns + drop dead lowercase fallback
aimansharief Aug 5, 2026
dbc9ab5
feat: summary/download uploads CSV to cloud storage, returns url
aimansharief Aug 5, 2026
0a05179
fix: reverting git ignore
aimansharief Aug 5, 2026
ed4d767
fix(viewer): resolve code-review findings + LP rollup read consolidation
aimansharief Aug 6, 2026
4a4bee1
test(viewer): regression tests for C1 (phantom-row guard) and C2 (con…
aimansharief Aug 6, 2026
219d66c
feat(lp): single enrol path for LP + child-batch cert inheritance; co…
aimansharief Aug 7, 2026
d98c275
feat(viewer): state loggers across viewer flow + wire enrol->LP boots…
aimansharief Aug 10, 2026
3118699
feat(viewer): Derive course ancestors from leaf nodes
aimansharief Aug 10, 2026
f4751ec
feat(lp): wire Strict/Adaptive/PriorLearning policy engine
aimansharief Aug 11, 2026
d2bf8ce
refactor(lp): viewer quality pass — dedup, OOP extraction, batch-exis…
aimansharief Aug 11, 2026
7e50911
feat(viewer): LP course-completion trigger, parent-LP resolution, cou…
aimansharief Aug 12, 2026
cf33771
fix: Advance LP for complete courses; issue cert only on transition
aimansharief Aug 12, 2026
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
21 changes: 21 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
echo "USERORG_ENABLE=${{ vars.USERORG_SERVICE_BUILD == 'true' }}" >> $GITHUB_ENV
echo "LMS_ENABLE=${{ vars.LMS_SERVICE_BUILD == 'true' }}" >> $GITHUB_ENV
echo "NOTIFICATION_ENABLE=${{ vars.NOTIFICATION_SERVICE_BUILD == 'true' }}" >> $GITHUB_ENV
echo "VIEWER_ENABLE=${{ vars.VIEWER_SERVICE_BUILD == 'true' }}" >> $GITHUB_ENV

# Cloud Storage Provider (CSP) — lookup from repo settings, default to azure
CSP="${{ vars.CLOUD_STORAGE_PROVIDER }}"
Expand Down Expand Up @@ -147,3 +148,23 @@ jobs:
tags: ${{ env.ORG_BASE }}/lern-notification-service:${{ env.IMAGE_TAG }}
build-args: |
CSP=${{ env.CSP }}

# ----------------------------------------------------------------
# VIEWER SERVICE (standalone / distributed deployment)
# ----------------------------------------------------------------
- name: Build Viewer Service
if: env.VIEWER_ENABLE == 'true'
env:
CSP: ${{ env.CSP }}
run: ./scripts/build-local.sh --service viewer

- name: Push Viewer Service Docker
if: env.VIEWER_ENABLE == 'true'
uses: docker/build-push-action@v4
with:
context: .
file: build/viewer/Dockerfile
push: true
tags: ${{ env.ORG_BASE }}/lern-viewer-service:${{ env.IMAGE_TAG }}
build-args: |
CSP=${{ env.CSP }}
55 changes: 54 additions & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,54 @@ jobs:
name: lern-artifacts
path: modules/lern/service/**/target/**

viewer-build:
needs: build-core
runs-on: ubuntu-latest

env:
CSP: ${{ vars.CLOUD_STORAGE_PROVIDER || 'azure' }}

services:
redis:
image: redis:4.0.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4

- name: Set up JDK 11
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'temurin'

- name: Restore Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ github.run_id }}

- name: Build and Generate Coverage Report (Viewer)
run: |
# -am builds the viewer's deps (core + lms modules); viewer-service is the standalone Play app.
mvn install -P viewer,${CSP} -pl modules/viewer/actors,modules/viewer/service -am -DskipTests -Dcheckstyle.skip
mvn verify -P viewer,${CSP} -pl modules/viewer/actors,modules/viewer/service \
-DreuseForks=false \
-DargLine="--add-opens java.base/sun.misc=ALL-UNNAMED -Djdk.attach.allowAttachSelf=true"

- name: Upload Viewer Artifacts
uses: actions/upload-artifact@v4
with:
name: viewer-artifacts
path: modules/viewer/**/target/**

sonar-analysis:
needs: [userorg-build, lms-build, notification-build, lern-build]
needs: [userorg-build, lms-build, notification-build, lern-build, viewer-build]
runs-on: ubuntu-latest

env:
Expand Down Expand Up @@ -252,6 +298,13 @@ jobs:
path: .
merge-multiple: true

- name: Download Viewer Artifacts
uses: actions/download-artifact@v4
with:
name: viewer-artifacts
path: .
merge-multiple: true

- name: Run Aggregated SonarQube Analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,4 @@ scripts/.keycloak-build/
keys/

# Claude
.claude/
.claude/
37 changes: 37 additions & 0 deletions build/viewer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Sunbird Viewer Service Dockerfile (standalone / distributed deployment)
# Stage 1: Extraction
FROM alpine:3.20 AS builder
RUN apk update && apk add unzip
WORKDIR /app
COPY modules/viewer/service/target/viewer-service-1.0-SNAPSHOT-dist.zip .
RUN unzip viewer-service-1.0-SNAPSHOT-dist.zip

# Stage 2: Runtime
FROM eclipse-temurin:11-jre-alpine

# Cloud Storage Provider selection — pass --build-arg CSP=<azure|aws|gcloud|oci> at build time.
# Defaults to azure. The Maven build must have been run with the matching profile (-P <csp>).
# cloud_storage_auth_type defaults to OIDC (Kubernetes Workload Identity).
# Override at runtime via K8s ConfigMap or: docker run -e sunbird_cloud_storage_auth_type=ACCESS_KEY
ARG CSP=azure
ENV sunbird_cloud_service_provider=${CSP}
ENV sunbird_cloud_storage_auth_type=OIDC

RUN apk upgrade --no-cache \
&& apk add --no-cache curl "zlib>=1.3.2-r0" \
&& adduser -u 1001 -h /home/sunbird/ -D sunbird \
&& mkdir -p /home/sunbird/

WORKDIR /home/sunbird/
COPY --from=builder --chown=sunbird:sunbird /app/viewer-service-1.0-SNAPSHOT /home/sunbird/viewer-service-1.0-SNAPSHOT
COPY modules/viewer/service/conf/logback.xml /home/sunbird/viewer-service-1.0-SNAPSHOT/conf/logback.xml

USER sunbird
EXPOSE 9000

CMD java -XX:+PrintFlagsFinal $JAVA_OPTIONS \
-Dlog4j2.formatMsgNoLookups=true \
-Dplay.server.http.idleTimeout=180s \
-Dlogback.configurationFile=/home/sunbird/viewer-service-1.0-SNAPSHOT/conf/logback.xml \
-cp '/home/sunbird/viewer-service-1.0-SNAPSHOT/lib/viewer-service-1.0-SNAPSHOT.jar:/home/sunbird/viewer-service-1.0-SNAPSHOT/lib/*' \
play.core.server.ProdServerStart /home/sunbird/viewer-service-1.0-SNAPSHOT
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ public final class JsonKey {

public static final String CLOUD_FOLDER_CONTENT = "sunbird_cloud_content_folder";
public static final String CLOUD_STORE_BASE_PATH = "cloud_storage_base_url";
public static final String CLOUD_STORAGE_REGION = "cloud_storage_region";
public static final String CLOUD_STORAGE_CNAME_URL= "cloud_storage_cname_url";
public static final String CLOUD_STORE_BASE_PATH_PLACEHOLDER = "cloud_store_base_path_placeholder";
public static final String TTL = "ttl";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ private static IStorageService getStorageService(
if (authType == StorageConfig.AuthType.ACCESS_KEY) {
builder.storageSecret(storageSecret);
}
// Region for providers that need it (e.g. AWS S3 outside us-east-1). No-op when unset:
// the provider SDK keeps its default (AWS -> us-east-1), so Azure/existing behaviour is unchanged.
String region = ProjectUtil.getConfigValue(JsonKey.CLOUD_STORAGE_REGION);
if (StringUtils.isNotBlank(region)) {
builder.region(region);
}
StorageConfig storageConfig = builder.build();
IStorageService storageService = StorageServiceFactory.getStorageService(storageConfig);
storageServiceMap.put(compositeKey, storageService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ sunbird_api_request_lower_case_fields=source,externalId,userName,provider,loginI
# Provide corresponding service provider container(azure,aws,gcloud)
sunbird_content_cloud_storage_container=sunbird-content-dev
sunbird_cloud_content_folder=content
# Object-key prefix (folder) for viewer summary CSV export; blank = container root
viewer_summary_upload_path=viewer-summary
sunbird_time_zone=Asia/Kolkata
sunbird_health_check_enable=true
sunbird_sync_read_wait_time=1500
Expand Down Expand Up @@ -103,6 +105,8 @@ kafka_assessment_topic=
sunbird_api_mgr_base_url=https://dev.sunbirded.org/api
enrollment_list_size=1000
cloud_storage_base_url=https://sunbirddev.blob.core.windows.net
# Region for providers that need it (e.g. AWS S3 outside us-east-1); blank = provider default
cloud_storage_region=
cloud_store_base_path_placeholder=CLOUD_BASE_PATH
#Release-5.3.0 - LR-556
content_service_mock_enabled=false
Expand Down Expand Up @@ -178,6 +182,12 @@ sunbird_username_num_digits=4
sunbird_user_bulk_upload_size=1001
bulk_upload_org_data_size=300
sunbird_framework_read_api=/v1/framework/read
lp_meta_cache_ttl=3600
framework_category_cache_ttl=86400
sunbird.channel.create.api.url=/channel/v3/create
sunbird.channel.update.api.url=/channel/v3/update
frameworkvalidation=false
frameworkvalidation=false
viewer_service_base_url=http://lern-service:9000
viewer_enabled=false
# LP transport topology: monolith (in-JVM) | distributed (HTTP).
deployment_mode=monolith
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package controllers.viewer;

import controllers.BaseController;
import org.apache.pekko.actor.ActorRef;
import org.sunbird.exception.ProjectCommonException;
import org.sunbird.keys.JsonKey;
import org.sunbird.request.Request;
import org.sunbird.response.ResponseCode;
import play.mvc.Http;
import play.mvc.Result;

import javax.inject.Inject;
import javax.inject.Named;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

/**
* Viewer resync API (monolith wiring) — recomputes a learner's collection roll-up from
* user_content_consumption. Idempotent (recompute-from-source): backfill, drift repair, or a
* recompute after a collection is republished. Mirrors the legacy POST /v1/activity/agg, but targets
* the viewer aggregator (viewer-aggregator-actor, op "aggregate"). Runs on the userId-hashed pool, so
* a resync serialises with any live /v1/view/end for that learner — no race with real-time roll-ups.
* POST /v1/view/agg { request: { userId, collectionId|courseId, contextId|batchId } }
*/
public class ViewAggregateController extends BaseController {

@Inject
@Named("viewer-aggregator-actor")
private ActorRef viewerAggregatorActor;

public CompletionStage<Result> agg(Http.Request httpRequest) {
try {
Request request = createAndInitRequest("aggregate", httpRequest.body().asJson(), httpRequest);
validate(request);
return actorResponseHandler(viewerAggregatorActor, request, timeout, null, httpRequest);
} catch (Exception e) {
return CompletableFuture.completedFuture(createCommonExceptionResponse(e, httpRequest));
}
}

private void validate(Request request) {
String userId = (String) request.get(JsonKey.USER_ID);
Object courseId = request.get(JsonKey.COURSE_ID);
if (userId == null || userId.trim().isEmpty()
|| courseId == null || courseId.toString().trim().isEmpty()) {
throw new ProjectCommonException(
ResponseCode.mandatoryParamsMissing.getErrorCode(),
"userId and courseId are mandatory",
ResponseCode.CLIENT_ERROR.getResponseCode());
}
}
}
59 changes: 59 additions & 0 deletions modules/lern/service/app/controllers/viewer/ViewController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package controllers.viewer;

import controllers.BaseController;
import org.apache.pekko.actor.ActorRef;
import org.sunbird.request.Request;
import play.mvc.Http;
import play.mvc.Result;

import javax.inject.Inject;
import javax.inject.Named;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

/**
* Viewer Service — granular view lifecycle APIs (monolith wiring).
* Dispatches to view-consumption-actor (bound in LernServiceActorStartModule).
* POST /v1/view/start -> viewStart
* POST /v1/view/update -> viewUpdate
* POST /v1/view/end -> viewEnd (synchronous recursive roll-up before responding)
*/
public class ViewController extends BaseController {

@Inject
@Named("view-consumption-actor")
private ActorRef viewConsumptionActor;

public CompletionStage<Result> viewStart(Http.Request httpRequest) {
return dispatch("viewStart", httpRequest);
}

public CompletionStage<Result> viewUpdate(Http.Request httpRequest) {
return dispatch("viewUpdate", httpRequest);
}

public CompletionStage<Result> viewEnd(Http.Request httpRequest) {
return dispatch("viewEnd", httpRequest);
}

public CompletionStage<Result> viewRead(Http.Request httpRequest) {
return dispatch("viewRead", httpRequest);
}

public CompletionStage<Result> assessmentSubmit(Http.Request httpRequest) {
return dispatch("viewAssess", httpRequest);
}

public CompletionStage<Result> assessmentRead(Http.Request httpRequest) {
return dispatch("assessmentRead", httpRequest);
}

private CompletionStage<Result> dispatch(String operation, Http.Request httpRequest) {
try {
Request request = createAndInitRequest(operation, httpRequest.body().asJson(), httpRequest);
return actorResponseHandler(viewConsumptionActor, request, timeout, null, httpRequest);
} catch (Exception e) {
return CompletableFuture.completedFuture(createCommonExceptionResponse(e, httpRequest));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package controllers.viewer;

import controllers.BaseController;
import org.apache.pekko.actor.ActorRef;
import org.sunbird.request.Request;
import play.mvc.Http;
import play.mvc.Result;

import javax.inject.Inject;
import javax.inject.Named;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

/**
* Viewer Service — read + summary APIs (monolith wiring).
* Dispatches to viewer-summary-actor (bound in LernServiceActorStartModule).
* POST /v1/summary/read
* GET /v1/summary/list/:userId
* DELETE /v1/summary/delete/:userId
*/
public class ViewSummaryController extends BaseController {

@Inject
@Named("viewer-summary-actor")
private ActorRef viewerSummaryActor;

public CompletionStage<Result> summaryRead(Http.Request httpRequest) {
return dispatchBody("summaryRead", httpRequest);
}

public CompletionStage<Result> summaryList(String userId, Http.Request httpRequest) {
try {
Request request = createAndInitRequest("summaryList", httpRequest);
request.getRequest().put("userId", userId);
return actorResponseHandler(viewerSummaryActor, request, timeout, null, httpRequest);
} catch (Exception e) {
return CompletableFuture.completedFuture(createCommonExceptionResponse(e, httpRequest));
}
}

public CompletionStage<Result> summaryDownload(String userId, Http.Request httpRequest) {
try {
Request request = createAndInitRequest("summaryDownload", httpRequest);
request.getRequest().put("userId", userId);
String[] fmt = httpRequest.queryString().getOrDefault("format", new String[]{"json"});
request.getRequest().put("format", fmt.length > 0 ? fmt[0] : "json");
return actorResponseHandler(viewerSummaryActor, request, timeout, null, httpRequest);
} catch (Exception e) {
return CompletableFuture.completedFuture(createCommonExceptionResponse(e, httpRequest));
}
}

public CompletionStage<Result> summaryDelete(String userId, Http.Request httpRequest) {
try {
Request request = httpRequest.body().asJson() != null
? createAndInitRequest("summaryDelete", httpRequest.body().asJson(), httpRequest)
: createAndInitRequest("summaryDelete", httpRequest);
request.getRequest().put("userId", userId);
return actorResponseHandler(viewerSummaryActor, request, timeout, null, httpRequest);
} catch (Exception e) {
return CompletableFuture.completedFuture(createCommonExceptionResponse(e, httpRequest));
}
}

private CompletionStage<Result> dispatchBody(String operation, Http.Request httpRequest) {
try {
Request request = createAndInitRequest(operation, httpRequest.body().asJson(), httpRequest);
return actorResponseHandler(viewerSummaryActor, request, timeout, null, httpRequest);
} catch (Exception e) {
return CompletableFuture.completedFuture(createCommonExceptionResponse(e, httpRequest));
}
}
}
Loading
Loading