O0: Docker/Odoo 19 CE scaffolding

docker-compose.yml (postgres 16 + odoo:19.0), odoo.conf, env template,
and setup/backup/update/demo-data scripts. update.sh always backs up
and records the previous image before upgrading modules, per the
lesson paid for on the Frappe track. .gitattributes pins LF on shell
scripts so they don't break under the container's bash on checkout
from Windows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-11 06:45:58 -04:00
commit 85f2b4b11a
11 changed files with 523 additions and 0 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# Copy to .env and edit before running docker compose.
# ── PostgreSQL ──
POSTGRES_DB=postgres
POSTGRES_USER=odoo
POSTGRES_PASSWORD=changeme_db_2026
POSTGRES_HOST=db
POSTGRES_PORT=5432
# ── Odoo ──
ODOO_ADMIN_PASSWORD=changeme_master_2026
ODOO_DB_NAME=school
HTTP_PORT=8069
LONGPOLLING_PORT=8072
# ── Image ──
ODOO_VERSION=19.0

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.sh text eol=lf
*.conf text eol=lf

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Environment
.env
# Python
__pycache__/
*.pyc
*.pyo
.venv/
# Odoo runtime
*.log
odoo-data/
filestore/
sessions/
# OS / editor
.DS_Store
Thumbs.db
.vscode/
*.swp
# Backups
backups/*.sql
backups/*.tar
backups/*.zip

283
CLAUDE.md Normal file
View File

@ -0,0 +1,283 @@
# CLAUDE.md — Track O: School ERP on Odoo 19 Community
Master build spec for Claude Code. Read this fully before writing any code.
**Also read, once, before starting:**
- `../shared/DOMAIN_MODEL.md` — entities and naming. Frozen. Do not deviate.
- `../shared/DEMO_SCRIPT.md` — the eight scenes. This is the definition of done.
- `../IMPLEMENTATION_PLAN.md` §2 for workstream order.
**Never read or modify anything under `../Development/`.** That is the Frappe track, a separate
product. Its conventions do not apply here and its code must not be referenced.
---
## 1. Non-negotiable constraints
1. **Odoo 19.0 Community only.** No Enterprise module may appear in any dependency list, ever. If a
feature seems to need one, it is out of scope or built from scratch — flag it, do not silently
add the dependency. Verified absent from Community: `hr_payroll`, `account_accountant`,
`account_reports`, `whatsapp`, `sign`, `documents`, `hr_appraisal`, `stock_barcode`.
2. **This is Metatroncube IP.** Do not vendor, copy or adapt code from OpenEduCat or any other
LGPL/AGPL education addon. Models are designed from `../shared/DOMAIN_MODEL.md`, not ported.
3. **Use stock Odoo where it fits.** `account` for all invoicing, `website_slides` for the entire
LMS, `fleet` for vehicles, `portal` for external users, `hr` and `hr_holidays` for staff,
`payment_*` for gateways, `survey` for quizzes. Writing a custom version of any of these is a bug.
4. **Addon suite, not a monolith.** Each `mc_education_*` module installs independently given its
declared dependencies, and ports to Odoo 20 independently.
5. **No Studio.** It is Enterprise. Every view, field and report is code in this repo.
---
## 2. Environment
```
Odoo 19.0 Community (github.com/odoo/odoo, branch 19.0)
Python 3.12
PostgreSQL 16
Node 20 (for asset bundling)
```
Repo layout:
```
Odoo/
├── CLAUDE.md ← this file
├── docker-compose.yml
├── .env.example
├── odoo.conf
├── scripts/
│ ├── setup.sh one-command bring-up
│ ├── update.sh rebuild + upgrade modules
│ ├── backup.sh pg_dump + filestore
│ └── demo-data.sh load the demo school
├── addons/
│ ├── mc_education_base/
│ ├── mc_education_admission/
│ ├── mc_education_fees/
│ ├── mc_education_attendance/
│ ├── mc_education_timetable/
│ ├── mc_education_exam/
│ ├── mc_education_portal/
│ ├── mc_education_lms/
│ └── mc_education_theme/
└── third_party/ OCA addons, pinned by commit SHA
```
`update.sh` **must** call `backup.sh` before any module upgrade, and record the previous image tag
for rollback. This is a lesson already paid for on the Frappe track — do not repeat it.
Pin every third-party addon to a commit SHA, never a branch. A moving branch inside a pinned image
breaks the build later with no obvious cause.
---
## 3. Standing security rule
> **Never trust an identifier supplied by the client.**
Every controller route and every method reachable from the portal that accepts a student, guardian,
invoice or enrollment identifier must resolve it against `request.env.user` and raise `AccessError`
for anything the user is not entitled to.
Concretely, for this codebase:
- **Every portal-reachable model gets a record rule.** No exceptions, including models you think are
only reached indirectly. Write the rule in the same commit as the model.
- **`sudo()` requires a comment stating what was checked immediately above it.** An uncommented
`sudo()` is treated as a defect in review.
- **Guardian access is by relationship, not by role.** A guardian sees a student because a
`mc.student.guardian` link exists between them, never because they hold the Guardian group.
- **Write a test for each rule.** The test asserts that guardian A cannot read student B's invoice,
attendance or marks. These tests are not optional and run in CI.
Scene 5 of the demo script requires showing a refused access attempt live on the call. Build toward
that being true, not staged.
---
## 4. Coding standards
- Odoo 19 conventions throughout. No APIs deprecated in 17 or 18.
- `_name`, `_description` and `_order` on every model. `_rec_name` where the display field is not
`name`.
- Computed fields declare `@api.depends` accurately and are `store=True` only when they need to be
searched or grouped. A stored compute with wrong depends is a silent data-corruption bug.
- Constraints: prefer `_sql_constraints` over `@api.constrains` when the database can express it.
The "one active enrollment per student per year" rule is a SQL constraint.
- `ondelete` is explicit on every `Many2one`. Think about whether it should be `restrict`
(financial and academic history) or `cascade` (child lines).
- `tracking=True` on fields a school will argue about later: enrollment status, fee amounts, marks,
attendance status.
- Every user-facing string wrapped for translation. The product ships in English now and will need
Tamil and French later — retrofitting i18n is miserable.
- `ir.model.access.csv` in the same commit as the model. Never a follow-up.
- Demo data in `demo/`, and it must be realistic per `../shared/DEMO_SCRIPT.md` — real-looking Indian
and Canadian names, plausible amounts, a full term of history.
Testing:
```bash
docker compose exec odoo odoo -d school --test-enable --stop-after-init -i mc_education_base
```
Write tests for: money arithmetic, grade computation from scales, enrollment constraints, and every
access rule. Do not write tests for view layouts.
---
## 5. Module specifications
Build in this order. `mc_education_base` is a gate — get it reviewed before fanning out.
### O1 · `mc_education_base`
Depends: `base`, `mail`, `contacts`, `hr`
| Model | Key fields |
|-------|-----------|
| `mc.academic.year` | `name` (`2026-27`), `date_start`, `date_end`, `is_current` |
| `mc.academic.term` | `name`, `year_id`, `date_start`, `date_end`, `sequence` |
| `mc.program` | `name`, `code`, `sequence_no` (sorting), `display_label`, `board`, `company_id` |
| `mc.subject` | `name`, `code`, `program_ids`, `is_elective` |
| `mc.batch` | `name`, `program_id`, `year_id`, `class_teacher_id`, `capacity`, `room_id` |
| `mc.room` | `name`, `capacity`, `building`, `type` |
| `mc.student` | `partner_id`, `admission_no`, `name`, `dob`, `gender`, `admission_date`, `photo`, `status`, `blood_group`, `address_id` |
| `mc.guardian` | `partner_id`, `name`, `occupation`, `phone`, `email` |
| `mc.student.guardian` | `student_id`, `guardian_id`, `relationship`, `is_primary` |
| `mc.teacher` | `employee_id`, `subject_ids`, `max_weekly_periods` |
| `mc.enrollment` | `student_id`, `program_id`, `batch_id`, `year_id`, `state`, `roll_no`, `date_enrolled` |
Requirements:
- Exactly one `mc.academic.year` may have `is_current = True`. Enforce it.
- One Active `mc.enrollment` per student per year — SQL constraint.
- `mc.program.sequence_no` drives sort order everywhere. Never sort grades by label.
- `admission_no` uses an `ir.sequence` configurable per company.
- Security groups: `School Administrator`, `School Staff`, `Teacher`, `Accountant`,
`Guardian` (portal), `Student` (portal).
Acceptance: a student can be created, given a guardian, enrolled in a batch, and the batch roster
lists them. Constraint tests pass. Access rules exist for all six groups.
### O2 · `mc_education_admission`
Depends: `mc_education_base`, `website`
- `mc.applicant` with a stage pipeline: Applied → Document Verification → Interview → Offered →
Accepted → Enrolled / Rejected. Use `mail.thread` and stock kanban stages.
- Public website form via stock `website` form handling. File uploads to `ir.attachment`.
- **Convert action**: applicant → `mc.student` + `mc.enrollment`, carrying every field and all
attachments. Zero re-typing. Application number persists on the student.
- Application number from a configurable `ir.sequence`.
Acceptance: Demo Scene 2 runs end to end, public form through to enrolled student.
### O3 · `mc_education_fees`
Depends: `mc_education_base`, `account`, `payment`
- `mc.fee.category` — Tuition, Lab, Library, Transport, Exam
- `mc.fee.structure` — per program + academic year; lines of category × amount
- `mc.fee.schedule` — installment plan; term-wise due dates and proportions
- `mc.fee.concession` — type (Sibling / Merit / Staff / Need-based), percent or fixed, reason,
approver
- **Invoices are stock `account.move`**, type `out_invoice`, with `mc_student_id` and
`mc_enrollment_id` added by `_inherit`. Do not build an invoice model.
- Concessions appear as negative invoice lines with a reason code. Never a reduced gross.
- Payment through stock `payment` providers.
Acceptance: Demo Scene 3. A structure generates a correct invoice, a sibling concession recalculates
it, an online payment posts a real journal entry and outstanding drops without a manual refresh.
### O4 · `mc_education_attendance`
Depends: `mc_education_base`
- `mc.attendance``student_id`, `date`, `session` (period or Daily), `subject_id`, `state`
(Present / Absent / Late / On Leave), `marked_by`, `batch_id`
- Unique constraint on student × date × session. Submitting twice must not duplicate.
- A bulk marking view: roster for a batch + period, all defaulted Present, exceptions toggled.
- Mobile-first. This is marked on a phone in a corridor.
- Teachers may only mark their own batches — record rule, not a UI check.
Acceptance: Demo Scene 4. Under three taps per exception. Re-submission is idempotent. A teacher
cannot open another teacher's batch by editing the URL.
### O5 · `mc_education_timetable`
Depends: `mc_education_base`
- `mc.timetable.slot``batch_id`, `weekday`, `period`, `subject_id`, `teacher_id`, `room_id`,
`year_id`, `term_id`
- Conflict validation on save: a teacher or a room cannot hold two slots in the same weekday+period.
- Three read views over one dataset: by batch, by teacher, by student (resolved through enrollment).
- **Auto-generation is out of scope.** Demo data is configured by hand.
Acceptance: Demo Scene 7. Three views, one dataset, readable at phone width.
### O6 · `mc_education_exam`
Depends: `mc_education_base`
- `mc.grading.scale` + `mc.grading.interval` — threshold, letter, point, description.
**Data rows, never code.** CBSE, ICSE, IB, Cambridge, Ontario and percentage all expressible.
- `mc.exam` — name, term, batch, subject, max marks, pass marks, date
- `mc.mark` — student, exam, marks obtained, computed grade
- Grid mark-entry view: whole class on one screen, tab between fields, keyboard only.
- QWeb report card: logo, all subjects, marks, grades, attendance summary, remarks, signature block.
Must be print-clean on A4.
Acceptance: Demo Scene 6. Grades compute from the scale. Changing the scale changes the grades with
no code change. PDF has no clipped columns.
### O7 · `mc_education_portal`
Depends: `mc_education_fees`, `mc_education_attendance`, `mc_education_timetable`,
`mc_education_exam`, `portal`
- Parent and student portal on stock `portal`. External users cost nothing in Community — this is a
commercial advantage, use it.
- **Child switcher** for guardians with more than one child. This is the most convincing single
feature in the demo; give it real design attention.
- Views: fees + pay online, attendance with a month calendar, timetable, results, notices.
- Every controller resolves the student from the authenticated user's guardian links. See §3.
Acceptance: Demo Scene 5, including the live refused-access demonstration.
### O8 · `mc_education_lms`
Depends: `mc_education_base`, `website_slides`
Thin glue only. Configure stock `website_slides`; link a channel to a `mc.batch` so enrolled students
see their courses. **Do not build a custom LMS.** If this module exceeds ~200 lines, something has
gone wrong.
Acceptance: Demo Scene 8.
### O9 · `mc_education_theme`
Depends: `mc_education_base`
- Per-company branding: logo, primary/secondary colour, school name, favicon, report letterhead.
- Applied to backend, portal, website and every QWeb report.
- Add OCA `web_responsive` (pinned SHA) — the stock Community backend needs it.
- **The ERPNext and Odoo brand names must not be visible** anywhere the demo audience will see.
Acceptance: the closing beat — swap to the second demo school in under five minutes.
---
## 6. How to work with me on this
- One workstream per branch: `o3-fees`, `o6-exam`.
- One model, one view, or one controller per task. "Build the fees module" produces shallow work.
- Quote the demo scene a task serves, so scope stays honest.
- Always ask for the `ir.model.access.csv` and record rules in the same change as the model.
- Ask for tests on money, grades, enrollment constraints and access rules. Not on view layouts.
- I will review every access rule and every migration by hand. Surface them, do not bury them in a
large diff.
- Commit at every green state.
When something in this spec turns out to be wrong — and some of it will be — say so and propose the
correction rather than working around it silently. Then update this file in the same PR.

40
docker-compose.yml Normal file
View File

@ -0,0 +1,40 @@
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-postgres}
POSTGRES_USER: ${POSTGRES_USER:-odoo}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme_db_2026}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-odoo}"]
interval: 5s
timeout: 5s
retries: 20
odoo:
image: odoo:${ODOO_VERSION:-19.0}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${HTTP_PORT:-8069}:8069"
- "${LONGPOLLING_PORT:-8072}:8072"
environment:
HOST: db
PORT: 5432
USER: ${POSTGRES_USER:-odoo}
PASSWORD: ${POSTGRES_PASSWORD:-changeme_db_2026}
volumes:
- odoo-data:/var/lib/odoo
- ./addons:/mnt/extra-addons/mc:ro
- ./third_party:/mnt/extra-addons/oca:ro
- ./odoo.conf:/etc/odoo/odoo.conf:ro
command: ["odoo", "-c", "/etc/odoo/odoo.conf"]
volumes:
db-data:
odoo-data:

10
odoo.conf Normal file
View File

@ -0,0 +1,10 @@
[options]
addons_path = /mnt/extra-addons/mc,/mnt/extra-addons/oca,/usr/lib/python3/dist-packages/odoo/addons
admin_passwd = changeme_master_2026
db_host = db
db_port = 5432
db_user = odoo
db_password = changeme_db_2026
db_name = school
proxy_mode = False
list_db = True

24
scripts/backup.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# Dumps the Odoo database and filestore to backups/<timestamp>/.
# Usage: ./scripts/backup.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
set -a; [ -f .env ] && source .env; set +a
DB_NAME="${ODOO_DB_NAME:-school}"
POSTGRES_USER="${POSTGRES_USER:-odoo}"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT_DIR="backups/${STAMP}"
mkdir -p "$OUT_DIR"
echo "[->] Dumping database '${DB_NAME}'..."
docker compose exec -T db pg_dump -U "$POSTGRES_USER" -Fc "$DB_NAME" > "${OUT_DIR}/${DB_NAME}.dump"
echo "[->] Archiving filestore..."
docker compose exec -T odoo tar -czf - -C "/var/lib/odoo/filestore" "${DB_NAME}" 2>/dev/null \
> "${OUT_DIR}/${DB_NAME}-filestore.tar.gz" || echo "[!] No filestore found for ${DB_NAME} yet — skipping."
echo "[OK] Backup written to ${OUT_DIR}/"

24
scripts/demo-data.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# Loads the demo school (St. Aloysius Public School) by installing every
# mc_education_* module found under ./addons with demo data enabled.
# See ../shared/DEMO_SCRIPT.md for what this data must support.
#
# Usage: ./scripts/demo-data.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
set -a; [ -f .env ] && source .env; set +a
DB_NAME="${ODOO_DB_NAME:-school}"
MODULES=$(ls addons | grep '^mc_education_' | paste -sd, -)
if [ -z "$MODULES" ]; then
echo "[!] No mc_education_* modules found under ./addons yet."
exit 1
fi
echo "[->] Creating/loading database '${DB_NAME}' with demo data for: ${MODULES}"
docker compose exec odoo odoo -c /etc/odoo/odoo.conf -d "$DB_NAME" -i "$MODULES" --without-demo=False --stop-after-init
echo "[OK] Demo school loaded. Log in at the credentials in shared/DEMO_SCRIPT.md."

34
scripts/setup.sh Normal file
View File

@ -0,0 +1,34 @@
#!/bin/bash
# One-command bring-up for the School ERP Odoo 19 Community stack.
# Usage: ./scripts/setup.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
if [ ! -f .env ]; then
echo "[!] .env not found — copying from .env.example"
cp .env.example .env
fi
echo "[->] Starting db + odoo..."
docker compose up -d db
docker compose up -d odoo
echo "[->] Waiting for Odoo to accept connections..."
set -a; source .env; set +a
PORT="${HTTP_PORT:-8069}"
MAX_WAIT=90
WAITED=0
until curl -sf "http://localhost:${PORT}/web/login" >/dev/null 2>&1; do
sleep 3
WAITED=$((WAITED + 3))
if [ "$WAITED" -ge "$MAX_WAIT" ]; then
echo "[x] Odoo did not come up within ${MAX_WAIT}s. Check: docker compose logs odoo"
exit 1
fi
echo -n "."
done
echo ""
echo "[OK] Odoo is up at http://localhost:${PORT}"
echo "[->] Next: ./scripts/demo-data.sh to load the demo school, or create a database via the browser."

49
scripts/update.sh Normal file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# Rebuilds/pulls the Odoo image and upgrades modules.
# ALWAYS backs up first and records the previous image tag for rollback —
# this is a lesson already paid for on the Frappe track. Do not skip it.
#
# Usage: ./scripts/update.sh [module1,module2,...]
# With no argument, upgrades every mc_education_* module found in ./addons.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
set -a; [ -f .env ] && source .env; set +a
DB_NAME="${ODOO_DB_NAME:-school}"
ODOO_VERSION="${ODOO_VERSION:-19.0}"
echo "[->] Backing up before update (required — do not skip)..."
./scripts/backup.sh
echo "[->] Recording current image digest for rollback..."
mkdir -p .rollback
PREV_DIGEST=$(docker compose images odoo --format json 2>/dev/null | grep -o '"Repository":"[^"]*","Tag":"[^"]*"' || true)
{
echo "timestamp=$(date +%Y%m%d-%H%M%S)"
echo "previous_image=odoo:${ODOO_VERSION}"
docker inspect --format='previous_image_id={{.Id}}' "$(docker compose images -q odoo 2>/dev/null)" 2>/dev/null || true
} > .rollback/last-update.env
echo "[OK] Rollback record written to .rollback/last-update.env"
echo "[->] Pulling latest odoo:${ODOO_VERSION} image..."
docker compose pull odoo
echo "[->] Restarting Odoo on the new image..."
docker compose up -d odoo
MODULES="${1:-}"
if [ -z "$MODULES" ]; then
MODULES=$(ls addons | grep '^mc_education_' | paste -sd, -)
fi
if [ -z "$MODULES" ]; then
echo "[!] No mc_education_* modules found under ./addons — nothing to upgrade."
exit 0
fi
echo "[->] Upgrading modules: ${MODULES}"
docker compose exec odoo odoo -c /etc/odoo/odoo.conf -d "$DB_NAME" -u "$MODULES" --stop-after-init
echo "[OK] Update complete. Previous image recorded in .rollback/last-update.env for manual rollback."

15
third_party/README.md vendored Normal file
View File

@ -0,0 +1,15 @@
# third_party/
OCA addons vendored into this repo, each pinned to a commit SHA — never a branch. A moving
branch inside a pinned image breaks the build later with no obvious cause (CLAUDE.md §2).
Nothing is vendored yet. The first entry will be OCA `web_responsive` (see O9,
`mc_education_theme`) — the stock Community backend needs it and Studio is out of scope.
When adding an addon:
```bash
git subtree add --prefix=third_party/<addon_name> <oca_repo_url> <commit_sha> --squash
```
Record the SHA and the reason it was pinned in this file.