diff --git a/addons/mc_education_admission/__init__.py b/addons/mc_education_admission/__init__.py new file mode 100644 index 0000000..4ba7940 --- /dev/null +++ b/addons/mc_education_admission/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizards +from . import controllers diff --git a/addons/mc_education_admission/__manifest__.py b/addons/mc_education_admission/__manifest__.py new file mode 100644 index 0000000..3fc11d2 --- /dev/null +++ b/addons/mc_education_admission/__manifest__.py @@ -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, +} diff --git a/addons/mc_education_admission/controllers/__init__.py b/addons/mc_education_admission/controllers/__init__.py new file mode 100644 index 0000000..9a89f51 --- /dev/null +++ b/addons/mc_education_admission/controllers/__init__.py @@ -0,0 +1,4 @@ +# No custom controller here on purpose: the public admission form uses +# Odoo's stock /website/form/ 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. diff --git a/addons/mc_education_admission/data/mc_applicant_sequence_data.xml b/addons/mc_education_admission/data/mc_applicant_sequence_data.xml new file mode 100644 index 0000000..edfc3e9 --- /dev/null +++ b/addons/mc_education_admission/data/mc_applicant_sequence_data.xml @@ -0,0 +1,10 @@ + + + + Applicant Application Number + mc.applicant.application_no + APP%(year)s + 4 + + + diff --git a/addons/mc_education_admission/data/mc_applicant_website_form_data.xml b/addons/mc_education_admission/data/mc_applicant_website_form_data.xml new file mode 100644 index 0000000..f37f99b --- /dev/null +++ b/addons/mc_education_admission/data/mc_applicant_website_form_data.xml @@ -0,0 +1,33 @@ + + + + + apply_admission + True + Apply for Admission + + + mc.applicant + + + diff --git a/addons/mc_education_admission/demo/mc_applicant_demo.xml b/addons/mc_education_admission/demo/mc_applicant_demo.xml new file mode 100644 index 0000000..4dcc0ce --- /dev/null +++ b/addons/mc_education_admission/demo/mc_applicant_demo.xml @@ -0,0 +1,37 @@ + + + + + Kavya Reddy + 2018-03-14 + + Suresh Reddy + father + +91 98450 11223 + suresh.reddy@example.com + document_verification + + + Ishaan Malhotra + 2015-07-22 + + Priya Malhotra + mother + +91 99000 44556 + priya.malhotra@example.com + interview + + + Emma Tremblay + 2018-11-02 + + Marc Tremblay + father + +1 519-555-0148 + marc.tremblay@example.com + offered + + diff --git a/addons/mc_education_admission/models/__init__.py b/addons/mc_education_admission/models/__init__.py new file mode 100644 index 0000000..9657b61 --- /dev/null +++ b/addons/mc_education_admission/models/__init__.py @@ -0,0 +1,2 @@ +from . import mc_applicant +from . import mc_student diff --git a/addons/mc_education_admission/models/mc_applicant.py b/addons/mc_education_admission/models/mc_applicant.py new file mode 100644 index 0000000..db09856 --- /dev/null +++ b/addons/mc_education_admission/models/mc_applicant.py @@ -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, + }, + } diff --git a/addons/mc_education_admission/models/mc_student.py b/addons/mc_education_admission/models/mc_student.py new file mode 100644 index 0000000..550d35d --- /dev/null +++ b/addons/mc_education_admission/models/mc_student.py @@ -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.", + ) diff --git a/addons/mc_education_admission/security/ir.model.access.csv b/addons/mc_education_admission/security/ir.model.access.csv new file mode 100644 index 0000000..ed0f8b7 --- /dev/null +++ b/addons/mc_education_admission/security/ir.model.access.csv @@ -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 diff --git a/addons/mc_education_admission/views/mc_applicant_convert_wizard_views.xml b/addons/mc_education_admission/views/mc_applicant_convert_wizard_views.xml new file mode 100644 index 0000000..9dc1d02 --- /dev/null +++ b/addons/mc_education_admission/views/mc_applicant_convert_wizard_views.xml @@ -0,0 +1,22 @@ + + + + mc.applicant.convert.wizard.form + mc.applicant.convert.wizard + +
+ + + + + + + +
+
+
+
+
+
diff --git a/addons/mc_education_admission/views/mc_applicant_views.xml b/addons/mc_education_admission/views/mc_applicant_views.xml new file mode 100644 index 0000000..816d4a6 --- /dev/null +++ b/addons/mc_education_admission/views/mc_applicant_views.xml @@ -0,0 +1,100 @@ + + + + mc.applicant.kanban + mc.applicant + + + + + + + + + +
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + mc.applicant.list + mc.applicant + + + + + + + + + + + + + mc.applicant.form + mc.applicant + +
+
+
+ +
+
+ + + + + + + + + + + + + + +
+ + +
+
+ + + Applicants + mc.applicant + kanban,list,form + + + +
diff --git a/addons/mc_education_admission/views/website_admission_menu.xml b/addons/mc_education_admission/views/website_admission_menu.xml new file mode 100644 index 0000000..8cb7fe0 --- /dev/null +++ b/addons/mc_education_admission/views/website_admission_menu.xml @@ -0,0 +1,9 @@ + + + + Admissions + /admissions/apply + + 40 + + diff --git a/addons/mc_education_admission/views/website_admission_templates.xml b/addons/mc_education_admission/views/website_admission_templates.xml new file mode 100644 index 0000000..9534405 --- /dev/null +++ b/addons/mc_education_admission/views/website_admission_templates.xml @@ -0,0 +1,146 @@ + + + + + + + /admissions/apply + + True + + diff --git a/addons/mc_education_admission/wizards/__init__.py b/addons/mc_education_admission/wizards/__init__.py new file mode 100644 index 0000000..67f6d0a --- /dev/null +++ b/addons/mc_education_admission/wizards/__init__.py @@ -0,0 +1 @@ +from . import mc_applicant_convert_wizard diff --git a/addons/mc_education_admission/wizards/mc_applicant_convert_wizard.py b/addons/mc_education_admission/wizards/mc_applicant_convert_wizard.py new file mode 100644 index 0000000..373aae6 --- /dev/null +++ b/addons/mc_education_admission/wizards/mc_applicant_convert_wizard.py @@ -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", + }