12 Commits

Author SHA1 Message Date
metatroncubeswdev
8c8c79aa64 O8: mc_education_lms - thin glue linking a batch to a course channel
Serves Demo Scene 8. Per spec this is glue only, not a custom LMS:
mc.batch gets one new field (channel_id -> slide.channel), and
mc.enrollment's create()/write() calls the stock
slide.channel._action_add_members() when an enrollment becomes active
for a batch that has a channel - that's the entire feature. 40 lines
of model code, well under the spec's own "~200 lines or something has
gone wrong" ceiling.

Demo data matches Scene 8's script exactly - "Mathematics - Algebra
Basics": a video lesson, a PDF handout, a 5-question quiz (verified:
each question has exactly one correct and one incorrect answer, the
minimum website_slides itself requires).

This module took far longer to get right than its size suggests, and
the reason is worth recording. A ForeignKeyViolation on an unrelated
model (mc.batch referencing a channel Postgres said was never
inserted, despite that channel being created earlier in the same
file) sent the investigation looking for an install-time flush-
ordering bug for a long time - checking whether attachment=True
binary fields interact badly with a pending FK write in the same
flush batch, splitting the demo data across multiple files, even
routing the batch-channel link through a post_init_hook to sidestep
it. All of that was chasing a symptom. Bisecting the actual XML down
to a single record eventually surfaced the real, simple cause:
type="base64" on an XML <field> is only valid paired with a file=
attribute pointing to a real file in the addon - inline base64 text
raises a ValueError that Odoo's demo-data loader catches and
downgrades to "installed without demo data", and in an earlier
configuration (batch-link in the same file) that same swallowed
error surfaced instead as the confusing FK violation. Fixed by saving
the handout as a real file (static/demo/algebra_handout.pdf) and
referencing it properly; the post_init_hook and file-splitting were
reverted since the real fix needed neither. Verified byte-for-byte:
the attachment Odoo stores is exactly 604 bytes, detected as
application/pdf, matching the source file's md5sum.

Also caught before it reached git: line-ending conversion on this
binary PDF ("LF will be replaced by CRLF"), which would have silently
corrupted it on checkout for any contributor with Windows's
core.autocrlf on. Added *.pdf (and common image types) as binary to
.gitattributes and confirmed the staged blob's md5sum matches the
source file exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:01:06 -04:00
metatroncubeswdev
151bc32747 O7: mc_education_portal - parent/student portal, the live refused-access demo
Serves Demo Scene 5 in full, including the specific requirement to
demonstrate a refused access attempt live on the call. Every route
that takes a student id resolves it through _get_authorized_student(),
which does no manual comparison of its own - it relies on mc.student's
own record rules (a real mc.student.guardian link for guardians,
partner_id for students, never group membership alone, per CLAUDE.md
sec 3's "Guardian access is by relationship, not by role") to filter
a plain search(), and turns an empty result into an explicit
AccessError. The child switcher - "the single most convincing single
feature in the demo" per the spec - is just that same search with no
id filter, returning exactly the caller's own children.

Verified with real HTTP requests (HttpCase), not with_user()
shortcuts alone: authenticated as a real guardian, opened their own
child's dashboard and all four sub-pages (fees/attendance/timetable/
results) and got 200 on every one, then requested another family's
child by id and got 403 with the exact refusal message, on every one
of those same five routes. That is "editing the student identifier in
the URL and showing that it is refused" performed for real inside the
test, not asserted from a domain expression.

That same real-request testing earned its keep twice over, catching
two gaps a with_user()-only test would have missed entirely:
  - mc.batch had never been portal-reachable before this module and
    had no access row at all for guardian/student - a legitimate
    own-child dashboard load failed with a raw ACL error, not even a
    record-rule denial. Added mc.batch rules scoped the same way as
    everything else (via mc.batch.enrollment_ids, a new reverse field
    this module adds).
  - Rendering the timetable page touches mc.subject, mc.room, mc.exam,
    mc.academic.year/term and hr.employee through related-field reads,
    each of which triggers its own ACL check independent of whatever
    rule scopes mc.timetable.slot itself. Granted broad (unrestricted)
    guardian/student read on the non-sensitive reference models
    (subject/room/exam/year/term names - catalog data, not per-student
    data, same reasoning already applied to Teacher/Staff/Accountant
    in earlier modules); used sudo() instead for hr.employee
    specifically, since employee records carry real HR data no
    blanket portal grant should touch, with a comment noting the
    student was already authorized by that point in the request.

Fees needs no new rule at all: mc_education_fees deliberately bills
the primary guardian's own partner_id (a design decision made back in
O3, for exactly this reason), so stock account+portal's own
partner_id-scoped visibility already covers it, and group_guardian/
group_student already imply base.group_portal from O1.

mc.notice is a new model - shared/DOMAIN_MODEL.md lists it as an
entity Track O needs but no module spec in CLAUDE.md sec 5 ever gave
it a field table; added here since it is specifically an O7 view
requirement, scoped by the same batch-or-school-wide pattern as
everything else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:32:38 -04:00
metatroncubeswdev
c77fb3d0e5 O6: mc_education_exam - grading scales as data, grid entry, report card PDF
Serves Demo Scene 6. mc.grading.scale + mc.grading.interval are pure
data (threshold/letter/point/description rows) - CBSE, ICSE, IB,
Cambridge, Ontario and a plain percentage scale all shipped as data
in data/mc_grading_scale_data.xml, noupdate="1" so a school's own
edits survive a module upgrade. mc.mark.grade resolves via
mc.program.grading_scale_id (new field on mc.program, since grading
board is a per-program concept already carrying "board" from O1) -
the moment this needed an `if board == "CBSE"` anywhere, per
shared/DOMAIN_MODEL.md sec 5, it would have been the wrong design;
verified instead that swapping a program's scale recomputes an
existing mark's grade with zero code involved
(test_changing_scale_recomputes_existing_marks).

Grid mark entry ("whole class on one screen, tab between fields,
keyboard only") is a plain editable list on mc.mark, filtered to one
exam - that native Odoo behavior already gives real Tab-key
navigation with no custom widget. mc.exam.action_enter_marks()
pre-creates a row per actively-enrolled student before opening it
(mirroring O4's bulk-attendance idempotency: creating it again never
duplicates or resets an already-entered mark).

The report card is a real rendered PDF, not just a template read by
inspection - rendered it for Aditya via odoo shell
(report._render_qweb_pdf), pulled the bytes out of the container, and
read the actual PDF: one A4 page, no clipped columns, correct grades
per subject matching the CBSE scale (92->A1, 85->A2, 78->B1, 67->B2,
58->C1), signature block, term correctly resolved. That last part
exposed a real logic bug before it shipped: the term-resolution
method originally preferred "today's date" over "the term with
marks", which would have shown an empty Term 1 when generating the
card *today* even though the demo's graded marks are all in Term 2 -
a report card is generated to review a term's results, typically
after that term's exams are done (often during the *next* term), so
marks-with-data now wins over the calendar, falling back to today's
date only for a student with nothing graded yet.

Attendance summary on the report degrades to None when
mc_education_attendance isn't installed (checked directly, not
assumed) - this module depends on mc_education_base only, matching
"each mc_education_* module installs independently" (CLAUDE.md sec
1.4), while still showing the real summary when both are installed
together for the actual demo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:16:49 -04:00
metatroncubeswdev
a36c89222b O5: mc_education_timetable - one dataset, three views, conflict-checked
Serves Demo Scene 7: mc.timetable.slot (batch, weekday, period,
subject, teacher, room, year, term) is the single dataset. "Three
read views" are three entry points into that same model rather than
three separate data structures - a stat button on mc.batch, on
mc.teacher, and on mc.student (resolved through the student's active
enrollment to their batch) each open the same list/search action with
a different domain. Auto-generation is out of scope per spec; this
module only configures slots by hand (demo data is a real Mon-Fri
week for Grade 8-A, since a timetable is a recurring weekly pattern
and one week fully represents it, unlike attendance/fees which
genuinely need a run of history).

Conflict prevention ("a teacher or a room cannot hold two slots in
the same weekday+period") follows the same two-layer pattern used for
mc.academic.year.is_current and mc.enrollment in O1: a partial unique
index per conflict type (teacher, room, and - not explicitly asked
for but an obvious extension of the same rule - batch, since a batch
can't be in two places at once either) is the actual guarantee, and a
pre-check in create()/write() raises a readable ValidationError
before the insert/update, not after, for the same reason established
building mc.enrollment: the DB index fires first and the friendly
message is unreachable otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 12:53:47 -04:00
metatroncubeswdev
e5aba291f1 O4: mc_education_attendance - mobile-first bulk marking, own batches only
Serves Demo Scene 4: mc.attendance (student x date x session, unique
constraint so resubmission can never duplicate) plus a bulk-marking
wizard - pick a batch/date/session, the roster loads pre-filled
Present (or whatever was already recorded, if reopening), tap to
change an exception, Submit. Submitting again for the same
batch/date/session updates the same rows rather than creating
duplicates - verified with a real test that marks a batch present,
then reopens and corrects one student, then asserts there are still
exactly two rows, not three.

The "teachers may only mark their own batches" rule is a record rule
(ir.rule scoped to group_teacher via batch_id.class_teacher_id.
user_id), not a UI check, per CLAUDE.md sec 3 and the O4 spec line
verbatim. Verified for real, not just declared: a teacher user who
is not the class teacher of a batch gets AccessError on create *and*
on reading an existing attendance row by id directly (the actual
"cannot open another teacher's batch by editing the URL" scenario),
while Administrator remains unrestricted since the rule's `groups`
field scopes it to teacher only.

Caught two Odoo 19 search-view schema changes while installing
against a live odoo:19.0 container - a plain read of the view XML
wouldn't have caught these, only trying to actually load it did:
neither the group-by `<group>` element nor the filter groups inside
a `<search>` view accept a `string` or `expand` attribute anymore
(confirmed against hr's own search views, which use bare `<group>`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 12:49:04 -04:00
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
metatroncubeswdev
508bf27417 O2: mc_education_admission - public form through to enrolled student
Serves Demo Scene 2 end to end: mc.applicant with the stage pipeline
(Applied -> Document Verification -> Interview -> Offered -> Accepted
-> Enrolled/Rejected) on mail.thread, a public admission page on the
website, and a convert wizard that turns an accepted applicant into a
real mc.student + mc.enrollment with zero re-typing.

The public form uses Odoo's stock /website/form/<model> mechanism,
not a custom controller (CLAUDE.md sec 1.3 - writing a custom version
of stock infrastructure is a bug). Verified the real mechanism against
core source first rather than assuming: website_hr_recruitment's own
data/config_data.xml is the template this follows (ir.model.
website_form_access + ir.model.fields.formbuilder_whitelist()).

This is the module's actual security boundary, and it's worth being
explicit about why it holds. The generic controller creates the record
as SUPERUSER - normal ir.model.access rows do not apply to it at all.
The only thing stopping a submitter from setting state, student_id,
application_no or company_id is that those fields are not in the
formbuilder_whitelist() call in data/mc_applicant_website_form_data.xml
(every field defaults to website_form_blacklisted=True and stays that
way unless explicitly opted in). Confirmed this isn't just theoretical:
posted state=enrolled and application_no=HACKED-0001 directly at
/website/form/mc.applicant on a live instance, and the resulting
record came back with the model's own default state=applied and a
server-generated APP20260004 - the injected values were silently
dropped, exactly as the whitelist should do. Also exercised a real
file upload (birth certificate) and the full convert-to-student path
(guardian dedup by email, application_no -> student.application_no,
enrollment, attachment reparenting) via odoo shell against the live
container, not just read by inspection.

mc.student gets a new application_no field (_inherit from this
module, not O1 - it only makes sense where admission is installed)
so "the application number persists on the student" is a stored fact,
not just a claim in the demo script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:55:48 -04:00
metatroncubeswdev
4a2e7b7016 fix: admin group membership on install, and db_name breaking the db list
- group_school_administrator now includes base.user_admin, matching
  core Odoo's own convention (see hr.group_hr_manager) - otherwise
  every fresh install requires a manual trip to Settings > Users just
  to see this module's own menus.
- odoo.conf: drop db_name. Found by hand while testing: when db_name
  is set and dbfilter is not, Odoo's list_dbs() returns db_name's
  value verbatim instead of querying postgres, so the database
  selector shows only that one database no matter how many actually
  exist. Silently breaks O9's "swap to the second demo school in
  under five minutes" requirement, which depends on switching between
  multiple real databases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:25:37 -04:00
metatroncubeswdev
709bd2eca7 O1: complete the gate - program, subject, room, batch, people, enrollment
Rounds out mc_education_base with the remaining O1 models: mc.program,
mc.subject, mc.room, mc.batch, mc.teacher, mc.student, mc.guardian,
mc.student.guardian, and mc.enrollment - the spine everything else in
the O1 table (attendance, timetable, exam, portal) is built against.

Two rules get the same "DB constraint is the real guarantee, the ORM
does a friendly pre-check" treatment as academic.year.is_current:

  - At most one primary guardian per student (mc.student.guardian):
    partial unique index on student_id WHERE is_primary, plus a
    create/write toggle.
  - At most one Active enrollment per student per year
    (mc.enrollment): partial unique index on (student_id, year_id)
    WHERE state='active'. This one is CLAUDE.md's flagship rule
    ("Enforce as a database constraint, not application logic").

Two more real bugs surfaced by testing against a live odoo:19.0
container rather than trusting the code by inspection:

  - The partial unique index fires at INSERT/UPDATE time, before
    @api.constrains ever runs - so a naive "index + constrains for a
    friendly message" design never reaches the friendly message, the
    raw IntegrityError wins the race. Fixed by pre-checking for a
    conflict in create()/write() before calling super(), with
    @api.constrains kept only as a backstop for batch creates.
  - create() issues a direct SQL INSERT that does not wait for
    unrelated pending writes in the ORM cache (e.g. withdrawing one
    enrollment right before creating its replacement, in the same
    method) - needs an explicit self.env.flush_all() first, same
    lesson as the is_current toggle.

Also caught before it became a permanent test flake: the enrollment
and academic-calendar tests originally hardcoded the same year names
("2025-26", "2026-27") and program code ("G8") as the demo data.
Passed in isolation, failed as soon as demo data was loaded first -
so verification here included a combined
`--without-demo=False --test-enable` run, matching what CI actually
does, not just an isolated test-tagged run. Renamed to TEST-prefixed
fixtures.

Security access rows added for all new models across the four
internal groups (Administrator: full CRUD everywhere; Staff: full
CRUD on the people/enrollment models that are front office's daily
job, read-only on academic structure; Teacher/Accountant: read-only
across the board, narrower record rules land with the modules that
need them - attendance, exam, portal). No portal-group access yet;
that is O7's job once explicit ownership-scoped record rules exist -
granting it now without those rules would be exactly the "identifier
supplied by the client" hole CLAUDE.md's standing security rule
warns about.

Demo data populates the shared/DEMO_SCRIPT.md cast: Meera Krishnan as
primary guardian of both Aditya (Grade 8-A) and Ananya (Grade 5-B) -
the multi-child guardian view the script calls "the single most
convincing portal feature" - plus Arun Prakash as Grade 8-A's class
teacher. Verified by querying the resulting database directly, not
just by the install succeeding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 09:58:36 -04:00
metatroncubeswdev
1e5ac14510 CLAUDE.md: correct two Odoo 19 API claims found building O1
Coding standards said "No APIs deprecated in 17 or 18" as if that
were sufficient - it isn't, because it's phrased as a non-regression
check against an assumed-correct baseline, and the baseline itself
(an 18-era mental model of _sql_constraints and res.groups) was
already wrong for 19. Verified against a real odoo:19.0 container
while building mc_education_base: _sql_constraints is replaced by
per-attribute models.Constraint(sql, message), and res.groups lost
category_id in favor of a new res.groups.privilege record referenced
via privilege_id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:53:52 -04:00
metatroncubeswdev
8b7bd91f74 O1: mc_education_base gate, first slice - academic calendar
mc.academic.year and mc.academic.term: the two models every other O1
model (program, batch, enrollment...) will hang off. Exactly one
current year is enforced two ways - the ORM toggles is_current off
the previous year on create/write, and a partial unique index on
(company_id) WHERE is_current backs it at the database level so the
rule holds even if something writes around the ORM.

Security groups for all six roles from the spec (Administrator,
Staff, Teacher, Accountant, Guardian, Student) are scaffolded now
since every later O1 model needs them, though only Administrator/
Staff have access rows on these two models so far.

Verified against a real odoo:19.0 container, not just read: module
installs clean with views, menus and demo data, and all 7 test
methods pass. That surfaced two things CLAUDE.md's Coding Standards
section didn't anticipate, since Odoo 19 moved past 17/18-era APIs
in ways not caught by an 18-era mental model:

  - `_sql_constraints` is gone; constraints are now per-attribute
    `models.Constraint(sql, message)`.
  - `res.groups.category_id` is gone; groups now hang off a new
    `res.groups.privilege` record, which carries the category.

Both addons/mc_education_base files already use the new APIs.
CLAUDE.md itself needs a note added in a follow-up so this isn't
rediscovered per-module - flagging here per its own closing
instruction ("say so and propose the correction... update this file
in the same PR") rather than leaving it implicit in this commit body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:53:34 -04:00
metatroncubeswdev
85f2b4b11a 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>
2026-09-11 06:45:58 -04:00