From d4e579acfa10764f109cf5f4eccd9f8541d9cfb3 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Thu, 13 Nov 2025 11:56:48 +0530 Subject: [PATCH 01/26] reset pagination for trashModal --- .../channelEdit/views/trash/TrashModal.vue | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue b/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue index 9052cf5720..271d12a8d0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue @@ -281,19 +281,24 @@ 'moveContentNodes', 'loadContentNodes', 'loadAncestors', + 'removeContentNodes', ]), loadNodes() { this.loading = true; + this.more = null; + this.moreLoading = false; if (!this.trashId) { this.loading = false; return; } - this.loadChildren({ parent: this.trashId, ordering: '-modified' }).then( - childrenResponse => { - this.loading = false; - this.more = childrenResponse.more || null; - }, - ); + this.removeContentNodes({ parentId: this.trashId }).then(() => { + this.loadChildren({ parent: this.trashId, ordering: '-modified' }).then( + childrenResponse => { + this.loading = false; + this.more = childrenResponse.more || null; + }, + ); + }); }, moveNodes(target) { return this.moveContentNodes({ From 1b2c0b7fdeaab523d4bdebbefd87e0cf63e2220e Mon Sep 17 00:00:00 2001 From: ozer550 Date: Mon, 24 Nov 2025 12:24:34 +0530 Subject: [PATCH 02/26] push new modififed field to sync --- .../contentcuration/frontend/shared/data/resources.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 37c307d850..1ddc0cdbfe 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1687,6 +1687,10 @@ export const ContentNode = new TreeResource({ async tableMove({ node, parent, payload }) { // Do direct table writes here rather than using add/update methods to avoid // creating unnecessary additional change events. + payload = { + ...payload, + modified: new Date().toISOString(), + }; const updated = await this.table.update(node.id, payload); // Update didn't succeed, this node probably doesn't exist, do a put instead, // but need to add in other parent info. From db37082a85a246c7a8880dbca9ced68bc4bccaa4 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 1 Dec 2025 12:18:10 -0800 Subject: [PATCH 03/26] Add double click blocking and error handling for user creation form submission. --- .../frontend/accounts/pages/Create.vue | 13 +++++++++- .../accounts/pages/__tests__/create.spec.js | 15 +++++++++++ .../contentcuration/tests/views/test_users.py | 26 ++++++++++++++++--- .../contentcuration/views/users.py | 16 ++++++++++-- 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index 91c4431ea2..65afaa16a2 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -213,9 +213,10 @@ @@ -260,6 +261,7 @@ return { valid: true, registrationFailed: false, + submitting: false, form: { first_name: '', last_name: '', @@ -482,6 +484,12 @@ // We need to check the "acceptedAgreement" here explicitly because it is not a // Vuetify form field and does not trigger the form validation. if (this.$refs.form.validate() && this.acceptedAgreement) { + // Prevent double submission + if (this.submitting) { + return Promise.resolve(); + } + + this.submitting = true; const cleanedData = this.clean(this.form); return this.register(cleanedData) .then(() => { @@ -517,6 +525,9 @@ this.registrationFailed = true; this.valid = false; } + }) + .finally(() => { + this.submitting = false; }); } else if (this.$refs.top.scrollIntoView) { this.$refs.top.scrollIntoView({ behavior: 'smooth' }); diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js index a2c2f40d71..11067e892e 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js @@ -179,4 +179,19 @@ describe('create', () => { expect(wrapper.vm.registrationFailed).toBe(true); }); }); + describe('double-submit prevention', () => { + it('should prevent multiple API calls on rapid clicks', async () => { + const [wrapper, mocks] = await makeWrapper(); + + // Click submit multiple times + const p1 = wrapper.vm.submit(); + const p2 = wrapper.vm.submit(); + const p3 = wrapper.vm.submit(); + + await Promise.all([p1, p2, p3]); + + // Only 1 API call should be made + expect(mocks.register).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/contentcuration/contentcuration/tests/views/test_users.py b/contentcuration/contentcuration/tests/views/test_users.py index a17da93f8a..4c5f635204 100644 --- a/contentcuration/contentcuration/tests/views/test_users.py +++ b/contentcuration/contentcuration/tests/views/test_users.py @@ -1,11 +1,13 @@ import json +from django.db import IntegrityError from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseForbidden from django.http.response import HttpResponseNotAllowed from django.http.response import HttpResponseRedirectBase from django.urls import reverse_lazy from mock import mock +from mock import patch from contentcuration.models import User from contentcuration.tests import testdata @@ -127,8 +129,8 @@ def setUp(self): first_name="Tester", last_name="Tester", email="tester@tester.com", - pasword1="tester123", - pasword2="tester123", + password1="tester123", + password2="tester123", uses="IDK", source="IDK", policies=json.dumps(dict(policy_etc=True)), @@ -148,8 +150,8 @@ def test_post__inactive_registration(self): self.assertIsInstance(response, HttpResponseNotAllowed) def test_post__password_too_short(self): - self.request_data["pasword1"] = "123" - self.request_data["pasword2"] = "123" + self.request_data["password1"] = "123" + self.request_data["password2"] = "123" response = self.post(self.view, self.request_data) self.assertIsInstance(response, HttpResponseBadRequest) self.assertIn("password1", response.content.decode()) @@ -160,6 +162,22 @@ def test_post__after_delete(self): response = self.post(self.view, self.request_data) self.assertIsInstance(response, HttpResponseForbidden) + @patch("contentcuration.views.users.UserRegistrationView.register") + def test_post__handles_integrity_error_gracefully(self, mock_register): + """Test that IntegrityError during registration returns 403 instead of 500""" + # Simulate IntegrityError (race condition on duplicate email) + mock_register.side_effect = IntegrityError( + 'duplicate key value violates unique constraint "contentcuration_user_email_key"' + ) + + response = self.post(self.view, self.request_data) + + # Should return 403 Forbidden, not 500 + self.assertIsInstance(response, HttpResponseForbidden) + # Error response should include "email" field + error_data = json.loads(response.content.decode()) + self.assertIn("email", error_data) + class UserActivationViewTestCase(StudioAPITestCase): def setUp(self): diff --git a/contentcuration/contentcuration/views/users.py b/contentcuration/contentcuration/views/users.py index 66a6652d0b..34a986895b 100644 --- a/contentcuration/contentcuration/views/users.py +++ b/contentcuration/contentcuration/views/users.py @@ -11,6 +11,7 @@ from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import PermissionDenied from django.core.mail import send_mail +from django.db import IntegrityError from django.http import HttpResponse from django.http import HttpResponseBadRequest from django.http import HttpResponseForbidden @@ -181,8 +182,19 @@ def get_form_kwargs(self): return kwargs def form_valid(self, form): - self.register(form) - return HttpResponse() + try: + self.register(form) + return HttpResponse() + except IntegrityError as e: + # Handle race condition where duplicate user is created between + # form validation and save (e.g., double submit) + logger.warning( + "IntegrityError during user registration, likely due to race condition: %s", + str(e), + extra={"email": form.cleaned_data.get("email")}, + ) + # Return same error as duplicate active account for consistency + return HttpResponseForbidden(json.dumps(["email"])) def form_invalid(self, form): # frontend handles the error messages From 52a27d5db422ebdcab1ffc5dc6c754f50329b9c9 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Tue, 2 Dec 2025 10:59:43 +0530 Subject: [PATCH 04/26] make changes in test to reflect new modified field operational changes --- .../__tests__/ContentNodeResource.spec.js | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js b/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js index 3f16719252..31bcb53bb4 100644 --- a/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js +++ b/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js @@ -574,8 +574,12 @@ describe('ContentNode methods', () => { it('should update the node with the payload', async () => { node.parent = parent.id; - await expect(ContentNode.tableMove({ node, parent, payload, change })).resolves.toBe(payload); - expect(table.update).toHaveBeenCalledWith(node.id, payload); + const result = await ContentNode.tableMove({ node, parent, payload, change }); + expect(result).toMatchObject({ ...payload, modified: expect.any(String) }); + expect(table.update).toHaveBeenCalledTimes(1); + const [updateId, updatePayload] = table.update.mock.calls[0]; + expect(updateId).toBe(node.id); + expect(updatePayload).toBe(result); expect(table.put).not.toBeCalled(); expect(table.update).not.toHaveBeenCalledWith(node.parent, { changed: true }); }); @@ -584,19 +588,23 @@ describe('ContentNode methods', () => { node.parent = parent.id; updated = false; const newPayload = { ...payload, root_id: parent.root_id }; - await expect(ContentNode.tableMove({ node, parent, payload, change })).resolves.toMatchObject( - newPayload, + const result = await ContentNode.tableMove({ node, parent, payload, change }); + expect(result).toMatchObject({ ...newPayload, modified: expect.any(String) }); + expect(table.update).toHaveBeenCalledWith( + node.id, + expect.objectContaining({ ...payload, modified: expect.any(String) }), ); - expect(table.update).toHaveBeenCalledWith(node.id, payload); - expect(table.put).toHaveBeenCalledWith(newPayload); + expect(table.put).toHaveBeenCalledWith(result); expect(table.update).not.toHaveBeenCalledWith(node.parent, { changed: true }); }); it('should mark the old parent as changed', async () => { - await expect(ContentNode.tableMove({ node, parent, payload, change })).resolves.toMatchObject( - payload, + const result = await ContentNode.tableMove({ node, parent, payload, change }); + expect(result).toMatchObject({ ...payload, modified: expect.any(String) }); + expect(table.update).toHaveBeenCalledWith( + node.id, + expect.objectContaining({ ...payload, modified: expect.any(String) }), ); - expect(table.update).toHaveBeenCalledWith(node.id, payload); expect(table.put).not.toBeCalled(); expect(table.update).toHaveBeenCalledWith(node.parent, { changed: true }); }); From 0dc4909f545c52e78ffc7603d8e58c1c5e92c9be Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:08:02 -0800 Subject: [PATCH 05/26] First pass, based off of suggestions from AI. --- docker/Dockerfile.demo | 43 ----- k8s/Chart.lock | 9 - k8s/Chart.yaml | 13 -- k8s/Makefile | 7 - k8s/charts/cloudsql-proxy-2.0.0.tgz | Bin 3650 -> 0 bytes k8s/charts/redis-12.1.1.tgz | Bin 65202 -> 0 bytes k8s/create-cloudsql-database.sh | 25 --- k8s/create-cloudsql-proxy.sh | 21 --- k8s/create-multiplexing-reverse-proxy-lb.sh | 38 ----- k8s/create-postgres-user-and-db.exp | 62 ------- k8s/encrypt-env-var.sh | 17 -- k8s/helm-deploy.sh | 38 ----- k8s/templates/_helpers.tpl | 134 --------------- k8s/templates/garbage-collect-cronjob.yaml | 78 --------- k8s/templates/ingress.yaml | 21 --- k8s/templates/job-template.yaml | 73 -------- .../mark-incomplete-mgmt-command-cronjob.yaml | 78 --------- k8s/templates/production-ingress.yaml | 23 --- ...set-storage-used-mgmt-command-cronjob.yaml | 78 --------- k8s/templates/studio-deployment.yaml | 157 ------------------ k8s/templates/studio-secrets.yaml | 17 -- k8s/templates/studio-service.yaml | 13 -- k8s/values.yaml | 70 -------- 23 files changed, 1015 deletions(-) delete mode 100644 docker/Dockerfile.demo delete mode 100644 k8s/Chart.lock delete mode 100644 k8s/Chart.yaml delete mode 100644 k8s/Makefile delete mode 100644 k8s/charts/cloudsql-proxy-2.0.0.tgz delete mode 100644 k8s/charts/redis-12.1.1.tgz delete mode 100755 k8s/create-cloudsql-database.sh delete mode 100755 k8s/create-cloudsql-proxy.sh delete mode 100755 k8s/create-multiplexing-reverse-proxy-lb.sh delete mode 100755 k8s/create-postgres-user-and-db.exp delete mode 100755 k8s/encrypt-env-var.sh delete mode 100755 k8s/helm-deploy.sh delete mode 100644 k8s/templates/_helpers.tpl delete mode 100644 k8s/templates/garbage-collect-cronjob.yaml delete mode 100644 k8s/templates/ingress.yaml delete mode 100644 k8s/templates/job-template.yaml delete mode 100644 k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml delete mode 100644 k8s/templates/production-ingress.yaml delete mode 100644 k8s/templates/set-storage-used-mgmt-command-cronjob.yaml delete mode 100644 k8s/templates/studio-deployment.yaml delete mode 100644 k8s/templates/studio-secrets.yaml delete mode 100644 k8s/templates/studio-service.yaml delete mode 100644 k8s/values.yaml diff --git a/docker/Dockerfile.demo b/docker/Dockerfile.demo deleted file mode 100644 index 2ae03758b6..0000000000 --- a/docker/Dockerfile.demo +++ /dev/null @@ -1,43 +0,0 @@ -FROM python:3.10-slim-bookworm - -# Set the timezone -RUN ln -fs /usr/share/zoneinfo/America/Los_Angeles /etc/localtime - -ENV DEBIAN_FRONTEND noninteractive -# Default Python file.open file encoding to UTF-8 instead of ASCII, workaround for le-utils setup.py issue -ENV LANG C.UTF-8 -RUN apt-get update && apt-get -y install python3-pip python3-dev gcc libpq-dev make git curl libjpeg-dev libssl-dev libffi-dev ffmpeg - -# Pin, Download and install node 18.x -RUN apt-get update \ - && apt-get install -y ca-certificates curl gnupg \ - && mkdir -p /etc/apt/keyrings \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ - && echo "Package: nodejs" >> /etc/apt/preferences.d/preferences \ - && echo "Pin: origin deb.nodesource.com" >> /etc/apt/preferences.d/preferences \ - && echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/preferences\ - && apt-get update \ - && apt-get install -y nodejs - -RUN corepack enable pnpm -COPY ./package.json . -COPY ./pnpm-lock.yaml . -RUN pnpm install - -COPY requirements.txt . - -RUN pip install --upgrade pip -RUN pip install --ignore-installed -r requirements.txt - -COPY . /contentcuration/ -WORKDIR /contentcuration - -# generate the node bundles -RUN mkdir -p contentcuration/static/js/bundles -RUN ln -s /node_modules /contentcuration/node_modules -RUN pnpm run build - -EXPOSE 8000 - -ENTRYPOINT ["make", "altprodserver"] diff --git a/k8s/Chart.lock b/k8s/Chart.lock deleted file mode 100644 index 3710092dd1..0000000000 --- a/k8s/Chart.lock +++ /dev/null @@ -1,9 +0,0 @@ -dependencies: -- name: cloudsql-proxy - repository: https://storage.googleapis.com/t3n-helm-charts - version: 2.0.0 -- name: redis - repository: https://charts.bitnami.com/bitnami - version: 12.1.1 -digest: sha256:8e1cf67168047aa098bae2eca9ddae32bb68ba68ca80668492760037fe8ce4ef -generated: "2020-11-25T04:47:31.298097908-08:00" diff --git a/k8s/Chart.yaml b/k8s/Chart.yaml deleted file mode 100644 index 0395068cfc..0000000000 --- a/k8s/Chart.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v2 -description: Kolibri Studio, the Content Curation tool for Kolibri! -name: studio -version: 0.3.0 -dependencies: - - name: cloudsql-proxy - version: 2.0.0 - repository: https://storage.googleapis.com/t3n-helm-charts - enabled: true - - name: redis - version: 12.1.1 - repository: https://charts.bitnami.com/bitnami - enabled: true diff --git a/k8s/Makefile b/k8s/Makefile deleted file mode 100644 index c81ba867d9..0000000000 --- a/k8s/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -DEPLOYMENT := `kubectl get deploy -l app=master-studio -o custom-columns=NAME:.metadata.name --no-headers` -POD := `kubectl get pods -o=custom-columns=NAME:.metadata.name --field-selector=status.phase=Running --no-headers -l app=master-studio | head -n 1` - -master-shell: - kubectl rollout status deployment/$(DEPLOYMENT) - echo Running bash inside $(POD) - kubectl exec -it $(POD) bash diff --git a/k8s/charts/cloudsql-proxy-2.0.0.tgz b/k8s/charts/cloudsql-proxy-2.0.0.tgz deleted file mode 100644 index 29a9046b19339b5238920ee0a5d10e186be26c5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3650 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 Ud8G1x0RRC1{~6e*I{;Jw08HXQ!~g&Q diff --git a/k8s/charts/redis-12.1.1.tgz b/k8s/charts/redis-12.1.1.tgz deleted file mode 100644 index b6735330f3901011fa135acd59ef0b310b2807e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65202 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 " -send -- "CREATE DATABASE $db; CREATE USER $user WITH ENCRYPTED PASSWORD '$user'; GRANT ALL PRIVILEGES ON DATABASE $db TO $user;" -send -- "\n" -expect -exact "postgres=>" -send -- "" -expect eof diff --git a/k8s/encrypt-env-var.sh b/k8s/encrypt-env-var.sh deleted file mode 100755 index c20e0774b0..0000000000 --- a/k8s/encrypt-env-var.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -# How to use: -# pipe a secret string into this script. -# This will output instructions on what you -# then need to add into your cloudbuild.yaml file. - -KEYRING=$1 -KEY=$2 - -gcloud kms encrypt \ - --plaintext-file=- \ - --ciphertext-file=- \ - --location=global \ - --keyring=$KEYRING \ - --key=$KEY \ - | base64 diff --git a/k8s/helm-deploy.sh b/k8s/helm-deploy.sh deleted file mode 100755 index 4f512559fd..0000000000 --- a/k8s/helm-deploy.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -RELEASE_NAME=$1 -STUDIO_APP_IMAGE_NAME=$2 -STUDIO_NGINX_IMAGE_NAME=$3 -STUDIO_BUCKET_NAME=$4 -COMMIT_SHA=$5 -PROJECT_ID=$6 -DATABASE_INSTANCE_NAME=$7 -DATABASE_REGION=$8 - -K8S_DIR=$(dirname $0) - -function get_secret { - gcloud secrets versions access --secret=$1 latest -} - -helm upgrade --install \ - --namespace $RELEASE_NAME --create-namespace \ - --set studioApp.postmarkApiKey=$(get_secret postmark-api-key) \ - --set studioApp.releaseCommit=$COMMIT_SHA \ - --set studioApp.imageName=$STUDIO_APP_IMAGE_NAME \ - --set studioNginx.imageName=$STUDIO_NGINX_IMAGE_NAME \ - --set studioApp.gcs.bucketName=$STUDIO_BUCKET_NAME \ - --set studioApp.gcs.writerServiceAccountKeyBase64Encoded=$(get_secret studio-gcs-service-account-key | base64 -w 0) \ - --set settings=contentcuration.production_settings \ - --set sentry.dsnKey=$(get_secret sentry-dsn-key) \ - --set redis.password=$(get_secret redis-password) \ - --set cloudsql-proxy.credentials.username=$(get_secret postgres-username) \ - --set cloudsql-proxy.credentials.password=$(get_secret postgres-password) \ - --set cloudsql-proxy.credentials.dbname=$(get_secret postgres-dbname) \ - --set cloudsql-proxy.cloudsql.instances[0].instance=$DATABASE_INSTANCE_NAME \ - --set cloudsql-proxy.cloudsql.instances[0].project=$PROJECT_ID \ - --set cloudsql-proxy.cloudsql.instances[0].region=$DATABASE_REGION \ - --set cloudsql-proxy.cloudsql.instances[0].port=5432 \ - $RELEASE_NAME $K8S_DIR diff --git a/k8s/templates/_helpers.tpl b/k8s/templates/_helpers.tpl deleted file mode 100644 index 1098c07dc7..0000000000 --- a/k8s/templates/_helpers.tpl +++ /dev/null @@ -1,134 +0,0 @@ -{{/* vim: set filetype=mustache: */}} -{{/* -Expand the name of the chart. -*/}} -{{- define "studio.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "studio.fullname" -}} -{{- if .Values.fullnameOverride -}} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} -{{- else -}} -{{- $name := default .Chart.Name .Values.nameOverride -}} -{{- if contains $name .Release.Name -}} -{{- .Release.Name | trunc 63 | trimSuffix "-" -}} -{{- else -}} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} -{{- end -}} -{{- end -}} -{{- end -}} - -{{- define "cloudsql-proxy.fullname" -}} -{{- $name := .Release.Name -}} -{{- printf "%s-%s" $name "cloudsql-proxy" | trunc 63 | trimSuffix "-" -}} -{{- end -}} -{{- define "redis.fullname" -}} -{{- $name := .Release.Name -}} -{{- printf "%s-%s" $name "redis" | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{- define "minio.url" -}} -{{- printf "http://%s-%s:%v" .Release.Name "minio" .Values.minio.service.port -}} -{{- end -}} - - -{{/* -Return the appropriate apiVersion for networkpolicy. -*/}} -{{- define "studio.networkPolicy.apiVersion" -}} -{{- if semverCompare ">=1.4-0, <1.7-0" .Capabilities.KubeVersion.GitVersion -}} -"extensions/v1" -{{- else if semverCompare "^1.7-0" .Capabilities.KubeVersion.GitVersion -}} -"networking.k8s.io/v1" -{{- end -}} -{{- end -}} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "studio.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{/* -Generate chart secret name -*/}} -{{- define "studio.secretName" -}} -{{ default (include "studio.fullname" .) .Values.existingSecret }} -{{- end -}} - -{{/* -Generate the shared environment variables between studio app and workers -*/}} -{{- define "studio.sharedEnvs" -}} -- name: DJANGO_SETTINGS_MODULE - value: {{ .Values.settings }} -- name: DJANGO_LOG_FILE - value: /var/log/django.log -- name: MPLBACKEND - value: PS -- name: STUDIO_BETA_MODE - value: "yes" -- name: RUN_MODE - value: k8s -- name: DATA_DB_NAME - valueFrom: - secretKeyRef: - key: postgres-database - name: {{ template "studio.fullname" . }} -- name: DATA_DB_PORT - value: "5432" -- name: DATA_DB_USER - valueFrom: - secretKeyRef: - key: postgres-user - name: {{ template "studio.fullname" . }} -- name: DATA_DB_PASS - valueFrom: - secretKeyRef: - key: postgres-password - name: {{ template "studio.fullname" . }} -- name: CELERY_TIMEZONE - value: America/Los_Angeles -- name: CELERY_REDIS_DB - value: "0" -- name: CELERY_BROKER_ENDPOINT - value: {{ template "redis.fullname" . }}-master -- name: CELERY_RESULT_BACKEND_ENDPOINT - value: {{ template "redis.fullname" . }}-master -- name: CELERY_REDIS_PASSWORD - valueFrom: - secretKeyRef: - key: redis-password - name: {{ template "studio.fullname" . }} -- name: AWS_S3_ENDPOINT_URL - value: https://storage.googleapis.com -- name: RELEASE_COMMIT_SHA - value: {{ .Values.studioApp.releaseCommit | default "" }} -- name: BRANCH_ENVIRONMENT - value: {{ .Release.Name }} -- name: SENTRY_DSN_KEY - valueFrom: - secretKeyRef: - key: sentry-dsn-key - name: {{ template "studio.fullname" . }} - optional: true -- name: AWS_BUCKET_NAME - value: {{ .Values.studioApp.gcs.bucketName }} -- name: EMAIL_CREDENTIALS_POSTMARK_API_KEY - {{ if .Values.studioApp.postmarkApiKey }} - valueFrom: - secretKeyRef: - key: postmark-api-key - name: {{ template "studio.fullname" . }} - {{ else }} - value: "" - {{ end }} - -{{- end -}} diff --git a/k8s/templates/garbage-collect-cronjob.yaml b/k8s/templates/garbage-collect-cronjob.yaml deleted file mode 100644 index 4395732541..0000000000 --- a/k8s/templates/garbage-collect-cronjob.yaml +++ /dev/null @@ -1,78 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-garbage-collect-job-config - labels: - tier: job - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -data: - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} - AWS_BUCKET_NAME: {{ .Values.studioApp.gcs.bucketName }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-garbage-collect-job-secret - labels: - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1beta1 -kind: CronJob -metadata: - name: {{ template "studio.fullname" . }}-garbage-collect-cronjob - labels: - tier: job - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -spec: - schedule: "@midnight" - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - command: - - python - - contentcuration/manage.py - - garbage_collect - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.production_settings - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-garbage-collect-job-config - - secretRef: - name: {{ template "studio.fullname" . }}-garbage-collect-job-secret - resources: - requests: - cpu: 0.5 - memory: 1Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/ingress.yaml b/k8s/templates/ingress.yaml deleted file mode 100644 index c2e199dc7a..0000000000 --- a/k8s/templates/ingress.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: {{ template "studio.fullname" . }} - labels: - app: {{ template "studio.fullname" . }} - tier: ingress - annotations: - ingress.kubernetes.io/rewrite-target: / - kubernetes.io/ingress.class: "nginx" - ingressClassName: "nginx" - -spec: - rules: - - host: {{.Release.Name}}.studio.cd.learningequality.org - http: - paths: - - backend: - serviceName: {{ template "studio.fullname" . }}-app - servicePort: 80 diff --git a/k8s/templates/job-template.yaml b/k8s/templates/job-template.yaml deleted file mode 100644 index 856f27371c..0000000000 --- a/k8s/templates/job-template.yaml +++ /dev/null @@ -1,73 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-db-migrate-config - labels: - app: {{ template "studio.fullname" . }} - annotations: - "helm.sh/hook": pre-install,pre-upgrade - "helm.sh/hook-delete-policy": before-hook-creation -data: - DJANGO_SETTINGS_MODULE: {{ .Values.settings }} - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - STUDIO_BETA_MODE: "yes" - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-db-migrate-secrets - labels: - app: studio - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} - annotations: - "helm.sh/hook": pre-install,pre-upgrade - "helm.sh/hook-delete-policy": before-hook-creation -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: {{ template "studio.fullname" . }}-migrate-job - labels: - app: {{ template "studio.fullname" . }} - annotations: - "helm.sh/hook": post-install,pre-upgrade - "helm.sh/hook-delete-policy": before-hook-creation -spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: dbmigrate - image: {{ .Values.studioApp.imageName }} - command: - - make - - migrate - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-db-migrate-config - - secretRef: - name: {{ template "studio.fullname" . }}-db-migrate-secrets - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.migration_production_settings - resources: - requests: - cpu: 1 - memory: 2Gi - limits: - cpu: 1 - memory: 2Gi diff --git a/k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml b/k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml deleted file mode 100644 index ad36b8b0e4..0000000000 --- a/k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml +++ /dev/null @@ -1,78 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-config - labels: - tier: job - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -data: - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} - AWS_BUCKET_NAME: {{ .Values.studioApp.gcs.bucketName }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-secrets - labels: - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1beta1 -kind: CronJob -metadata: - name: mark-incomplete-cronjob - labels: - tier: job - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -spec: - schedule: "00 12 10 */36 *" - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - command: - - python - - contentcuration/manage.py - - mark_incomplete - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.production_settings - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-config - - secretRef: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-secrets - resources: - requests: - cpu: 0.5 - memory: 1Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/production-ingress.yaml b/k8s/templates/production-ingress.yaml deleted file mode 100644 index 68f10481ba..0000000000 --- a/k8s/templates/production-ingress.yaml +++ /dev/null @@ -1,23 +0,0 @@ -{{- if .Values.productionIngress -}} ---- -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: {{ template "studio.fullname" . }}-production - labels: - app: {{ template "studio.fullname" . }} - tier: ingress - type: production - annotations: - ingress.kubernetes.io/rewrite-target: / - kubernetes.io/ingress.class: "nginx" - ingressClassName: "nginx" -spec: - rules: - - host: {{.Release.Name}}.studio.learningequality.org - http: - paths: - - backend: - serviceName: {{ template "studio.fullname" . }}-app - servicePort: 80 -{{- end }} diff --git a/k8s/templates/set-storage-used-mgmt-command-cronjob.yaml b/k8s/templates/set-storage-used-mgmt-command-cronjob.yaml deleted file mode 100644 index cd30ba6f2f..0000000000 --- a/k8s/templates/set-storage-used-mgmt-command-cronjob.yaml +++ /dev/null @@ -1,78 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-set-storage-used-job-config - labels: - tier: job - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -data: - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} - AWS_BUCKET_NAME: {{ .Values.studioApp.gcs.bucketName }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-set-storage-used-job-secrets - labels: - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1beta1 -kind: CronJob -metadata: - name: set-storage-used-cronjob - labels: - tier: job - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -spec: - schedule: "@midnight" - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - command: - - python - - contentcuration/manage.py - - set_storage_used - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.production_settings - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-set-storage-used-job-config - - secretRef: - name: {{ template "studio.fullname" . }}-set-storage-used-job-secrets - resources: - requests: - cpu: 0.5 - memory: 1Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/studio-deployment.yaml b/k8s/templates/studio-deployment.yaml deleted file mode 100644 index f6a74f36fa..0000000000 --- a/k8s/templates/studio-deployment.yaml +++ /dev/null @@ -1,157 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ template "studio.fullname" . }} - labels: - tier: app - app: {{ template "studio.fullname" . }} -spec: - replicas: {{ .Values.studioApp.replicas }} - selector: - matchLabels: - app: {{ template "studio.fullname" . }} - tier: frontend - template: - metadata: - annotations: - checksum: {{ include (print $.Template.BasePath "/job-template.yaml") . | sha256sum }} - labels: - app: {{ template "studio.fullname" . }} - tier: frontend - spec: - initContainers: - - name: collectstatic - image: {{ .Values.studioApp.imageName }} - workingDir: /contentcuration/ - command: - - make - args: - - collectstatic - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.collectstatic_settings - - name: STATICFILES_DIR - value: /app/contentworkshop_static/ - volumeMounts: - - mountPath: /app/contentworkshop_static/ - name: staticfiles - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - workingDir: /contentcuration/contentcuration/ - command: - - gunicorn - args: - - contentcuration.wsgi:application - - --timeout=4000 - - --workers=2 - - --bind=0.0.0.0:{{ .Values.studioApp.appPort }} - - --pid=/tmp/contentcuration.pid - env: {{ include "studio.sharedEnvs" . | nindent 8 }} - - name: SEND_USER_ACTIVATION_NOTIFICATION_EMAIL - value: "true" - - name: DATA_DB_HOST - value: {{ template "cloudsql-proxy.fullname" . }} - - name: GOOGLE_CLOUD_STORAGE_SERVICE_ACCOUNT_CREDENTIALS - value: /var/secrets/gcs-writer-service-account-key.json - ports: - - containerPort: {{ .Values.studioApp.appPort }} - readinessProbe: - httpGet: - path: /healthz - port: {{ .Values.studioApp.appPort }} - initialDelaySeconds: 5 - periodSeconds: 2 - failureThreshold: 3 - resources: - requests: - cpu: 0.5 - memory: 2Gi - limits: - memory: 2Gi - volumeMounts: - - mountPath: /var/secrets - name: gcs-writer-service-account-key - readOnly: true - - name: nginx-proxy - image: {{ .Values.studioNginx.imageName }} - env: - - name: AWS_S3_ENDPOINT_URL - value: https://storage.googleapis.com - - name: AWS_BUCKET_NAME - value: {{ .Values.studioApp.gcs.bucketName }} - ports: - - containerPort: {{ .Values.studioNginx.port }} - volumeMounts: - - mountPath: /app/contentworkshop_static/ - name: staticfiles - resources: - requests: - cpu: 0.2 - memory: 256Mi - limits: - memory: 512Mi - volumes: - - emptyDir: {} - name: staticfiles - - name: gcs-writer-service-account-key - secret: - secretName: {{ template "studio.fullname" . }} - items: - - key: gcs-writer-service-account-key - path: gcs-writer-service-account-key.json - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{template "studio.fullname" . }}-workers -spec: - replicas: {{ .Values.studioWorkers.replicas }} - selector: - matchLabels: - app: {{ template "studio.fullname" . }}-workers - tier: workers - template: - metadata: - labels: - app: {{ template "studio.fullname" . }}-workers - tier: workers - spec: - containers: - - name: worker - image: {{ .Values.studioApp.imageName }} - command: - - make - {{- if not .Values.productionIngress }} - - setup - {{- end }} - - prodceleryworkers - env: {{ include "studio.sharedEnvs" . | nindent 8 }} - - name: DATA_DB_HOST - value: {{ template "cloudsql-proxy.fullname" . }} - resources: - requests: - cpu: 0.5 - memory: 2Gi - limits: - cpu: 2 - memory: 8Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/studio-secrets.yaml b/k8s/templates/studio-secrets.yaml deleted file mode 100644 index 51f2589a3e..0000000000 --- a/k8s/templates/studio-secrets.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }} - labels: - app: studio - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - postmark-api-key: {{ .Values.studioApp.postmarkApiKey | default "" | b64enc }} - redis-password: {{ .Values.redis.password | default "" | b64enc }} - postgres-user: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - postgres-password: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - postgres-database: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - sentry-dsn-key: {{ .Values.sentry.dsnKey | b64enc }} - gcs-writer-service-account-key: {{ .Values.studioApp.gcs.writerServiceAccountKeyBase64Encoded }} diff --git a/k8s/templates/studio-service.yaml b/k8s/templates/studio-service.yaml deleted file mode 100644 index 8f70e6f54d..0000000000 --- a/k8s/templates/studio-service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ template "studio.fullname" . }}-app -spec: - ports: - - port: 80 - targetPort: {{ .Values.studioNginx.port }} - selector: - app: {{ template "studio.fullname" . }} - tier: frontend - type: NodePort diff --git a/k8s/values.yaml b/k8s/values.yaml deleted file mode 100644 index 11db6c1559..0000000000 --- a/k8s/values.yaml +++ /dev/null @@ -1,70 +0,0 @@ ---- -# A set of values that are meant to be used for a production setup. -# This includes: -# - an external Postgres, GCS Storage, and external Redis -# - real email sending -# - studio production settings -# -# Note that the secrets will have to be filled up by the caller -# through helm upgrade --set. See REPLACEME placeholders -# for values that need to be set. - -settings: contentcuration.sandbox_settings - -productionIngress: true - -studioApp: - imageName: "REPLACEME" - postmarkApiKey: "REPLACEME" - releaseCommit: "" - replicas: 5 - appPort: 8081 - gcs: - bucketName: develop-studio-content - writerServiceAccountKeyBase64Encoded: "REPLACEME" - pgbouncer: - replicas: 3 - pool_size: 10 - reserve_pool_size: 10 - -studioNginx: - imageName: "REPLACEME" - port: 8080 - -sentry: - dsnKey: "" - -cloudsql-proxy: - enabled: true - cloudsql: - instances: - - instance: "REPLACEME" - project: "REPLACEME" - region: "REPLACEME" - port: 5432 - credentials: - username: "" - password: "" - dbname: "" - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" - -redis: - enabled: true - -studioWorkers: - replicas: 5 - - -studioProber: - imageName: "REPLACEME" - loginProberUsername: "REPLACEME" - loginProberPassword: "REPLACEME" - port: 9313 From ff6209a5d52355500254f0c7cc6a54411531b083 Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:09:13 -0800 Subject: [PATCH 06/26] Removing vestigial git module definition --- .gitmodules | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .gitmodules diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index fc49a89d8a..0000000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "kolibri"] - path = kolibri - url = https://github.com/learningequality/kolibri.git -[submodule "contentcuration/kolibri"] - path = contentcuration/kolibri - url = https://github.com/learningequality/kolibri.git From c14ec2bf1d25936b84b89adef1b94300c94db211 Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:11:56 -0800 Subject: [PATCH 07/26] Removing symlink to prod dockerfile. Does not appear to be used anywhere. --- docker/Dockerfile.prod | 1 - 1 file changed, 1 deletion(-) delete mode 120000 docker/Dockerfile.prod diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod deleted file mode 120000 index 11036b6d36..0000000000 --- a/docker/Dockerfile.prod +++ /dev/null @@ -1 +0,0 @@ -../k8s/images/app/Dockerfile \ No newline at end of file From 840dcad28810719c3e3c31ea122e5085ae2b54ba Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:22:45 -0800 Subject: [PATCH 08/26] Clearing out prober code. Doing as a single commit in case of ressurection. --- deploy/cloudprober.cfg | 187 ------------------ deploy/prober-entrypoint.sh | 8 - deploy/probers/base.py | 112 ----------- deploy/probers/channel_creation_probe.py | 35 ---- deploy/probers/channel_edit_page_probe.py | 23 --- deploy/probers/channel_update_probe.py | 30 --- deploy/probers/login_page_probe.py | 14 -- deploy/probers/postgres_probe.py | 36 ---- .../postgres_read_contentnode_probe.py | 38 ---- .../postgres_write_contentnode_probe.py | 64 ------ deploy/probers/postmark_api_probe.py | 36 ---- deploy/probers/publishing_status_probe.py | 53 ----- deploy/probers/task_queue_probe.py | 25 --- deploy/probers/topic_creation_probe.py | 41 ---- deploy/probers/unapplied_changes_probe.py | 26 --- deploy/probers/worker_probe.py | 24 --- k8s/images/prober/Dockerfile | 9 - 17 files changed, 761 deletions(-) delete mode 100644 deploy/cloudprober.cfg delete mode 100755 deploy/prober-entrypoint.sh delete mode 100644 deploy/probers/base.py delete mode 100755 deploy/probers/channel_creation_probe.py delete mode 100755 deploy/probers/channel_edit_page_probe.py delete mode 100755 deploy/probers/channel_update_probe.py delete mode 100755 deploy/probers/login_page_probe.py delete mode 100755 deploy/probers/postgres_probe.py delete mode 100755 deploy/probers/postgres_read_contentnode_probe.py delete mode 100755 deploy/probers/postgres_write_contentnode_probe.py delete mode 100755 deploy/probers/postmark_api_probe.py delete mode 100755 deploy/probers/publishing_status_probe.py delete mode 100755 deploy/probers/task_queue_probe.py delete mode 100755 deploy/probers/topic_creation_probe.py delete mode 100755 deploy/probers/unapplied_changes_probe.py delete mode 100755 deploy/probers/worker_probe.py delete mode 100644 k8s/images/prober/Dockerfile diff --git a/deploy/cloudprober.cfg b/deploy/cloudprober.cfg deleted file mode 100644 index c5a129455e..0000000000 --- a/deploy/cloudprober.cfg +++ /dev/null @@ -1,187 +0,0 @@ -probe { - name: "google_homepage" - type: HTTP - targets { - host_names: "www.google.com" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "facebook_homepage" - type: HTTP - targets { - host_names: "www.facebook.com" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "studio_homepage" - type: HTTP - targets { - host_names: "studio.learningequality.org" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "login" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/login_page_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "postgres" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postgres_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "workers" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/worker_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 5000 # 5s -} - -probe { - name: "channel_creation" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/channel_creation_probe.py" - } - interval_msec: 300000 # 5mins - timeout_msec: 10000 # 10s -} - -probe { - name: "channel_update" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/channel_update_probe.py" - } - interval_msec: 60000 # 1min - timeout_msec: 10000 # 10s -} - -probe { - name: "channel_edit_page" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/channel_edit_page_probe.py" - } - interval_msec: 10000 # 10s - timeout_msec: 10000 # 10s -} - -probe { - name: "postgres_read_contentnode" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postgres_read_contentnode_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "postgres_write_contentnode" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postgres_write_contentnode_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "topic_creation" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/topic_creation_probe.py" - } - interval_msec: 300000 # 5mins - timeout_msec: 20000 # 20s -} - -probe { - name: "postmark_api" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postmark_api_probe.py" - } - interval_msec: 300000 # 5 minutes - timeout_msec: 5000 # 5s -} - -probe { - name: "publishing_status" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/publishing_status_probe.py" - } - interval_msec: 3600000 # 1 hour - timeout_msec: 10000 # 10s -} - -probe { - name: "unapplied_changes_status" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/unapplied_changes_probe.py" - } - interval_msec: 1800000 # 30 minutes - timeout_msec: 20000 # 20s -} - -probe { - name: "task_queue_status" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/task_queue_probe.py" - } - interval_msec: 600000 # 10 minutes - timeout_msec: 10000 # 10s -} - -# Note: When deploying on GKE, the error logs can be found under GCE VM instance. diff --git a/deploy/prober-entrypoint.sh b/deploy/prober-entrypoint.sh deleted file mode 100755 index 323e03cab0..0000000000 --- a/deploy/prober-entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -curl -L -o cloudprober.zip https://github.com/google/cloudprober/releases/download/v0.10.2/cloudprober-v0.10.2-linux-x86_64.zip -unzip -p cloudprober.zip > /bin/cloudprober -chmod +x /bin/cloudprober - -cd deploy/ -cloudprober -logtostderr -config_file cloudprober.cfg diff --git a/deploy/probers/base.py b/deploy/probers/base.py deleted file mode 100644 index 7f85a18c16..0000000000 --- a/deploy/probers/base.py +++ /dev/null @@ -1,112 +0,0 @@ -import datetime -import os - -import requests - -USERNAME = os.getenv("PROBER_STUDIO_USERNAME") or "a@a.com" -PASSWORD = os.getenv("PROBER_STUDIO_PASSWORD") or "a" -PRODUCTION_MODE_ON = os.getenv("PROBER_STUDIO_PRODUCTION_MODE_ON") or False -STUDIO_BASE_URL = os.getenv("PROBER_STUDIO_BASE_URL") or "http://127.0.0.1:8080" - - -class BaseProbe(object): - - metric = "STUB_METRIC" - develop_only = False - prober_name = "PROBER" - - def __init__(self): - self.session = requests.Session() - self.session.headers.update( - {"User-Agent": "Studio-Internal-Prober={}".format(self.prober_name)} - ) - - def do_probe(self): - pass - - def _login(self): - # get our initial csrf - url = self._construct_studio_url("/en/accounts/") - r = self.session.get(url) - r.raise_for_status() - csrf = self.session.cookies.get("csrftoken") - formdata = { - "username": USERNAME, - "password": PASSWORD, - } - headers = { - "referer": url, - "X-Studio-Internal-Prober": "LOGIN-PROBER", - "X-CSRFToken": csrf, - } - - r = self.session.post( - self._construct_studio_url("/en/accounts/login/"), - json=formdata, - headers=headers, - allow_redirects=False, - ) - r.raise_for_status() - - # Since logging into Studio with correct username and password should redirect, fail otherwise - if r.status_code != 302: - raise ProberException("Cannot log into Studio.") - - return r - - def _construct_studio_url(self, path): - path_stripped = path.lstrip("/") - url = "{base_url}/{path}".format(base_url=STUDIO_BASE_URL, path=path_stripped) - return url - - def request( - self, - path, - action="GET", - data=None, - headers=None, - contenttype="application/json", - ): - data = data or {} - headers = headers or {} - - # Make sure session is logged in - if not self.session.cookies.get("csrftoken"): - self._login() - - url = self._construct_studio_url(path) - - headers.update( - { - "X-CSRFToken": self.session.cookies.get("csrftoken"), - } - ) - - headers.update({"Content-Type": contenttype}) - headers.update({"X-Studio-Internal-Prober": self.prober_name}) - response = self.session.request(action, url, data=data, headers=headers) - response.raise_for_status() - - return response - - def run(self): - - if self.develop_only and PRODUCTION_MODE_ON: - return - - start_time = datetime.datetime.now() - - self.do_probe() - - end_time = datetime.datetime.now() - elapsed = (end_time - start_time).total_seconds() * 1000 - - print( # noqa: T201 - "{metric_name} {latency_ms}".format( - metric_name=self.metric, latency_ms=elapsed - ) - ) - - -class ProberException(Exception): - pass diff --git a/deploy/probers/channel_creation_probe.py b/deploy/probers/channel_creation_probe.py deleted file mode 100755 index b7ab8d4254..0000000000 --- a/deploy/probers/channel_creation_probe.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe - - -class ChannelCreationProbe(BaseProbe): - - metric = "channel_creation_latency_msec" - develop_only = True - prober_name = "CHANNEL-CREATION-PROBER" - - def _get_user_id(self): - response = self.request("api/internal/authenticate_user_internal") - return json.loads(response.content)["user_id"] - - def do_probe(self): - payload = { - "description": "description", - "language": "en-PT", - "name": "test", - "thumbnail": "b3897c3d96bde7f1cff77ce368924098.png", - "content_defaults": "{}", - "editors": [self._get_user_id()], - } - self.request( - "api/channel", - action="POST", - data=payload, - contenttype="application/x-www-form-urlencoded", - ) - - -if __name__ == "__main__": - ChannelCreationProbe().run() diff --git a/deploy/probers/channel_edit_page_probe.py b/deploy/probers/channel_edit_page_probe.py deleted file mode 100755 index 2b3b80d2a3..0000000000 --- a/deploy/probers/channel_edit_page_probe.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe - - -class ChannelEditPageProbe(BaseProbe): - - metric = "channel_edit_page_latency_msec" - prober_name = "CHANNEL-EDIT-PAGE-PROBER" - - def _get_channel(self): - response = self.request("api/probers/get_prober_channel") - return json.loads(response.content) - - def do_probe(self): - channel = self._get_channel() - path = "channels/{}/edit".format(channel["id"]) - self.request(path) - - -if __name__ == "__main__": - ChannelEditPageProbe().run() diff --git a/deploy/probers/channel_update_probe.py b/deploy/probers/channel_update_probe.py deleted file mode 100755 index 1951df9348..0000000000 --- a/deploy/probers/channel_update_probe.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe - - -class ChannelUpdateProbe(BaseProbe): - - metric = "channel_update_latency_msec" - prober_name = "CHANNEL-UPDATE-PROBER" - develop_only = True - - def _get_channel(self): - response = self.request("api/probers/get_prober_channel") - return json.loads(response.content) - - def do_probe(self): - channel = self._get_channel() - payload = {"name": "New Test Name", "id": channel["id"]} - path = "api/channel/{}".format(channel["id"]) - self.request( - path, - action="PATCH", - data=payload, - contenttype="application/x-www-form-urlencoded", - ) - - -if __name__ == "__main__": - ChannelUpdateProbe().run() diff --git a/deploy/probers/login_page_probe.py b/deploy/probers/login_page_probe.py deleted file mode 100755 index 42ed9a43e3..0000000000 --- a/deploy/probers/login_page_probe.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class LoginProbe(BaseProbe): - - metric = "login_latency_msec" - - def do_probe(self): - self._login() - - -if __name__ == "__main__": - LoginProbe().run() diff --git a/deploy/probers/postgres_probe.py b/deploy/probers/postgres_probe.py deleted file mode 100755 index 3aa29acc0c..0000000000 --- a/deploy/probers/postgres_probe.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python -import os - -import psycopg2 -from base import BaseProbe - - -# Use dev options if no env set -DB_HOST = os.getenv("DATA_DB_HOST") or "localhost" -DB_PORT = 5432 -DB_NAME = os.getenv("DATA_DB_NAME") or "kolibri-studio" -DB_USER = os.getenv("DATA_DB_USER") or "learningequality" -DB_PASSWORD = os.getenv("DATA_DB_PASS") or "kolibri" -TIMEOUT_SECONDS = 2 - - -class PostgresProbe(BaseProbe): - metric = "postgres_latency_msec" - - def do_probe(self): - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - connect_timeout=TIMEOUT_SECONDS, - ) - cur = conn.cursor() - cur.execute("SELECT datname FROM pg_database;") - cur.fetchone() # raises exception if cur.execute() produced no results - conn.close() - - -if __name__ == "__main__": - PostgresProbe().run() diff --git a/deploy/probers/postgres_read_contentnode_probe.py b/deploy/probers/postgres_read_contentnode_probe.py deleted file mode 100755 index fa4767f404..0000000000 --- a/deploy/probers/postgres_read_contentnode_probe.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python -import os - -import psycopg2 -from base import BaseProbe - - -# Use dev options if no env set -DB_HOST = os.getenv("DATA_DB_HOST") or "localhost" -DB_PORT = 5432 -DB_NAME = os.getenv("DATA_DB_NAME") or "kolibri-studio" -DB_USER = os.getenv("DATA_DB_USER") or "learningequality" -DB_PASSWORD = os.getenv("DATA_DB_PASS") or "kolibri" -TIMEOUT_SECONDS = 2 - - -class PostgresReadContentnodeProbe(BaseProbe): - metric = "postgres_read_contentnode_latency_msec" - - def do_probe(self): - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - connect_timeout=TIMEOUT_SECONDS, - ) - cur = conn.cursor() - cur.execute("SELECT * FROM contentcuration_contentnode LIMIT 1;") - num = cur.fetchone() - conn.close() - if not num: - raise Exception("Reading a ContentNode in PostgreSQL database failed.") - - -if __name__ == "__main__": - PostgresReadContentnodeProbe().run() diff --git a/deploy/probers/postgres_write_contentnode_probe.py b/deploy/probers/postgres_write_contentnode_probe.py deleted file mode 100755 index 7785116fe4..0000000000 --- a/deploy/probers/postgres_write_contentnode_probe.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python -import os -from datetime import datetime - -import psycopg2 -from base import BaseProbe - -# Use dev options if no env set -DB_HOST = os.getenv("DATA_DB_HOST") or "localhost" -DB_PORT = 5432 -DB_NAME = os.getenv("DATA_DB_NAME") or "kolibri-studio" -DB_USER = os.getenv("DATA_DB_USER") or "learningequality" -DB_PASSWORD = os.getenv("DATA_DB_PASS") or "kolibri" -TIMEOUT_SECONDS = 2 - - -class PostgresWriteContentnodeProbe(BaseProbe): - metric = "postgres_write_contentnode_latency_msec" - - develop_only = True - - def do_probe(self): - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - connect_timeout=TIMEOUT_SECONDS, - ) - cur = conn.cursor() - now = datetime.now() - cur.execute( - """ - INSERT INTO contentcuration_contentnode(id, content_id, kind_id, title, description,sort_order, created, - modified, changed, lft, rght, tree_id, level, published, node_id, freeze_authoring_data, publishing, role_visibility) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s); - """, - ( - "testpostgreswriteprobe", - "testprobecontentid", - "topic", - "test postgres write contentnode probe", - "test postgres write contentnode probe", - 1, - now, - now, - True, - 1, - 1, - 1, - 1, - False, - "testprobenodeid", - False, - False, - "test", - ), - ) - conn.close() - - -if __name__ == "__main__": - PostgresWriteContentnodeProbe().run() diff --git a/deploy/probers/postmark_api_probe.py b/deploy/probers/postmark_api_probe.py deleted file mode 100755 index 30cbb1741c..0000000000 --- a/deploy/probers/postmark_api_probe.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python -import requests -from base import BaseProbe - -POSTMARK_SERVICE_STATUS_URL = "https://status.postmarkapp.com/api/1.0/services" - -# (See here for API details: https://status.postmarkapp.com/api) -ALL_POSSIBLE_STATUSES = ["UP", "MAINTENANCE", "DELAY", "DEGRADED", "DOWN"] - -PASSING_POSTMARK_STATUSES = { - "/services/smtp": ["UP", "MAINTENANCE"], - "/services/api": ALL_POSSIBLE_STATUSES, - "/services/inbound": ALL_POSSIBLE_STATUSES, - "/services/web": ALL_POSSIBLE_STATUSES, -} - - -class PostmarkProbe(BaseProbe): - metric = "postmark_api_latency_msec" - - def do_probe(self): - r = requests.get(url=POSTMARK_SERVICE_STATUS_URL) - for service in r.json(): - allowed_statuses = PASSING_POSTMARK_STATUSES.get(service["url"]) - passing = service["status"] in allowed_statuses - - if passing: - continue - raise Exception( - "Postmark's `%s` service has status %s, but we require one of the following: %s" - % (service["name"], service["status"], allowed_statuses) - ) - - -if __name__ == "__main__": - PostmarkProbe().run() diff --git a/deploy/probers/publishing_status_probe.py b/deploy/probers/publishing_status_probe.py deleted file mode 100755 index fffe67eb92..0000000000 --- a/deploy/probers/publishing_status_probe.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python -import datetime -import os - -from base import BaseProbe -from base import ProberException -from base import PRODUCTION_MODE_ON - - -ALERT_THRESHOLD = int( - os.getenv("PROBER_PUBLISHING_ALERT_THRESHOLD") or 2 * 3600 -) # default = 2 hours -DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" - - -class PublishingStatusProbe(BaseProbe): - - metric = "max_publishing_duration_sec" - prober_name = "PUBLISHING_STATUS_PROBER" - - def run(self): - if self.develop_only and PRODUCTION_MODE_ON: - return - - r = self.request("api/probers/publishing_status/") - results = r.json() - now = datetime.datetime.now() - max_duration = 0 - channel_ids = [] - - for result in results: - duration = ( - now - datetime.datetime.strptime(result["performed"], DATE_FORMAT) - ).seconds - max_duration = max(max_duration, duration) - if duration >= ALERT_THRESHOLD or not result["task_id"]: - channel_ids.append(result["channel_id"]) - - if max_duration > 0: - print( # noqa: T201 - "{metric_name} {duration_sec}".format( - metric_name=self.metric, duration_sec=max_duration - ) - ) - - if channel_ids: - raise ProberException( - "Publishing alert for channels: {}".format(", ".join(channel_ids)) - ) - - -if __name__ == "__main__": - PublishingStatusProbe().run() diff --git a/deploy/probers/task_queue_probe.py b/deploy/probers/task_queue_probe.py deleted file mode 100755 index 6148176856..0000000000 --- a/deploy/probers/task_queue_probe.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class TaskQueueProbe(BaseProbe): - - metric = "task_queue_ping_latency_msec" - threshold = 50 - - def do_probe(self): - r = self.request("api/probers/task_queue_status/") - r.raise_for_status() - results = r.json() - - task_count = results.get("queued_task_count", 0) - if task_count >= self.threshold: - raise Exception( - "Task queue length is over threshold! {} > {}".format( - task_count, self.threshold - ) - ) - - -if __name__ == "__main__": - TaskQueueProbe().run() diff --git a/deploy/probers/topic_creation_probe.py b/deploy/probers/topic_creation_probe.py deleted file mode 100755 index 6c7090c598..0000000000 --- a/deploy/probers/topic_creation_probe.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe -from le_utils.constants import content_kinds - - -class TopicCreationProbe(BaseProbe): - - metric = "topic_creation_latency_msec" - develop_only = True - prober_name = "TOPIC-CREATION-PROBER" - - def _get_channel(self): - response = self.request("api/probers/get_prober_channel") - return json.loads(response.content) - - def do_probe(self): - channel = self._get_channel() - payload = { - "title": "Statistics and Probeability", - "kind": content_kinds.TOPIC, - } - response = self.request( - "api/contentnode", action="POST", data=json.dumps(payload) - ) - - # Test saving to channel works - new_topic = json.loads(response.content) - new_topic.update({"parent": channel["main_tree"]}) - path = "api/contentnode/{}".format(new_topic["id"]) - self.request( - path, - action="PUT", - data=payload, - contenttype="application/x-www-form-urlencoded", - ) - - -if __name__ == "__main__": - TopicCreationProbe().run() diff --git a/deploy/probers/unapplied_changes_probe.py b/deploy/probers/unapplied_changes_probe.py deleted file mode 100755 index 6065f3df28..0000000000 --- a/deploy/probers/unapplied_changes_probe.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class UnappliedChangesProbe(BaseProbe): - - metric = "unapplied__changes_ping_latency_msec" - - def do_probe(self): - r = self.request("api/probers/unapplied_changes_status/") - r.raise_for_status() - results = r.json() - - active_task_count = results.get("active_task_count", 0) - unapplied_changes_count = results.get("unapplied_changes_count", 0) - - if active_task_count == 0 and unapplied_changes_count > 0: - raise Exception( - "There are unapplied changes and no active tasks! {} unapplied changes".format( - unapplied_changes_count - ) - ) - - -if __name__ == "__main__": - UnappliedChangesProbe().run() diff --git a/deploy/probers/worker_probe.py b/deploy/probers/worker_probe.py deleted file mode 100755 index 211dc2e6a1..0000000000 --- a/deploy/probers/worker_probe.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class WorkerProbe(BaseProbe): - - metric = "worker_ping_latency_msec" - - def do_probe(self): - r = self.request("api/probers/celery_worker_status/") - r.raise_for_status() - results = r.json() - - active_workers = [] - for worker_hostname, worker_status in results.items(): - if "ok" in worker_status.keys(): - active_workers.append(worker_hostname) - - if not active_workers: - raise Exception("No workers are running!") - - -if __name__ == "__main__": - WorkerProbe().run() diff --git a/k8s/images/prober/Dockerfile b/k8s/images/prober/Dockerfile deleted file mode 100644 index d3d18ee8a6..0000000000 --- a/k8s/images/prober/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM ubuntu:bionic - -RUN apt-get update && apt-get install -y curl python-pip unzip - -RUN pip install requests>=2.20.0 && pip install psycopg2-binary==2.7.4 && pip install le-utils>=0.1.19 - -COPY ./deploy/cloudprober.cfg /deploy/ -COPY ./deploy/prober-entrypoint.sh /deploy/ -COPY ./deploy/probers /deploy/probers/ From aae5494aff8c7773635526dab7302b4f000ed8ff Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:35:07 -0800 Subject: [PATCH 09/26] Moving nginx files out of `deploy` and housing them with other image-specific files. --- k8s/images/nginx/Dockerfile | 13 ++++++++++--- {deploy => k8s/images/nginx}/includes/README.md | 0 .../images/nginx}/includes/content/_proxy.conf | 0 .../images/nginx}/includes/content/default.conf | 0 .../includes/content/develop-studio-content.conf | 0 .../nginx}/includes/content/studio-content.conf | 0 {deploy => k8s/images/nginx}/mime.types | 0 {deploy => k8s/images/nginx}/nginx.conf | 0 8 files changed, 10 insertions(+), 3 deletions(-) rename {deploy => k8s/images/nginx}/includes/README.md (100%) rename {deploy => k8s/images/nginx}/includes/content/_proxy.conf (100%) rename {deploy => k8s/images/nginx}/includes/content/default.conf (100%) rename {deploy => k8s/images/nginx}/includes/content/develop-studio-content.conf (100%) rename {deploy => k8s/images/nginx}/includes/content/studio-content.conf (100%) rename {deploy => k8s/images/nginx}/mime.types (100%) rename {deploy => k8s/images/nginx}/nginx.conf (100%) diff --git a/k8s/images/nginx/Dockerfile b/k8s/images/nginx/Dockerfile index ab38a1118a..3cf58f9a17 100644 --- a/k8s/images/nginx/Dockerfile +++ b/k8s/images/nginx/Dockerfile @@ -1,8 +1,15 @@ FROM nginx:1.25 +# Build from inside the directory by overriding this. +ARG SRC_DIR=k8s/images/nginx + RUN rm /etc/nginx/conf.d/* # if there's stuff here, nginx won't read sites-enabled -COPY deploy/nginx.conf /etc/nginx/nginx.conf -COPY deploy/includes /etc/nginx/includes -COPY k8s/images/nginx/entrypoint.sh /usr/bin +COPY ${SRC_DIR}/nginx.conf /etc/nginx/nginx.conf +COPY ${SRC_DIR}/includes /etc/nginx/includes +COPY ${SRC_DIR}/entrypoint.sh /usr/bin + +# Really seems like it _should_ be here, as it's referenced by `nginx.conf`. +# But it's hasn't been for years. +# COPY ${SRC_DIR}/mime.types /etc/nginx/mime.types CMD ["entrypoint.sh"] diff --git a/deploy/includes/README.md b/k8s/images/nginx/includes/README.md similarity index 100% rename from deploy/includes/README.md rename to k8s/images/nginx/includes/README.md diff --git a/deploy/includes/content/_proxy.conf b/k8s/images/nginx/includes/content/_proxy.conf similarity index 100% rename from deploy/includes/content/_proxy.conf rename to k8s/images/nginx/includes/content/_proxy.conf diff --git a/deploy/includes/content/default.conf b/k8s/images/nginx/includes/content/default.conf similarity index 100% rename from deploy/includes/content/default.conf rename to k8s/images/nginx/includes/content/default.conf diff --git a/deploy/includes/content/develop-studio-content.conf b/k8s/images/nginx/includes/content/develop-studio-content.conf similarity index 100% rename from deploy/includes/content/develop-studio-content.conf rename to k8s/images/nginx/includes/content/develop-studio-content.conf diff --git a/deploy/includes/content/studio-content.conf b/k8s/images/nginx/includes/content/studio-content.conf similarity index 100% rename from deploy/includes/content/studio-content.conf rename to k8s/images/nginx/includes/content/studio-content.conf diff --git a/deploy/mime.types b/k8s/images/nginx/mime.types similarity index 100% rename from deploy/mime.types rename to k8s/images/nginx/mime.types diff --git a/deploy/nginx.conf b/k8s/images/nginx/nginx.conf similarity index 100% rename from deploy/nginx.conf rename to k8s/images/nginx/nginx.conf From 200f537abdfbda9dec7b0affdfafbe6ad6ccb523 Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 11:57:59 -0800 Subject: [PATCH 10/26] WIP: Moving prod dockerfiles out of defunct k8s dir. --- .github/workflows/containerbuild.yml | 4 ++-- cloudbuild-pr.yaml | 2 +- cloudbuild-production.yaml | 6 +++--- docker-compose.yml | 2 +- {k8s/images => docker/prod}/app/Dockerfile | 0 {k8s/images => docker/prod}/app/Makefile | 0 {k8s/images => docker/prod}/nginx/Dockerfile | 0 {k8s/images => docker/prod}/nginx/Makefile | 0 {k8s/images => docker/prod}/nginx/entrypoint.sh | 0 {k8s/images => docker/prod}/nginx/includes/README.md | 0 .../prod}/nginx/includes/content/_proxy.conf | 0 .../prod}/nginx/includes/content/default.conf | 2 +- .../nginx/includes/content/develop-studio-content.conf | 0 .../prod}/nginx/includes/content/studio-content.conf | 0 {k8s/images => docker/prod}/nginx/mime.types | 0 {k8s/images => docker/prod}/nginx/nginx.conf | 0 16 files changed, 8 insertions(+), 8 deletions(-) rename {k8s/images => docker/prod}/app/Dockerfile (100%) rename {k8s/images => docker/prod}/app/Makefile (100%) rename {k8s/images => docker/prod}/nginx/Dockerfile (100%) rename {k8s/images => docker/prod}/nginx/Makefile (100%) rename {k8s/images => docker/prod}/nginx/entrypoint.sh (100%) rename {k8s/images => docker/prod}/nginx/includes/README.md (100%) rename {k8s/images => docker/prod}/nginx/includes/content/_proxy.conf (100%) rename {k8s/images => docker/prod}/nginx/includes/content/default.conf (95%) rename {k8s/images => docker/prod}/nginx/includes/content/develop-studio-content.conf (100%) rename {k8s/images => docker/prod}/nginx/includes/content/studio-content.conf (100%) rename {k8s/images => docker/prod}/nginx/mime.types (100%) rename {k8s/images => docker/prod}/nginx/nginx.conf (100%) diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index 7b367f0eb0..7feb27f226 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -79,7 +79,7 @@ jobs: with: skip_after_successful_duplicate: false github_token: ${{ github.token }} - paths: '["k8s/images/nginx/*", ".github/workflows/containerbuild.yml"]' + paths: '["docker/prod/nginx/*", ".github/workflows/containerbuild.yml"]' build_nginx: name: nginx - test build of nginx Docker image @@ -100,6 +100,6 @@ jobs: uses: docker/build-push-action@v6 with: context: ./ - file: ./k8s/images/nginx/Dockerfile + file: ./docker/prod/nginx/Dockerfile platforms: linux/amd64 push: false diff --git a/cloudbuild-pr.yaml b/cloudbuild-pr.yaml index 2fb21ce2c5..1cce544614 100644 --- a/cloudbuild-pr.yaml +++ b/cloudbuild-pr.yaml @@ -20,7 +20,7 @@ steps: waitFor: ['-'] # don't wait for previous steps args: [ 'build', - '-f', 'k8s/images/nginx/Dockerfile', + '-f', 'docker/prod/nginx/Dockerfile', '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', diff --git a/cloudbuild-production.yaml b/cloudbuild-production.yaml index 3ff333a67f..3e5188fca2 100644 --- a/cloudbuild-production.yaml +++ b/cloudbuild-production.yaml @@ -12,7 +12,7 @@ steps: - > docker build --build_arg COMMIT_SHA=$COMMIT_SHA - -f k8s/images/app/Dockerfile + -f docker/prod/app/Dockerfile --cache-from gcr.io/$PROJECT_ID/learningequality-studio-app:latest -t gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA -t gcr.io/$PROJECT_ID/learningequality-studio-app:latest @@ -23,7 +23,7 @@ steps: waitFor: ['-'] # don't wait for previous steps args: [ 'build', - '-f', 'k8s/images/nginx/Dockerfile', + '-f', 'docker/prod/nginx/Dockerfile', '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', @@ -40,7 +40,7 @@ steps: waitFor: ['pull-prober-image-cache'] # don't wait for previous steps args: [ 'build', - '-f', 'k8s/images/prober/Dockerfile', + '-f', 'docker/prod/prober/Dockerfile', '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', diff --git a/docker-compose.yml b/docker-compose.yml index 3a07894c8d..0fd57a2743 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: platform: linux/amd64 build: context: . - dockerfile: k8s/images/nginx/Dockerfile + dockerfile: docker/prod/nginx/Dockerfile ports: - "8081:8080" depends_on: diff --git a/k8s/images/app/Dockerfile b/docker/prod/app/Dockerfile similarity index 100% rename from k8s/images/app/Dockerfile rename to docker/prod/app/Dockerfile diff --git a/k8s/images/app/Makefile b/docker/prod/app/Makefile similarity index 100% rename from k8s/images/app/Makefile rename to docker/prod/app/Makefile diff --git a/k8s/images/nginx/Dockerfile b/docker/prod/nginx/Dockerfile similarity index 100% rename from k8s/images/nginx/Dockerfile rename to docker/prod/nginx/Dockerfile diff --git a/k8s/images/nginx/Makefile b/docker/prod/nginx/Makefile similarity index 100% rename from k8s/images/nginx/Makefile rename to docker/prod/nginx/Makefile diff --git a/k8s/images/nginx/entrypoint.sh b/docker/prod/nginx/entrypoint.sh similarity index 100% rename from k8s/images/nginx/entrypoint.sh rename to docker/prod/nginx/entrypoint.sh diff --git a/k8s/images/nginx/includes/README.md b/docker/prod/nginx/includes/README.md similarity index 100% rename from k8s/images/nginx/includes/README.md rename to docker/prod/nginx/includes/README.md diff --git a/k8s/images/nginx/includes/content/_proxy.conf b/docker/prod/nginx/includes/content/_proxy.conf similarity index 100% rename from k8s/images/nginx/includes/content/_proxy.conf rename to docker/prod/nginx/includes/content/_proxy.conf diff --git a/k8s/images/nginx/includes/content/default.conf b/docker/prod/nginx/includes/content/default.conf similarity index 95% rename from k8s/images/nginx/includes/content/default.conf rename to docker/prod/nginx/includes/content/default.conf index 404bd64075..44c005f21b 100644 --- a/k8s/images/nginx/includes/content/default.conf +++ b/docker/prod/nginx/includes/content/default.conf @@ -1,4 +1,4 @@ -# DO NOT RENAME: referenced by k8s/images/nginx/entrypoint.sh +# DO NOT RENAME: referenced by docker/prod/nginx/entrypoint.sh # assume development location @emulator { diff --git a/k8s/images/nginx/includes/content/develop-studio-content.conf b/docker/prod/nginx/includes/content/develop-studio-content.conf similarity index 100% rename from k8s/images/nginx/includes/content/develop-studio-content.conf rename to docker/prod/nginx/includes/content/develop-studio-content.conf diff --git a/k8s/images/nginx/includes/content/studio-content.conf b/docker/prod/nginx/includes/content/studio-content.conf similarity index 100% rename from k8s/images/nginx/includes/content/studio-content.conf rename to docker/prod/nginx/includes/content/studio-content.conf diff --git a/k8s/images/nginx/mime.types b/docker/prod/nginx/mime.types similarity index 100% rename from k8s/images/nginx/mime.types rename to docker/prod/nginx/mime.types diff --git a/k8s/images/nginx/nginx.conf b/docker/prod/nginx/nginx.conf similarity index 100% rename from k8s/images/nginx/nginx.conf rename to docker/prod/nginx/nginx.conf From 1dbd0db6626f3e5ad418f178eb050d85d50c2d84 Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 12:15:49 -0800 Subject: [PATCH 11/26] WIP: Flattening out new image structure, following current naming standard. And deleting defunct cloudbuild prod yaml --- .github/workflows/containerbuild.yml | 4 +- cloudbuild-production.yaml | 99 ------------------- docker-compose.yml | 2 +- .../Dockerfile => Dockerfile.nginx.prod} | 2 +- .../{prod/app/Dockerfile => Dockerfile.prod} | 0 docker/prod/app/Makefile | 4 - docker/prod/nginx/Makefile | 9 -- 7 files changed, 4 insertions(+), 116 deletions(-) delete mode 100644 cloudbuild-production.yaml rename docker/{prod/nginx/Dockerfile => Dockerfile.nginx.prod} (94%) rename docker/{prod/app/Dockerfile => Dockerfile.prod} (100%) delete mode 100644 docker/prod/app/Makefile delete mode 100644 docker/prod/nginx/Makefile diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index 7feb27f226..68f1be456c 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -79,7 +79,7 @@ jobs: with: skip_after_successful_duplicate: false github_token: ${{ github.token }} - paths: '["docker/prod/nginx/*", ".github/workflows/containerbuild.yml"]' + paths: '["docker/Dockerfile.nginx.prod", "nginx/*", ".github/workflows/containerbuild.yml"]' build_nginx: name: nginx - test build of nginx Docker image @@ -100,6 +100,6 @@ jobs: uses: docker/build-push-action@v6 with: context: ./ - file: ./docker/prod/nginx/Dockerfile + file: ./docker/Dockerfile.nginx.prod platforms: linux/amd64 push: false diff --git a/cloudbuild-production.yaml b/cloudbuild-production.yaml deleted file mode 100644 index 3e5188fca2..0000000000 --- a/cloudbuild-production.yaml +++ /dev/null @@ -1,99 +0,0 @@ -steps: -- name: 'gcr.io/cloud-builders/docker' - id: pull-app-image-cache - args: ['pull', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest'] - -- name: 'gcr.io/cloud-builders/docker' - id: build-app-image - entrypoint: bash - waitFor: ['pull-app-image-cache'] # wait for app image cache pull to finish - args: - - -c - - > - docker build - --build_arg COMMIT_SHA=$COMMIT_SHA - -f docker/prod/app/Dockerfile - --cache-from gcr.io/$PROJECT_ID/learningequality-studio-app:latest - -t gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA - -t gcr.io/$PROJECT_ID/learningequality-studio-app:latest - . - -- name: 'gcr.io/cloud-builders/docker' - id: build-nginx-image - waitFor: ['-'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/prod/nginx/Dockerfile', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: pull-prober-image-cache - waitFor: ['-'] - args: ['pull', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest'] - -- name: 'gcr.io/cloud-builders/docker' - id: build-prober-image - waitFor: ['pull-prober-image-cache'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/prod/prober/Dockerfile', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: push-app-image - waitFor: ['build-app-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/docker' - id: push-nginx-image - waitFor: ['build-nginx-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/docker' - id: push-prober-image - waitFor: ['build-prober-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA'] - -- name: 'gcr.io/$PROJECT_ID/helm' - id: helm-deploy-studio-instance - waitFor: ['push-app-image', 'push-nginx-image'] - dir: "k8s" - env: - - 'CLOUDSDK_COMPUTE_ZONE=us-central1-f' - - 'CLOUDSDK_CONTAINER_CLUSTER=contentworkshop-central' - entrypoint: 'bash' - args: - - -c - - > - /builder/helm.bash && - ./helm-deploy.sh - $BRANCH_NAME - gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA - gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA - $_STORAGE_BUCKET - $COMMIT_SHA - $PROJECT_ID - $_DATABASE_INSTANCE_NAME - us-central1 - - -substitutions: - _DATABASE_INSTANCE_NAME: develop # by default, connect to the develop DB - _STORAGE_BUCKET: develop-studio-content - -timeout: 3600s -images: - - gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest - - gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA - - gcr.io/$PROJECT_ID/learningequality-studio-app:latest - - gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA - - 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA' - - 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest' diff --git a/docker-compose.yml b/docker-compose.yml index 0fd57a2743..2a0eed7799 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: platform: linux/amd64 build: context: . - dockerfile: docker/prod/nginx/Dockerfile + dockerfile: docker/Dockerfile.nginx.prod ports: - "8081:8080" depends_on: diff --git a/docker/prod/nginx/Dockerfile b/docker/Dockerfile.nginx.prod similarity index 94% rename from docker/prod/nginx/Dockerfile rename to docker/Dockerfile.nginx.prod index 3cf58f9a17..a01753b143 100644 --- a/docker/prod/nginx/Dockerfile +++ b/docker/Dockerfile.nginx.prod @@ -1,7 +1,7 @@ FROM nginx:1.25 # Build from inside the directory by overriding this. -ARG SRC_DIR=k8s/images/nginx +ARG SRC_DIR=docker/prod/nginx RUN rm /etc/nginx/conf.d/* # if there's stuff here, nginx won't read sites-enabled COPY ${SRC_DIR}/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/prod/app/Dockerfile b/docker/Dockerfile.prod similarity index 100% rename from docker/prod/app/Dockerfile rename to docker/Dockerfile.prod diff --git a/docker/prod/app/Makefile b/docker/prod/app/Makefile deleted file mode 100644 index d958cc266a..0000000000 --- a/docker/prod/app/Makefile +++ /dev/null @@ -1,4 +0,0 @@ -COMMIT := nlatest - -imagebuild: - docker build ../../../ -f $$PWD/Dockerfile -t gcr.io/github-learningequality-studio/app:$(COMMIT) diff --git a/docker/prod/nginx/Makefile b/docker/prod/nginx/Makefile deleted file mode 100644 index 9c2d01dc60..0000000000 --- a/docker/prod/nginx/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -CONTAINER_NAME := "contentworkshop-app-nginx-proxy" -CONTAINER_VERSION := v4 -GCLOUD_PROJECT := contentworkshop-159920 -GIT_PROJECT_ROOT := `git rev-parse --show-toplevel` - -all: appcodeupdate imagebuild imagepush - -imagebuild: - docker build -t learningequality/$(CONTAINER_NAME):$(CONTAINER_VERSION) -f ./Dockerfile $(GIT_PROJECT_ROOT) From c4155bff363ebebbb8a9e9a48ffffafe5ad59bda Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 12:17:33 -0800 Subject: [PATCH 12/26] Removing defunct cloudbuild-pr.yaml Making a separate commit for bookmarking purposes. Seems we used to deploy to a separate/distinct dev cluster, and create new postgres instances for every single pr. --- cloudbuild-pr.yaml | 102 --------------------------------------------- 1 file changed, 102 deletions(-) delete mode 100644 cloudbuild-pr.yaml diff --git a/cloudbuild-pr.yaml b/cloudbuild-pr.yaml deleted file mode 100644 index 1cce544614..0000000000 --- a/cloudbuild-pr.yaml +++ /dev/null @@ -1,102 +0,0 @@ -steps: -- name: 'gcr.io/cloud-builders/docker' - id: pull-app-image-cache - args: ['pull', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest'] - -- name: 'gcr.io/cloud-builders/docker' - id: build-app-image - waitFor: ['pull-app-image-cache'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/Dockerfile.demo', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: build-nginx-image - waitFor: ['-'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/prod/nginx/Dockerfile', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: push-app-image - waitFor: ['build-app-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/docker' - id: push-nginx-image - waitFor: ['build-nginx-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/gcloud' - id: decrypt-gcs-service-account - waitFor: ['-'] - args: [ - 'kms', 'decrypt', - '--location=global', '--keyring=builder-secrets', '--key=secret-encrypter', - '--ciphertext-file=k8s/build-secrets/$PROJECT_ID-gcs-service-account.json.enc', - '--plaintext-file=gcs-service-account.json' - ] - -- name: 'gcr.io/cloud-builders/gcloud' - id: create-new-database - waitFor: ['-'] - dir: "k8s" - entrypoint: 'bash' - args: [ - '-c', - './create-cloudsql-database.sh $_RELEASE_NAME $_DATABASE_INSTANCE_NAME' - ] - -- name: 'gcr.io/$PROJECT_ID/helm' - id: helm-deploy-studio-instance - waitFor: ['decrypt-gcs-service-account', 'push-app-image', 'push-nginx-image'] - dir: "k8s" - env: - - 'CLOUDSDK_COMPUTE_ZONE=us-central1-f' - - 'CLOUDSDK_CONTAINER_CLUSTER=dev-qa-cluster' - secretEnv: ['POSTMARK_API_KEY'] - entrypoint: 'bash' - args: - - -c - - > - /builder/helm.bash && - ./helm-deploy.sh - $_RELEASE_NAME - $_STORAGE_BUCKET - $COMMIT_SHA - $$POSTMARK_API_KEY - "" - "" - $_POSTGRES_USERNAME - $_RELEASE_NAME - $_POSTGRES_PASSWORD - $PROJECT_ID-$_DATABASE_INSTANCE_NAME-sql-proxy-gcloud-sqlproxy.sqlproxy - ../gcs-service-account.json - $PROJECT_ID - -- name: 'gcr.io/cloud-builders/gsutil' - id: remove-tarball-in-gcs - waitFor: ['helm-deploy-studio-instance'] - args: ['rm', $_TARBALL_LOCATION] - -timeout: 3600s -secrets: -- kmsKeyName: projects/ops-central/locations/global/keyRings/builder-secrets/cryptoKeys/secret-encrypter - secretEnv: - POSTMARK_API_KEY: CiQA7z1GH3QhvCEWNn6KS64t/c8BEQng5I4CdMC6VGNxJkWmZrwSTgB+R8mv/PSrzlDmCYSOZc4bugWA+K+lJ8nIll1BBsZZEV5M9GuOCYVn6sVWg9pCIVujwyb4EvEy1QaKmZCzAnTw9aHEXDH0sruAUHBaTA== - -images: - - 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA' - - 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest' - - 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA' - - 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest' From cb8cdfed994bbec6a6b100bc94b989035461beee Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 12:23:39 -0800 Subject: [PATCH 13/26] Flattening file structure further. Standardizing on image-specific docker locations. --- docker-compose.yml | 2 +- docker/Dockerfile.nginx.prod | 2 +- docker/{prod => }/nginx/entrypoint.sh | 0 docker/{prod => }/nginx/includes/README.md | 0 docker/{prod => }/nginx/includes/content/_proxy.conf | 0 docker/{prod => }/nginx/includes/content/default.conf | 2 +- .../nginx/includes/content/develop-studio-content.conf | 0 docker/{prod => }/nginx/includes/content/studio-content.conf | 0 docker/{prod => }/nginx/mime.types | 0 docker/{prod => }/nginx/nginx.conf | 0 docker/{ => studio-dev}/entrypoint.py | 0 11 files changed, 3 insertions(+), 3 deletions(-) rename docker/{prod => }/nginx/entrypoint.sh (100%) rename docker/{prod => }/nginx/includes/README.md (100%) rename docker/{prod => }/nginx/includes/content/_proxy.conf (100%) rename docker/{prod => }/nginx/includes/content/default.conf (95%) rename docker/{prod => }/nginx/includes/content/develop-studio-content.conf (100%) rename docker/{prod => }/nginx/includes/content/studio-content.conf (100%) rename docker/{prod => }/nginx/mime.types (100%) rename docker/{prod => }/nginx/nginx.conf (100%) rename docker/{ => studio-dev}/entrypoint.py (100%) diff --git a/docker-compose.yml b/docker-compose.yml index 2a0eed7799..9ec8d1c34f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: studio-app: <<: *studio-worker - entrypoint: python docker/entrypoint.py + entrypoint: python docker/studio-dev/entrypoint.py command: pnpm run devserver ports: - "8080:8080" diff --git a/docker/Dockerfile.nginx.prod b/docker/Dockerfile.nginx.prod index a01753b143..ba33210a28 100644 --- a/docker/Dockerfile.nginx.prod +++ b/docker/Dockerfile.nginx.prod @@ -1,7 +1,7 @@ FROM nginx:1.25 # Build from inside the directory by overriding this. -ARG SRC_DIR=docker/prod/nginx +ARG SRC_DIR=docker/nginx RUN rm /etc/nginx/conf.d/* # if there's stuff here, nginx won't read sites-enabled COPY ${SRC_DIR}/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/prod/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh similarity index 100% rename from docker/prod/nginx/entrypoint.sh rename to docker/nginx/entrypoint.sh diff --git a/docker/prod/nginx/includes/README.md b/docker/nginx/includes/README.md similarity index 100% rename from docker/prod/nginx/includes/README.md rename to docker/nginx/includes/README.md diff --git a/docker/prod/nginx/includes/content/_proxy.conf b/docker/nginx/includes/content/_proxy.conf similarity index 100% rename from docker/prod/nginx/includes/content/_proxy.conf rename to docker/nginx/includes/content/_proxy.conf diff --git a/docker/prod/nginx/includes/content/default.conf b/docker/nginx/includes/content/default.conf similarity index 95% rename from docker/prod/nginx/includes/content/default.conf rename to docker/nginx/includes/content/default.conf index 44c005f21b..c2c95df613 100644 --- a/docker/prod/nginx/includes/content/default.conf +++ b/docker/nginx/includes/content/default.conf @@ -1,4 +1,4 @@ -# DO NOT RENAME: referenced by docker/prod/nginx/entrypoint.sh +# DO NOT RENAME: referenced by docker/nginx/entrypoint.sh # assume development location @emulator { diff --git a/docker/prod/nginx/includes/content/develop-studio-content.conf b/docker/nginx/includes/content/develop-studio-content.conf similarity index 100% rename from docker/prod/nginx/includes/content/develop-studio-content.conf rename to docker/nginx/includes/content/develop-studio-content.conf diff --git a/docker/prod/nginx/includes/content/studio-content.conf b/docker/nginx/includes/content/studio-content.conf similarity index 100% rename from docker/prod/nginx/includes/content/studio-content.conf rename to docker/nginx/includes/content/studio-content.conf diff --git a/docker/prod/nginx/mime.types b/docker/nginx/mime.types similarity index 100% rename from docker/prod/nginx/mime.types rename to docker/nginx/mime.types diff --git a/docker/prod/nginx/nginx.conf b/docker/nginx/nginx.conf similarity index 100% rename from docker/prod/nginx/nginx.conf rename to docker/nginx/nginx.conf diff --git a/docker/entrypoint.py b/docker/studio-dev/entrypoint.py similarity index 100% rename from docker/entrypoint.py rename to docker/studio-dev/entrypoint.py From 0c39da8bc51064f40e3fe0f40fb07f74a6c9ac0e Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 13:59:38 -0800 Subject: [PATCH 14/26] Bringing in temporary symlinks for infra-side CD. Will delete after both have been updated. --- k8s/images/app/Dockerfile | 1 + k8s/images/nginx/Dockerfile | 1 + 2 files changed, 2 insertions(+) create mode 120000 k8s/images/app/Dockerfile create mode 120000 k8s/images/nginx/Dockerfile diff --git a/k8s/images/app/Dockerfile b/k8s/images/app/Dockerfile new file mode 120000 index 0000000000..4750df1fc3 --- /dev/null +++ b/k8s/images/app/Dockerfile @@ -0,0 +1 @@ +docker/Dockerfile.prod \ No newline at end of file diff --git a/k8s/images/nginx/Dockerfile b/k8s/images/nginx/Dockerfile new file mode 120000 index 0000000000..a4867c19b0 --- /dev/null +++ b/k8s/images/nginx/Dockerfile @@ -0,0 +1 @@ +docker/Dockerfile.nginx.prod \ No newline at end of file From 1394abac6f1a79620a83a015629de6cf4860f634 Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 14:33:24 -0800 Subject: [PATCH 15/26] Cleaning up more prober code. --- Makefile | 6 +----- docker-compose.yml | 11 ----------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 002d337323..dc1e70b51e 100644 --- a/Makefile +++ b/Makefile @@ -171,11 +171,7 @@ dcbuild: $(DOCKER_COMPOSE) build dcup: .docker/minio .docker/postgres - # run all services except for cloudprober - $(DOCKER_COMPOSE) up studio-app celery-worker - -dcup-cloudprober: .docker/minio .docker/postgres - # run all services including cloudprober + # run all services $(DOCKER_COMPOSE) up dcdown: diff --git a/docker-compose.yml b/docker-compose.yml index 9ec8d1c34f..68bb5e2500 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,17 +83,6 @@ services: redis: image: redis:6.0.9 - cloudprober: - <<: *studio-worker - working_dir: /src/deploy - entrypoint: "" - # sleep 30 seconds allowing some time for the studio app to start up - command: '/bin/bash -c "sleep 30 && /bin/cloudprober --config_file ./cloudprober.cfg"' - # wait until the main app and celery worker have started - depends_on: - - studio-app - - celery-worker - volumes: minio: From b50bdb94aee25c2b49726c31d2bc9be962873b78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 00:04:02 +0000 Subject: [PATCH 16/26] Configure Celery for graceful shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements soft shutdown feature from Celery 5.5.3 to prevent task interruption during pod termination. Resolves #5000. Changes: - Add worker_soft_shutdown_timeout (28s) to Celery config in settings.py - Add REMAP_SIGTERM=SIGQUIT to K8s shared env vars to trigger soft shutdown When K8s sends SIGTERM during pod termination, workers will now: 1. Stop accepting new tasks 2. Continue processing current task for up to 28 seconds 3. Exit cleanly if task completes, or timeout after 28s 4. Allow K8s 2s buffer before 30s grace period expires 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- contentcuration/contentcuration/settings.py | 5 +++++ docker-compose.yml | 1 + 2 files changed, 6 insertions(+) diff --git a/contentcuration/contentcuration/settings.py b/contentcuration/contentcuration/settings.py index 0f18ed0131..285e7bef76 100644 --- a/contentcuration/contentcuration/settings.py +++ b/contentcuration/contentcuration/settings.py @@ -348,6 +348,11 @@ def gettext(s): "result_serializer": "json", "result_extended": True, "worker_send_task_events": True, + # Graceful shutdown: allow 28 seconds for tasks to complete before forced termination + # This is 2 seconds less than Kubernetes terminationGracePeriodSeconds (30s) + "worker_soft_shutdown_timeout": int( + os.getenv("CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", "28") + ), } # When cleaning up orphan nodes, only clean up any that have been last modified diff --git a/docker-compose.yml b/docker-compose.yml index 68bb5e2500..719fc797ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ x-studio-environment: CELERY_BROKER_ENDPOINT: redis CELERY_RESULT_BACKEND_ENDPOINT: redis CELERY_REDIS_PASSWORD: "" + REMAP_SIGTERM: "SIGQUIT" PROBER_STUDIO_BASE_URL: http://studio-app:8080/{path} x-studio-worker: From 8ca219b3d2d481bd5a061d05eeb88226bfd31802 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Wed, 17 Dec 2025 15:13:21 -0800 Subject: [PATCH 17/26] Update paths for nginx Dockerfile in workflow --- .github/workflows/containerbuild.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index 68f1be456c..0056d99cb4 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -79,7 +79,7 @@ jobs: with: skip_after_successful_duplicate: false github_token: ${{ github.token }} - paths: '["docker/Dockerfile.nginx.prod", "nginx/*", ".github/workflows/containerbuild.yml"]' + paths: '["docker/Dockerfile.nginx.prod", "docker/nginx/*", ".github/workflows/containerbuild.yml"]' build_nginx: name: nginx - test build of nginx Docker image From eb5084737d4ae47b4d9435029215bebcb855f3f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 22:56:16 +0000 Subject: [PATCH 18/26] Fix Language foreign key column lengths (#5618) When Language.id was changed from max_length=7 to max_length=14 in migration 0081, Django 1.9 did not cascade the primary key column size change to foreign key and many-to-many junction table columns. This migration fixes those columns for databases that were created before the migration squash. It is idempotent - columns that are already varchar(14) are not modified. Fixes: contentcuration_channel.language_id Fixes: contentcuration_channel_included_languages.language_id Fixes: contentcuration_contentnode.language_id Fixes: contentcuration_file.language_id Adds migration test that deliberately corrupts the DB columns then runs the migration and confirms the fix. --- .../0155_fix_language_foreign_key_length.py | 83 +++++++++++++++++++ ...est_language_fk_column_length_migration.py | 79 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py create mode 100644 contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py diff --git a/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py b/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py new file mode 100644 index 0000000000..495efc02f3 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py @@ -0,0 +1,83 @@ +# Generated manually to fix Language foreign key column lengths +# See https://github.com/learningequality/studio/issues/5618 +# +# When Language.id was changed from max_length=7 to max_length=14 in migration +# 0081, Django 1.9 did not cascade the primary key column size change to +# foreign key and many-to-many junction table columns. This migration fixes +# those columns for databases that were created before the migration squash. +# +# This migration is idempotent - it only alters columns that are still varchar(7). +from django.db import migrations + + +# SQL to fix each column, checking if it needs to be altered first +FORWARD_SQL = """ +DO $$ +BEGIN + -- Fix contentcuration_channel.language_id + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_channel' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_channel + ALTER COLUMN language_id TYPE varchar(14); + END IF; + + -- Fix contentcuration_channel_included_languages.language_id (M2M junction table) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_channel_included_languages' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_channel_included_languages + ALTER COLUMN language_id TYPE varchar(14); + END IF; + + -- Fix contentcuration_contentnode.language_id + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_contentnode' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_contentnode + ALTER COLUMN language_id TYPE varchar(14); + END IF; + + -- Fix contentcuration_file.language_id + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_file' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_file + ALTER COLUMN language_id TYPE varchar(14); + END IF; +END $$; +""" + +# Reverse SQL is a no-op since we don't want to shrink the columns back +# (that could cause data loss if longer language codes have been inserted) +REVERSE_SQL = """ +-- No-op: Cannot safely reverse this migration as it may cause data loss +SELECT 1; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0154_alter_assessmentitem_type"), + ] + + operations = [ + migrations.RunSQL(FORWARD_SQL, REVERSE_SQL), + ] diff --git a/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py b/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py new file mode 100644 index 0000000000..e31831d97d --- /dev/null +++ b/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py @@ -0,0 +1,79 @@ +""" +Test for migration 0161_fix_language_foreign_key_length. + +This test verifies that the migration correctly fixes Language foreign key +columns that are varchar(7) instead of varchar(14). +""" +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + + +# The columns that should be fixed by the migration +COLUMNS_TO_CHECK = [ + ("contentcuration_channel", "language_id"), + ("contentcuration_channel_included_languages", "language_id"), + ("contentcuration_contentnode", "language_id"), + ("contentcuration_file", "language_id"), +] + + +def get_column_max_length(table_name, column_name): + """Get the character_maximum_length for a varchar column.""" + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT character_maximum_length + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = %s + AND column_name = %s + """, + [table_name, column_name], + ) + row = cursor.fetchone() + return row[0] if row else None + + +def set_column_to_varchar7(table_name, column_name): + """Shrink a varchar column to varchar(7) to simulate bad production state.""" + with connection.cursor() as cursor: + cursor.execute( + f"ALTER TABLE {table_name} ALTER COLUMN {column_name} TYPE varchar(7)" + ) + + +class TestLanguageForeignKeyLengthMigration(TransactionTestCase): + """ + Test that migration 0155 fixes varchar(7) Language FK columns to varchar(14). + + This simulates the production database state where Language.id was changed + from max_length=7 to max_length=14, but Django 1.9 didn't cascade the change + to foreign key columns. + """ + + def test_migration_fixes_varchar7_columns(self): + # First, shrink all columns back to varchar(7) to simulate bad state + for table_name, column_name in COLUMNS_TO_CHECK: + set_column_to_varchar7(table_name, column_name) + # Verify the column is now varchar(7) + self.assertEqual( + get_column_max_length(table_name, column_name), + 7, + f"{table_name}.{column_name} should be varchar(7) before migration", + ) + + # Run migration 0161 from 0160 + executor = MigrationExecutor(connection) + executor.migrate([("contentcuration", "0154_alter_assessmentitem_type")]) + executor = MigrationExecutor(connection) + executor.loader.build_graph() + executor.migrate([("contentcuration", "0155_fix_language_foreign_key_length")]) + + # Verify all columns are now varchar(14) + for table_name, column_name in COLUMNS_TO_CHECK: + self.assertEqual( + get_column_max_length(table_name, column_name), + 14, + f"{table_name}.{column_name} should be varchar(14) after migration", + ) From f05c8e00ffa2df30dd5295675c50737aafc5cebb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 21:55:13 +0000 Subject: [PATCH 19/26] Fix sync missing fields: language, provider, aggregator, role_visibility Add missing fields to resource details sync operation: - language: ContentNode language field - provider: Provider organization field - aggregator: Aggregator organization field - role_visibility: Visible to field (learner/coach) These fields were not being synced when users selected the "Resource details" checkbox during sync operations. They are now included in the sync_resource_details field list. Also add comprehensive unit tests to verify: - Each field syncs correctly when sync_resource_details=True - All four fields sync together - Fields do not sync when sync_resource_details=False Fixes #4930 --- .../contentcuration/tests/test_sync.py | 243 +++++++++++++++++- contentcuration/contentcuration/utils/sync.py | 4 + 2 files changed, 239 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/tests/test_sync.py b/contentcuration/contentcuration/tests/test_sync.py index 8d011cc1db..73abc842c0 100644 --- a/contentcuration/contentcuration/tests/test_sync.py +++ b/contentcuration/contentcuration/tests/test_sync.py @@ -4,6 +4,7 @@ from le_utils.constants import content_kinds from le_utils.constants import file_formats from le_utils.constants import format_presets +from le_utils.constants import roles from le_utils.constants.labels import accessibility_categories from le_utils.constants.labels import learning_activities from le_utils.constants.labels import levels @@ -17,6 +18,7 @@ from contentcuration.models import Channel from contentcuration.models import ContentTag from contentcuration.models import File +from contentcuration.models import Language from contentcuration.models import License from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase @@ -108,7 +110,6 @@ def test_sync_files_add(self): if child.title == contentnode.title: target_child = child break - self.assertIsNotNone(target_child) self.assertEqual(target_child.files.count(), contentnode.files.count()) db_file = self._add_temp_file_to_content_node(contentnode) @@ -172,7 +173,6 @@ def test_sync_assessment_item_add(self): source_node_id=contentnode.node_id ) - self.assertIsNotNone(target_child) self.assertEqual( target_child.assessment_items.count(), contentnode.assessment_items.count() ) @@ -224,7 +224,6 @@ def test_sync_tags_add(self): source_node_id=contentnode.node_id ) - self.assertIsNotNone(target_child) self.assertEqual(target_child.tags.count(), contentnode.tags.count()) tag = ContentTag.objects.create(tag_name="tagname") @@ -263,7 +262,6 @@ def test_sync_tags_add_multiple_tags(self): source_node_id=contentnode.node_id ) - self.assertIsNotNone(target_child) self.assertEqual(target_child.tags.count(), contentnode.tags.count()) # Create the same tag twice @@ -314,8 +312,6 @@ def test_sync_channel_titles_and_descriptions(self): source_node_id=contentnode.node_id ) - self.assertIsNotNone(target_child) - for key, value in labels.items(): setattr(contentnode, key, value) contentnode.save() @@ -407,8 +403,6 @@ def test_sync_channel_other_metadata_labels(self): source_node_id=contentnode.node_id ) - self.assertIsNotNone(target_child) - for key, value in labels.items(): setattr(contentnode, key, {value: True}) contentnode.save() @@ -429,6 +423,239 @@ def test_sync_channel_other_metadata_labels(self): for key, value in labels.items(): self.assertEqual(getattr(target_child, key), {value: True}) + def test_sync_language_field(self): + """ + Test that the language field is synced correctly when sync_resource_details is True. + """ + self.assertFalse(self.channel.has_changes()) + self.assertFalse(self.derivative_channel.has_changes()) + + contentnode = ( + self.channel.main_tree.get_descendants() + .exclude(kind_id=content_kinds.TOPIC) + .first() + ) + + target_child = self.derivative_channel.main_tree.get_descendants().get( + source_node_id=contentnode.node_id + ) + + # Set a different language on the original node + spanish_language = Language.objects.get(id="es") + contentnode.language = spanish_language + contentnode.save() + + sync_channel( + self.derivative_channel, + sync_titles_and_descriptions=False, + sync_resource_details=True, + sync_files=False, + sync_assessment_items=False, + ) + + self.assertTrue(self.channel.has_changes()) + self.assertTrue(self.derivative_channel.has_changes()) + + target_child.refresh_from_db() + self.assertEqual(target_child.language, spanish_language) + + def test_sync_provider_field(self): + """ + Test that the provider field is synced correctly when sync_resource_details is True. + """ + self.assertFalse(self.channel.has_changes()) + self.assertFalse(self.derivative_channel.has_changes()) + + contentnode = ( + self.channel.main_tree.get_descendants() + .exclude(kind_id=content_kinds.TOPIC) + .first() + ) + + target_child = self.derivative_channel.main_tree.get_descendants().get( + source_node_id=contentnode.node_id + ) + + # Set a provider on the original node + contentnode.provider = "Test Provider Organization" + contentnode.save() + + sync_channel( + self.derivative_channel, + sync_titles_and_descriptions=False, + sync_resource_details=True, + sync_files=False, + sync_assessment_items=False, + ) + + self.assertTrue(self.channel.has_changes()) + self.assertTrue(self.derivative_channel.has_changes()) + + target_child.refresh_from_db() + self.assertEqual(target_child.provider, "Test Provider Organization") + + def test_sync_aggregator_field(self): + """ + Test that the aggregator field is synced correctly when sync_resource_details is True. + """ + self.assertFalse(self.channel.has_changes()) + self.assertFalse(self.derivative_channel.has_changes()) + + contentnode = ( + self.channel.main_tree.get_descendants() + .exclude(kind_id=content_kinds.TOPIC) + .first() + ) + + target_child = self.derivative_channel.main_tree.get_descendants().get( + source_node_id=contentnode.node_id + ) + + # Set an aggregator on the original node + contentnode.aggregator = "Test Aggregator Organization" + contentnode.save() + + sync_channel( + self.derivative_channel, + sync_titles_and_descriptions=False, + sync_resource_details=True, + sync_files=False, + sync_assessment_items=False, + ) + + self.assertTrue(self.channel.has_changes()) + self.assertTrue(self.derivative_channel.has_changes()) + + target_child.refresh_from_db() + self.assertEqual(target_child.aggregator, "Test Aggregator Organization") + + def test_sync_role_visibility_field(self): + """ + Test that the role_visibility field is synced correctly when sync_resource_details is True. + """ + self.assertFalse(self.channel.has_changes()) + self.assertFalse(self.derivative_channel.has_changes()) + + contentnode = ( + self.channel.main_tree.get_descendants() + .exclude(kind_id=content_kinds.TOPIC) + .first() + ) + + target_child = self.derivative_channel.main_tree.get_descendants().get( + source_node_id=contentnode.node_id + ) + + # Set role_visibility to COACH on the original node + contentnode.role_visibility = roles.COACH + contentnode.save() + + sync_channel( + self.derivative_channel, + sync_titles_and_descriptions=False, + sync_resource_details=True, + sync_files=False, + sync_assessment_items=False, + ) + + self.assertTrue(self.channel.has_changes()) + self.assertTrue(self.derivative_channel.has_changes()) + + target_child.refresh_from_db() + self.assertEqual(target_child.role_visibility, roles.COACH) + + def test_sync_all_missing_fields(self): + """ + Test that all four previously missing fields (language, provider, aggregator, + role_visibility) are synced together when sync_resource_details is True. + """ + self.assertFalse(self.channel.has_changes()) + self.assertFalse(self.derivative_channel.has_changes()) + + contentnode = ( + self.channel.main_tree.get_descendants() + .exclude(kind_id=content_kinds.TOPIC) + .first() + ) + + target_child = self.derivative_channel.main_tree.get_descendants().get( + source_node_id=contentnode.node_id + ) + + # Set all four fields on the original node + french_language = Language.objects.get(id="fr") + contentnode.language = french_language + contentnode.provider = "Comprehensive Test Provider" + contentnode.aggregator = "Comprehensive Test Aggregator" + contentnode.role_visibility = roles.COACH + contentnode.save() + + sync_channel( + self.derivative_channel, + sync_titles_and_descriptions=False, + sync_resource_details=True, + sync_files=False, + sync_assessment_items=False, + ) + + self.assertTrue(self.channel.has_changes()) + self.assertTrue(self.derivative_channel.has_changes()) + + target_child.refresh_from_db() + self.assertEqual(target_child.language, french_language) + self.assertEqual(target_child.provider, "Comprehensive Test Provider") + self.assertEqual(target_child.aggregator, "Comprehensive Test Aggregator") + self.assertEqual(target_child.role_visibility, roles.COACH) + + def test_sync_missing_fields_not_synced_without_flag(self): + """ + Test that the four fields (language, provider, aggregator, role_visibility) + are NOT synced when sync_resource_details is False. + """ + self.assertFalse(self.channel.has_changes()) + self.assertFalse(self.derivative_channel.has_changes()) + + contentnode = ( + self.channel.main_tree.get_descendants() + .exclude(kind_id=content_kinds.TOPIC) + .first() + ) + + target_child = self.derivative_channel.main_tree.get_descendants().get( + source_node_id=contentnode.node_id + ) + + # Store original values + original_language = target_child.language + original_provider = target_child.provider + original_aggregator = target_child.aggregator + original_role_visibility = target_child.role_visibility + + # Modify all four fields in the original node + german_language = Language.objects.get(id="de") + contentnode.language = german_language + contentnode.provider = "Should Not Sync Provider" + contentnode.aggregator = "Should Not Sync Aggregator" + contentnode.role_visibility = roles.COACH + contentnode.save() + + # Sync WITHOUT sync_resource_details + sync_channel( + self.derivative_channel, + sync_titles_and_descriptions=False, + sync_resource_details=False, + sync_files=False, + sync_assessment_items=False, + ) + + target_child.refresh_from_db() + + # Verify fields remain unchanged + self.assertEqual(target_child.language, original_language) + self.assertEqual(target_child.provider, original_provider) + self.assertEqual(target_child.aggregator, original_aggregator) + self.assertEqual(target_child.role_visibility, original_role_visibility) + class ContentIDTestCase(SyncTestMixin, StudioAPITestCase): def setUp(self): diff --git a/contentcuration/contentcuration/utils/sync.py b/contentcuration/contentcuration/utils/sync.py index a11ce4aeab..2987d1c75b 100644 --- a/contentcuration/contentcuration/utils/sync.py +++ b/contentcuration/contentcuration/utils/sync.py @@ -71,6 +71,10 @@ def sync_node( "license_description", "copyright_holder", "author", + "language", + "provider", + "aggregator", + "role_visibility", "extra_fields", "categories", "learner_needs", From 1208feee8ed78d91d23b46663a7949385685342d Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 12 Jan 2026 09:02:26 -0800 Subject: [PATCH 20/26] Narrow Language FK fix to only included_languages M2M table After verification on production servers, only the contentcuration_channel_included_languages junction table is affected by the varchar(7) issue. The other FK columns (channel.language_id, contentnode.language_id, file.language_id) are already varchar(14). This simplifies the migration to only fix the one affected column. Co-Authored-By: Claude Opus 4.5 --- .../0155_fix_language_foreign_key_length.py | 48 +++-------------- ...est_language_fk_column_length_migration.py | 54 +++++++++---------- 2 files changed, 30 insertions(+), 72 deletions(-) diff --git a/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py b/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py index 495efc02f3..28824ed21e 100644 --- a/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py +++ b/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py @@ -1,31 +1,19 @@ -# Generated manually to fix Language foreign key column lengths +# Generated manually to fix Language foreign key column length in M2M junction table # See https://github.com/learningequality/studio/issues/5618 # # When Language.id was changed from max_length=7 to max_length=14 in migration -# 0081, Django 1.9 did not cascade the primary key column size change to -# foreign key and many-to-many junction table columns. This migration fixes -# those columns for databases that were created before the migration squash. +# 0081, Django 1.9 did not cascade the primary key column size change to the +# many-to-many junction table column. This migration fixes that column for +# databases that were created before the migration squash. # -# This migration is idempotent - it only alters columns that are still varchar(7). +# This migration is idempotent - it only alters the column if it is still varchar(7). from django.db import migrations -# SQL to fix each column, checking if it needs to be altered first +# SQL to fix the column, checking if it needs to be altered first FORWARD_SQL = """ DO $$ BEGIN - -- Fix contentcuration_channel.language_id - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'contentcuration_channel' - AND column_name = 'language_id' - AND character_maximum_length = 7 - ) THEN - ALTER TABLE contentcuration_channel - ALTER COLUMN language_id TYPE varchar(14); - END IF; - -- Fix contentcuration_channel_included_languages.language_id (M2M junction table) IF EXISTS ( SELECT 1 FROM information_schema.columns @@ -37,30 +25,6 @@ ALTER TABLE contentcuration_channel_included_languages ALTER COLUMN language_id TYPE varchar(14); END IF; - - -- Fix contentcuration_contentnode.language_id - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'contentcuration_contentnode' - AND column_name = 'language_id' - AND character_maximum_length = 7 - ) THEN - ALTER TABLE contentcuration_contentnode - ALTER COLUMN language_id TYPE varchar(14); - END IF; - - -- Fix contentcuration_file.language_id - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'contentcuration_file' - AND column_name = 'language_id' - AND character_maximum_length = 7 - ) THEN - ALTER TABLE contentcuration_file - ALTER COLUMN language_id TYPE varchar(14); - END IF; END $$; """ diff --git a/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py b/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py index e31831d97d..625a1fd10c 100644 --- a/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py +++ b/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py @@ -1,21 +1,17 @@ """ -Test for migration 0161_fix_language_foreign_key_length. +Test for migration 0155_fix_language_foreign_key_length. -This test verifies that the migration correctly fixes Language foreign key -columns that are varchar(7) instead of varchar(14). +This test verifies that the migration correctly fixes the Language foreign key +column in the included_languages M2M junction table from varchar(7) to varchar(14). """ from django.db import connection from django.db.migrations.executor import MigrationExecutor from django.test import TransactionTestCase -# The columns that should be fixed by the migration -COLUMNS_TO_CHECK = [ - ("contentcuration_channel", "language_id"), - ("contentcuration_channel_included_languages", "language_id"), - ("contentcuration_contentnode", "language_id"), - ("contentcuration_file", "language_id"), -] +# The M2M junction table column that should be fixed by the migration +TABLE_NAME = "contentcuration_channel_included_languages" +COLUMN_NAME = "language_id" def get_column_max_length(table_name, column_name): @@ -45,35 +41,33 @@ def set_column_to_varchar7(table_name, column_name): class TestLanguageForeignKeyLengthMigration(TransactionTestCase): """ - Test that migration 0155 fixes varchar(7) Language FK columns to varchar(14). + Test that migration 0155 fixes varchar(7) Language FK column to varchar(14). This simulates the production database state where Language.id was changed from max_length=7 to max_length=14, but Django 1.9 didn't cascade the change - to foreign key columns. + to the M2M junction table column. """ - def test_migration_fixes_varchar7_columns(self): - # First, shrink all columns back to varchar(7) to simulate bad state - for table_name, column_name in COLUMNS_TO_CHECK: - set_column_to_varchar7(table_name, column_name) - # Verify the column is now varchar(7) - self.assertEqual( - get_column_max_length(table_name, column_name), - 7, - f"{table_name}.{column_name} should be varchar(7) before migration", - ) + def test_migration_fixes_varchar7_column(self): + # First, shrink column back to varchar(7) to simulate bad state + set_column_to_varchar7(TABLE_NAME, COLUMN_NAME) + # Verify the column is now varchar(7) + self.assertEqual( + get_column_max_length(TABLE_NAME, COLUMN_NAME), + 7, + f"{TABLE_NAME}.{COLUMN_NAME} should be varchar(7) before migration", + ) - # Run migration 0161 from 0160 + # Run migration 0155 executor = MigrationExecutor(connection) executor.migrate([("contentcuration", "0154_alter_assessmentitem_type")]) executor = MigrationExecutor(connection) executor.loader.build_graph() executor.migrate([("contentcuration", "0155_fix_language_foreign_key_length")]) - # Verify all columns are now varchar(14) - for table_name, column_name in COLUMNS_TO_CHECK: - self.assertEqual( - get_column_max_length(table_name, column_name), - 14, - f"{table_name}.{column_name} should be varchar(14) after migration", - ) + # Verify column is now varchar(14) + self.assertEqual( + get_column_max_length(TABLE_NAME, COLUMN_NAME), + 14, + f"{TABLE_NAME}.{COLUMN_NAME} should be varchar(14) after migration", + ) From f17438677eae2ecd958a3c81810374b32e5e3335 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Wed, 7 Jan 2026 14:52:49 -0800 Subject: [PATCH 21/26] Upgrade le-utils to version 0.2.14 --- requirements.in | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.in b/requirements.in index 27ca67281c..c86a1d37c3 100644 --- a/requirements.in +++ b/requirements.in @@ -5,7 +5,7 @@ djangorestframework==3.15.1 psycopg2-binary==2.9.10 django-js-reverse==0.10.2 django-registration==3.4 -le-utils>=0.2.12 +le-utils==0.2.14 gunicorn==23.0.0 django-postmark==0.1.6 jsonfield==3.1.0 diff --git a/requirements.txt b/requirements.txt index 9863c77097..2e227661d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -162,7 +162,7 @@ language-data==1.3.0 # via langcodes latex2mathml==3.78.0 # via -r requirements.in -le-utils==0.2.12 +le-utils==0.2.14 # via -r requirements.in marisa-trie==1.2.1 # via language-data From 5890cf8d549d0a53407f3f46287981b0aed09cab Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Wed, 7 Jan 2026 14:54:00 -0800 Subject: [PATCH 22/26] Add test for and update publish logic to publish pre/post tests for UNIT modality topics. --- .../constants/completion_criteria.py | 1 + .../tests/test_exportchannel.py | 106 ++++++++++++++++++ .../contentcuration/utils/publish.py | 40 +++++-- 3 files changed, 139 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/constants/completion_criteria.py b/contentcuration/contentcuration/constants/completion_criteria.py index 1a8c101e38..2aabe53262 100644 --- a/contentcuration/contentcuration/constants/completion_criteria.py +++ b/contentcuration/contentcuration/constants/completion_criteria.py @@ -52,6 +52,7 @@ def _build_validator(): completion_criteria.APPROX_TIME, completion_criteria.REFERENCE, }, + content_kinds.TOPIC: {completion_criteria.MASTERY}, } diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py index 5c850597d7..b87b310344 100644 --- a/contentcuration/contentcuration/tests/test_exportchannel.py +++ b/contentcuration/contentcuration/tests/test_exportchannel.py @@ -16,6 +16,7 @@ from kolibri_content.router import set_active_content_database from le_utils.constants import exercises from le_utils.constants import format_presets +from le_utils.constants import modalities from le_utils.constants.labels import accessibility_categories from le_utils.constants.labels import learning_activities from le_utils.constants.labels import levels @@ -304,6 +305,83 @@ def setUp(self): } first_topic_first_child.save() + # Add a UNIT topic with directly attached assessment items + unit_assessment_id_1 = uuid.uuid4().hex + unit_assessment_id_2 = uuid.uuid4().hex + + unit_topic = create_node( + {"kind_id": "topic", "title": "Test Unit Topic", "children": []}, + parent=self.content_channel.main_tree, + ) + unit_topic.extra_fields = { + "options": { + "modality": modalities.UNIT, + "completion_criteria": { + "model": "mastery", + "threshold": { + "mastery_model": exercises.PRE_POST_TEST, + "pre_post_test": { + "assessment_item_ids": [ + unit_assessment_id_1, + unit_assessment_id_2, + ], + "version_a_item_ids": [unit_assessment_id_1], + "version_b_item_ids": [unit_assessment_id_2], + }, + }, + }, + } + } + unit_topic.save() + + cc.AssessmentItem.objects.create( + contentnode=unit_topic, + assessment_id=unit_assessment_id_1, + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=json.dumps( + [ + {"answer": "4", "correct": True, "order": 1}, + {"answer": "3", "correct": False, "order": 2}, + ] + ), + hints=json.dumps([]), + raw_data="{}", + order=1, + randomize=False, + ) + + cc.AssessmentItem.objects.create( + contentnode=unit_topic, + assessment_id=unit_assessment_id_2, + type=exercises.SINGLE_SELECTION, + question="What is 3+3?", + answers=json.dumps( + [ + {"answer": "6", "correct": True, "order": 1}, + {"answer": "5", "correct": False, "order": 2}, + ] + ), + hints=json.dumps([]), + raw_data="{}", + order=2, + randomize=False, + ) + + # Add a LESSON child topic under the UNIT with a video child + lesson_topic = create_node( + { + "kind_id": "topic", + "title": "Test Lesson Topic", + "children": [ + {"kind_id": "video", "title": "Unit Lesson Video", "children": []}, + ], + }, + parent=unit_topic, + ) + lesson_topic.extra_fields = {"options": {"modality": modalities.LESSON}} + lesson_topic.save() + set_channel_icon_encoding(self.content_channel) self.tempdb = create_content_database( self.content_channel, True, self.admin_user.id, True @@ -348,6 +426,10 @@ def test_contentnode_incomplete_not_published(self): assert incomplete_nodes.count() > 0 for node in complete_nodes: + # Skip nodes that are known to fail validation and not be published: + # - "Bad mastery test" exercise has no mastery model (checked separately below) + if node.title == "Bad mastery test": + continue # if a parent node is incomplete, this node is excluded as well. if node.get_ancestors().filter(complete=False).count() == 0: assert kolibri_nodes.filter(pk=node.node_id).count() == 1 @@ -642,6 +724,30 @@ def test_qti_archive_contains_manifest_and_assessment_ids(self): for i, ai in enumerate(qti_exercise.assessment_items.order_by("order")): self.assertEqual(assessment_ids[i], hex_to_qti_id(ai.assessment_id)) + def test_unit_topic_publishes_with_exercise_zip(self): + """Test that a TOPIC node with UNIT modality gets its directly + attached assessment items compiled into a zip file during publishing.""" + unit_topic = cc.ContentNode.objects.get(title="Test Unit Topic") + + # Assert UNIT topic has exercise file in Studio + unit_files = cc.File.objects.filter( + contentnode=unit_topic, + preset_id=format_presets.EXERCISE, + ) + self.assertEqual( + unit_files.count(), + 1, + "UNIT topic should have exactly one exercise archive file", + ) + + # Assert NO assessment metadata in Kolibri export for UNIT topics + # UNIT topics store assessment config in options/completion_criteria instead + published_unit = kolibri_models.ContentNode.objects.get(title="Test Unit Topic") + self.assertFalse( + published_unit.assessmentmetadata.exists(), + "UNIT topic should NOT have assessment metadata", + ) + class EmptyChannelTestCase(StudioTestCase): @classmethod diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 3e28f2d0e0..9e9e190105 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -35,6 +35,7 @@ from le_utils.constants import exercises from le_utils.constants import file_formats from le_utils.constants import format_presets +from le_utils.constants import modalities from le_utils.constants import roles from search.models import ChannelFullTextSearch from search.models import ContentNodeFullTextSearch @@ -227,6 +228,22 @@ def assign_license_to_contentcuration_nodes(channel, license): ] +def has_assessments(node): + """Check if a node should have its assessment items published. + + Returns True for EXERCISE nodes and TOPIC nodes with UNIT modality + that have assessment items. + """ + if node.kind_id == content_kinds.EXERCISE: + return True + if node.kind_id == content_kinds.TOPIC: + options = node.extra_fields.get("options", {}) if node.extra_fields else {} + if options.get("modality") == modalities.UNIT: + # Only return True if the UNIT has assessment items + return node.assessment_items.filter(deleted=False).exists() + return False + + class TreeMapper: def __init__( self, @@ -296,9 +313,10 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 # Only process nodes that are either non-topics or have non-topic descendants if node.is_publishable(): - # early validation to make sure we don't have any exercises without mastery models - # which should be unlikely when the node is complete, but just in case - if node.kind_id == content_kinds.EXERCISE: + # early validation to make sure we don't have any nodes with assessments + # without mastery models, which should be unlikely when the node is complete, + # but just in case + if has_assessments(node): try: # migrates and extracts the mastery model from the exercise _, mastery_model = parse_assessment_metadata(node) @@ -306,8 +324,8 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 raise ValueError("Exercise does not have a mastery model") except Exception as e: logging.warning( - "Unable to parse exercise {id} mastery model: {error}".format( - id=node.pk, error=str(e) + "Unable to parse exercise {id} {title} mastery model: {error}".format( + id=node.pk, title=node.title, error=str(e) ) ) return @@ -322,7 +340,7 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 metadata, ) - if node.kind_id == content_kinds.EXERCISE: + if has_assessments(node): exercise_data = process_assessment_metadata(node) any_free_response = any( t == exercises.FREE_RESPONSE @@ -359,10 +377,16 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 ) generator.create_exercise_archive() - create_kolibri_assessment_metadata(node, kolibrinode) + # Only create assessment metadata for exercises, not UNIT topics + # UNIT topics store their assessment config in options/completion_criteria + if node.kind_id == content_kinds.EXERCISE: + create_kolibri_assessment_metadata(node, kolibrinode) elif node.kind_id == content_kinds.SLIDESHOW: create_slideshow_manifest(node, user_id=self.user_id) - elif node.kind_id == content_kinds.TOPIC: + + # TOPIC nodes need to recurse into children, including UNIT topics + # that also had their assessments processed above + if node.kind_id == content_kinds.TOPIC: for child in node.children.all(): self.recurse_nodes(child, metadata) create_associated_file_objects(kolibrinode, node) From 0ad254f526f34d7d0d9a645fc433374e5e55984b Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 19 Jan 2026 16:47:04 -0800 Subject: [PATCH 23/26] Make topic completion criterion validation more rigorous. --- .../constants/completion_criteria.py | 34 ++++++++- .../tests/test_completion_criteria.py | 73 +++++++++++++++++++ .../contentcuration/views/internal.py | 4 +- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/constants/completion_criteria.py b/contentcuration/contentcuration/constants/completion_criteria.py index 2aabe53262..49d351bd7d 100644 --- a/contentcuration/contentcuration/constants/completion_criteria.py +++ b/contentcuration/contentcuration/constants/completion_criteria.py @@ -3,7 +3,9 @@ from jsonschema.validators import validator_for from le_utils.constants import completion_criteria from le_utils.constants import content_kinds +from le_utils.constants import exercises from le_utils.constants import mastery_criteria +from le_utils.constants import modalities def _build_validator(): @@ -56,7 +58,7 @@ def _build_validator(): } -def check_model_for_kind(data, kind): +def check_model_for_kind(data, kind, modality=None): model = data.get("model") if kind is None or model is None or kind not in ALLOWED_MODELS_PER_KIND: return @@ -69,11 +71,37 @@ def check_model_for_kind(data, kind): ) ) + if kind == content_kinds.TOPIC: + check_topic_completion_criteria(data, modality) -def validate(data, kind=None): + +def check_topic_completion_criteria(data, modality): + """ + Validates topic-specific completion criteria rules: + - Topics can only have completion criteria if modality is UNIT + - Topics can only use PRE_POST_TEST mastery model + """ + # Topics can only have completion criteria with UNIT modality + if modality != modalities.UNIT: + raise ValidationError( + "Topics can only have completion criteria with UNIT modality" + ) + + # Topics can only use PRE_POST_TEST mastery model + threshold = data.get("threshold", {}) + mastery_model = threshold.get("mastery_model") + if mastery_model is not None and mastery_model != exercises.PRE_POST_TEST: + raise ValidationError( + "mastery_model '{}' is invalid for topic content kind; " + "only '{}' is allowed".format(mastery_model, exercises.PRE_POST_TEST) + ) + + +def validate(data, kind=None, modality=None): """ :param data: Dictionary of data to validate :param kind: A str of the node content kind + :param modality: A str of the node modality (required for topics with completion criteria) :raises: ValidationError: When invalid """ # empty dicts are okay @@ -105,4 +133,4 @@ def validate(data, kind=None): e.error_list.extend(error_descriptions) raise e - check_model_for_kind(data, kind) + check_model_for_kind(data, kind, modality) diff --git a/contentcuration/contentcuration/tests/test_completion_criteria.py b/contentcuration/contentcuration/tests/test_completion_criteria.py index a0daec10d7..09cb1529fc 100644 --- a/contentcuration/contentcuration/tests/test_completion_criteria.py +++ b/contentcuration/contentcuration/tests/test_completion_criteria.py @@ -2,7 +2,9 @@ from django.test import SimpleTestCase from le_utils.constants import completion_criteria from le_utils.constants import content_kinds +from le_utils.constants import exercises from le_utils.constants import mastery_criteria +from le_utils.constants import modalities from contentcuration.constants.completion_criteria import validate @@ -40,3 +42,74 @@ def test_validate__content_kind(self): }, kind=content_kinds.DOCUMENT, ) + + def _make_preposttest_threshold(self): + """Helper to create a valid pre_post_test threshold structure.""" + # UUIDs must be 32 hex characters + uuid_a = "a" * 32 + uuid_b = "b" * 32 + return { + "mastery_model": exercises.PRE_POST_TEST, + "pre_post_test": { + "assessment_item_ids": [uuid_a, uuid_b], + "version_a_item_ids": [uuid_a], + "version_b_item_ids": [uuid_b], + }, + } + + def test_validate__topic_with_unit_modality_and_preposttest__success(self): + """Topic with UNIT modality and PRE_POST_TEST mastery model should pass validation.""" + validate( + { + "model": completion_criteria.MASTERY, + "threshold": self._make_preposttest_threshold(), + }, + kind=content_kinds.TOPIC, + modality=modalities.UNIT, + ) + + def test_validate__topic_with_unit_modality_and_wrong_mastery_model__fail(self): + """Topic with UNIT modality but non-PRE_POST_TEST mastery model should fail.""" + with self.assertRaisesRegex( + ValidationError, "mastery_model.*invalid for.*topic" + ): + validate( + { + "model": completion_criteria.MASTERY, + "threshold": { + "mastery_model": mastery_criteria.M_OF_N, + "m": 3, + "n": 5, + }, + }, + kind=content_kinds.TOPIC, + modality=modalities.UNIT, + ) + + def test_validate__topic_with_non_unit_modality_and_completion_criteria__fail(self): + """Topic with non-UNIT modality (e.g., LESSON) should not have completion criteria.""" + with self.assertRaisesRegex( + ValidationError, "only.*completion criteria.*UNIT modality" + ): + validate( + { + "model": completion_criteria.MASTERY, + "threshold": self._make_preposttest_threshold(), + }, + kind=content_kinds.TOPIC, + modality=modalities.LESSON, + ) + + def test_validate__topic_with_no_modality_and_completion_criteria__fail(self): + """Topic with no modality should not have completion criteria.""" + with self.assertRaisesRegex( + ValidationError, "only.*completion criteria.*UNIT modality" + ): + validate( + { + "model": completion_criteria.MASTERY, + "threshold": self._make_preposttest_threshold(), + }, + kind=content_kinds.TOPIC, + modality=None, + ) diff --git a/contentcuration/contentcuration/views/internal.py b/contentcuration/contentcuration/views/internal.py index 93be3e3043..07b4014b00 100644 --- a/contentcuration/contentcuration/views/internal.py +++ b/contentcuration/contentcuration/views/internal.py @@ -839,7 +839,9 @@ def create_node(node_data, parent_node, sort_order): # noqa: C901 if "options" in extra_fields and "completion_criteria" in extra_fields["options"]: try: completion_criteria.validate( - extra_fields["options"]["completion_criteria"], kind=node_data["kind"] + extra_fields["options"]["completion_criteria"], + kind=node_data["kind"], + modality=extra_fields["options"].get("modality"), ) except completion_criteria.ValidationError: raise NodeValidationError( From 2d7d22c923c6de213f73cedd5d4c6b8e972428cd Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 19 Jan 2026 16:47:50 -0800 Subject: [PATCH 24/26] Run completion criterion checking on all node types. --- contentcuration/contentcuration/models.py | 25 ++---- .../tests/test_contentnodes.py | 89 +++++++++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c2d94744e0..524da39cfc 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2290,22 +2290,15 @@ def mark_complete(self): # noqa C901 ) if not (self.extra_fields.get("mastery_model") or criterion): errors.append("Missing mastery criterion") - if criterion: - try: - completion_criteria.validate( - criterion, kind=content_kinds.EXERCISE - ) - except completion_criteria.ValidationError: - errors.append("Mastery criterion is defined but is invalid") - else: - criterion = self.extra_fields and self.extra_fields.get( - "options", {} - ).get("completion_criteria", {}) - if criterion: - try: - completion_criteria.validate(criterion, kind=self.kind_id) - except completion_criteria.ValidationError: - errors.append("Completion criterion is defined but is invalid") + options = self.extra_fields and self.extra_fields.get("options", {}) or {} + criterion = options.get("completion_criteria", {}) + if criterion: + try: + completion_criteria.validate( + criterion, kind=self.kind_id, modality=options.get("modality") + ) + except completion_criteria.ValidationError: + errors.append("Completion criterion is defined but is invalid") self.complete = not errors return errors diff --git a/contentcuration/contentcuration/tests/test_contentnodes.py b/contentcuration/contentcuration/tests/test_contentnodes.py index bc4f73b0b9..4db7ffcbc4 100644 --- a/contentcuration/contentcuration/tests/test_contentnodes.py +++ b/contentcuration/contentcuration/tests/test_contentnodes.py @@ -10,6 +10,7 @@ from le_utils.constants import content_kinds from le_utils.constants import exercises from le_utils.constants import format_presets +from le_utils.constants import modalities from mixer.backend.django import mixer from mock import patch @@ -1481,3 +1482,91 @@ def test_create_video_null_extra_fields(self): new_obj.mark_complete() except AttributeError: self.fail("Null extra_fields not handled") + + def _make_preposttest_extra_fields(self, modality): + """Helper to create extra_fields with valid pre_post_test completion criteria.""" + uuid_a = "a" * 32 + uuid_b = "b" * 32 + return { + "options": { + "modality": modality, + "completion_criteria": { + "model": completion_criteria.MASTERY, + "threshold": { + "mastery_model": exercises.PRE_POST_TEST, + "pre_post_test": { + "assessment_item_ids": [uuid_a, uuid_b], + "version_a_item_ids": [uuid_a], + "version_b_item_ids": [uuid_b], + }, + }, + }, + } + } + + def test_create_topic_unit_modality_valid_preposttest_complete(self): + """Topic with UNIT modality and valid PRE_POST_TEST completion criteria should be complete.""" + channel = testdata.channel() + new_obj = ContentNode( + title="Unit Topic", + kind_id=content_kinds.TOPIC, + parent=channel.main_tree, + extra_fields=self._make_preposttest_extra_fields(modalities.UNIT), + ) + new_obj.save() + new_obj.mark_complete() + self.assertTrue(new_obj.complete) + + def test_create_topic_unit_modality_wrong_mastery_model_incomplete(self): + """Topic with UNIT modality but M_OF_N mastery model should be incomplete.""" + channel = testdata.channel() + new_obj = ContentNode( + title="Unit Topic", + kind_id=content_kinds.TOPIC, + parent=channel.main_tree, + extra_fields={ + "options": { + "modality": modalities.UNIT, + "completion_criteria": { + "model": completion_criteria.MASTERY, + "threshold": { + "mastery_model": exercises.M_OF_N, + "m": 3, + "n": 5, + }, + }, + } + }, + ) + new_obj.save() + new_obj.mark_complete() + self.assertFalse(new_obj.complete) + + def test_create_topic_lesson_modality_with_completion_criteria_incomplete(self): + """Topic with LESSON modality should not have completion criteria.""" + channel = testdata.channel() + new_obj = ContentNode( + title="Lesson Topic", + kind_id=content_kinds.TOPIC, + parent=channel.main_tree, + extra_fields=self._make_preposttest_extra_fields(modalities.LESSON), + ) + new_obj.save() + new_obj.mark_complete() + self.assertFalse(new_obj.complete) + + def test_create_topic_no_modality_with_completion_criteria_incomplete(self): + """Topic with no modality should not have completion criteria.""" + channel = testdata.channel() + extra_fields = self._make_preposttest_extra_fields(modalities.UNIT) + # Remove the modality + del extra_fields["options"]["modality"] + new_obj = ContentNode( + title="Topic Without Modality", + kind_id=content_kinds.TOPIC, + parent=channel.main_tree, + extra_fields=extra_fields, + ) + new_obj.save() + new_obj.mark_complete() + self.assertFalse(new_obj.complete) From 95648062e57526dc6c9f9974533aa0fd7d4a7de3 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 19 Jan 2026 17:00:56 -0800 Subject: [PATCH 25/26] Temporarily hide draft button. --- .../frontend/channelEdit/pages/StagingTreePage/index.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue index 6939d232fc..4991a89e25 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue @@ -227,6 +227,7 @@ Date: Tue, 20 Jan 2026 11:11:03 -0800 Subject: [PATCH 26/26] Add strong requirement for UNIT folders to require preposttest completion criteria. --- contentcuration/contentcuration/models.py | 11 ++++++++++- .../contentcuration/tests/test_contentnodes.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 524da39cfc..a3f15770cd 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -54,6 +54,7 @@ from le_utils.constants import file_formats from le_utils.constants import format_presets from le_utils.constants import languages +from le_utils.constants import modalities from le_utils.constants import roles from model_utils import FieldTracker from mptt.models import MPTTModel @@ -2292,10 +2293,18 @@ def mark_complete(self): # noqa C901 errors.append("Missing mastery criterion") options = self.extra_fields and self.extra_fields.get("options", {}) or {} criterion = options.get("completion_criteria", {}) + modality = options.get("modality") + # UNIT modality topics must have completion criteria + if ( + self.kind_id == content_kinds.TOPIC + and modality == modalities.UNIT + and not criterion + ): + errors.append("UNIT modality topics must have completion criteria") if criterion: try: completion_criteria.validate( - criterion, kind=self.kind_id, modality=options.get("modality") + criterion, kind=self.kind_id, modality=modality ) except completion_criteria.ValidationError: errors.append("Completion criterion is defined but is invalid") diff --git a/contentcuration/contentcuration/tests/test_contentnodes.py b/contentcuration/contentcuration/tests/test_contentnodes.py index 4db7ffcbc4..5ce8b472a4 100644 --- a/contentcuration/contentcuration/tests/test_contentnodes.py +++ b/contentcuration/contentcuration/tests/test_contentnodes.py @@ -1570,3 +1570,21 @@ def test_create_topic_no_modality_with_completion_criteria_incomplete(self): new_obj.save() new_obj.mark_complete() self.assertFalse(new_obj.complete) + + def test_create_topic_unit_modality_without_completion_criteria_incomplete(self): + """Topic with UNIT modality MUST have completion criteria - it's not optional.""" + channel = testdata.channel() + new_obj = ContentNode( + title="Unit Topic Without Criteria", + kind_id=content_kinds.TOPIC, + parent=channel.main_tree, + extra_fields={ + "options": { + "modality": modalities.UNIT, + # No completion_criteria + } + }, + ) + new_obj.save() + new_obj.mark_complete() + self.assertFalse(new_obj.complete)