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

147 lines
10 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Hand-written to match the DOM structure/classes of Odoo core's own
s_website_form snippet (website/views/snippets/s_website_form.xml)
exactly, so the stock frontend widget (form.js, loaded by every
website page via website.layout) picks it up automatically - no
custom JS, no custom submit controller. Posting still goes through
Odoo's generic /website/form/<model> controller; see
data/mc_applicant_website_form_data.xml for the field whitelist
that is the actual security boundary on that path.
-->
<template id="admission_apply_page" name="Admission Application">
<t t-call="website.layout">
<div id="wrap">
<section class="s_website_form pt32 pb32" data-snippet="s_website_form">
<div class="container-fluid">
<h1 class="mb-4">Apply for Admission</h1>
<form action="/website/form/" method="post" enctype="multipart/form-data"
class="o_mark_required" data-mark="*"
data-model_name="mc.applicant"
data-success-mode="message">
<div class="s_website_form_rows row s_col_no_bgcolor">
<div data-name="Field" class="s_website_form_field mb-3 col-12 s_website_form_required" data-type="char">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-3 s_website_form_label" for="admission_name">
<span class="s_website_form_label_content">Student Name</span>
<span class="s_website_form_mark"> *</span>
</label>
<div class="col-sm-9">
<input class="form-control s_website_form_input" type="text"
name="name" id="admission_name" required="1"/>
</div>
</div>
</div>
<div data-name="Field" class="s_website_form_field mb-3 col-12" data-type="date">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-3 s_website_form_label" for="admission_dob">
<span class="s_website_form_label_content">Date of Birth</span>
</label>
<div class="col-sm-9">
<input class="form-control s_website_form_input" type="date"
name="dob" id="admission_dob"/>
</div>
</div>
</div>
<div data-name="Field" class="s_website_form_field mb-3 col-12 s_website_form_required" data-type="many2one">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-3 s_website_form_label" for="admission_program_id">
<span class="s_website_form_label_content">Grade Applied For</span>
<span class="s_website_form_mark"> *</span>
</label>
<div class="col-sm-9">
<select class="form-select s_website_form_input"
name="program_id" id="admission_program_id" required="1">
<option value="">Select a grade...</option>
<!-- Program display labels are public information (a
school's grade list is meant to be seen by anyone
filling this form) - sudo() only reads name/id for
this dropdown, nothing is written here. -->
<t t-foreach="request.env['mc.program'].sudo().search([], order='sequence_no')" t-as="program">
<option t-att-value="program.id" t-esc="program.display_label"/>
</t>
</select>
</div>
</div>
</div>
<div data-name="Field" class="s_website_form_field mb-3 col-12 s_website_form_required" data-type="char">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-3 s_website_form_label" for="admission_guardian_name">
<span class="s_website_form_label_content">Parent / Guardian Name</span>
<span class="s_website_form_mark"> *</span>
</label>
<div class="col-sm-9">
<input class="form-control s_website_form_input" type="text"
name="guardian_name" id="admission_guardian_name" required="1"/>
</div>
</div>
</div>
<div data-name="Field" class="s_website_form_field mb-3 col-12 s_website_form_required" data-type="tel">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-3 s_website_form_label" for="admission_guardian_phone">
<span class="s_website_form_label_content">Parent / Guardian Phone</span>
<span class="s_website_form_mark"> *</span>
</label>
<div class="col-sm-9">
<input class="form-control s_website_form_input" type="tel"
name="guardian_phone" id="admission_guardian_phone" required="1"/>
</div>
</div>
</div>
<div data-name="Field" class="s_website_form_field mb-3 col-12 s_website_form_required" data-type="email">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-3 s_website_form_label" for="admission_guardian_email">
<span class="s_website_form_label_content">Parent / Guardian Email</span>
<span class="s_website_form_mark"> *</span>
</label>
<div class="col-sm-9">
<input class="form-control s_website_form_input" type="email"
name="guardian_email" id="admission_guardian_email" required="1"/>
</div>
</div>
</div>
<div data-name="Field" class="s_website_form_field mb-3 col-12" data-type="binary">
<div class="row s_col_no_resize s_col_no_bgcolor">
<label class="col-form-label col-sm-3 s_website_form_label" for="admission_birth_certificate">
<span class="s_website_form_label_content">Birth Certificate</span>
</label>
<div class="col-sm-9">
<!-- "birth_certificate" matches no real field on mc.applicant,
so Odoo's generic form controller stores it as a plain
ir.attachment linked to the new record - exactly the
"file uploads to ir.attachment" the spec asks for,
with no dedicated Binary field needed on the model. -->
<input class="form-control s_website_form_input" type="file"
name="birth_certificate" id="admission_birth_certificate"/>
</div>
</div>
</div>
<div class="mb-0 py-2 col-12 s_website_form_submit text-end s_website_form_no_submit_label" data-name="Submit Button">
<div class="s_website_form_label"/>
<span id="s_website_form_result"/>
<a href="#" role="button" class="btn btn-primary s_website_form_send">Submit Application</a>
</div>
</div>
</form>
</div>
</section>
</div>
</t>
</template>
<record id="website_page_admission_apply" model="website.page">
<field name="url">/admissions/apply</field>
<field name="view_id" ref="admission_apply_page"/>
<field name="is_published">True</field>
</record>
</odoo>