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 <app>/<block>/<setting> 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 <noreply@anthropic.com>
This commit is contained in:
parent
db50c3f15c
commit
7ac5880f20
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
@ -29,8 +29,21 @@ with no code change.
|
|||||||
'website_slides',
|
'website_slides',
|
||||||
'account',
|
'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': [],
|
'demo': [],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_frontend': [
|
||||||
|
'community_school/static/src/js/school_attendance.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
'images': ['static/description/banner.png'],
|
'images': ['static/description/banner.png'],
|
||||||
'application': True,
|
'application': True,
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
1
addons/community_school/controllers/__init__.py
Normal file
1
addons/community_school/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import attendance
|
||||||
72
addons/community_school/controllers/attendance.py
Normal file
72
addons/community_school/controllers/attendance.py
Normal file
@ -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}
|
||||||
25
addons/community_school/data/mail_templates.xml
Normal file
25
addons/community_school/data/mail_templates.xml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="mail_template_absence_notice" model="mail.template">
|
||||||
|
<field name="name">School: Absence Notice</field>
|
||||||
|
<field name="model_id" ref="model_community_school_attendance"/>
|
||||||
|
<field name="subject">Absence notice: {{ object.enrollment_id.student_id.partner_id.name }}</field>
|
||||||
|
<field name="partner_to">{{ object.enrollment_id.student_id.parent_partner_id.id }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||||
|
<p>Dear <t t-out="object.enrollment_id.student_id.parent_partner_id.name or ''">Parent</t>,</p>
|
||||||
|
<p>
|
||||||
|
This is to let you know that
|
||||||
|
<t t-out="object.enrollment_id.student_id.partner_id.name or ''"/>
|
||||||
|
was marked absent from
|
||||||
|
<t t-out="object.class_id.name or ''"/>
|
||||||
|
on <t t-out="format_date(object.date)"/>.
|
||||||
|
</p>
|
||||||
|
<p t-if="object.notes">Notes: <t t-out="object.notes"/></p>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
8
addons/community_school/models/__init__.py
Normal file
8
addons/community_school/models/__init__.py
Normal file
@ -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
|
||||||
12
addons/community_school/models/res_config_settings.py
Normal file
12
addons/community_school/models/res_config_settings.py
Normal file
@ -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.",
|
||||||
|
)
|
||||||
7
addons/community_school/models/res_partner.py
Normal file
7
addons/community_school/models/res_partner.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
is_teacher = fields.Boolean(string='Teacher')
|
||||||
31
addons/community_school/models/school_attendance.py
Normal file
31
addons/community_school/models/school_attendance.py
Normal file
@ -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)
|
||||||
65
addons/community_school/models/school_class.py
Normal file
65
addons/community_school/models/school_class.py
Normal file
@ -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
|
||||||
55
addons/community_school/models/school_enrollment.py
Normal file
55
addons/community_school/models/school_enrollment.py
Normal file
@ -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
|
||||||
16
addons/community_school/models/school_level.py
Normal file
16
addons/community_school/models/school_level.py
Normal file
@ -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.')
|
||||||
32
addons/community_school/models/school_student.py
Normal file
32
addons/community_school/models/school_student.py
Normal file
@ -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
|
||||||
17
addons/community_school/models/school_term.py
Normal file
17
addons/community_school/models/school_term.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
@ -1 +1,17 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
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
|
||||||
|
|||||||
|
26
addons/community_school/security/school_security.xml
Normal file
26
addons/community_school/security/school_security.xml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="module_category_school" model="ir.module.category">
|
||||||
|
<field name="name">School</field>
|
||||||
|
<field name="sequence">21</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="privilege_school" model="res.groups.privilege">
|
||||||
|
<field name="name">School</field>
|
||||||
|
<field name="category_id" ref="module_category_school"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_school_coordinator" model="res.groups">
|
||||||
|
<field name="name">School Coordinator</field>
|
||||||
|
<field name="privilege_id" ref="privilege_school"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
<field name="comment">Can manage terms, levels, classes, students and enrollment.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_school_teacher" model="res.groups">
|
||||||
|
<field name="name">Teacher</field>
|
||||||
|
<field name="privilege_id" ref="privilege_school"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
<field name="comment">Can take attendance for their own classes.</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
40
addons/community_school/static/src/js/school_attendance.js
Normal file
40
addons/community_school/static/src/js/school_attendance.js
Normal file
@ -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);
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
from . import test_school
|
||||||
|
from . import test_school_attendance
|
||||||
79
addons/community_school/tests/test_school.py
Normal file
79
addons/community_school/tests/test_school.py
Normal file
@ -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)
|
||||||
55
addons/community_school/tests/test_school_attendance.py
Normal file
55
addons/community_school/tests/test_school_attendance.py
Normal file
@ -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',
|
||||||
|
})
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="portal_no_classes" name="School Attendance: No Classes">
|
||||||
|
<t t-call="website.layout">
|
||||||
|
<div class="container" style="max-width: 480px; margin-top: 60px;">
|
||||||
|
<p class="alert alert-info">You are not assigned as a teacher to any class.</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="portal_attendance" name="School Attendance">
|
||||||
|
<t t-call="website.layout">
|
||||||
|
<div class="container o_school_attendance" style="max-width: 640px; margin-top: 24px; margin-bottom: 60px;"
|
||||||
|
t-att-data-class-id="selected_class.id" t-att-data-date="selected_date">
|
||||||
|
<h2>Attendance</h2>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Class</label>
|
||||||
|
<select class="form-select o_attendance_class_select">
|
||||||
|
<t t-foreach="classes" t-as="klass">
|
||||||
|
<option t-att-value="klass.id" t-att-selected="'selected' if klass.id == selected_class.id else None" t-out="klass.name"/>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Date</label>
|
||||||
|
<input type="date" class="form-control o_attendance_date_input" t-att-value="selected_date"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Student</th><th>Status</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="roster" t-as="row">
|
||||||
|
<tr t-att-data-enrollment-id="row['enrollment'].id">
|
||||||
|
<td t-out="row['enrollment'].student_id.partner_id.name"/>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm o_attendance_state_select">
|
||||||
|
<t t-set="current_state" t-value="row['attendance'].state if row['attendance'] else 'present'"/>
|
||||||
|
<option value="present" t-att-selected="'selected' if current_state == 'present' else None">Present</option>
|
||||||
|
<option value="absent" t-att-selected="'selected' if current_state == 'absent' else None">Absent</option>
|
||||||
|
<option value="late" t-att-selected="'selected' if current_state == 'late' else None">Late</option>
|
||||||
|
<option value="excused" t-att-selected="'selected' if current_state == 'excused' else None">Excused</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm o_attendance_notes_input"
|
||||||
|
t-att-value="row['attendance'].notes if row['attendance'] else ''"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button class="btn btn-primary o_attendance_save_btn" type="button">Save Attendance</button>
|
||||||
|
<div class="o_attendance_result mt-2" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
21
addons/community_school/views/res_config_settings_views.xml
Normal file
21
addons/community_school/views/res_config_settings_views.xml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form_school" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.school</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="School" string="School" name="community_school"
|
||||||
|
groups="community_school.group_school_coordinator">
|
||||||
|
<block title="School" id="school_settings">
|
||||||
|
<setting id="school_at_risk_threshold_setting" string="At-Risk Attendance Threshold"
|
||||||
|
help="Students below this attendance percentage are flagged at-risk">
|
||||||
|
<field name="school_at_risk_attendance_threshold"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
35
addons/community_school/views/school_menus.xml
Normal file
35
addons/community_school/views/school_menus.xml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_school_root" name="School" sequence="26"
|
||||||
|
groups="community_school.group_school_coordinator,community_school.group_school_teacher"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_students" name="Students"
|
||||||
|
parent="menu_school_root" action="action_school_student" sequence="10"
|
||||||
|
groups="community_school.group_school_coordinator"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_classes" name="Classes"
|
||||||
|
parent="menu_school_root" action="action_school_class" sequence="20"
|
||||||
|
groups="community_school.group_school_coordinator"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_enrollments" name="Enrollments"
|
||||||
|
parent="menu_school_root" action="action_school_enrollment" sequence="30"
|
||||||
|
groups="community_school.group_school_coordinator"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_at_risk" name="At-Risk Students"
|
||||||
|
parent="menu_school_root" action="action_school_enrollment_at_risk" sequence="35"
|
||||||
|
groups="community_school.group_school_coordinator"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_attendance_report" name="Attendance Report"
|
||||||
|
parent="menu_school_root" action="action_school_attendance_report" sequence="40"
|
||||||
|
groups="community_school.group_school_coordinator"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_configuration" name="Configuration"
|
||||||
|
parent="menu_school_root" sequence="90"
|
||||||
|
groups="community_school.group_school_coordinator"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_terms" name="Terms"
|
||||||
|
parent="menu_school_configuration" action="action_school_term" sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_school_levels" name="Levels"
|
||||||
|
parent="menu_school_configuration" action="action_school_level" sequence="20"/>
|
||||||
|
</odoo>
|
||||||
231
addons/community_school/views/school_views.xml
Normal file
231
addons/community_school/views/school_views.xml
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Term -->
|
||||||
|
<record id="view_school_term_list" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.term.list</field>
|
||||||
|
<field name="model">community.school.term</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="School Terms">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="start_date"/>
|
||||||
|
<field name="end_date"/>
|
||||||
|
<field name="registration_open"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_school_term_form" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.term.form</field>
|
||||||
|
<field name="model">community.school.term</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="School Term">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title"><h1><field name="name"/></h1></div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="start_date"/>
|
||||||
|
<field name="end_date"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="registration_open"/>
|
||||||
|
<field name="registration_deadline"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="action_school_term" model="ir.actions.act_window">
|
||||||
|
<field name="name">Terms</field>
|
||||||
|
<field name="res_model">community.school.term</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Level -->
|
||||||
|
<record id="view_school_level_list" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.level.list</field>
|
||||||
|
<field name="model">community.school.level</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="School Levels" editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="min_age"/>
|
||||||
|
<field name="max_age"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="action_school_level" model="ir.actions.act_window">
|
||||||
|
<field name="name">Levels</field>
|
||||||
|
<field name="res_model">community.school.level</field>
|
||||||
|
<field name="view_mode">list</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Class -->
|
||||||
|
<record id="view_school_class_list" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.class.list</field>
|
||||||
|
<field name="model">community.school.class</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="School Classes">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="teacher_id"/>
|
||||||
|
<field name="weekday"/>
|
||||||
|
<field name="enrolled_count"/>
|
||||||
|
<field name="max_students"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_school_class_form" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.class.form</field>
|
||||||
|
<field name="model">community.school.class</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="School Class">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title"><h1><field name="name" readonly="1"/></h1></div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="level_id"/>
|
||||||
|
<field name="term_id"/>
|
||||||
|
<field name="teacher_id"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="weekday"/>
|
||||||
|
<field name="start_time" widget="float_time"/>
|
||||||
|
<field name="end_time" widget="float_time"/>
|
||||||
|
<field name="location"/>
|
||||||
|
<field name="max_students"/>
|
||||||
|
<field name="enrolled_count"/>
|
||||||
|
<field name="slide_channel_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="action_school_class" model="ir.actions.act_window">
|
||||||
|
<field name="name">Classes</field>
|
||||||
|
<field name="res_model">community.school.class</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Student -->
|
||||||
|
<record id="view_school_student_list" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.student.list</field>
|
||||||
|
<field name="model">community.school.student</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Students">
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="parent_partner_id"/>
|
||||||
|
<field name="age"/>
|
||||||
|
<field name="proficiency"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_school_student_form" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.student.form</field>
|
||||||
|
<field name="model">community.school.student</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Student">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title"><h1><field name="partner_id"/></h1></div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="parent_partner_id"/>
|
||||||
|
<field name="date_of_birth"/>
|
||||||
|
<field name="age" readonly="1"/>
|
||||||
|
<field name="proficiency"/>
|
||||||
|
<field name="grade_ref"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="emergency_contact_name"/>
|
||||||
|
<field name="emergency_contact_phone"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Health Notes">
|
||||||
|
<field name="health_notes" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Enrollments" name="enrollments">
|
||||||
|
<field name="enrollment_ids">
|
||||||
|
<list>
|
||||||
|
<field name="class_id"/>
|
||||||
|
<field name="term_id"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="payment_state"/>
|
||||||
|
<field name="attendance_rate" widget="percentage"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="action_school_student" model="ir.actions.act_window">
|
||||||
|
<field name="name">Students</field>
|
||||||
|
<field name="res_model">community.school.student</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Enrollment -->
|
||||||
|
<record id="view_school_enrollment_list" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.enrollment.list</field>
|
||||||
|
<field name="model">community.school.enrollment</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Enrollments">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="class_id"/>
|
||||||
|
<field name="term_id"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="payment_state"/>
|
||||||
|
<field name="attendance_rate" widget="percentage"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="action_school_enrollment" model="ir.actions.act_window">
|
||||||
|
<field name="name">Enrollments</field>
|
||||||
|
<field name="res_model">community.school.enrollment</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_school_enrollment_at_risk" model="ir.actions.act_window">
|
||||||
|
<field name="name">At-Risk Students</field>
|
||||||
|
<field name="res_model">community.school.enrollment</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="domain">[('is_at_risk', '=', True)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Attendance pivot report -->
|
||||||
|
<record id="view_school_attendance_pivot" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.attendance.pivot</field>
|
||||||
|
<field name="model">community.school.attendance</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<pivot string="Attendance">
|
||||||
|
<field name="class_id" type="row"/>
|
||||||
|
<field name="date" type="col" interval="week"/>
|
||||||
|
<field name="state" type="row"/>
|
||||||
|
</pivot>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_school_attendance_list" model="ir.ui.view">
|
||||||
|
<field name="name">community.school.attendance.list</field>
|
||||||
|
<field name="model">community.school.attendance</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Attendance">
|
||||||
|
<field name="date"/>
|
||||||
|
<field name="class_id"/>
|
||||||
|
<field name="enrollment_id"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="notes"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="action_school_attendance_report" model="ir.actions.act_window">
|
||||||
|
<field name="name">Attendance Report</field>
|
||||||
|
<field name="res_model">community.school.attendance</field>
|
||||||
|
<field name="view_mode">pivot,list</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Loading…
x
Reference in New Issue
Block a user