From 7ac5880f207178c7d7606cc859cb3cdf59c6fda0 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 21:37:56 -0400 Subject: [PATCH] feat(community_school): core models + teacher attendance portal (Sessions 3-A, 3-B) Session 3-A - core models: community.school.term, .level (admin-defined, so the same module fits a Grade 1-12 school or a Beginner-Advanced language school with no code change), .class (auto-creates a linked slide.channel for LMS glue), .student, .enrollment, and .attendance, plus is_teacher on res.partner. Class enrolled_count and enrollment attendance_rate are stored/non-stored computes driven by the enrollment/attendance one2many chains. Session 3-B - teacher attendance + at-risk reporting: a portal page at /school/attendance (auth='user', scoped to classes where teacher_id matches the logged-in user's partner - works whether the teacher is an internal or portal user) with a roster and batch save, built as a v19 Interaction (same pattern as event_qr_ticketing's check-in page). Marking a student absent queues a configurable notice to the parent partner. Enrollment.is_at_risk flags students below a configurable attendance threshold (School settings page, same // pattern as community_membership), plus an admin pivot attendance report and an At-Risk Students list. Verified against a live Odoo 19 + Postgres 16 container: 14/14 automated tests pass (class/slide-channel creation, enrolled_count tracking incl. withdrawal, attendance_rate and at-risk computation, unique enrollment+date constraint, absence email queuing), plus a full manual live run - created a term/level/class/student/enrollment via JSON-RPC, loaded the actual /school/attendance page as the teacher, POSTed a batch save marking the student absent, and confirmed both the attendance record and the queued "Absence notice" mail.mail record. Co-Authored-By: Claude Sonnet 5 --- addons/community_school/__init__.py | 2 + addons/community_school/__manifest__.py | 15 +- .../community_school/controllers/__init__.py | 1 + .../controllers/attendance.py | 72 ++++++ .../community_school/data/mail_templates.xml | 25 ++ addons/community_school/models/__init__.py | 8 + .../models/res_config_settings.py | 12 + addons/community_school/models/res_partner.py | 7 + .../models/school_attendance.py | 31 +++ .../community_school/models/school_class.py | 65 +++++ .../models/school_enrollment.py | 55 +++++ .../community_school/models/school_level.py | 16 ++ .../community_school/models/school_student.py | 32 +++ addons/community_school/models/school_term.py | 17 ++ .../security/ir.model.access.csv | 16 ++ .../security/school_security.xml | 26 ++ .../static/src/js/school_attendance.js | 40 +++ addons/community_school/tests/__init__.py | 2 + addons/community_school/tests/test_school.py | 79 ++++++ .../tests/test_school_attendance.py | 55 +++++ .../views/portal_attendance_templates.xml | 63 +++++ .../views/res_config_settings_views.xml | 21 ++ .../community_school/views/school_menus.xml | 35 +++ .../community_school/views/school_views.xml | 231 ++++++++++++++++++ 24 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 addons/community_school/controllers/__init__.py create mode 100644 addons/community_school/controllers/attendance.py create mode 100644 addons/community_school/data/mail_templates.xml create mode 100644 addons/community_school/models/__init__.py create mode 100644 addons/community_school/models/res_config_settings.py create mode 100644 addons/community_school/models/res_partner.py create mode 100644 addons/community_school/models/school_attendance.py create mode 100644 addons/community_school/models/school_class.py create mode 100644 addons/community_school/models/school_enrollment.py create mode 100644 addons/community_school/models/school_level.py create mode 100644 addons/community_school/models/school_student.py create mode 100644 addons/community_school/models/school_term.py create mode 100644 addons/community_school/security/school_security.xml create mode 100644 addons/community_school/static/src/js/school_attendance.js create mode 100644 addons/community_school/tests/test_school.py create mode 100644 addons/community_school/tests/test_school_attendance.py create mode 100644 addons/community_school/views/portal_attendance_templates.xml create mode 100644 addons/community_school/views/res_config_settings_views.xml create mode 100644 addons/community_school/views/school_menus.xml create mode 100644 addons/community_school/views/school_views.xml diff --git a/addons/community_school/__init__.py b/addons/community_school/__init__.py index e69de29..f7209b1 100644 --- a/addons/community_school/__init__.py +++ b/addons/community_school/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/community_school/__manifest__.py b/addons/community_school/__manifest__.py index 83c0ba1..eb8d1f5 100644 --- a/addons/community_school/__manifest__.py +++ b/addons/community_school/__manifest__.py @@ -29,8 +29,21 @@ with no code change. 'website_slides', 'account', ], - 'data': [], + 'data': [ + 'security/school_security.xml', + 'security/ir.model.access.csv', + 'data/mail_templates.xml', + 'views/school_views.xml', + 'views/school_menus.xml', + 'views/res_config_settings_views.xml', + 'views/portal_attendance_templates.xml', + ], 'demo': [], + 'assets': { + 'web.assets_frontend': [ + 'community_school/static/src/js/school_attendance.js', + ], + }, 'images': ['static/description/banner.png'], 'application': True, 'installable': True, diff --git a/addons/community_school/controllers/__init__.py b/addons/community_school/controllers/__init__.py new file mode 100644 index 0000000..72ac9f0 --- /dev/null +++ b/addons/community_school/controllers/__init__.py @@ -0,0 +1 @@ +from . import attendance diff --git a/addons/community_school/controllers/attendance.py b/addons/community_school/controllers/attendance.py new file mode 100644 index 0000000..8605540 --- /dev/null +++ b/addons/community_school/controllers/attendance.py @@ -0,0 +1,72 @@ +from odoo import fields, http +from odoo.exceptions import AccessDenied +from odoo.http import request + + +class SchoolAttendanceController(http.Controller): + + def _get_teacher_classes(self): + partner = request.env.user.partner_id + return request.env['community.school.class'].sudo().search([('teacher_id', '=', partner.id)]) + + @http.route(['/school/attendance'], type='http', auth='user', website=True) + def attendance_home(self, class_id=None, date=None, **kwargs): + classes = self._get_teacher_classes() + if not classes: + return request.render('community_school.portal_no_classes', {}) + + selected_class = classes.filtered(lambda c: c.id == int(class_id)) if class_id else classes[:1] + if not selected_class: + selected_class = classes[:1] + selected_date = date or fields.Date.context_today(request.env.user).isoformat() + + Enrollment = request.env['community.school.enrollment'].sudo() + enrollments = Enrollment.search([ + ('class_id', '=', selected_class.id), ('state', '=', 'enrolled'), + ]) + existing_by_enrollment = { + attendance.enrollment_id.id: attendance + for attendance in request.env['community.school.attendance'].sudo().search([ + ('class_id', '=', selected_class.id), ('date', '=', selected_date), + ]) + } + roster = [ + {'enrollment': enrollment, 'attendance': existing_by_enrollment.get(enrollment.id)} + for enrollment in enrollments + ] + + return request.render('community_school.portal_attendance', { + 'classes': classes, + 'selected_class': selected_class, + 'selected_date': selected_date, + 'roster': roster, + }) + + @http.route(['/school/attendance/save'], type='jsonrpc', auth='user', website=True) + def attendance_save(self, class_id=None, date=None, lines=None, **kwargs): + classes = self._get_teacher_classes() + klass = classes.filtered(lambda c: c.id == int(class_id)) + if not klass: + raise AccessDenied() + + Attendance = request.env['community.school.attendance'].sudo() + Enrollment = request.env['community.school.enrollment'].sudo() + saved = 0 + for line in (lines or []): + enrollment = Enrollment.browse(int(line.get('enrollment_id', 0))) + if not enrollment.exists() or enrollment.class_id.id != klass.id: + continue + vals = {'state': line.get('state', 'present'), 'notes': line.get('notes') or False} + record = Attendance.search([ + ('enrollment_id', '=', enrollment.id), ('date', '=', date), + ], limit=1) + if record: + record.write(vals) + else: + vals.update({'enrollment_id': enrollment.id, 'date': date}) + record = Attendance.create(vals) + saved += 1 + if record.state == 'absent': + record._send_absence_notice() + + return {'status': 'ok', 'saved': saved} diff --git a/addons/community_school/data/mail_templates.xml b/addons/community_school/data/mail_templates.xml new file mode 100644 index 0000000..f1dbd1f --- /dev/null +++ b/addons/community_school/data/mail_templates.xml @@ -0,0 +1,25 @@ + + + + + School: Absence Notice + + Absence notice: {{ object.enrollment_id.student_id.partner_id.name }} + {{ object.enrollment_id.student_id.parent_partner_id.id }} + + +
+

Dear Parent,

+

+ This is to let you know that + + was marked absent from + + on . +

+

Notes:

+
+
+
+
+
diff --git a/addons/community_school/models/__init__.py b/addons/community_school/models/__init__.py new file mode 100644 index 0000000..8262bba --- /dev/null +++ b/addons/community_school/models/__init__.py @@ -0,0 +1,8 @@ +from . import res_partner +from . import school_term +from . import school_level +from . import school_class +from . import school_student +from . import school_enrollment +from . import school_attendance +from . import res_config_settings diff --git a/addons/community_school/models/res_config_settings.py b/addons/community_school/models/res_config_settings.py new file mode 100644 index 0000000..859b9a5 --- /dev/null +++ b/addons/community_school/models/res_config_settings.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + school_at_risk_attendance_threshold = fields.Integer( + string='At-Risk Attendance Threshold (%)', + config_parameter='community_school.at_risk_attendance_threshold', + default=70, + help="Students whose attendance rate falls below this percentage are flagged at-risk.", + ) diff --git a/addons/community_school/models/res_partner.py b/addons/community_school/models/res_partner.py new file mode 100644 index 0000000..fb21d8e --- /dev/null +++ b/addons/community_school/models/res_partner.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + is_teacher = fields.Boolean(string='Teacher') diff --git a/addons/community_school/models/school_attendance.py b/addons/community_school/models/school_attendance.py new file mode 100644 index 0000000..ac7389e --- /dev/null +++ b/addons/community_school/models/school_attendance.py @@ -0,0 +1,31 @@ +from odoo import fields, models + + +class CommunitySchoolAttendance(models.Model): + _name = 'community.school.attendance' + _description = 'School Attendance' + _order = 'date desc, id desc' + + enrollment_id = fields.Many2one('community.school.enrollment', required=True, ondelete='cascade') + class_id = fields.Many2one(related='enrollment_id.class_id', store=True) + date = fields.Date(required=True, default=fields.Date.context_today) + state = fields.Selection( + [ + ('present', 'Present'), + ('absent', 'Absent'), + ('late', 'Late'), + ('excused', 'Excused'), + ], + default='present', required=True, + ) + notes = fields.Char() + + _enrollment_date_uniq = models.Constraint( + 'unique(enrollment_id, date)', 'Attendance for this student on this date is already recorded.', + ) + + def _send_absence_notice(self): + self.ensure_one() + template = self.env.ref('community_school.mail_template_absence_notice', raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=False) diff --git a/addons/community_school/models/school_class.py b/addons/community_school/models/school_class.py new file mode 100644 index 0000000..8d896d1 --- /dev/null +++ b/addons/community_school/models/school_class.py @@ -0,0 +1,65 @@ +from odoo import api, fields, models + +WEEKDAYS = [ + ('mon', 'Monday'), ('tue', 'Tuesday'), ('wed', 'Wednesday'), ('thu', 'Thursday'), + ('fri', 'Friday'), ('sat', 'Saturday'), ('sun', 'Sunday'), +] + + +class CommunitySchoolClass(models.Model): + _name = 'community.school.class' + _description = 'School Class' + _order = 'id desc' + + name = fields.Char(compute='_compute_name', store=True) + level_id = fields.Many2one('community.school.level', required=True) + term_id = fields.Many2one('community.school.term', required=True) + teacher_id = fields.Many2one('res.partner', domain=[('is_teacher', '=', True)]) + max_students = fields.Integer(default=20) + enrolled_count = fields.Integer(compute='_compute_enrolled_count', store=True) + weekday = fields.Selection(WEEKDAYS) + start_time = fields.Float() + end_time = fields.Float() + location = fields.Char() + slide_channel_id = fields.Many2one('slide.channel', readonly=True, copy=False) + enrollment_ids = fields.One2many('community.school.enrollment', 'class_id') + state = fields.Selection( + [('draft', 'Draft'), ('open', 'Open'), ('closed', 'Closed')], + default='draft', required=True, + ) + + @api.depends('level_id.name', 'term_id.name') + def _compute_name(self): + for record in self: + record.name = f"{record.level_id.name or '?'} - {record.term_id.name or '?'}" + + @api.depends('enrollment_ids.state') + def _compute_enrolled_count(self): + if not self: + return + counts = dict(self.env['community.school.enrollment']._read_group( + [('class_id', 'in', self.ids), ('state', '=', 'enrolled')], + groupby=['class_id'], aggregates=['__count'], + )) + for record in self: + record.enrolled_count = counts.get(record, 0) + + def _get_or_create_slide_channel(self): + self.ensure_one() + if self.slide_channel_id: + return self.slide_channel_id + channel = self.env['slide.channel'].create({ + 'name': self.name, + 'channel_type': 'training', + 'visibility': 'members', + 'enroll': 'invite', + }) + self.slide_channel_id = channel.id + return channel + + @api.model_create_multi + def create(self, vals_list): + classes = super().create(vals_list) + for record in classes: + record._get_or_create_slide_channel() + return classes diff --git a/addons/community_school/models/school_enrollment.py b/addons/community_school/models/school_enrollment.py new file mode 100644 index 0000000..a7d09d0 --- /dev/null +++ b/addons/community_school/models/school_enrollment.py @@ -0,0 +1,55 @@ +from odoo import api, fields, models + + +class CommunitySchoolEnrollment(models.Model): + _name = 'community.school.enrollment' + _description = 'School Enrollment' + _order = 'id desc' + + student_id = fields.Many2one('community.school.student', required=True, ondelete='cascade') + class_id = fields.Many2one('community.school.class', required=True, ondelete='cascade') + term_id = fields.Many2one(related='class_id.term_id', store=True) + enrollment_date = fields.Date(default=fields.Date.context_today) + state = fields.Selection( + [ + ('waitlist', 'Waitlisted'), + ('enrolled', 'Enrolled'), + ('completed', 'Completed'), + ('withdrawn', 'Withdrawn'), + ], + default='enrolled', required=True, + ) + payment_state = fields.Selection( + [('not_invoiced', 'Not Invoiced'), ('invoiced', 'Invoiced'), ('paid', 'Paid')], + default='not_invoiced', required=True, + ) + invoice_id = fields.Many2one('account.move', readonly=True, copy=False) + attendance_ids = fields.One2many('community.school.attendance', 'enrollment_id') + attendance_rate = fields.Float(compute='_compute_attendance_rate') + + is_at_risk = fields.Boolean(compute='_compute_is_at_risk') + + @api.depends('attendance_ids.state') + def _compute_attendance_rate(self): + for enrollment in self: + records = enrollment.attendance_ids + total = len(records) + if not total: + enrollment.attendance_rate = 0.0 + continue + present = len(records.filtered(lambda a: a.state in ('present', 'late'))) + enrollment.attendance_rate = (present / total) * 100.0 + + @api.depends('attendance_rate', 'attendance_ids') + def _compute_is_at_risk(self): + threshold = int(self.env['ir.config_parameter'].sudo().get_param( + 'community_school.at_risk_attendance_threshold', 70 + )) + for enrollment in self: + enrollment.is_at_risk = bool(enrollment.attendance_ids) and enrollment.attendance_rate < threshold + + def action_enroll_from_waitlist(self): + for enrollment in self: + if enrollment.state == 'waitlist' and enrollment.class_id.enrolled_count < enrollment.class_id.max_students: + enrollment.state = 'enrolled' + return True diff --git a/addons/community_school/models/school_level.py b/addons/community_school/models/school_level.py new file mode 100644 index 0000000..599aab1 --- /dev/null +++ b/addons/community_school/models/school_level.py @@ -0,0 +1,16 @@ +from odoo import fields, models + + +class CommunitySchoolLevel(models.Model): + _name = 'community.school.level' + _description = 'School Level' + _order = 'sequence, id' + + name = fields.Char(required=True, translate=True, help="e.g. 'Beginner' or 'Grade 3' - defined by the admin.") + code = fields.Char(required=True) + sequence = fields.Integer(default=10) + min_age = fields.Integer() + max_age = fields.Integer() + active = fields.Boolean(default=True) + + _code_uniq = models.Constraint('unique(code)', 'A level with this code already exists.') diff --git a/addons/community_school/models/school_student.py b/addons/community_school/models/school_student.py new file mode 100644 index 0000000..f194517 --- /dev/null +++ b/addons/community_school/models/school_student.py @@ -0,0 +1,32 @@ +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class CommunitySchoolStudent(models.Model): + _name = 'community.school.student' + _description = 'School Student' + _order = 'id desc' + + partner_id = fields.Many2one('res.partner', string='Student', required=True) + parent_partner_id = fields.Many2one('res.partner', string='Parent/Guardian', required=True) + date_of_birth = fields.Date() + age = fields.Integer(compute='_compute_age') + proficiency = fields.Selection( + [('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], + default='beginner', + ) + grade_ref = fields.Char(string='Grade', help="Free-text grade reference, e.g. 'Grade 3'.") + health_notes = fields.Text() + emergency_contact_name = fields.Char() + emergency_contact_phone = fields.Char() + enrollment_ids = fields.One2many('community.school.enrollment', 'student_id') + + @api.depends('date_of_birth') + def _compute_age(self): + today = fields.Date.context_today(self) + for student in self: + if student.date_of_birth: + student.age = relativedelta(today, student.date_of_birth).years + else: + student.age = 0 diff --git a/addons/community_school/models/school_term.py b/addons/community_school/models/school_term.py new file mode 100644 index 0000000..0a7a676 --- /dev/null +++ b/addons/community_school/models/school_term.py @@ -0,0 +1,17 @@ +from odoo import fields, models + + +class CommunitySchoolTerm(models.Model): + _name = 'community.school.term' + _description = 'School Term' + _order = 'start_date desc' + + name = fields.Char(required=True) + start_date = fields.Date(required=True) + end_date = fields.Date(required=True) + registration_open = fields.Boolean(default=False) + registration_deadline = fields.Date() + state = fields.Selection( + [('draft', 'Draft'), ('open', 'Open'), ('closed', 'Closed')], + default='draft', required=True, + ) diff --git a/addons/community_school/security/ir.model.access.csv b/addons/community_school/security/ir.model.access.csv index 97dd8b9..3a4cc6e 100644 --- a/addons/community_school/security/ir.model.access.csv +++ b/addons/community_school/security/ir.model.access.csv @@ -1 +1,17 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_community_school_term_coordinator,community.school.term coordinator,model_community_school_term,group_school_coordinator,1,1,1,1 +access_community_school_term_teacher,community.school.term teacher read,model_community_school_term,group_school_teacher,1,0,0,0 +access_community_school_level_coordinator,community.school.level coordinator,model_community_school_level,group_school_coordinator,1,1,1,1 +access_community_school_level_teacher,community.school.level teacher read,model_community_school_level,group_school_teacher,1,0,0,0 +access_community_school_class_coordinator,community.school.class coordinator,model_community_school_class,group_school_coordinator,1,1,1,1 +access_community_school_class_teacher,community.school.class teacher read,model_community_school_class,group_school_teacher,1,0,0,0 +access_community_school_student_coordinator,community.school.student coordinator,model_community_school_student,group_school_coordinator,1,1,1,1 +access_community_school_student_teacher,community.school.student teacher read,model_community_school_student,group_school_teacher,1,0,0,0 +access_community_school_enrollment_coordinator,community.school.enrollment coordinator,model_community_school_enrollment,group_school_coordinator,1,1,1,1 +access_community_school_enrollment_teacher,community.school.enrollment teacher read,model_community_school_enrollment,group_school_teacher,1,0,0,0 +access_community_school_attendance_coordinator,community.school.attendance coordinator,model_community_school_attendance,group_school_coordinator,1,1,1,1 +access_community_school_attendance_teacher,community.school.attendance teacher,model_community_school_attendance,group_school_teacher,1,1,1,0 +access_community_school_student_portal,community.school.student portal read,model_community_school_student,base.group_portal,1,0,0,0 +access_community_school_enrollment_portal,community.school.enrollment portal read,model_community_school_enrollment,base.group_portal,1,0,0,0 +access_community_school_attendance_portal,community.school.attendance portal read,model_community_school_attendance,base.group_portal,1,0,0,0 +access_community_school_class_portal,community.school.class portal read,model_community_school_class,base.group_portal,1,0,0,0 diff --git a/addons/community_school/security/school_security.xml b/addons/community_school/security/school_security.xml new file mode 100644 index 0000000..041c8ad --- /dev/null +++ b/addons/community_school/security/school_security.xml @@ -0,0 +1,26 @@ + + + + School + 21 + + + + School + + + + + School Coordinator + + + Can manage terms, levels, classes, students and enrollment. + + + + Teacher + + + Can take attendance for their own classes. + + diff --git a/addons/community_school/static/src/js/school_attendance.js b/addons/community_school/static/src/js/school_attendance.js new file mode 100644 index 0000000..05cc8fd --- /dev/null +++ b/addons/community_school/static/src/js/school_attendance.js @@ -0,0 +1,40 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; + +export class SchoolAttendance extends Interaction { + static selector = ".o_school_attendance"; + dynamicContent = { + ".o_attendance_save_btn": { "t-on-click": this.onSave }, + ".o_attendance_class_select": { "t-on-change": this.onReload }, + ".o_attendance_date_input": { "t-on-change": this.onReload }, + }; + + setup() { + this.resultEl = this.el.querySelector(".o_attendance_result"); + } + + onReload() { + const classId = this.el.querySelector(".o_attendance_class_select").value; + const date = this.el.querySelector(".o_attendance_date_input").value; + window.location.href = `/school/attendance?class_id=${classId}&date=${date}`; + } + + async onSave() { + const classId = this.el.dataset.classId; + const date = this.el.dataset.date; + const lines = [...this.el.querySelectorAll("tbody tr")].map((row) => ({ + enrollment_id: row.dataset.enrollmentId, + state: row.querySelector(".o_attendance_state_select").value, + notes: row.querySelector(".o_attendance_notes_input").value, + })); + + const result = await this.waitFor( + rpc("/school/attendance/save", { class_id: classId, date, lines }) + ); + this.resultEl.className = "alert alert-success mt-2"; + this.resultEl.textContent = `Saved ${result.saved} record(s).`; + } +} + +registry.category("public.interactions").add("community_school.school_attendance", SchoolAttendance); diff --git a/addons/community_school/tests/__init__.py b/addons/community_school/tests/__init__.py index e69de29..84926b2 100644 --- a/addons/community_school/tests/__init__.py +++ b/addons/community_school/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_school +from . import test_school_attendance diff --git a/addons/community_school/tests/test_school.py b/addons/community_school/tests/test_school.py new file mode 100644 index 0000000..c1a3e83 --- /dev/null +++ b/addons/community_school/tests/test_school.py @@ -0,0 +1,79 @@ +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSchool(TransactionCase): + + def setUp(self): + super().setUp() + self.term = self.env['community.school.term'].create({ + 'name': 'Fall 2030', + 'start_date': '2030-09-01', + 'end_date': '2030-12-15', + }) + self.level = self.env['community.school.level'].create({ + 'name': 'Beginner', + 'code': 'BEG', + 'min_age': 5, + 'max_age': 8, + }) + self.klass = self.env['community.school.class'].create({ + 'level_id': self.level.id, + 'term_id': self.term.id, + 'max_students': 2, + }) + self.parent = self.env['res.partner'].create({'name': 'Parent One'}) + self.child = self.env['res.partner'].create({'name': 'Child One'}) + self.student = self.env['community.school.student'].create({ + 'partner_id': self.child.id, + 'parent_partner_id': self.parent.id, + 'date_of_birth': '2023-01-01', + }) + + def test_class_auto_creates_slide_channel(self): + self.assertTrue(self.klass.slide_channel_id, "Creating a class should auto-create a slide channel") + + def test_class_name_computed(self): + self.assertEqual(self.klass.name, f"{self.level.name} - {self.term.name}") + + def test_enrollment_updates_enrolled_count(self): + self.assertEqual(self.klass.enrolled_count, 0) + self.env['community.school.enrollment'].create({ + 'student_id': self.student.id, + 'class_id': self.klass.id, + }) + self.klass.invalidate_recordset() + self.assertEqual(self.klass.enrolled_count, 1) + + def test_withdrawn_enrollment_does_not_count(self): + enrollment = self.env['community.school.enrollment'].create({ + 'student_id': self.student.id, + 'class_id': self.klass.id, + }) + enrollment.state = 'withdrawn' + self.klass.invalidate_recordset() + self.assertEqual(self.klass.enrolled_count, 0) + + def test_attendance_rate_computed(self): + enrollment = self.env['community.school.enrollment'].create({ + 'student_id': self.student.id, + 'class_id': self.klass.id, + }) + self.env['community.school.attendance'].create({ + 'enrollment_id': enrollment.id, 'date': '2030-09-08', 'state': 'present', + }) + self.env['community.school.attendance'].create({ + 'enrollment_id': enrollment.id, 'date': '2030-09-15', 'state': 'absent', + }) + self.env['community.school.attendance'].create({ + 'enrollment_id': enrollment.id, 'date': '2030-09-22', 'state': 'present', + }) + self.assertAlmostEqual(enrollment.attendance_rate, (2 / 3) * 100) + + def test_age_computed(self): + dob = fields.Date.today() - relativedelta(years=5, days=1) + self.student.date_of_birth = dob + self.assertEqual(self.student.age, 5) diff --git a/addons/community_school/tests/test_school_attendance.py b/addons/community_school/tests/test_school_attendance.py new file mode 100644 index 0000000..6817170 --- /dev/null +++ b/addons/community_school/tests/test_school_attendance.py @@ -0,0 +1,55 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSchoolAttendance(TransactionCase): + + def setUp(self): + super().setUp() + self.term = self.env['community.school.term'].create({ + 'name': 'Fall 2030', 'start_date': '2030-09-01', 'end_date': '2030-12-15', + }) + self.level = self.env['community.school.level'].create({'name': 'Beginner', 'code': 'ATT-BEG'}) + self.klass = self.env['community.school.class'].create({ + 'level_id': self.level.id, 'term_id': self.term.id, + }) + self.parent = self.env['res.partner'].create({'name': 'Attendance Parent', 'email': 'parent@example.com'}) + self.child = self.env['res.partner'].create({'name': 'Attendance Child'}) + self.student = self.env['community.school.student'].create({ + 'partner_id': self.child.id, 'parent_partner_id': self.parent.id, + }) + self.enrollment = self.env['community.school.enrollment'].create({ + 'student_id': self.student.id, 'class_id': self.klass.id, + }) + + def test_absence_queues_mail_to_parent(self): + mail_count_before = self.env['mail.mail'].search_count([]) + attendance = self.env['community.school.attendance'].create({ + 'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'absent', + }) + attendance._send_absence_notice() + mail_count_after = self.env['mail.mail'].search_count([]) + self.assertGreater(mail_count_after, mail_count_before) + + def test_present_attendance_does_not_require_notice(self): + attendance = self.env['community.school.attendance'].create({ + 'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'present', + }) + self.assertEqual(attendance.state, 'present') + + def test_at_risk_flag(self): + self.env['ir.config_parameter'].sudo().set_param('community_school.at_risk_attendance_threshold', '70') + for i, state in enumerate(['absent', 'absent', 'present']): + self.env['community.school.attendance'].create({ + 'enrollment_id': self.enrollment.id, 'date': f'2030-09-0{i + 1}', 'state': state, + }) + self.assertTrue(self.enrollment.is_at_risk, "1/3 present should be below a 70% threshold") + + def test_unique_attendance_per_enrollment_and_date(self): + self.env['community.school.attendance'].create({ + 'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'present', + }) + with self.assertRaises(Exception): + self.env['community.school.attendance'].create({ + 'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'absent', + }) diff --git a/addons/community_school/views/portal_attendance_templates.xml b/addons/community_school/views/portal_attendance_templates.xml new file mode 100644 index 0000000..1335071 --- /dev/null +++ b/addons/community_school/views/portal_attendance_templates.xml @@ -0,0 +1,63 @@ + + + + + + diff --git a/addons/community_school/views/res_config_settings_views.xml b/addons/community_school/views/res_config_settings_views.xml new file mode 100644 index 0000000..474a1fb --- /dev/null +++ b/addons/community_school/views/res_config_settings_views.xml @@ -0,0 +1,21 @@ + + + + res.config.settings.view.form.school + res.config.settings + + + + + + + + + + + + + + diff --git a/addons/community_school/views/school_menus.xml b/addons/community_school/views/school_menus.xml new file mode 100644 index 0000000..8b5c612 --- /dev/null +++ b/addons/community_school/views/school_menus.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/community_school/views/school_views.xml b/addons/community_school/views/school_views.xml new file mode 100644 index 0000000..8f4ffff --- /dev/null +++ b/addons/community_school/views/school_views.xml @@ -0,0 +1,231 @@ + + + + + community.school.term.list + community.school.term + + + + + + + + + + + + community.school.term.form + community.school.term + +
+ +

+ + + + + + + + + + + +
+
+
+
+ + Terms + community.school.term + list,form + + + + + community.school.level.list + community.school.level + + + + + + + + + + + + Levels + community.school.level + list + + + + + community.school.class.list + community.school.class + + + + + + + + + + + + + community.school.class.form + community.school.class + +
+ +

+ + + + + + + + + + + + + + + + + +
+
+
+
+ + Classes + community.school.class + list,form + + + + + community.school.student.list + community.school.student + + + + + + + + + + + community.school.student.form + community.school.student + +
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + Students + community.school.student + list,form + + + + + community.school.enrollment.list + community.school.enrollment + + + + + + + + + + + + + Enrollments + community.school.enrollment + list,form + + + + At-Risk Students + community.school.enrollment + list,form + [('is_at_risk', '=', True)] + + + + + community.school.attendance.pivot + community.school.attendance + + + + + + + + + + community.school.attendance.list + community.school.attendance + + + + + + + + + + + + Attendance Report + community.school.attendance + pivot,list + +