Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .github/workflows/deploytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ jobs:
pnpm rebuild node-sass
- name: Build frontend
run: pnpm run build
- name: Upload frontend bundle
uses: actions/upload-artifact@v7
with:
name: studio-frontend-bundle
path: |
contentcuration/contentcuration/static/studio/
contentcuration/build/webpack-stats.json
if-no-files-found: error
retention-days: 1
make_messages:
name: Build all message files
needs: pre_job
Expand Down Expand Up @@ -79,3 +88,146 @@ jobs:
sudo apt-get install -y gettext
- name: Test Django makemessages
run: python contentcuration/manage.py makemessages --all
browser_smoke_test:
name: Browser smoke test
needs: [pre_job, build_assets]
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: learningequality
POSTGRES_PASSWORD: kolibri
POSTGRES_DB: kolibri-studio
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:6.0.9
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
env:
DJANGO_SETTINGS_MODULE: contentcuration.settings
DATA_DB_HOST: localhost
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_BUCKET_NAME: content
CELERY_BROKER_ENDPOINT: localhost
CELERY_REDIS_DB: "0"
CELERY_REDIS_PASSWORD: ""
# generate_storage_url() only handles k8s / docker-compose / unset — an
# unrecognized value leaves the URL unbound (500). MinIO runs on :9000
# here, which is exactly the docker-compose path.
RUN_MODE: docker-compose
SMOKE_EMAIL: smokeadmin@example.com
SMOKE_PASSWORD: smokepass1234
steps:
- uses: actions/checkout@v7
- name: Set up MinIO
run: |
docker run -d -p 9000:9000 --name minio \
-e "MINIO_ROOT_USER=development" \
-e "MINIO_ROOT_PASSWORD=development" \
-e "MINIO_DEFAULT_BUCKETS=content:public" \
bitnamilegacy/minio:2024.5.28
- name: Install gettext (for compilemessages)
run: |
sudo apt-get update -y
sudo apt-get install -y gettext
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: '3.10'
activate-environment: "true"
enable-cache: "true"
- name: Install python dependencies
run: |
uv pip sync requirements.txt
# WhiteNoise lets gunicorn serve /static/ without nginx (see
# integration_testing/smoke_wsgi.py). Smoke-test-only, so not in
# requirements.txt.
uv pip install "whitenoise<7"
- name: Download frontend bundle
uses: actions/download-artifact@v8
with:
name: studio-frontend-bundle

@rtibblesbot rtibblesbot Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

blocking: Downloaded bundle lands one directory too shallow → 500 on every page, smoke test times out. upload-artifact strips the least-common-ancestor (contentcuration/), so the artifact holds build/webpack-stats.json + contentcuration/static/studio/…. This download has no path:, so it extracts into the workspace root: webpack-stats.json ends up at ./build/… but WEBPACK_LOADER["STATS_FILE"] resolves to <repo>/contentcuration/build/webpack-stats.json, and the static bundle lands at ./contentcuration/static/studio/ instead of the contentcuration/contentcuration/static/studio/ dir collectstatic reads. gunicorn logs OSError: Error reading .../contentcuration/build/webpack-stats.json.

Fix — extract under the stripped prefix to restore the original layout:

      - name: Download frontend bundle
        uses: actions/download-artifact@v8
        with:
          name: studio-frontend-bundle
          path: contentcuration

Verify the job goes green (and that collectstatic then finds the bundle) before merge.

# upload-artifact strips the least-common-ancestor of the uploaded
# paths (here contentcuration/), so extract back under it: the bundle
# must land at contentcuration/contentcuration/static/studio/ and the
# stats file at contentcuration/build/webpack-stats.json (STATS_FILE).
path: contentcuration
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-v1
- name: Install Chromium and system deps
run: uvx --from "playwright<2" playwright install --with-deps chromium
- name: Prepare database
run: |
python contentcuration/manage.py migrate --noinput
python contentcuration/manage.py loadconstants
- name: Create smoke test user
shell: python
run: |
import os
import sys
# contentcuration package lives one level down; put it on sys.path
# so django.setup() can import contentcuration.settings.
sys.path.insert(0, "contentcuration")
import django
django.setup()
from django.contrib.auth import get_user_model
# Studio's custom User model: create_superuser(email, first_name, last_name, password).
# is_active defaults to False, so we have to flip it explicitly —
# otherwise the user exists but can't log in.
u = get_user_model().objects.create_superuser(
os.environ["SMOKE_EMAIL"],
"Smoke",
"Admin",
password=os.environ["SMOKE_PASSWORD"],
)
u.is_active = True
u.save()
- name: Prepare static and translations
run: |
python contentcuration/manage.py collectstatic --noinput
cd contentcuration && python manage.py compilemessages
- name: Start gunicorn (WhiteNoise serves /static/, no nginx)
run: |
nohup gunicorn integration_testing.smoke_wsgi:application \
--pythonpath . \
--timeout=120 --workers=1 --threads=1 \
--bind=0.0.0.0:8080 --log-level=info \
> "${{ runner.temp }}/gunicorn.log" 2>&1 &
echo "gunicorn started in background"
- name: Run browser smoke test
# SCREENSHOT_DIR uses the runner context, which is only available in a
# step env — not the job-level env.
env:
SCREENSHOT_DIR: ${{ runner.temp }}/smoke_test_screenshots
run: uv run --script integration_testing/smoke_test.py
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v7
with:
name: smoke_test_screenshots
path: ${{ runner.temp }}/smoke_test_screenshots
if-no-files-found: ignore
- name: Upload gunicorn log on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: smoke_test_gunicorn_log
path: ${{ runner.temp }}/gunicorn.log
if-no-files-found: ignore
184 changes: 184 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<!-- Generic guidance for all coding agents (Claude Code, Zed, Cursor, etc.) -->

# Kolibri Studio Development Guide for AI Coding Agents

**Project:** Kolibri Studio — web app for authoring and publishing learning channels to Kolibri
**Stack:** Python/Django backend, Vue.js 2.7 frontend, Kolibri Design System (KDS) + legacy Vuetify 1.5, Postgres/Redis/MinIO/Celery services, pytest/Jest testing
**Platform:** web (server-deployed, not packaged for clients)

## Quick Start

```bash
uv pip sync requirements.txt requirements-dev.txt # Python deps
pnpm install # Node deps
pre-commit install # Required — commits fail without this
make dcservicesup # Bring up postgres / redis / minio in docker
pnpm devsetup # Migrate + load sample data + create admin
pnpm devserver # Django :8080 + Webpack watcher
```

→ Full setup: `README.md` | Local production-shape stack: `make dcup` (uses `docker-compose.yml`)

## Critical Gotchas

### ⚠️ BEFORE Writing Any Vue Component, Search for Existing Ones

Do not create a new component without first searching for an existing solution:
1. **Kolibri Design System** ([docs](https://design-system.learningequality.org/)) — `KButton`, `KCircularLoader`, `KTextbox`, `KSelect`, `KModal`, `KCheckbox`, `KIcon`, `KTable`, etc.
2. **`contentcuration/contentcuration/frontend/shared/`** — Studio-specific shared components.
3. **Vuetify 1.5** — for legacy widgets KDS doesn't cover (data tables, complex layout primitives). See the next gotcha before reaching for Vuetify in new code.

If a component does 80% of what you need, wrap it — do not rewrite.

### ⚠️ Use KDS, Not Vuetify, for New Code

KDS is the design-system source of truth. Vuetify is legacy and being phased out. **Do not introduce Vuetify into a fresh component** — new files use KDS. When you're already editing a file for another reason and a Vuetify widget in it has a KDS equivalent, migrating it is welcome — **keep it small and in-scope** (the component you're touching, not a sweep of the whole file). Don't open unrelated PRs solely to migrate, and don't half-convert a component and leave it mixed.

### ⚠️ Use Theme Tokens, Not Hard-Coded Colors

Never use raw color values. Access theme colors via `$themeTokens` and `$themePalette`:
```vue
<template>
<div :style="{ color: $themeTokens.text, backgroundColor: $themeTokens.surface }">
<span :style="{ color: $themeTokens.annotation }">secondary text</span>
</div>
</template>
```
For computed dynamic styles, use `$computedClass`.

### ⚠️ Style Blocks, Not Inline — RTL Depends On It

Non-dynamic styles go in `<style>` blocks. RTLCSS auto-flips directional properties (`padding-left` → `padding-right`) in style blocks but **cannot flip inline styles**. Dynamic directional styles must check `isRtl`.

### ⚠️ Prefer Composition API for New Code

Studio's existing code is largely Options API, but new components and refactors should use Composition API. It's fine to add Composition API to an existing Options API file when the existing logic isn't worth restructuring — be aware that a partial mix usually leads to a follow-up refactor.

### ⚠️ No New Vuex — Use Composition API for New State

Vuex 3 is deprecated in Studio. **New state goes through Composition API** — composables built on `ref`/`reactive`/`computed`, scoped to the consuming component or a `provide`/`inject` boundary. Existing Vuex modules live under `frontend/<app>/vuex/` (including the IndexedDB-backed sync store); when you're already editing code that leans on one and the slice is small, moving it to a composable is welcome — **keep it small and in-scope**, don't restructure a whole module in an unrelated PR. Leave the sync store alone unless the work is specifically about it.

### ⚠️ Studio Uses an IndexedDB-Backed Change-Sync Architecture — Don't Mutate Synced Models Directly

Edits in the editor write to Dexie tables in the browser via the Resource layer (`contentcuration/contentcuration/frontend/shared/data/`), which generates Change records that flow to the server's `/api/sync/` endpoint (`contentcuration/contentcuration/viewsets/sync/`). The server applies changes via a change-type registry (`viewsets/sync/base.py`, `viewsets/sync/constants.py`) and broadcasts back.

Direct `axios.post` or direct ORM `save()` on a synced model bypasses the change pipeline and corrupts the offline → online merge. **Rule:** if a model is part of the sync framework, all writes go through the Resource layer (frontend) or the change-application registry (backend). Backend system-internal operations (publishing, garbage collection) may bypass; anything user-facing must not. See `CLAUDE.md` for a fuller description.

### ⚠️ Responsive Layout: Plain CSS First, Vuetify Grid Only as a Last Resort

New layout uses plain `<div>`s with CSS (flexbox/grid) in a `<style>` block, plus KDS's `useKResponsiveWindow` composable when a breakpoint has to drive logic:
```javascript
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';

const { windowIsSmall, windowWidth } = useKResponsiveWindow();
```
Vuetify's `v-container` / `v-row` / `v-col` grid is legacy, like the rest of Vuetify. Inside a file that already uses it, leave it alone — no refactor required. Reach for it in new code only when plain CSS genuinely can't express the layout.

### ⚠️ Internationalize All User-Visible Text

Use `createTranslator` from `shared/i18n` (Studio re-exports Kolibri's i18n there) — never hard-code strings in templates:
```javascript
import { createTranslator } from 'shared/i18n';

const strings = createTranslator('ChannelStrings', {
title: { message: 'Channel title', context: 'Form field label' },
});
const { title$ } = strings; // title$() returns the translated string
```

### ⚠️ API Calls via the Resource Pattern

For synced models, use the Resource layer in `contentcuration/contentcuration/frontend/shared/data/` (see sync gotcha above). For non-synced endpoints, use the existing Resource-style wrappers in `shared/data/resources.js`. Never use raw `fetch` or `axios` for domain operations.

### ⚠️ Backend APIs: Use `ValuesViewset`

Studio has `ValuesViewset` / `ReadOnlyValuesViewset` vendored at `contentcuration/contentcuration/viewsets/base.py`. Use them for new API endpoints — define a `values` tuple and `annotate_queryset` for computed fields rather than relying on default serializer output. Apply permission classes from `contentcuration/contentcuration/viewsets/`.

### ⚠️ Testing Is Required

- **Python:** `pytest` from repo root (uses `pytest.ini` → `contentcuration.test_settings`). Django API tests extend `APITestCase` from `rest_framework.test`. Other Django tests extend `django.test.TestCase`.
- **Frontend:** Jest runner + Vue Testing Library via `@testing-library/vue` (`render`). Write new tests with VTL. `@vue/test-utils` (`mount`/`shallowMount`) still appears in many existing tests but is **deprecated** — there are open issues to remove those tests, so do not add new ones. `describe`/`it`/`expect` are Jest globals — do NOT import them. Use `jest.fn()` and `jest.mock()`.
- **TDD:** Write a failing test first, then make it pass. Especially for bug fixes — always write a test that reproduces the bug before fixing it.

### ⚠️ Pre-commit Auto-Fixes Files

When a commit fails: pre-commit auto-fixes files → **`git add` the fixed files** → re-commit. Never bypass with `--no-verify`.

## Project Structure

```
studio/
├── contentcuration/ # Django project root (also contains other apps)
│ ├── contentcuration/ # Main Django app
│ │ ├── frontend/ # Vue source
│ │ │ ├── channelEdit/
│ │ │ ├── channelList/
│ │ │ ├── settings/
│ │ │ ├── accounts/
│ │ │ ├── administration/
│ │ │ └── shared/ # Shared components + sync data layer
│ │ ├── viewsets/ # DRF ValuesViewsets — sync/ contains the change framework
│ │ ├── models.py, urls.py, settings.py, dev_settings.py, test_settings.py
│ │ └── static/studio/ # webpack output (git-ignored)
│ ├── automation/ # Django app — automation workflows
│ ├── kolibri_content/ # Django app — Kolibri content schema
│ ├── kolibri_public/ # Django app — public catalog API
│ ├── search/ # Django app — full-text search
│ ├── manage.py
│ └── build/ # webpack-stats.json (git-ignored)
├── docker/ # Dockerfile.{dev,prod,nginx.prod,postgres.dev}
├── integration_testing/
│ ├── features/ # Gherkin BDD specs (manual reference)
│ └── smoke_test.py # CI smoke test
├── jest_config/ # Jest config
├── webpack.config.js
├── Makefile # dc* targets + altprodserver
└── docker-compose.yml / docker-compose.prod.yml
```

**Settings split:**
- `contentcuration.dev_settings` — local development. Use this for local `manage.py` commands.
- `contentcuration.settings` — production. Used by `make altprodserver` and the CI smoke test.
- `contentcuration.test_settings` — pytest.

## Code Quality

Studio follows the same code-quality principles as Kolibri. → See https://kolibri-dev.readthedocs.io/en/latest/code_quality.html.md for detailed examples (LLM-friendly Markdown version; drop `.md` for the HTML rendering).

## Key Conventions

**Python:** F-strings preferred. One import per line. All imports at file top — inline imports only to prevent circular imports. Descriptive migration names (no `_auto_`).

**Vue:** PascalCase filenames. Component `name` must match filename.

**Git:** Imperative commit messages. **PR titles do NOT use Conventional Commits prefix** (e.g. `feat:`, `fix:`) — plain English titles. Individual commit messages may use CC prefixes. Black/Prettier enforced by pre-commit.

**Don't guess — look at existing code** for patterns: `contentcuration/contentcuration/viewsets/` for API patterns, `contentcuration/contentcuration/frontend/shared/data/` for sync framework, existing `__tests__/` directories for test patterns.

## Running Tests

```bash
pytest # all Python tests
pytest contentcuration/contentcuration/tests/ -k name # filter by name
pnpm test # all Jest tests
pnpm jest --config jest_config/jest.conf.js <path> # single file
pre-commit run --all-files # lint all
pre-commit run --files path/to/File.vue # lint specific files
```

Important: bare `pnpm jest` will NOT load `modulePaths` correctly — always go through `pnpm test` or pass `--config jest_config/jest.conf.js` explicitly. Always go through `pre-commit` — do not invoke ESLint / Black / Flake8 directly.

## Docs Reference

The Kolibri-dev docs site serves an LLM-friendly Markdown variant of every page — append `.md` to any URL below. The whole index is at https://kolibri-dev.readthedocs.io/en/latest/llms.txt.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the kolibri docs here are generally applicable but confirming that this is an intentional repurposing and not copypasta

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, purely because it contains lots of general info.


- Code quality: https://kolibri-dev.readthedocs.io/en/latest/code_quality.html.md
- Testing (general): https://kolibri-dev.readthedocs.io/en/latest/testing.html.md
- Frontend testing: https://kolibri-dev.readthedocs.io/en/latest/frontend_architecture/unit_testing.html.md
- Backend testing: https://kolibri-dev.readthedocs.io/en/latest/backend_architecture/testing.html.md
- i18n: https://kolibri-dev.readthedocs.io/en/latest/i18n.html.md
- Frontend architecture: https://kolibri-dev.readthedocs.io/en/latest/frontend_architecture/index.html.md
- Backend architecture: https://kolibri-dev.readthedocs.io/en/latest/backend_architecture/index.html.md
- Development workflow: https://kolibri-dev.readthedocs.io/en/latest/development_workflow.html.md

Local: `README.md`, `Makefile` (common commands), `docker-compose.yml` (service config).
Loading
Loading