metatroncubeswdev d6f891838b O3: mc_education_fees - structures, schedules, concessions, invoicing
Serves Demo Scene 3: a fee structure (category x amount lines) with a
term-wise installment schedule generates a correct account.move for a
given enrollment + term, a concession recalculates it correctly, and
the invoice is billed to the primary guardian's partner so stock
portal invoice visibility (partner_id-based) works with no new record
rule. Invoices are plain account.move (_inherit adds mc_student_id/
mc_enrollment_id only) - no invoice model was built, per CLAUDE.md
sec 1.3.

Fixed a real modeling mistake before it shipped: the schedule's
"percentages must total 100%" rule was originally a blocking
@api.constrains on every line write, which breaks the normal workflow
of adding one term at a time (every intermediate state before the
last line is, correctly, under 100%) - and would have broken this
module's own demo data loading, since each schedule line is a
separate XML record. Moved the check to where it actually matters:
the invoice-generation wizard now raises a clear UserError if the
resolved schedule doesn't total 100% at the point of use, while a
live constraint still blocks the one thing that's unambiguously wrong
at any point - allocating more than 100%.

Also found, by testing money arithmetic against a live odoo:19.0
container rather than trusting the arithmetic by inspection: every
generated invoice total came back at exactly 1.15x the expected
amount, because the standing "School Fee" product picked up the demo
company's default sales tax. Fixed by explicitly clearing taxes_id on
the product - school fees are correctly untaxed (education services
are GST-exempt in India), not just conveniently untaxed for the test.

Testing this module's access rules surfaced two real bugs in the
security model, not just test bugs, fixed here:
  - group_school_staff (from O1) never implied base.group_user, so
    any real user holding only this app's custom groups lacked
    ordinary internal-user access to core models like res.company -
    caught directly via a test user unable to even create an
    mc.fee.structure (whose company_id defaults through
    self.env.company).
  - mc.fee.concession reveals sensitive per-student financial data
    (e.g. a need-based hardship discount and its reason). Staff had
    read access, and since group_teacher implies group_school_staff,
    teachers inherited it too - exposing family financial
    circumstances to a role with no legitimate need for it. Removed
    the staff access row; only Administrator/Accountant keep it now.
    Fee *structure* (per-program pricing, not sensitive) correctly
    stays staff/teacher-readable.

CLAUDE.md gets one more Odoo 19 API correction:
res.users.groups_id -> group_ids.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 12:34:26 -04:00

14 KiB
Raw Permalink Blame History

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.
  • Correction (found building O1, verified against a real odoo:19.0 container — an 18-era mental model gets both of these wrong):
    • _sql_constraints = [(name, sql, message), ...] is gone. Declare each constraint as its own class attribute instead: _my_constraint = models.Constraint(sql, message). The rule below ("prefer a database constraint when the database can express it") still holds — only the syntax changed.
    • res.groups.category_id is gone. A group now points at a res.groups.privilege record via privilege_id, and the privilege carries category_id (still an ir.module.category). Create one res.groups.privilege per module category and point every group at it. See addons/mc_education_base/security/mc_education_security.xml for the pattern.
    • Correction (found building O3): res.users.groups_id is gone too — the field is now group_ids. Same [(6, 0, [group_ids...])] Many2many-write syntax otherwise. Matters for every test that creates a user in a specific group (see addons/mc_education_fees/tests/test_access.py), and will matter again for O7's portal users.
  • _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 a database constraint (models.Constraint, see above) over @api.constrains when the database can express it. The "one active enrollment per student per year" rule is a database 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:

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.attendancestudent_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.slotbatch_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.