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>
99 lines
3.4 KiB
Python
99 lines
3.4 KiB
Python
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,
|
|
},
|
|
}
|