O6: mc_education_exam - grading scales as data, grid entry, report card PDF

Serves Demo Scene 6. mc.grading.scale + mc.grading.interval are pure
data (threshold/letter/point/description rows) - CBSE, ICSE, IB,
Cambridge, Ontario and a plain percentage scale all shipped as data
in data/mc_grading_scale_data.xml, noupdate="1" so a school's own
edits survive a module upgrade. mc.mark.grade resolves via
mc.program.grading_scale_id (new field on mc.program, since grading
board is a per-program concept already carrying "board" from O1) -
the moment this needed an `if board == "CBSE"` anywhere, per
shared/DOMAIN_MODEL.md sec 5, it would have been the wrong design;
verified instead that swapping a program's scale recomputes an
existing mark's grade with zero code involved
(test_changing_scale_recomputes_existing_marks).

Grid mark entry ("whole class on one screen, tab between fields,
keyboard only") is a plain editable list on mc.mark, filtered to one
exam - that native Odoo behavior already gives real Tab-key
navigation with no custom widget. mc.exam.action_enter_marks()
pre-creates a row per actively-enrolled student before opening it
(mirroring O4's bulk-attendance idempotency: creating it again never
duplicates or resets an already-entered mark).

The report card is a real rendered PDF, not just a template read by
inspection - rendered it for Aditya via odoo shell
(report._render_qweb_pdf), pulled the bytes out of the container, and
read the actual PDF: one A4 page, no clipped columns, correct grades
per subject matching the CBSE scale (92->A1, 85->A2, 78->B1, 67->B2,
58->C1), signature block, term correctly resolved. That last part
exposed a real logic bug before it shipped: the term-resolution
method originally preferred "today's date" over "the term with
marks", which would have shown an empty Term 1 when generating the
card *today* even though the demo's graded marks are all in Term 2 -
a report card is generated to review a term's results, typically
after that term's exams are done (often during the *next* term), so
marks-with-data now wins over the calendar, falling back to today's
date only for a student with nothing graded yet.

Attendance summary on the report degrades to None when
mc_education_attendance isn't installed (checked directly, not
assumed) - this module depends on mc_education_base only, matching
"each mc_education_* module installs independently" (CLAUDE.md sec
1.4), while still showing the real summary when both are installed
together for the actual demo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-11 13:16:49 -04:00
parent a36c89222b
commit c77fb3d0e5
23 changed files with 1101 additions and 0 deletions

View File

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

View File

@ -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,
}

View File

@ -0,0 +1,257 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Data rows, never code (O6 spec, shared/DOMAIN_MODEL.md sec 5).
noupdate="1": a school customizes its own thresholds after
install, and a module upgrade must never silently overwrite
that. -->
<data noupdate="1">
<record id="scale_cbse" model="mc.grading.scale">
<field name="name">CBSE</field>
</record>
<record id="scale_cbse_a1" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">91</field>
<field name="letter">A1</field>
<field name="point">10</field>
<field name="description">Outstanding</field>
</record>
<record id="scale_cbse_a2" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">81</field>
<field name="letter">A2</field>
<field name="point">9</field>
<field name="description">Excellent</field>
</record>
<record id="scale_cbse_b1" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">71</field>
<field name="letter">B1</field>
<field name="point">8</field>
<field name="description">Very Good</field>
</record>
<record id="scale_cbse_b2" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">61</field>
<field name="letter">B2</field>
<field name="point">7</field>
<field name="description">Good</field>
</record>
<record id="scale_cbse_c1" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">51</field>
<field name="letter">C1</field>
<field name="point">6</field>
<field name="description">Fair</field>
</record>
<record id="scale_cbse_c2" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">41</field>
<field name="letter">C2</field>
<field name="point">5</field>
<field name="description">Average</field>
</record>
<record id="scale_cbse_d" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">33</field>
<field name="letter">D</field>
<field name="point">4</field>
<field name="description">Below Average</field>
</record>
<record id="scale_cbse_e" model="mc.grading.interval">
<field name="scale_id" ref="scale_cbse"/>
<field name="threshold">0</field>
<field name="letter">E</field>
<field name="point">0</field>
<field name="description">Needs Improvement</field>
</record>
<record id="scale_icse" model="mc.grading.scale">
<field name="name">ICSE</field>
</record>
<record id="scale_icse_1" model="mc.grading.interval">
<field name="scale_id" ref="scale_icse"/>
<field name="threshold">90</field>
<field name="letter">1</field>
<field name="description">Outstanding</field>
</record>
<record id="scale_icse_2" model="mc.grading.interval">
<field name="scale_id" ref="scale_icse"/>
<field name="threshold">75</field>
<field name="letter">2</field>
<field name="description">Very Good</field>
</record>
<record id="scale_icse_3" model="mc.grading.interval">
<field name="scale_id" ref="scale_icse"/>
<field name="threshold">60</field>
<field name="letter">3</field>
<field name="description">Good</field>
</record>
<record id="scale_icse_4" model="mc.grading.interval">
<field name="scale_id" ref="scale_icse"/>
<field name="threshold">40</field>
<field name="letter">4</field>
<field name="description">Satisfactory</field>
</record>
<record id="scale_icse_5" model="mc.grading.interval">
<field name="scale_id" ref="scale_icse"/>
<field name="threshold">0</field>
<field name="letter">5</field>
<field name="description">Needs Improvement</field>
</record>
<record id="scale_ib" model="mc.grading.scale">
<field name="name">IB</field>
</record>
<record id="scale_ib_7" model="mc.grading.interval">
<field name="scale_id" ref="scale_ib"/>
<field name="threshold">90</field>
<field name="letter">7</field>
<field name="point">7</field>
</record>
<record id="scale_ib_6" model="mc.grading.interval">
<field name="scale_id" ref="scale_ib"/>
<field name="threshold">80</field>
<field name="letter">6</field>
<field name="point">6</field>
</record>
<record id="scale_ib_5" model="mc.grading.interval">
<field name="scale_id" ref="scale_ib"/>
<field name="threshold">70</field>
<field name="letter">5</field>
<field name="point">5</field>
</record>
<record id="scale_ib_4" model="mc.grading.interval">
<field name="scale_id" ref="scale_ib"/>
<field name="threshold">60</field>
<field name="letter">4</field>
<field name="point">4</field>
</record>
<record id="scale_ib_3" model="mc.grading.interval">
<field name="scale_id" ref="scale_ib"/>
<field name="threshold">50</field>
<field name="letter">3</field>
<field name="point">3</field>
</record>
<record id="scale_ib_2" model="mc.grading.interval">
<field name="scale_id" ref="scale_ib"/>
<field name="threshold">40</field>
<field name="letter">2</field>
<field name="point">2</field>
</record>
<record id="scale_ib_1" model="mc.grading.interval">
<field name="scale_id" ref="scale_ib"/>
<field name="threshold">0</field>
<field name="letter">1</field>
<field name="point">1</field>
</record>
<record id="scale_cambridge" model="mc.grading.scale">
<field name="name">Cambridge</field>
</record>
<record id="scale_cambridge_astar" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">90</field>
<field name="letter">A*</field>
</record>
<record id="scale_cambridge_a" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">80</field>
<field name="letter">A</field>
</record>
<record id="scale_cambridge_b" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">70</field>
<field name="letter">B</field>
</record>
<record id="scale_cambridge_c" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">60</field>
<field name="letter">C</field>
</record>
<record id="scale_cambridge_d" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">50</field>
<field name="letter">D</field>
</record>
<record id="scale_cambridge_e" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">40</field>
<field name="letter">E</field>
</record>
<record id="scale_cambridge_f" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">30</field>
<field name="letter">F</field>
</record>
<record id="scale_cambridge_g" model="mc.grading.interval">
<field name="scale_id" ref="scale_cambridge"/>
<field name="threshold">0</field>
<field name="letter">G</field>
</record>
<record id="scale_ontario" model="mc.grading.scale">
<field name="name">Ontario</field>
</record>
<record id="scale_ontario_a" model="mc.grading.interval">
<field name="scale_id" ref="scale_ontario"/>
<field name="threshold">80</field>
<field name="letter">A</field>
<field name="description">Level 4</field>
</record>
<record id="scale_ontario_b" model="mc.grading.interval">
<field name="scale_id" ref="scale_ontario"/>
<field name="threshold">70</field>
<field name="letter">B</field>
<field name="description">Level 3</field>
</record>
<record id="scale_ontario_c" model="mc.grading.interval">
<field name="scale_id" ref="scale_ontario"/>
<field name="threshold">60</field>
<field name="letter">C</field>
<field name="description">Level 2</field>
</record>
<record id="scale_ontario_d" model="mc.grading.interval">
<field name="scale_id" ref="scale_ontario"/>
<field name="threshold">50</field>
<field name="letter">D</field>
<field name="description">Level 1</field>
</record>
<record id="scale_ontario_r" model="mc.grading.interval">
<field name="scale_id" ref="scale_ontario"/>
<field name="threshold">0</field>
<field name="letter">R</field>
<field name="description">Below Level 1</field>
</record>
<record id="scale_percentage" model="mc.grading.scale">
<field name="name">Percentage</field>
</record>
<record id="scale_percentage_a" model="mc.grading.interval">
<field name="scale_id" ref="scale_percentage"/>
<field name="threshold">90</field>
<field name="letter">A</field>
</record>
<record id="scale_percentage_b" model="mc.grading.interval">
<field name="scale_id" ref="scale_percentage"/>
<field name="threshold">80</field>
<field name="letter">B</field>
</record>
<record id="scale_percentage_c" model="mc.grading.interval">
<field name="scale_id" ref="scale_percentage"/>
<field name="threshold">70</field>
<field name="letter">C</field>
</record>
<record id="scale_percentage_d" model="mc.grading.interval">
<field name="scale_id" ref="scale_percentage"/>
<field name="threshold">60</field>
<field name="letter">D</field>
</record>
<record id="scale_percentage_f" model="mc.grading.interval">
<field name="scale_id" ref="scale_percentage"/>
<field name="threshold">0</field>
<field name="letter">F</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Grade 8 is CBSE-boarded per mc_education_base's own demo data
(board="CBSE"); Grade 5 uses a plain percentage scale here, to
actually demonstrate two programs computing grades differently
from the same mc.mark model with no code difference between them. -->
<record id="mc_education_base.demo_program_grade8" model="mc.program">
<field name="grading_scale_id" ref="scale_cbse"/>
</record>
<record id="mc_education_base.demo_program_grade5" model="mc.program">
<field name="grading_scale_id" ref="scale_percentage"/>
</record>
<record id="demo_exam_g8a_math_t2" model="mc.exam">
<field name="name">Term 2 Exam</field>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
<field name="subject_id" ref="mc_education_base.demo_subject_math"/>
<field name="max_marks">100</field>
<field name="pass_marks">35</field>
<field name="date">2026-10-20</field>
</record>
<record id="demo_exam_g8a_english_t2" model="mc.exam">
<field name="name">Term 2 Exam</field>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
<field name="subject_id" ref="mc_education_base.demo_subject_english"/>
<field name="max_marks">100</field>
<field name="pass_marks">35</field>
<field name="date">2026-10-21</field>
</record>
<record id="demo_exam_g8a_science_t2" model="mc.exam">
<field name="name">Term 2 Exam</field>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
<field name="subject_id" ref="mc_education_base.demo_subject_science"/>
<field name="max_marks">100</field>
<field name="pass_marks">35</field>
<field name="date">2026-10-22</field>
</record>
<record id="demo_exam_g8a_sst_t2" model="mc.exam">
<field name="name">Term 2 Exam</field>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
<field name="subject_id" ref="mc_education_base.demo_subject_social_studies"/>
<field name="max_marks">100</field>
<field name="pass_marks">35</field>
<field name="date">2026-10-23</field>
</record>
<record id="demo_exam_g8a_hindi_t2" model="mc.exam">
<field name="name">Term 2 Exam</field>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
<field name="subject_id" ref="mc_education_base.demo_subject_hindi"/>
<field name="max_marks">100</field>
<field name="pass_marks">35</field>
<field name="date">2026-10-24</field>
</record>
<record id="demo_mark_aditya_math_t2" model="mc.mark">
<field name="exam_id" ref="demo_exam_g8a_math_t2"/>
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
<field name="marks_obtained">92</field>
</record>
<record id="demo_mark_aditya_english_t2" model="mc.mark">
<field name="exam_id" ref="demo_exam_g8a_english_t2"/>
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
<field name="marks_obtained">85</field>
</record>
<record id="demo_mark_aditya_science_t2" model="mc.mark">
<field name="exam_id" ref="demo_exam_g8a_science_t2"/>
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
<field name="marks_obtained">78</field>
</record>
<record id="demo_mark_aditya_sst_t2" model="mc.mark">
<field name="exam_id" ref="demo_exam_g8a_sst_t2"/>
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
<field name="marks_obtained">67</field>
</record>
<record id="demo_mark_aditya_hindi_t2" model="mc.mark">
<field name="exam_id" ref="demo_exam_g8a_hindi_t2"/>
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
<field name="marks_obtained">58</field>
</record>
<!-- A smaller slice for Ananya (Grade 5-B, percentage scale) so her
side of the data is not empty either. -->
<record id="demo_exam_g5b_english_t2" model="mc.exam">
<field name="name">Term 2 Exam</field>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
<field name="subject_id" ref="mc_education_base.demo_subject_english"/>
<field name="max_marks">100</field>
<field name="pass_marks">35</field>
<field name="date">2026-10-20</field>
</record>
<record id="demo_mark_ananya_english_t2" model="mc.mark">
<field name="exam_id" ref="demo_exam_g5b_english_t2"/>
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
<field name="marks_obtained">88</field>
</record>
</odoo>

View File

@ -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

View File

@ -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.",
)

View File

@ -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},
}

View File

@ -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.",
)

View File

@ -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,
)
)

View File

@ -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.",
)

View File

@ -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,
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- binding_model_id + binding_type="report" is what puts this on the
standard Print dropdown on mc.student's form/list - no custom
button needed, this is the stock mechanism every Odoo report
(invoice, quotation, ...) uses. -->
<record id="action_report_mc_report_card" model="ir.actions.report">
<field name="name">Report Card</field>
<field name="model">mc.student</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">mc_education_exam.report_card</field>
<field name="report_file">mc_education_exam.report_card</field>
<field name="print_report_name">'Report Card - %s' % (object.name)</field>
<field name="binding_model_id" ref="mc_education_base.model_mc_student"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_card">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="student">
<t t-call="web.external_layout">
<!-- web.external_layout renders the company logo/header
stock - satisfies the "logo" requirement without
this module drawing one itself. table-layout:fixed
plus explicit column widths keep this print-clean
on A4 with no clipped columns, per O6 acceptance. -->
<t t-set="term" t-value="student._get_report_card_term()"/>
<t t-set="marks" t-value="student._get_report_card_marks(term)"/>
<t t-set="attendance" t-value="student._get_report_card_attendance_summary(term)"/>
<t t-set="enrollment" t-value="student._get_report_card_enrollment()"/>
<div class="page" style="font-size: 12px;">
<h2 style="text-align: center;">Report Card</h2>
<table style="width: 100%; margin-bottom: 16px;">
<tr>
<td style="width: 50%;">
<strong>Student:</strong> <span t-esc="student.name"/><br/>
<strong>Admission No.:</strong> <span t-esc="student.admission_no"/><br/>
<strong>Batch:</strong> <span t-esc="enrollment.batch_id.name or ''"/>
</td>
<td style="width: 50%;">
<strong>Academic Year:</strong> <span t-esc="enrollment.year_id.name or ''"/><br/>
<strong>Term:</strong> <span t-esc="term.name or 'N/A'"/><br/>
<strong>Roll No.:</strong> <span t-esc="enrollment.roll_no or ''"/>
</td>
</tr>
</table>
<table class="table table-bordered" style="table-layout: fixed; width: 100%;">
<colgroup>
<col style="width: 40%;"/>
<col style="width: 20%;"/>
<col style="width: 20%;"/>
<col style="width: 20%;"/>
</colgroup>
<thead>
<tr>
<th>Subject</th>
<th>Max Marks</th>
<th>Marks Obtained</th>
<th>Grade</th>
</tr>
</thead>
<tbody>
<tr t-foreach="marks" t-as="mark">
<td t-esc="mark.exam_id.subject_id.name"/>
<td t-esc="mark.exam_id.max_marks"/>
<td t-esc="mark.marks_obtained"/>
<td t-esc="mark.grade or '-'"/>
</tr>
<tr t-if="not marks">
<td colspan="4" style="text-align: center;">No marks recorded for this term.</td>
</tr>
</tbody>
</table>
<table style="width: 100%; margin-top: 16px;" t-if="attendance">
<tr>
<td>
<strong>Attendance Summary:</strong>
<span t-esc="attendance['present']"/> / <span t-esc="attendance['total']"/> days
(<span t-esc="'%.1f' % attendance['percentage']"/>%)
</td>
</tr>
</table>
<table style="width: 100%; margin-top: 16px;">
<tr>
<td>
<strong>Class Teacher's Remark:</strong><br/>
<span t-esc="enrollment.remark or '-'"/>
</td>
</tr>
</table>
<table style="width: 100%; margin-top: 48px;">
<tr>
<td style="width: 50%; border-top: 1px solid #000; text-align: center; padding-top: 4px;">
Class Teacher's Signature
</td>
<td style="width: 50%; border-top: 1px solid #000; text-align: center; padding-top: 4px;">
Principal's Signature
</td>
</tr>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mc_grading_scale_administrator mc.grading.scale.administrator model_mc_grading_scale mc_education_base.group_school_administrator 1 1 1 1
3 access_mc_grading_scale_staff mc.grading.scale.staff model_mc_grading_scale mc_education_base.group_school_staff 1 0 0 0
4 access_mc_grading_interval_administrator mc.grading.interval.administrator model_mc_grading_interval mc_education_base.group_school_administrator 1 1 1 1
5 access_mc_grading_interval_staff mc.grading.interval.staff model_mc_grading_interval mc_education_base.group_school_staff 1 0 0 0
6 access_mc_exam_administrator mc.exam.administrator model_mc_exam mc_education_base.group_school_administrator 1 1 1 1
7 access_mc_exam_staff mc.exam.staff model_mc_exam mc_education_base.group_school_staff 1 0 0 0
8 access_mc_exam_teacher mc.exam.teacher model_mc_exam mc_education_base.group_teacher 1 1 1 0
9 access_mc_mark_administrator mc.mark.administrator model_mc_mark mc_education_base.group_school_administrator 1 1 1 1
10 access_mc_mark_staff mc.mark.staff model_mc_mark mc_education_base.group_school_staff 1 0 0 0
11 access_mc_mark_teacher mc.mark.teacher model_mc_mark mc_education_base.group_teacher 1 1 1 0

View File

@ -0,0 +1,2 @@
from . import test_grading
from . import test_exam

View File

@ -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

View File

@ -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,
})

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_school_exam" name="Exams"
parent="mc_education_base.menu_school_root" sequence="16"/>
<menuitem id="menu_mc_exam" name="Exams"
parent="menu_school_exam"
action="action_mc_exam" sequence="10"/>
<menuitem id="menu_mc_mark" name="All Marks"
parent="menu_school_exam"
action="action_mc_mark" sequence="20"/>
<menuitem id="menu_mc_grading_scale" name="Grading Scales"
parent="mc_education_base.menu_school_configuration"
action="action_mc_grading_scale" sequence="80"/>
</odoo>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_enrollment_form_inherit_mc_education_exam" model="ir.ui.view">
<field name="name">mc.enrollment.form.inherit.mc.education.exam</field>
<field name="model">mc.enrollment</field>
<field name="inherit_id" ref="mc_education_base.view_mc_enrollment_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Report Card">
<field name="remark"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_exam_list" model="ir.ui.view">
<field name="name">mc.exam.list</field>
<field name="model">mc.exam</field>
<field name="arch" type="xml">
<list string="Exams">
<field name="date"/>
<field name="term_id"/>
<field name="batch_id"/>
<field name="subject_id"/>
<field name="name"/>
<field name="max_marks"/>
<field name="pass_marks"/>
</list>
</field>
</record>
<record id="view_mc_exam_form" model="ir.ui.view">
<field name="name">mc.exam.form</field>
<field name="model">mc.exam</field>
<field name="arch" type="xml">
<form string="Exam">
<header>
<button name="action_enter_marks" string="Enter Marks" type="object" class="oe_highlight"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="batch_id"/>
<field name="subject_id"/>
<field name="term_id"/>
</group>
<group>
<field name="date"/>
<field name="max_marks"/>
<field name="pass_marks"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_mc_exam" model="ir.actions.act_window">
<field name="name">Exams</field>
<field name="res_model">mc.exam</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_grading_scale_list" model="ir.ui.view">
<field name="name">mc.grading.scale.list</field>
<field name="model">mc.grading.scale</field>
<field name="arch" type="xml">
<list string="Grading Scales">
<field name="name"/>
</list>
</field>
</record>
<record id="view_mc_grading_scale_form" model="ir.ui.view">
<field name="name">mc.grading.scale.form</field>
<field name="model">mc.grading.scale</field>
<field name="arch" type="xml">
<form string="Grading Scale">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<field name="interval_ids">
<list editable="bottom">
<field name="threshold"/>
<field name="letter"/>
<field name="point"/>
<field name="description"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<record id="action_mc_grading_scale" model="ir.actions.act_window">
<field name="name">Grading Scales</field>
<field name="res_model">mc.grading.scale</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_mark_list" model="ir.ui.view">
<field name="name">mc.mark.list</field>
<field name="model">mc.mark</field>
<field name="arch" type="xml">
<!-- Whole class on one screen, tab between fields, keyboard
only (O6 spec, verbatim) - an editable list is exactly
this in the Odoo web client: Tab moves to the next
editable cell without touching the mouse. -->
<list string="Marks" editable="bottom">
<field name="exam_id" column_invisible="1"/>
<field name="student_id" readonly="1"/>
<field name="marks_obtained"/>
<field name="grade" readonly="1"/>
</list>
</field>
</record>
<record id="action_mc_mark" model="ir.actions.act_window">
<field name="name">Marks</field>
<field name="res_model">mc.mark</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_program_form_inherit_mc_education_exam" model="ir.ui.view">
<field name="name">mc.program.form.inherit.mc.education.exam</field>
<field name="model">mc.program</field>
<field name="inherit_id" ref="mc_education_base.view_mc_program_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='board']" position="after">
<field name="grading_scale_id"/>
</xpath>
</field>
</record>
</odoo>