diff --git a/addons/mc_education_exam/__init__.py b/addons/mc_education_exam/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/mc_education_exam/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/mc_education_exam/__manifest__.py b/addons/mc_education_exam/__manifest__.py new file mode 100644 index 0000000..a46d72e --- /dev/null +++ b/addons/mc_education_exam/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "School ERP - Exam", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Grading scales as data, grid mark entry, print-clean report cards.", + "author": "Metatroncube Software Solutions LLP", + "license": "Other proprietary", + "depends": ["mc_education_base"], + "data": [ + "security/ir.model.access.csv", + "data/mc_grading_scale_data.xml", + "views/mc_grading_scale_views.xml", + "views/mc_exam_views.xml", + "views/mc_mark_views.xml", + "views/mc_program_views.xml", + "views/mc_enrollment_views.xml", + "views/mc_education_exam_menus.xml", + "report/mc_report_card_report.xml", + "report/mc_report_card_templates.xml", + ], + "demo": [ + "demo/mc_exam_demo.xml", + ], + "installable": True, + "application": False, +} diff --git a/addons/mc_education_exam/data/mc_grading_scale_data.xml b/addons/mc_education_exam/data/mc_grading_scale_data.xml new file mode 100644 index 0000000..e1909a8 --- /dev/null +++ b/addons/mc_education_exam/data/mc_grading_scale_data.xml @@ -0,0 +1,257 @@ + + + + + + + CBSE + + + + 91 + A1 + 10 + Outstanding + + + + 81 + A2 + 9 + Excellent + + + + 71 + B1 + 8 + Very Good + + + + 61 + B2 + 7 + Good + + + + 51 + C1 + 6 + Fair + + + + 41 + C2 + 5 + Average + + + + 33 + D + 4 + Below Average + + + + 0 + E + 0 + Needs Improvement + + + + ICSE + + + + 90 + 1 + Outstanding + + + + 75 + 2 + Very Good + + + + 60 + 3 + Good + + + + 40 + 4 + Satisfactory + + + + 0 + 5 + Needs Improvement + + + + IB + + + + 90 + 7 + 7 + + + + 80 + 6 + 6 + + + + 70 + 5 + 5 + + + + 60 + 4 + 4 + + + + 50 + 3 + 3 + + + + 40 + 2 + 2 + + + + 0 + 1 + 1 + + + + Cambridge + + + + 90 + A* + + + + 80 + A + + + + 70 + B + + + + 60 + C + + + + 50 + D + + + + 40 + E + + + + 30 + F + + + + 0 + G + + + + Ontario + + + + 80 + A + Level 4 + + + + 70 + B + Level 3 + + + + 60 + C + Level 2 + + + + 50 + D + Level 1 + + + + 0 + R + Below Level 1 + + + + Percentage + + + + 90 + A + + + + 80 + B + + + + 70 + C + + + + 60 + D + + + + 0 + F + + + + diff --git a/addons/mc_education_exam/demo/mc_exam_demo.xml b/addons/mc_education_exam/demo/mc_exam_demo.xml new file mode 100644 index 0000000..d58ea84 --- /dev/null +++ b/addons/mc_education_exam/demo/mc_exam_demo.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + Term 2 Exam + + + + 100 + 35 + 2026-10-20 + + + Term 2 Exam + + + + 100 + 35 + 2026-10-21 + + + Term 2 Exam + + + + 100 + 35 + 2026-10-22 + + + Term 2 Exam + + + + 100 + 35 + 2026-10-23 + + + Term 2 Exam + + + + 100 + 35 + 2026-10-24 + + + + + + 92 + + + + + 85 + + + + + 78 + + + + + 67 + + + + + 58 + + + + + Term 2 Exam + + + + 100 + 35 + 2026-10-20 + + + + + 88 + + diff --git a/addons/mc_education_exam/models/__init__.py b/addons/mc_education_exam/models/__init__.py new file mode 100644 index 0000000..6446ddd --- /dev/null +++ b/addons/mc_education_exam/models/__init__.py @@ -0,0 +1,6 @@ +from . import mc_grading_scale +from . import mc_program +from . import mc_exam +from . import mc_mark +from . import mc_enrollment +from . import mc_student diff --git a/addons/mc_education_exam/models/mc_enrollment.py b/addons/mc_education_exam/models/mc_enrollment.py new file mode 100644 index 0000000..b743f39 --- /dev/null +++ b/addons/mc_education_exam/models/mc_enrollment.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class McEnrollment(models.Model): + _inherit = "mc.enrollment" + + remark = fields.Text( + string="Class Teacher Remark", + help="Printed on the student's report card for this enrollment.", + ) diff --git a/addons/mc_education_exam/models/mc_exam.py b/addons/mc_education_exam/models/mc_exam.py new file mode 100644 index 0000000..4cb1484 --- /dev/null +++ b/addons/mc_education_exam/models/mc_exam.py @@ -0,0 +1,61 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class McExam(models.Model): + _name = "mc.exam" + _inherit = ["mail.thread"] + _description = "Exam" + _order = "date desc" + _rec_name = "display_name" + + name = fields.Char(string="Name", required=True) + term_id = fields.Many2one("mc.academic.term", string="Term", required=True, ondelete="restrict") + batch_id = fields.Many2one("mc.batch", string="Batch", required=True, ondelete="restrict") + subject_id = fields.Many2one("mc.subject", string="Subject", required=True, ondelete="restrict") + max_marks = fields.Float(string="Max Marks", required=True, default=100) + pass_marks = fields.Float(string="Pass Marks", required=True, default=35) + date = fields.Date(string="Date", required=True) + mark_ids = fields.One2many("mc.mark", "exam_id", string="Marks") + + _batch_subject_term_uniq = models.Constraint( + "unique(batch_id, subject_id, term_id, name)", + "An exam with this name already exists for this batch, subject and term.", + ) + + @api.depends("name", "batch_id.name", "subject_id.name") + def _compute_display_name(self): + for exam in self: + exam.display_name = "%s - %s (%s)" % ( + exam.name, exam.batch_id.name or "?", exam.subject_id.name or "?", + ) + + @api.constrains("max_marks", "pass_marks") + def _check_marks_positive(self): + for exam in self: + if exam.max_marks <= 0: + raise ValidationError("Max marks must be greater than zero.") + if exam.pass_marks < 0 or exam.pass_marks > exam.max_marks: + raise ValidationError("Pass marks must be between 0 and max marks.") + + def action_enter_marks(self): + self.ensure_one() + enrollments = self.env["mc.enrollment"].search([ + ("batch_id", "=", self.batch_id.id), ("state", "=", "active"), + ]) + existing_student_ids = set(self.mark_ids.mapped("student_id").ids) + to_create = [ + {"exam_id": self.id, "student_id": enrollment.student_id.id, "marks_obtained": 0} + for enrollment in enrollments + if enrollment.student_id.id not in existing_student_ids + ] + if to_create: + self.env["mc.mark"].create(to_create) + return { + "type": "ir.actions.act_window", + "name": "Enter Marks - %s" % self.display_name, + "res_model": "mc.mark", + "view_mode": "list", + "domain": [("exam_id", "=", self.id)], + "context": {"default_exam_id": self.id}, + } diff --git a/addons/mc_education_exam/models/mc_grading_scale.py b/addons/mc_education_exam/models/mc_grading_scale.py new file mode 100644 index 0000000..7947013 --- /dev/null +++ b/addons/mc_education_exam/models/mc_grading_scale.py @@ -0,0 +1,53 @@ +from odoo import fields, models + + +class McGradingScale(models.Model): + _name = "mc.grading.scale" + _description = "Grading Scale" + _order = "name" + _rec_name = "name" + + name = fields.Char( + string="Name", required=True, + help="e.g. CBSE, ICSE, IB, Cambridge, Ontario, Percentage.", + ) + interval_ids = fields.One2many("mc.grading.interval", "scale_id", string="Grade Intervals") + + _name_uniq = models.Constraint( + "unique(name)", + "A grading scale with this name already exists.", + ) + + def get_grade_interval(self, percentage): + """The grade for a percentage is the interval with the highest + threshold at or below it - e.g. "90 and above = A1", "80-89 = A2". + Grading rules live here, as data on mc.grading.interval, so a + different board is configuration, never a code change (O6 spec, + shared/DOMAIN_MODEL.md sec 5 - the moment you write `if board == + "CBSE"` you have already gotten this wrong). + """ + self.ensure_one() + candidates = self.interval_ids.filtered(lambda i: percentage >= i.threshold) + return candidates.sorted("threshold", reverse=True)[:1] + + +class McGradingInterval(models.Model): + _name = "mc.grading.interval" + _description = "Grading Interval" + _order = "threshold desc" + _rec_name = "letter" + + scale_id = fields.Many2one("mc.grading.scale", string="Scale", required=True, ondelete="cascade") + threshold = fields.Float(string="Threshold %", required=True) + letter = fields.Char(string="Letter", required=True) + point = fields.Float(string="Grade Point") + description = fields.Char(string="Description") + + _scale_threshold_uniq = models.Constraint( + "unique(scale_id, threshold)", + "This scale already has an interval at this threshold.", + ) + _threshold_range = models.Constraint( + "check(threshold >= 0 and threshold <= 100)", + "A threshold must be between 0 and 100.", + ) diff --git a/addons/mc_education_exam/models/mc_mark.py b/addons/mc_education_exam/models/mc_mark.py new file mode 100644 index 0000000..c045b51 --- /dev/null +++ b/addons/mc_education_exam/models/mc_mark.py @@ -0,0 +1,45 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class McMark(models.Model): + _name = "mc.mark" + _description = "Mark" + _order = "exam_id, student_id" + _rec_name = "display_name" + + exam_id = fields.Many2one("mc.exam", string="Exam", required=True, ondelete="cascade") + student_id = fields.Many2one("mc.student", string="Student", required=True, ondelete="restrict") + marks_obtained = fields.Float(string="Marks Obtained", required=True) + grade = fields.Char(string="Grade", compute="_compute_grade", store=True) + + _exam_student_uniq = models.Constraint( + "unique(exam_id, student_id)", + "This student already has marks recorded for this exam.", + ) + + @api.depends("student_id.name", "exam_id.display_name") + def _compute_display_name(self): + for mark in self: + mark.display_name = "%s - %s" % (mark.student_id.name or "?", mark.exam_id.display_name or "?") + + @api.depends("marks_obtained", "exam_id.max_marks", "exam_id.batch_id.program_id.grading_scale_id.interval_ids.threshold") + def _compute_grade(self): + for mark in self: + scale = mark.exam_id.batch_id.program_id.grading_scale_id + if not scale or not mark.exam_id.max_marks: + mark.grade = False + continue + percentage = mark.marks_obtained / mark.exam_id.max_marks * 100 + interval = scale.get_grade_interval(percentage) + mark.grade = interval.letter if interval else False + + @api.constrains("marks_obtained", "exam_id") + def _check_marks_within_range(self): + for mark in self: + if mark.marks_obtained < 0 or mark.marks_obtained > mark.exam_id.max_marks: + raise ValidationError( + "Marks obtained (%.2f) must be between 0 and the exam's max marks (%.2f)." % ( + mark.marks_obtained, mark.exam_id.max_marks, + ) + ) diff --git a/addons/mc_education_exam/models/mc_program.py b/addons/mc_education_exam/models/mc_program.py new file mode 100644 index 0000000..396e147 --- /dev/null +++ b/addons/mc_education_exam/models/mc_program.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class McProgram(models.Model): + _inherit = "mc.program" + + grading_scale_id = fields.Many2one( + "mc.grading.scale", string="Grading Scale", + help="Drives how marks in this program's exams turn into letter grades. " + "Change it here to change every future grade computation - no code.", + ) diff --git a/addons/mc_education_exam/models/mc_student.py b/addons/mc_education_exam/models/mc_student.py new file mode 100644 index 0000000..6201320 --- /dev/null +++ b/addons/mc_education_exam/models/mc_student.py @@ -0,0 +1,69 @@ +from odoo import fields, models + + +class McStudent(models.Model): + _inherit = "mc.student" + + def _get_report_card_term(self): + """The term a report card prints for: the most recent term this + student actually has marks in, or - nothing graded yet - whichever + term's date range covers today. Marks win over the calendar + deliberately: a report card is generated to show a term's *results*, + typically reviewed after that term's exams are done and marks + entered (which is often during the *next* term, not the graded + term itself) - resolving strictly by today's date would show an + empty current term instead of the graded one. Lets "Print Report + Card" work with no extra picker on the common path, per Demo + Scene 6 ("As Principal, generate Aditya's report card" - one + click, no term-selection wizard). + """ + self.ensure_one() + enrollment = self._get_report_card_enrollment() + if not enrollment: + return self.env["mc.academic.term"] + marks = self.env["mc.mark"].search([("student_id", "=", self.id)]) + terms_with_marks = marks.exam_id.term_id.filtered( + lambda t: t.year_id == enrollment.year_id + ) + if terms_with_marks: + return terms_with_marks.sorted("date_start", reverse=True)[:1] + today = fields.Date.context_today(self) + return self.env["mc.academic.term"].search([ + ("year_id", "=", enrollment.year_id.id), + ("date_start", "<=", today), ("date_end", ">=", today), + ], limit=1) + + def _get_report_card_enrollment(self): + self.ensure_one() + return self.env["mc.enrollment"].search([ + ("student_id", "=", self.id), ("state", "=", "active"), + ], limit=1) + + def _get_report_card_marks(self, term): + self.ensure_one() + return self.env["mc.mark"].search([ + ("student_id", "=", self.id), ("exam_id.term_id", "=", term.id), + ]) + + def _get_report_card_attendance_summary(self, term): + """Attendance summary is optional: mc_education_exam does not + depend on mc_education_attendance (each mc_education_* module + installs independently, CLAUDE.md sec 1.4), so this degrades to + None rather than failing when attendance isn't installed. + """ + self.ensure_one() + try: + attendance_model = self.env["mc.attendance"] + except KeyError: + return None + records = attendance_model.search([ + ("student_id", "=", self.id), + ("date", ">=", term.date_start), ("date", "<=", term.date_end), + ]) + total = len(records) + present = len(records.filtered(lambda a: a.state in ("present", "late"))) + return { + "total": total, + "present": present, + "percentage": (present / total * 100) if total else 0.0, + } diff --git a/addons/mc_education_exam/report/mc_report_card_report.xml b/addons/mc_education_exam/report/mc_report_card_report.xml new file mode 100644 index 0000000..4f5233f --- /dev/null +++ b/addons/mc_education_exam/report/mc_report_card_report.xml @@ -0,0 +1,17 @@ + + + + + Report Card + mc.student + qweb-pdf + mc_education_exam.report_card + mc_education_exam.report_card + 'Report Card - %s' % (object.name) + + report + + diff --git a/addons/mc_education_exam/report/mc_report_card_templates.xml b/addons/mc_education_exam/report/mc_report_card_templates.xml new file mode 100644 index 0000000..89e8850 --- /dev/null +++ b/addons/mc_education_exam/report/mc_report_card_templates.xml @@ -0,0 +1,95 @@ + + + + diff --git a/addons/mc_education_exam/security/ir.model.access.csv b/addons/mc_education_exam/security/ir.model.access.csv new file mode 100644 index 0000000..11fb92f --- /dev/null +++ b/addons/mc_education_exam/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mc_grading_scale_administrator,mc.grading.scale.administrator,model_mc_grading_scale,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_grading_scale_staff,mc.grading.scale.staff,model_mc_grading_scale,mc_education_base.group_school_staff,1,0,0,0 +access_mc_grading_interval_administrator,mc.grading.interval.administrator,model_mc_grading_interval,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_grading_interval_staff,mc.grading.interval.staff,model_mc_grading_interval,mc_education_base.group_school_staff,1,0,0,0 +access_mc_exam_administrator,mc.exam.administrator,model_mc_exam,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_exam_staff,mc.exam.staff,model_mc_exam,mc_education_base.group_school_staff,1,0,0,0 +access_mc_exam_teacher,mc.exam.teacher,model_mc_exam,mc_education_base.group_teacher,1,1,1,0 +access_mc_mark_administrator,mc.mark.administrator,model_mc_mark,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_mark_staff,mc.mark.staff,model_mc_mark,mc_education_base.group_school_staff,1,0,0,0 +access_mc_mark_teacher,mc.mark.teacher,model_mc_mark,mc_education_base.group_teacher,1,1,1,0 diff --git a/addons/mc_education_exam/tests/__init__.py b/addons/mc_education_exam/tests/__init__.py new file mode 100644 index 0000000..0fa366b --- /dev/null +++ b/addons/mc_education_exam/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_grading +from . import test_exam diff --git a/addons/mc_education_exam/tests/test_exam.py b/addons/mc_education_exam/tests/test_exam.py new file mode 100644 index 0000000..5fb08b5 --- /dev/null +++ b/addons/mc_education_exam/tests/test_exam.py @@ -0,0 +1,84 @@ +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestExam(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.year = cls.env["mc.academic.year"].create({ + "name": "TEST-EXAM-2026-27", + "date_start": "2026-06-01", "date_end": "2027-04-30", + }) + cls.term = cls.env["mc.academic.term"].create({ + "name": "TEST EXAM Term", "year_id": cls.year.id, + "date_start": "2026-06-01", "date_end": "2026-09-30", + }) + cls.program = cls.env["mc.program"].create({ + "name": "TEST EXAM Program", "code": "TEST-EXAM-P1", "sequence_no": 1, + "display_label": "Test Grade", + }) + cls.batch = cls.env["mc.batch"].create({ + "name": "TEST EXAM Batch", "program_id": cls.program.id, "year_id": cls.year.id, + }) + cls.subject = cls.env["mc.subject"].create({"name": "TEST EXAM Subject", "code": "TEST-EXAM-S1"}) + cls.exam = cls.env["mc.exam"].create({ + "name": "TEST EXAM Midterm", "term_id": cls.term.id, "batch_id": cls.batch.id, + "subject_id": cls.subject.id, "max_marks": 100, "pass_marks": 35, "date": "2026-07-01", + }) + cls.student1 = cls.env["mc.student"].create({ + "partner_id": cls.env["res.partner"].create({"name": "EXAM Student One"}).id, + "name": "EXAM Student One", + }) + cls.student2 = cls.env["mc.student"].create({ + "partner_id": cls.env["res.partner"].create({"name": "EXAM Student Two"}).id, + "name": "EXAM Student Two", + }) + cls.env["mc.enrollment"].create({ + "student_id": cls.student1.id, "program_id": cls.program.id, + "batch_id": cls.batch.id, "year_id": cls.year.id, "state": "active", + }) + cls.env["mc.enrollment"].create({ + "student_id": cls.student2.id, "program_id": cls.program.id, + "batch_id": cls.batch.id, "year_id": cls.year.id, "state": "active", + }) + + def test_pass_marks_cannot_exceed_max_marks(self): + with self.assertRaises(ValidationError): + self.env["mc.exam"].create({ + "name": "Bad Exam", "term_id": self.term.id, "batch_id": self.batch.id, + "subject_id": self.subject.id, "max_marks": 100, "pass_marks": 150, + "date": "2026-07-02", + }) + + @mute_logger("odoo.sql_db") + def test_duplicate_mark_for_same_student_exam_rejected(self): + self.env["mc.mark"].create({ + "exam_id": self.exam.id, "student_id": self.student1.id, "marks_obtained": 80, + }) + with self.assertRaises(IntegrityError): + with self.cr.savepoint(): + self.env["mc.mark"].create({ + "exam_id": self.exam.id, "student_id": self.student1.id, "marks_obtained": 90, + }) + + def test_enter_marks_creates_a_row_per_active_enrollment(self): + action = self.exam.action_enter_marks() + self.assertEqual(action["domain"], [("exam_id", "=", self.exam.id)]) + marks = self.env["mc.mark"].search(action["domain"]) + self.assertEqual(set(marks.mapped("student_id")), {self.student1, self.student2}) + self.assertTrue(all(m.marks_obtained == 0 for m in marks)) + + def test_enter_marks_does_not_duplicate_existing_rows(self): + self.env["mc.mark"].create({ + "exam_id": self.exam.id, "student_id": self.student1.id, "marks_obtained": 75, + }) + self.exam.action_enter_marks() + marks = self.env["mc.mark"].search([("exam_id", "=", self.exam.id)]) + self.assertEqual(len(marks), 2) + student1_mark = marks.filtered(lambda m: m.student_id == self.student1) + self.assertEqual(student1_mark.marks_obtained, 75) # untouched, not reset to 0 diff --git a/addons/mc_education_exam/tests/test_grading.py b/addons/mc_education_exam/tests/test_grading.py new file mode 100644 index 0000000..46cf760 --- /dev/null +++ b/addons/mc_education_exam/tests/test_grading.py @@ -0,0 +1,85 @@ +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestGrading(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.year = cls.env["mc.academic.year"].create({ + "name": "TEST-GRADE-2026-27", + "date_start": "2026-06-01", "date_end": "2027-04-30", + }) + cls.term = cls.env["mc.academic.term"].create({ + "name": "TEST GRADE Term", "year_id": cls.year.id, + "date_start": "2026-06-01", "date_end": "2026-09-30", + }) + cls.scale_a = cls.env["mc.grading.scale"].create({"name": "TEST Scale A"}) + cls.env["mc.grading.interval"].create({ + "scale_id": cls.scale_a.id, "threshold": 90, "letter": "A1", + }) + cls.env["mc.grading.interval"].create({ + "scale_id": cls.scale_a.id, "threshold": 75, "letter": "A2", + }) + cls.env["mc.grading.interval"].create({ + "scale_id": cls.scale_a.id, "threshold": 0, "letter": "F", + }) + + cls.scale_b = cls.env["mc.grading.scale"].create({"name": "TEST Scale B"}) + cls.env["mc.grading.interval"].create({ + "scale_id": cls.scale_b.id, "threshold": 50, "letter": "PASS", + }) + cls.env["mc.grading.interval"].create({ + "scale_id": cls.scale_b.id, "threshold": 0, "letter": "FAIL", + }) + + cls.program = cls.env["mc.program"].create({ + "name": "TEST GRADE Program", "code": "TEST-GRADE-P1", "sequence_no": 1, + "display_label": "Test Grade", "grading_scale_id": cls.scale_a.id, + }) + cls.batch = cls.env["mc.batch"].create({ + "name": "TEST GRADE Batch", "program_id": cls.program.id, "year_id": cls.year.id, + }) + cls.exam = cls.env["mc.exam"].create({ + "name": "TEST GRADE Exam", "term_id": cls.term.id, "batch_id": cls.batch.id, + "subject_id": cls.env["mc.subject"].create({"name": "TEST GRADE Subject", "code": "TEST-GRADE-S1"}).id, + "max_marks": 100, "pass_marks": 35, "date": "2026-07-01", + }) + partner = cls.env["res.partner"].create({"name": "Grade Test Student"}) + cls.student = cls.env["mc.student"].create({ + "partner_id": partner.id, "name": "Grade Test Student", + }) + + def test_scale_picks_highest_matching_threshold(self): + self.assertEqual(self.scale_a.get_grade_interval(95).letter, "A1") + self.assertEqual(self.scale_a.get_grade_interval(90).letter, "A1") + self.assertEqual(self.scale_a.get_grade_interval(89.9).letter, "A2") + self.assertEqual(self.scale_a.get_grade_interval(10).letter, "F") + + def test_mark_grade_computed_from_program_scale(self): + mark = self.env["mc.mark"].create({ + "exam_id": self.exam.id, "student_id": self.student.id, "marks_obtained": 92, + }) + self.assertEqual(mark.grade, "A1") + + def test_changing_scale_recomputes_existing_marks(self): + mark = self.env["mc.mark"].create({ + "exam_id": self.exam.id, "student_id": self.student.id, "marks_obtained": 60, + }) + self.assertEqual(mark.grade, "F") # 60 < 75 threshold under Scale A + self.program.grading_scale_id = self.scale_b.id + self.assertEqual(mark.grade, "PASS") # same 60%, Scale B calls it a pass + + def test_no_scale_configured_leaves_grade_blank(self): + self.program.grading_scale_id = False + mark = self.env["mc.mark"].create({ + "exam_id": self.exam.id, "student_id": self.student.id, "marks_obtained": 92, + }) + self.assertFalse(mark.grade) + + def test_marks_out_of_range_rejected(self): + with self.assertRaises(ValidationError): + self.env["mc.mark"].create({ + "exam_id": self.exam.id, "student_id": self.student.id, "marks_obtained": 150, + }) diff --git a/addons/mc_education_exam/views/mc_education_exam_menus.xml b/addons/mc_education_exam/views/mc_education_exam_menus.xml new file mode 100644 index 0000000..e315d95 --- /dev/null +++ b/addons/mc_education_exam/views/mc_education_exam_menus.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/addons/mc_education_exam/views/mc_enrollment_views.xml b/addons/mc_education_exam/views/mc_enrollment_views.xml new file mode 100644 index 0000000..4a55ff7 --- /dev/null +++ b/addons/mc_education_exam/views/mc_enrollment_views.xml @@ -0,0 +1,15 @@ + + + + mc.enrollment.form.inherit.mc.education.exam + mc.enrollment + + + + + + + + + + diff --git a/addons/mc_education_exam/views/mc_exam_views.xml b/addons/mc_education_exam/views/mc_exam_views.xml new file mode 100644 index 0000000..9cbbbd6 --- /dev/null +++ b/addons/mc_education_exam/views/mc_exam_views.xml @@ -0,0 +1,55 @@ + + + + mc.exam.list + mc.exam + + + + + + + + + + + + + + + mc.exam.form + mc.exam + +
+
+
+ +
+
+ + + + + + + + + + + + +
+ + +
+
+ + + Exams + mc.exam + list,form + +
diff --git a/addons/mc_education_exam/views/mc_grading_scale_views.xml b/addons/mc_education_exam/views/mc_grading_scale_views.xml new file mode 100644 index 0000000..1e20c82 --- /dev/null +++ b/addons/mc_education_exam/views/mc_grading_scale_views.xml @@ -0,0 +1,41 @@ + + + + mc.grading.scale.list + mc.grading.scale + + + + + + + + + mc.grading.scale.form + mc.grading.scale + +
+ +
+
+ + + + + + + + +
+
+
+
+ + + Grading Scales + mc.grading.scale + list,form + +
diff --git a/addons/mc_education_exam/views/mc_mark_views.xml b/addons/mc_education_exam/views/mc_mark_views.xml new file mode 100644 index 0000000..6ac8591 --- /dev/null +++ b/addons/mc_education_exam/views/mc_mark_views.xml @@ -0,0 +1,25 @@ + + + + mc.mark.list + mc.mark + + + + + + + + + + + + + Marks + mc.mark + list + + diff --git a/addons/mc_education_exam/views/mc_program_views.xml b/addons/mc_education_exam/views/mc_program_views.xml new file mode 100644 index 0000000..b4b1d44 --- /dev/null +++ b/addons/mc_education_exam/views/mc_program_views.xml @@ -0,0 +1,13 @@ + + + + mc.program.form.inherit.mc.education.exam + mc.program + + + + + + + +