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>
This commit is contained in:
metatroncubeswdev 2026-09-11 11:55:48 -04:00
parent 4a2e7b7016
commit 508bf27417
16 changed files with 596 additions and 0 deletions

View File

@ -0,0 +1,3 @@
from . import models
from . import wizards
from . import controllers

View File

@ -0,0 +1,23 @@
{
"name": "School ERP - Admission",
"version": "19.0.1.0.0",
"category": "Education",
"summary": "Public admission form through to an enrolled student, zero re-typing.",
"author": "Metatroncube Software Solutions LLP",
"license": "Other proprietary",
"depends": ["mc_education_base", "website"],
"data": [
"security/ir.model.access.csv",
"data/mc_applicant_sequence_data.xml",
"data/mc_applicant_website_form_data.xml",
"views/mc_applicant_views.xml",
"views/mc_applicant_convert_wizard_views.xml",
"views/website_admission_templates.xml",
"views/website_admission_menu.xml",
],
"demo": [
"demo/mc_applicant_demo.xml",
],
"installable": True,
"application": False,
}

View File

@ -0,0 +1,4 @@
# No custom controller here on purpose: the public admission form uses
# Odoo's stock /website/form/<model> mechanism (see
# data/mc_applicant_website_form_data.xml), not a reimplementation of it.
# CLAUDE.md sec 1.3: writing a custom version of stock infrastructure is a bug.

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="seq_mc_applicant_application_no" model="ir.sequence">
<field name="name">Applicant Application Number</field>
<field name="code">mc.applicant.application_no</field>
<field name="prefix">APP%(year)s</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Security-critical: this is the ONLY thing that decides which
mc.applicant fields a fully anonymous website visitor can set.
Odoo's generic /website/form/<model> controller creates the record
as SUPERUSER, so field-level access rights do not apply here at
all - the sole guard is website_form_blacklisted (default True on
every field of every model since Odoo 15) plus website_form_access
on the model itself. Never add application_no, state, student_id
or company_id to this list: those must only ever be set by
server-side code (see models/mc_applicant.py, wizards/).
Pattern copied verbatim from Odoo core's own
website_hr_recruitment/data/config_data.xml.
-->
<record id="model_mc_applicant" model="ir.model">
<field name="website_form_key">apply_admission</field>
<field name="website_form_access">True</field>
<field name="website_form_label">Apply for Admission</field>
</record>
<function model="ir.model.fields" name="formbuilder_whitelist">
<value>mc.applicant</value>
<value eval="[
'name',
'dob',
'program_id',
'guardian_name',
'guardian_relationship',
'guardian_phone',
'guardian_email',
]"/>
</function>
</odoo>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Populates the pipeline kanban so it reads as a real pipeline, not
an empty board, without pre-empting the live Scene 2 walkthrough
(Applied -> ... -> Enroll), which starts from a fresh public-form
submission during the demo itself. -->
<record id="demo_applicant_kavya_reddy" model="mc.applicant">
<field name="name">Kavya Reddy</field>
<field name="dob">2018-03-14</field>
<field name="program_id" ref="mc_education_base.demo_program_grade5"/>
<field name="guardian_name">Suresh Reddy</field>
<field name="guardian_relationship">father</field>
<field name="guardian_phone">+91 98450 11223</field>
<field name="guardian_email">suresh.reddy@example.com</field>
<field name="state">document_verification</field>
</record>
<record id="demo_applicant_ishaan_malhotra" model="mc.applicant">
<field name="name">Ishaan Malhotra</field>
<field name="dob">2015-07-22</field>
<field name="program_id" ref="mc_education_base.demo_program_grade8"/>
<field name="guardian_name">Priya Malhotra</field>
<field name="guardian_relationship">mother</field>
<field name="guardian_phone">+91 99000 44556</field>
<field name="guardian_email">priya.malhotra@example.com</field>
<field name="state">interview</field>
</record>
<record id="demo_applicant_emma_tremblay" model="mc.applicant">
<field name="name">Emma Tremblay</field>
<field name="dob">2018-11-02</field>
<field name="program_id" ref="mc_education_base.demo_program_grade5"/>
<field name="guardian_name">Marc Tremblay</field>
<field name="guardian_relationship">father</field>
<field name="guardian_phone">+1 519-555-0148</field>
<field name="guardian_email">marc.tremblay@example.com</field>
<field name="state">offered</field>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
from . import mc_applicant
from . import mc_student

View File

@ -0,0 +1,98 @@
from odoo import api, fields, models
class McApplicant(models.Model):
_name = "mc.applicant"
_inherit = ["mail.thread"]
_description = "Applicant"
_order = "create_date desc"
_rec_name = "name"
name = fields.Char(string="Applicant Name", required=True, tracking=True)
dob = fields.Date(string="Date of Birth")
program_id = fields.Many2one(
"mc.program", string="Grade Applied For", required=True, tracking=True,
)
guardian_name = fields.Char(string="Guardian Name", required=True)
guardian_relationship = fields.Selection(
[
("father", "Father"),
("mother", "Mother"),
("legal_guardian", "Legal Guardian"),
("other", "Other"),
],
string="Guardian Relationship", default="legal_guardian",
)
guardian_phone = fields.Char(string="Guardian Phone")
guardian_email = fields.Char(string="Guardian Email", required=True)
application_no = fields.Char(string="Application No.", copy=False, tracking=True)
state = fields.Selection(
[
("applied", "Applied"),
("document_verification", "Document Verification"),
("interview", "Interview"),
("offered", "Offered"),
("accepted", "Accepted"),
("enrolled", "Enrolled"),
("rejected", "Rejected"),
],
string="Stage", default="applied", required=True, tracking=True, group_expand="_read_group_state",
)
student_id = fields.Many2one(
"mc.student", string="Student", readonly=True, copy=False,
help="Set once this applicant has been converted. The application number persists onto the student record.",
)
company_id = fields.Many2one(
"res.company", string="Company", required=True,
default=lambda self: self.env.company,
)
_application_no_uniq = models.Constraint(
"unique(application_no)",
"An applicant with this application number already exists.",
)
@api.model
def _read_group_state(self, states, domain):
# Keeps every kanban column visible even when empty, so the pipeline
# always reads as a pipeline rather than losing stages that have
# nothing in them right now.
return [key for key, _label in self._fields["state"].selection]
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get("application_no"):
vals["application_no"] = self.env["ir.sequence"].next_by_code(
"mc.applicant.application_no"
)
return super().create(vals_list)
def action_set_document_verification(self):
self.write({"state": "document_verification"})
def action_set_interview(self):
self.write({"state": "interview"})
def action_set_offered(self):
self.write({"state": "offered"})
def action_set_accepted(self):
self.write({"state": "accepted"})
def action_reject(self):
self.write({"state": "rejected"})
def action_open_convert_wizard(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"res_model": "mc.applicant.convert.wizard",
"view_mode": "form",
"target": "new",
"context": {
"default_applicant_id": self.id,
"default_program_id": self.program_id.id,
},
}

View File

@ -0,0 +1,11 @@
from odoo import fields, models
class McStudent(models.Model):
_inherit = "mc.student"
application_no = fields.Char(
string="Application No.", copy=False, readonly=True,
help="The application number from the admission pipeline, preserved on "
"the student record so nothing about the original application is lost.",
)

View File

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mc_applicant_administrator,mc.applicant.administrator,model_mc_applicant,mc_education_base.group_school_administrator,1,1,1,1
access_mc_applicant_staff,mc.applicant.staff,model_mc_applicant,mc_education_base.group_school_staff,1,1,1,0
access_mc_applicant_convert_wizard_administrator,mc.applicant.convert.wizard.administrator,model_mc_applicant_convert_wizard,mc_education_base.group_school_administrator,1,1,1,1
access_mc_applicant_convert_wizard_staff,mc.applicant.convert.wizard.staff,model_mc_applicant_convert_wizard,mc_education_base.group_school_staff,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mc_applicant_administrator mc.applicant.administrator model_mc_applicant mc_education_base.group_school_administrator 1 1 1 1
3 access_mc_applicant_staff mc.applicant.staff model_mc_applicant mc_education_base.group_school_staff 1 1 1 0
4 access_mc_applicant_convert_wizard_administrator mc.applicant.convert.wizard.administrator model_mc_applicant_convert_wizard mc_education_base.group_school_administrator 1 1 1 1
5 access_mc_applicant_convert_wizard_staff mc.applicant.convert.wizard.staff model_mc_applicant_convert_wizard mc_education_base.group_school_staff 1 1 1 1

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_applicant_convert_wizard_form" model="ir.ui.view">
<field name="name">mc.applicant.convert.wizard.form</field>
<field name="model">mc.applicant.convert.wizard</field>
<field name="arch" type="xml">
<form string="Enroll Applicant">
<group>
<field name="applicant_id" invisible="1"/>
<field name="program_id"/>
<field name="year_id"/>
<field name="batch_id"/>
<field name="roll_no"/>
</group>
<footer>
<button name="action_convert" string="Enroll" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_applicant_kanban" model="ir.ui.view">
<field name="name">mc.applicant.kanban</field>
<field name="model">mc.applicant</field>
<field name="arch" type="xml">
<kanban default_group_by="state" records_draggable="1">
<field name="name"/>
<field name="program_id"/>
<field name="guardian_name"/>
<field name="application_no"/>
<field name="state"/>
<templates>
<t t-name="card">
<div class="oe_kanban_card">
<strong><field name="name"/></strong>
<div><field name="program_id"/></div>
<div class="text-muted">
<field name="guardian_name"/>
</div>
<div class="text-muted">
<field name="application_no"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_mc_applicant_list" model="ir.ui.view">
<field name="name">mc.applicant.list</field>
<field name="model">mc.applicant</field>
<field name="arch" type="xml">
<list string="Applicants">
<field name="application_no"/>
<field name="name"/>
<field name="program_id"/>
<field name="guardian_name"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<record id="view_mc_applicant_form" model="ir.ui.view">
<field name="name">mc.applicant.form</field>
<field name="model">mc.applicant</field>
<field name="arch" type="xml">
<form string="Applicant">
<header>
<button name="action_set_document_verification" string="Move to Document Verification"
type="object" class="oe_highlight" invisible="state != 'applied'"/>
<button name="action_set_interview" string="Move to Interview"
type="object" class="oe_highlight" invisible="state != 'document_verification'"/>
<button name="action_set_offered" string="Make Offer"
type="object" class="oe_highlight" invisible="state != 'interview'"/>
<button name="action_set_accepted" string="Mark Accepted"
type="object" class="oe_highlight" invisible="state != 'offered'"/>
<button name="action_open_convert_wizard" string="Enroll"
type="object" class="oe_highlight" invisible="state != 'accepted'"/>
<button name="action_reject" string="Reject"
type="object" invisible="state in ('enrolled', 'rejected')"/>
<field name="state" widget="statusbar"
statusbar_visible="applied,document_verification,interview,offered,accepted,enrolled"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="application_no" readonly="1"/>
<field name="dob"/>
<field name="program_id"/>
</group>
<group>
<field name="guardian_name"/>
<field name="guardian_relationship"/>
<field name="guardian_phone"/>
<field name="guardian_email"/>
</group>
</group>
<field name="student_id" invisible="not student_id" readonly="1"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_mc_applicant" model="ir.actions.act_window">
<field name="name">Applicants</field>
<field name="res_model">mc.applicant</field>
<field name="view_mode">kanban,list,form</field>
</record>
<menuitem id="menu_mc_applicant" name="Admissions"
parent="mc_education_base.menu_school_root"
action="action_mc_applicant" sequence="5"/>
</odoo>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="website_menu_admission_apply" model="website.menu">
<field name="name">Admissions</field>
<field name="url">/admissions/apply</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence">40</field>
</record>
</odoo>

View File

@ -0,0 +1,146 @@
<?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>

View File

@ -0,0 +1 @@
from . import mc_applicant_convert_wizard

View File

@ -0,0 +1,92 @@
from odoo import fields, models
class McApplicantConvertWizard(models.TransientModel):
_name = "mc.applicant.convert.wizard"
_description = "Convert Applicant to Student"
applicant_id = fields.Many2one(
"mc.applicant", string="Applicant", required=True, readonly=True,
)
program_id = fields.Many2one("mc.program", string="Program", required=True)
year_id = fields.Many2one(
"mc.academic.year", string="Academic Year", required=True,
default=lambda self: self.env["mc.academic.year"].search(
[("is_current", "=", True)], limit=1,
),
)
batch_id = fields.Many2one(
"mc.batch", string="Batch", required=True,
domain="[('program_id', '=', program_id), ('year_id', '=', year_id)]",
)
roll_no = fields.Char(string="Roll No.")
def _find_or_create_guardian(self, applicant):
partner = self.env["res.partner"].search(
[("email", "=", applicant.guardian_email)], limit=1,
)
if partner:
guardian = self.env["mc.guardian"].search(
[("partner_id", "=", partner.id)], limit=1,
)
if guardian:
return guardian
else:
partner = self.env["res.partner"].create({
"name": applicant.guardian_name,
"email": applicant.guardian_email,
"phone": applicant.guardian_phone,
})
return self.env["mc.guardian"].create({
"partner_id": partner.id,
"name": applicant.guardian_name,
"phone": applicant.guardian_phone,
"email": applicant.guardian_email,
})
def action_convert(self):
self.ensure_one()
applicant = self.applicant_id
guardian = self._find_or_create_guardian(applicant)
student_partner = self.env["res.partner"].create({"name": applicant.name})
student = self.env["mc.student"].create({
"partner_id": student_partner.id,
"name": applicant.name,
"dob": applicant.dob,
"admission_date": fields.Date.context_today(self),
"status": "active",
"application_no": applicant.application_no,
})
self.env["mc.student.guardian"].create({
"student_id": student.id,
"guardian_id": guardian.id,
"relationship": applicant.guardian_relationship or "other",
"is_primary": True,
})
self.env["mc.enrollment"].create({
"student_id": student.id,
"program_id": self.program_id.id,
"batch_id": self.batch_id.id,
"year_id": self.year_id.id,
"state": "active",
"roll_no": self.roll_no,
"date_enrolled": fields.Date.context_today(self),
})
# Every attachment collected during the application (birth
# certificate, etc.) moves with it - zero re-typing, zero re-upload.
attachments = self.env["ir.attachment"].search([
("res_model", "=", "mc.applicant"), ("res_id", "=", applicant.id),
])
attachments.write({"res_model": "mc.student", "res_id": student.id})
applicant.write({"student_id": student.id, "state": "enrolled"})
return {
"type": "ir.actions.act_window",
"res_model": "mc.student",
"res_id": student.id,
"view_mode": "form",
"target": "current",
}