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:
parent
a36c89222b
commit
c77fb3d0e5
1
addons/mc_education_exam/__init__.py
Normal file
1
addons/mc_education_exam/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
26
addons/mc_education_exam/__manifest__.py
Normal file
26
addons/mc_education_exam/__manifest__.py
Normal 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,
|
||||
}
|
||||
257
addons/mc_education_exam/data/mc_grading_scale_data.xml
Normal file
257
addons/mc_education_exam/data/mc_grading_scale_data.xml
Normal 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>
|
||||
102
addons/mc_education_exam/demo/mc_exam_demo.xml
Normal file
102
addons/mc_education_exam/demo/mc_exam_demo.xml
Normal 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>
|
||||
6
addons/mc_education_exam/models/__init__.py
Normal file
6
addons/mc_education_exam/models/__init__.py
Normal 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
|
||||
10
addons/mc_education_exam/models/mc_enrollment.py
Normal file
10
addons/mc_education_exam/models/mc_enrollment.py
Normal 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.",
|
||||
)
|
||||
61
addons/mc_education_exam/models/mc_exam.py
Normal file
61
addons/mc_education_exam/models/mc_exam.py
Normal 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},
|
||||
}
|
||||
53
addons/mc_education_exam/models/mc_grading_scale.py
Normal file
53
addons/mc_education_exam/models/mc_grading_scale.py
Normal 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.",
|
||||
)
|
||||
45
addons/mc_education_exam/models/mc_mark.py
Normal file
45
addons/mc_education_exam/models/mc_mark.py
Normal 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,
|
||||
)
|
||||
)
|
||||
11
addons/mc_education_exam/models/mc_program.py
Normal file
11
addons/mc_education_exam/models/mc_program.py
Normal 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.",
|
||||
)
|
||||
69
addons/mc_education_exam/models/mc_student.py
Normal file
69
addons/mc_education_exam/models/mc_student.py
Normal 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,
|
||||
}
|
||||
17
addons/mc_education_exam/report/mc_report_card_report.xml
Normal file
17
addons/mc_education_exam/report/mc_report_card_report.xml
Normal 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>
|
||||
95
addons/mc_education_exam/report/mc_report_card_templates.xml
Normal file
95
addons/mc_education_exam/report/mc_report_card_templates.xml
Normal 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>
|
||||
11
addons/mc_education_exam/security/ir.model.access.csv
Normal file
11
addons/mc_education_exam/security/ir.model.access.csv
Normal 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
|
||||
|
2
addons/mc_education_exam/tests/__init__.py
Normal file
2
addons/mc_education_exam/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import test_grading
|
||||
from . import test_exam
|
||||
84
addons/mc_education_exam/tests/test_exam.py
Normal file
84
addons/mc_education_exam/tests/test_exam.py
Normal 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
|
||||
85
addons/mc_education_exam/tests/test_grading.py
Normal file
85
addons/mc_education_exam/tests/test_grading.py
Normal 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,
|
||||
})
|
||||
17
addons/mc_education_exam/views/mc_education_exam_menus.xml
Normal file
17
addons/mc_education_exam/views/mc_education_exam_menus.xml
Normal 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>
|
||||
15
addons/mc_education_exam/views/mc_enrollment_views.xml
Normal file
15
addons/mc_education_exam/views/mc_enrollment_views.xml
Normal 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>
|
||||
55
addons/mc_education_exam/views/mc_exam_views.xml
Normal file
55
addons/mc_education_exam/views/mc_exam_views.xml
Normal 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>
|
||||
41
addons/mc_education_exam/views/mc_grading_scale_views.xml
Normal file
41
addons/mc_education_exam/views/mc_grading_scale_views.xml
Normal 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>
|
||||
25
addons/mc_education_exam/views/mc_mark_views.xml
Normal file
25
addons/mc_education_exam/views/mc_mark_views.xml
Normal 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>
|
||||
13
addons/mc_education_exam/views/mc_program_views.xml
Normal file
13
addons/mc_education_exam/views/mc_program_views.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user