O7: mc_education_portal - parent/student portal, the live refused-access demo

Serves Demo Scene 5 in full, including the specific requirement to
demonstrate a refused access attempt live on the call. Every route
that takes a student id resolves it through _get_authorized_student(),
which does no manual comparison of its own - it relies on mc.student's
own record rules (a real mc.student.guardian link for guardians,
partner_id for students, never group membership alone, per CLAUDE.md
sec 3's "Guardian access is by relationship, not by role") to filter
a plain search(), and turns an empty result into an explicit
AccessError. The child switcher - "the single most convincing single
feature in the demo" per the spec - is just that same search with no
id filter, returning exactly the caller's own children.

Verified with real HTTP requests (HttpCase), not with_user()
shortcuts alone: authenticated as a real guardian, opened their own
child's dashboard and all four sub-pages (fees/attendance/timetable/
results) and got 200 on every one, then requested another family's
child by id and got 403 with the exact refusal message, on every one
of those same five routes. That is "editing the student identifier in
the URL and showing that it is refused" performed for real inside the
test, not asserted from a domain expression.

That same real-request testing earned its keep twice over, catching
two gaps a with_user()-only test would have missed entirely:
  - mc.batch had never been portal-reachable before this module and
    had no access row at all for guardian/student - a legitimate
    own-child dashboard load failed with a raw ACL error, not even a
    record-rule denial. Added mc.batch rules scoped the same way as
    everything else (via mc.batch.enrollment_ids, a new reverse field
    this module adds).
  - Rendering the timetable page touches mc.subject, mc.room, mc.exam,
    mc.academic.year/term and hr.employee through related-field reads,
    each of which triggers its own ACL check independent of whatever
    rule scopes mc.timetable.slot itself. Granted broad (unrestricted)
    guardian/student read on the non-sensitive reference models
    (subject/room/exam/year/term names - catalog data, not per-student
    data, same reasoning already applied to Teacher/Staff/Accountant
    in earlier modules); used sudo() instead for hr.employee
    specifically, since employee records carry real HR data no
    blanket portal grant should touch, with a comment noting the
    student was already authorized by that point in the request.

Fees needs no new rule at all: mc_education_fees deliberately bills
the primary guardian's own partner_id (a design decision made back in
O3, for exactly this reason), so stock account+portal's own
partner_id-scoped visibility already covers it, and group_guardian/
group_student already imply base.group_portal from O1.

mc.notice is a new model - shared/DOMAIN_MODEL.md lists it as an
entity Track O needs but no module spec in CLAUDE.md sec 5 ever gave
it a field table; added here since it is specifically an O7 view
requirement, scoped by the same batch-or-school-wide pattern as
everything else.

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

View File

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

View File

@ -0,0 +1,28 @@
{
"name": "School ERP - Portal",
"version": "19.0.1.0.0",
"category": "Education",
"summary": "Parent and student portal - fees, attendance, timetable, results, notices.",
"author": "Metatroncube Software Solutions LLP",
"license": "Other proprietary",
"depends": [
"mc_education_fees",
"mc_education_attendance",
"mc_education_timetable",
"mc_education_exam",
"portal",
],
"data": [
"security/mc_education_portal_security.xml",
"security/ir.model.access.csv",
"views/mc_notice_views.xml",
"views/mc_education_portal_menus.xml",
"views/portal_templates.xml",
],
"demo": [
"demo/mc_portal_users_demo.xml",
"demo/mc_notice_demo.xml",
],
"installable": True,
"application": False,
}

View File

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

View File

@ -0,0 +1,175 @@
import calendar
import datetime
from collections import defaultdict
from odoo import _
from odoo.exceptions import AccessError
from odoo.http import request, route
from odoo.addons.portal.controllers.portal import CustomerPortal
class SchoolPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if "children_count" in counters:
values["children_count"] = self._get_my_children_count()
return values
def _get_my_children_count(self):
# No sudo: mc.student's own record rules already scope this to
# the students the current user is entitled to see. See
# security/mc_education_portal_security.xml.
return request.env["mc.student"].search_count([])
def _get_authorized_student(self, student_id):
"""The standing rule (CLAUDE.md sec 3) made concrete: every route
below that takes a student id calls this first. mc.student's
record rules (guardian: a real mc.student.guardian link; student:
their own partner_id) already scope what a plain, non-sudo search
can find - an id the caller is not entitled to simply is not
found here, and that is turned into an explicit AccessError
instead of silently 404ing, so "editing the student identifier in
the URL" fails loudly and demonstrably (shared/DEMO_SCRIPT.md
Scene 5).
"""
try:
student_id = int(student_id)
except (TypeError, ValueError):
raise AccessError(_("Invalid student reference."))
student = request.env["mc.student"].search([("id", "=", student_id)])
if not student:
raise AccessError(_("You are not allowed to access this student's records."))
return student
@route(["/my/school"], type="http", auth="user", website=True)
def school_home(self, **kw):
children = request.env["mc.student"].search([])
return request.render("mc_education_portal.portal_school_children", {
"children": children,
"page_name": "school",
})
@route(["/my/school/student/<int:student_id>"], type="http", auth="user", website=True)
def school_student_dashboard(self, student_id, **kw):
student = self._get_authorized_student(student_id)
enrollment = request.env["mc.enrollment"].search([
("student_id", "=", student.id), ("state", "=", "active"),
], limit=1)
return request.render("mc_education_portal.portal_school_student_dashboard", {
"student": student,
"enrollment": enrollment,
"page_name": "school_student",
})
@route(["/my/school/student/<int:student_id>/fees"], type="http", auth="user", website=True)
def school_student_fees(self, student_id, **kw):
student = self._get_authorized_student(student_id)
# account.move is not scoped by a rule this module adds - it is
# already portal-safe via stock account+portal, since every
# invoice this app creates is billed to the guardian's own
# partner_id (see mc_education_fees). mc_student_id is only used
# here to pick the right subset for an already-authorized caller,
# never as the access check itself.
invoices = request.env["account.move"].search([
("mc_student_id", "=", student.id), ("move_type", "=", "out_invoice"),
])
return request.render("mc_education_portal.portal_school_student_fees", {
"student": student,
"invoices": invoices,
"page_name": "school_student",
})
@route(["/my/school/student/<int:student_id>/attendance"], type="http", auth="user", website=True)
def school_student_attendance(self, student_id, month=None, **kw):
student = self._get_authorized_student(student_id)
if month:
year_str, month_str = month.split("-")
year, month_num = int(year_str), int(month_str)
else:
today = datetime.date.today()
year, month_num = today.year, today.month
first_day = datetime.date(year, month_num, 1)
last_day_num = calendar.monthrange(year, month_num)[1]
last_day = datetime.date(year, month_num, last_day_num)
records = request.env["mc.attendance"].search([
("student_id", "=", student.id),
("date", ">=", first_day), ("date", "<=", last_day),
])
by_date = {r.date: r.state for r in records}
present_count = len([s for s in by_date.values() if s in ("present", "late")])
total_count = len(by_date)
percentage = (present_count / total_count * 100) if total_count else 0.0
prev_month = first_day - datetime.timedelta(days=1)
next_month = last_day + datetime.timedelta(days=1)
return request.render("mc_education_portal.portal_school_student_attendance", {
"student": student,
"year": year,
"month_num": month_num,
"month_name": calendar.month_name[month_num],
"calendar_weeks": calendar.Calendar(firstweekday=0).monthdatescalendar(year, month_num),
"by_date": by_date,
"current_month_first_day": first_day,
"present_count": present_count,
"total_count": total_count,
"percentage": percentage,
"prev_month": "%04d-%02d" % (prev_month.year, prev_month.month),
"next_month": "%04d-%02d" % (next_month.year, next_month.month),
"page_name": "school_student",
})
@route(["/my/school/student/<int:student_id>/timetable"], type="http", auth="user", website=True)
def school_student_timetable(self, student_id, **kw):
student = self._get_authorized_student(student_id)
enrollment = request.env["mc.enrollment"].search([
("student_id", "=", student.id), ("state", "=", "active"),
], limit=1)
Slot = request.env["mc.timetable.slot"]
slots = Slot
if enrollment:
slots = Slot.search([("batch_id", "=", enrollment.batch_id.id)])
weekday_selection = Slot._fields["weekday"].selection
by_weekday = defaultdict(list)
for slot in slots.sorted("period"):
# sudo() only for the teacher's *name* on an already-authorized
# student's timetable: hr.employee carries real HR data
# (address, phone, ...) that guardian/student must never get
# a blanket grant on, but "who teaches this period" is exactly
# what a timetable communicates and student_id was already
# checked above via _get_authorized_student.
by_weekday[slot.weekday].append(slot.sudo())
return request.render("mc_education_portal.portal_school_student_timetable", {
"student": student,
"weekday_order": [w[0] for w in weekday_selection],
"weekday_labels": dict(weekday_selection),
"by_weekday": by_weekday,
"page_name": "school_student",
})
@route(["/my/school/student/<int:student_id>/results"], type="http", auth="user", website=True)
def school_student_results(self, student_id, **kw):
student = self._get_authorized_student(student_id)
term = student._get_report_card_term()
marks = student._get_report_card_marks(term) if term else request.env["mc.mark"]
return request.render("mc_education_portal.portal_school_student_results", {
"student": student,
"term": term,
"marks": marks,
"page_name": "school_student",
})
@route(["/my/school/notices"], type="http", auth="user", website=True)
def school_notices(self, **kw):
# No sudo: mc.notice's own record rules already scope this to
# school-wide notices plus the current user's own children's
# batches. See security/mc_education_portal_security.xml.
notices = request.env["mc.notice"].search([], order="date desc")
return request.render("mc_education_portal.portal_school_notices", {
"notices": notices,
"page_name": "school",
})

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_notice_school_wide" model="mc.notice">
<field name="title">Term 2 begins October 1st</field>
<field name="date">2026-09-20</field>
<field name="body"><![CDATA[<p>Term 2 classes begin on <strong>October 1, 2026</strong>. Fee installments for Term 2 are due by October 15.</p>]]></field>
</record>
<record id="demo_notice_grade8a" model="mc.notice">
<field name="title">Grade 8-A: Term 2 exam schedule published</field>
<field name="date">2026-09-25</field>
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
<field name="body"><![CDATA[<p>The Term 2 exam schedule for Grade 8-A has been published. Please check the Results section closer to the exam dates.</p>]]></field>
</record>
</odoo>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Matches shared/DEMO_SCRIPT.md's cast table exactly: Meera logs in
as parent@demo.school and sees both Aditya and Ananya via the
child switcher; Aditya logs in as student@demo.school and sees
only his own record. Reusing the existing partners from
mc_education_base's demo data (not creating new ones) is what
makes the mc.guardian.partner_id / mc.student.partner_id
resolution used by every record rule in this module actually
line up.
A hardcoded demo password is fine for a demo database; a real
rehearsal or production rollout should use Odoo's normal
invite/reset-password flow instead. -->
<record id="demo_user_meera_krishnan" model="res.users">
<field name="name">Meera Krishnan</field>
<field name="login">parent@demo.school</field>
<field name="email">parent@demo.school</field>
<field name="password">demo1234</field>
<field name="partner_id" ref="mc_education_base.demo_partner_meera_krishnan"/>
<field name="group_ids" eval="[(6, 0, [ref('mc_education_base.group_guardian')])]"/>
</record>
<record id="demo_user_aditya_krishnan" model="res.users">
<field name="name">Aditya Krishnan</field>
<field name="login">student@demo.school</field>
<field name="email">student@demo.school</field>
<field name="password">demo1234</field>
<field name="partner_id" ref="mc_education_base.demo_partner_aditya_krishnan"/>
<field name="group_ids" eval="[(6, 0, [ref('mc_education_base.group_student')])]"/>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
from . import mc_batch
from . import mc_notice

View File

@ -0,0 +1,10 @@
from odoo import fields, models
class McBatch(models.Model):
_inherit = "mc.batch"
# Needed so record rules on this module's portal-reachable models
# (mc.notice, mc.timetable.slot) can express "a batch one of my
# children is in" as a plain domain path, without a subquery.
enrollment_ids = fields.One2many("mc.enrollment", "batch_id", string="Enrollments")

View File

@ -0,0 +1,22 @@
from odoo import fields, models
class McNotice(models.Model):
_name = "mc.notice"
_inherit = ["mail.thread"]
_description = "Notice"
_order = "date desc"
_rec_name = "title"
title = fields.Char(string="Title", required=True, tracking=True)
body = fields.Html(string="Body", required=True)
date = fields.Date(string="Date", required=True, default=fields.Date.context_today)
batch_id = fields.Many2one(
"mc.batch", string="Batch",
help="Leave blank for a school-wide notice, or set to target one batch's "
"guardians and students only.",
)
company_id = fields.Many2one(
"res.company", string="Company", required=True,
default=lambda self: self.env.company,
)

View File

@ -0,0 +1,34 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mc_notice_administrator,mc.notice.administrator,model_mc_notice,mc_education_base.group_school_administrator,1,1,1,1
access_mc_notice_staff,mc.notice.staff,model_mc_notice,mc_education_base.group_school_staff,1,1,1,1
access_mc_notice_teacher,mc.notice.teacher,model_mc_notice,mc_education_base.group_teacher,1,0,0,0
access_mc_notice_guardian,mc.notice.guardian,model_mc_notice,mc_education_base.group_guardian,1,0,0,0
access_mc_notice_student,mc.notice.student,model_mc_notice,mc_education_base.group_student,1,0,0,0
access_mc_student_guardian_portal,mc.student.guardian.portal,mc_education_base.model_mc_student,mc_education_base.group_guardian,1,0,0,0
access_mc_student_student_portal,mc.student.student.portal,mc_education_base.model_mc_student,mc_education_base.group_student,1,0,0,0
access_mc_guardian_guardian_portal,mc.guardian.guardian.portal,mc_education_base.model_mc_guardian,mc_education_base.group_guardian,1,0,0,0
access_mc_guardian_student_portal,mc.guardian.student.portal,mc_education_base.model_mc_guardian,mc_education_base.group_student,1,0,0,0
access_mc_student_guardian_link_guardian_portal,mc.student.guardian.link.guardian.portal,mc_education_base.model_mc_student_guardian,mc_education_base.group_guardian,1,0,0,0
access_mc_student_guardian_link_student_portal,mc.student.guardian.link.student.portal,mc_education_base.model_mc_student_guardian,mc_education_base.group_student,1,0,0,0
access_mc_enrollment_guardian_portal,mc.enrollment.guardian.portal,mc_education_base.model_mc_enrollment,mc_education_base.group_guardian,1,0,0,0
access_mc_enrollment_student_portal,mc.enrollment.student.portal,mc_education_base.model_mc_enrollment,mc_education_base.group_student,1,0,0,0
access_mc_batch_guardian_portal,mc.batch.guardian.portal,mc_education_base.model_mc_batch,mc_education_base.group_guardian,1,0,0,0
access_mc_batch_student_portal,mc.batch.student.portal,mc_education_base.model_mc_batch,mc_education_base.group_student,1,0,0,0
access_mc_attendance_guardian_portal,mc.attendance.guardian.portal,mc_education_attendance.model_mc_attendance,mc_education_base.group_guardian,1,0,0,0
access_mc_attendance_student_portal,mc.attendance.student.portal,mc_education_attendance.model_mc_attendance,mc_education_base.group_student,1,0,0,0
access_mc_mark_guardian_portal,mc.mark.guardian.portal,mc_education_exam.model_mc_mark,mc_education_base.group_guardian,1,0,0,0
access_mc_mark_student_portal,mc.mark.student.portal,mc_education_exam.model_mc_mark,mc_education_base.group_student,1,0,0,0
access_mc_timetable_slot_guardian_portal,mc.timetable.slot.guardian.portal,mc_education_timetable.model_mc_timetable_slot,mc_education_base.group_guardian,1,0,0,0
access_mc_timetable_slot_student_portal,mc.timetable.slot.student.portal,mc_education_timetable.model_mc_timetable_slot,mc_education_base.group_student,1,0,0,0
access_mc_academic_year_guardian_portal,mc.academic.year.guardian.portal,mc_education_base.model_mc_academic_year,mc_education_base.group_guardian,1,0,0,0
access_mc_academic_year_student_portal,mc.academic.year.student.portal,mc_education_base.model_mc_academic_year,mc_education_base.group_student,1,0,0,0
access_mc_academic_term_guardian_portal,mc.academic.term.guardian.portal,mc_education_base.model_mc_academic_term,mc_education_base.group_guardian,1,0,0,0
access_mc_academic_term_student_portal,mc.academic.term.student.portal,mc_education_base.model_mc_academic_term,mc_education_base.group_student,1,0,0,0
access_mc_program_guardian_portal,mc.program.guardian.portal,mc_education_base.model_mc_program,mc_education_base.group_guardian,1,0,0,0
access_mc_program_student_portal,mc.program.student.portal,mc_education_base.model_mc_program,mc_education_base.group_student,1,0,0,0
access_mc_subject_guardian_portal,mc.subject.guardian.portal,mc_education_base.model_mc_subject,mc_education_base.group_guardian,1,0,0,0
access_mc_subject_student_portal,mc.subject.student.portal,mc_education_base.model_mc_subject,mc_education_base.group_student,1,0,0,0
access_mc_room_guardian_portal,mc.room.guardian.portal,mc_education_base.model_mc_room,mc_education_base.group_guardian,1,0,0,0
access_mc_room_student_portal,mc.room.student.portal,mc_education_base.model_mc_room,mc_education_base.group_student,1,0,0,0
access_mc_exam_guardian_portal,mc.exam.guardian.portal,mc_education_exam.model_mc_exam,mc_education_base.group_guardian,1,0,0,0
access_mc_exam_student_portal,mc.exam.student.portal,mc_education_exam.model_mc_exam,mc_education_base.group_student,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mc_notice_administrator mc.notice.administrator model_mc_notice mc_education_base.group_school_administrator 1 1 1 1
3 access_mc_notice_staff mc.notice.staff model_mc_notice mc_education_base.group_school_staff 1 1 1 1
4 access_mc_notice_teacher mc.notice.teacher model_mc_notice mc_education_base.group_teacher 1 0 0 0
5 access_mc_notice_guardian mc.notice.guardian model_mc_notice mc_education_base.group_guardian 1 0 0 0
6 access_mc_notice_student mc.notice.student model_mc_notice mc_education_base.group_student 1 0 0 0
7 access_mc_student_guardian_portal mc.student.guardian.portal mc_education_base.model_mc_student mc_education_base.group_guardian 1 0 0 0
8 access_mc_student_student_portal mc.student.student.portal mc_education_base.model_mc_student mc_education_base.group_student 1 0 0 0
9 access_mc_guardian_guardian_portal mc.guardian.guardian.portal mc_education_base.model_mc_guardian mc_education_base.group_guardian 1 0 0 0
10 access_mc_guardian_student_portal mc.guardian.student.portal mc_education_base.model_mc_guardian mc_education_base.group_student 1 0 0 0
11 access_mc_student_guardian_link_guardian_portal mc.student.guardian.link.guardian.portal mc_education_base.model_mc_student_guardian mc_education_base.group_guardian 1 0 0 0
12 access_mc_student_guardian_link_student_portal mc.student.guardian.link.student.portal mc_education_base.model_mc_student_guardian mc_education_base.group_student 1 0 0 0
13 access_mc_enrollment_guardian_portal mc.enrollment.guardian.portal mc_education_base.model_mc_enrollment mc_education_base.group_guardian 1 0 0 0
14 access_mc_enrollment_student_portal mc.enrollment.student.portal mc_education_base.model_mc_enrollment mc_education_base.group_student 1 0 0 0
15 access_mc_batch_guardian_portal mc.batch.guardian.portal mc_education_base.model_mc_batch mc_education_base.group_guardian 1 0 0 0
16 access_mc_batch_student_portal mc.batch.student.portal mc_education_base.model_mc_batch mc_education_base.group_student 1 0 0 0
17 access_mc_attendance_guardian_portal mc.attendance.guardian.portal mc_education_attendance.model_mc_attendance mc_education_base.group_guardian 1 0 0 0
18 access_mc_attendance_student_portal mc.attendance.student.portal mc_education_attendance.model_mc_attendance mc_education_base.group_student 1 0 0 0
19 access_mc_mark_guardian_portal mc.mark.guardian.portal mc_education_exam.model_mc_mark mc_education_base.group_guardian 1 0 0 0
20 access_mc_mark_student_portal mc.mark.student.portal mc_education_exam.model_mc_mark mc_education_base.group_student 1 0 0 0
21 access_mc_timetable_slot_guardian_portal mc.timetable.slot.guardian.portal mc_education_timetable.model_mc_timetable_slot mc_education_base.group_guardian 1 0 0 0
22 access_mc_timetable_slot_student_portal mc.timetable.slot.student.portal mc_education_timetable.model_mc_timetable_slot mc_education_base.group_student 1 0 0 0
23 access_mc_academic_year_guardian_portal mc.academic.year.guardian.portal mc_education_base.model_mc_academic_year mc_education_base.group_guardian 1 0 0 0
24 access_mc_academic_year_student_portal mc.academic.year.student.portal mc_education_base.model_mc_academic_year mc_education_base.group_student 1 0 0 0
25 access_mc_academic_term_guardian_portal mc.academic.term.guardian.portal mc_education_base.model_mc_academic_term mc_education_base.group_guardian 1 0 0 0
26 access_mc_academic_term_student_portal mc.academic.term.student.portal mc_education_base.model_mc_academic_term mc_education_base.group_student 1 0 0 0
27 access_mc_program_guardian_portal mc.program.guardian.portal mc_education_base.model_mc_program mc_education_base.group_guardian 1 0 0 0
28 access_mc_program_student_portal mc.program.student.portal mc_education_base.model_mc_program mc_education_base.group_student 1 0 0 0
29 access_mc_subject_guardian_portal mc.subject.guardian.portal mc_education_base.model_mc_subject mc_education_base.group_guardian 1 0 0 0
30 access_mc_subject_student_portal mc.subject.student.portal mc_education_base.model_mc_subject mc_education_base.group_student 1 0 0 0
31 access_mc_room_guardian_portal mc.room.guardian.portal mc_education_base.model_mc_room mc_education_base.group_guardian 1 0 0 0
32 access_mc_room_student_portal mc.room.student.portal mc_education_base.model_mc_room mc_education_base.group_student 1 0 0 0
33 access_mc_exam_guardian_portal mc.exam.guardian.portal mc_education_exam.model_mc_exam mc_education_base.group_guardian 1 0 0 0
34 access_mc_exam_student_portal mc.exam.student.portal mc_education_exam.model_mc_exam mc_education_base.group_student 1 0 0 0

View File

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
CLAUDE.md sec 3, standing rule: "Every portal-reachable model gets
a record rule. No exceptions, including models you think are only
reached indirectly." This module is what makes mc.student,
mc.guardian, mc.student.guardian, mc.enrollment, mc.attendance,
mc.mark, mc.timetable.slot and mc.notice portal-reachable for the
first time, so every one of them gets a rule here - scoped to
group_guardian and group_student separately, never to a role name
alone. "Guardian access is by relationship, not by role" (same
section): the guardian-side domains all resolve through the real
mc.student.guardian link, never just "user is in the Guardian
group".
account.move needs no rule here: it is already portal-reachable
via stock account+portal (group_guardian/group_student already
imply base.group_portal, set in mc_education_base), and
mc_education_fees deliberately bills the primary guardian's own
partner_id so that stock rule already scopes it correctly with
nothing added here.
-->
<record id="rule_mc_student_guardian" model="ir.rule">
<field name="name">Student: guardian sees own children only</field>
<field name="model_id" ref="mc_education_base.model_mc_student"/>
<field name="domain_force">[('guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_student_self" model="ir.rule">
<field name="name">Student: sees own record only</field>
<field name="model_id" ref="mc_education_base.model_mc_student"/>
<field name="domain_force">[('partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<record id="rule_mc_guardian_self" model="ir.rule">
<field name="name">Guardian: sees own record only</field>
<field name="model_id" ref="mc_education_base.model_mc_guardian"/>
<field name="domain_force">[('partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_guardian_of_self" model="ir.rule">
<field name="name">Student: sees own guardians only</field>
<field name="model_id" ref="mc_education_base.model_mc_guardian"/>
<field name="domain_force">[('student_link_ids.student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<record id="rule_mc_student_guardian_link_guardian" model="ir.rule">
<field name="name">Student-Guardian link: guardian sees own links only</field>
<field name="model_id" ref="mc_education_base.model_mc_student_guardian"/>
<field name="domain_force">[('guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_student_guardian_link_student" model="ir.rule">
<field name="name">Student-Guardian link: student sees own links only</field>
<field name="model_id" ref="mc_education_base.model_mc_student_guardian"/>
<field name="domain_force">[('student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<record id="rule_mc_enrollment_guardian" model="ir.rule">
<field name="name">Enrollment: guardian sees own children's enrollments only</field>
<field name="model_id" ref="mc_education_base.model_mc_enrollment"/>
<field name="domain_force">[('student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_enrollment_student" model="ir.rule">
<field name="name">Enrollment: student sees own enrollments only</field>
<field name="model_id" ref="mc_education_base.model_mc_enrollment"/>
<field name="domain_force">[('student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<!-- Caught by an HttpCase test rendering the real dashboard template
(portal_school_student_dashboard shows enrollment.batch_id.name):
mc.batch was never portal-reachable before this module, and had
no access row for guardian/student at all - a legitimate own-
child page load failed with an ACL error, not just a record-rule
denial, until this was added. -->
<record id="rule_mc_batch_guardian" model="ir.rule">
<field name="name">Batch: guardian sees own children's batches only</field>
<field name="model_id" ref="mc_education_base.model_mc_batch"/>
<field name="domain_force">[('enrollment_ids.student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_batch_student" model="ir.rule">
<field name="name">Batch: student sees own batch only</field>
<field name="model_id" ref="mc_education_base.model_mc_batch"/>
<field name="domain_force">[('enrollment_ids.student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<record id="rule_mc_attendance_guardian" model="ir.rule">
<field name="name">Attendance: guardian sees own children's attendance only</field>
<field name="model_id" ref="mc_education_attendance.model_mc_attendance"/>
<field name="domain_force">[('student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_attendance_student" model="ir.rule">
<field name="name">Attendance: student sees own attendance only</field>
<field name="model_id" ref="mc_education_attendance.model_mc_attendance"/>
<field name="domain_force">[('student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<record id="rule_mc_mark_guardian" model="ir.rule">
<field name="name">Mark: guardian sees own children's marks only</field>
<field name="model_id" ref="mc_education_exam.model_mc_mark"/>
<field name="domain_force">[('student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_mark_student" model="ir.rule">
<field name="name">Mark: student sees own marks only</field>
<field name="model_id" ref="mc_education_exam.model_mc_mark"/>
<field name="domain_force">[('student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<record id="rule_mc_timetable_slot_guardian" model="ir.rule">
<field name="name">Timetable: guardian sees own children's batches only</field>
<field name="model_id" ref="mc_education_timetable.model_mc_timetable_slot"/>
<field name="domain_force">[('batch_id.enrollment_ids.student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_timetable_slot_student" model="ir.rule">
<field name="name">Timetable: student sees own batch only</field>
<field name="model_id" ref="mc_education_timetable.model_mc_timetable_slot"/>
<field name="domain_force">[('batch_id.enrollment_ids.student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
<record id="rule_mc_notice_guardian" model="ir.rule">
<field name="name">Notice: guardian sees school-wide + own children's batch notices</field>
<field name="model_id" ref="model_mc_notice"/>
<field name="domain_force">['|', ('batch_id', '=', False), ('batch_id.enrollment_ids.student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_guardian'))]"/>
</record>
<record id="rule_mc_notice_student" model="ir.rule">
<field name="name">Notice: student sees school-wide + own batch notices</field>
<field name="model_id" ref="model_mc_notice"/>
<field name="domain_force">['|', ('batch_id', '=', False), ('batch_id.enrollment_ids.student_id.partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('mc_education_base.group_student'))]"/>
</record>
</odoo>

View File

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

View File

@ -0,0 +1,246 @@
from odoo.exceptions import AccessError
from odoo.tests.common import HttpCase, TransactionCase
class TestPortalAccess(TransactionCase):
"""The core of Demo Scene 5: a guardian must never be able to reach
another family's data, and this must hold at the record-rule layer
(the actual enforcement every controller in this module relies on),
not just because no UI link happens to point at it. Every test here
tries the cross-family access and asserts it is refused - this is
"editing the student identifier in the URL" in test form.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.year = cls.env["mc.academic.year"].create({
"name": "TEST-PORTAL-2026-27",
"date_start": "2026-06-01", "date_end": "2027-04-30",
})
cls.term = cls.env["mc.academic.term"].create({
"name": "TEST PORTAL 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 PORTAL Program", "code": "TEST-PORTAL-P1", "sequence_no": 1,
"display_label": "Test Grade",
})
cls.batch_a = cls.env["mc.batch"].create({
"name": "TEST PORTAL Batch A", "program_id": cls.program.id, "year_id": cls.year.id,
})
cls.batch_b = cls.env["mc.batch"].create({
"name": "TEST PORTAL Batch B", "program_id": cls.program.id, "year_id": cls.year.id,
})
cls.subject = cls.env["mc.subject"].create({"name": "TEST PORTAL Subject", "code": "TEST-PORTAL-S1"})
cls.family_a = cls._make_family(cls, "A", cls.batch_a)
cls.family_b = cls._make_family(cls, "B", cls.batch_b)
@staticmethod
def _make_family(cls, tag, batch):
guardian_partner = cls.env["res.partner"].create({"name": f"Guardian {tag}"})
guardian = cls.env["mc.guardian"].create({
"partner_id": guardian_partner.id, "name": f"Guardian {tag}",
})
guardian_user = cls.env["res.users"].create({
"name": f"Guardian {tag}", "login": f"test_portal_guardian_{tag.lower()}",
"email": f"test_portal_guardian_{tag.lower()}@example.com",
"partner_id": guardian_partner.id,
"group_ids": [(6, 0, [cls.env.ref("mc_education_base.group_guardian").id])],
})
student_partner = cls.env["res.partner"].create({"name": f"Student {tag}"})
student = cls.env["mc.student"].create({
"partner_id": student_partner.id, "name": f"Student {tag}",
})
student_user = cls.env["res.users"].create({
"name": f"Student {tag}", "login": f"test_portal_student_{tag.lower()}",
"email": f"test_portal_student_{tag.lower()}@example.com",
"partner_id": student_partner.id,
"group_ids": [(6, 0, [cls.env.ref("mc_education_base.group_student").id])],
})
cls.env["mc.student.guardian"].create({
"student_id": student.id, "guardian_id": guardian.id,
"relationship": "father", "is_primary": True,
})
enrollment = cls.env["mc.enrollment"].create({
"student_id": student.id, "program_id": cls.program.id,
"batch_id": batch.id, "year_id": cls.year.id, "state": "active",
})
attendance = cls.env["mc.attendance"].create({
"student_id": student.id, "batch_id": batch.id,
"date": "2026-07-01", "state": "present",
})
exam = cls.env["mc.exam"].create({
"name": f"TEST PORTAL Exam {tag}", "term_id": cls.term.id, "batch_id": batch.id,
"subject_id": cls.subject.id, "max_marks": 100, "pass_marks": 35, "date": "2026-07-05",
})
mark = cls.env["mc.mark"].create({
"exam_id": exam.id, "student_id": student.id, "marks_obtained": 80,
})
return {
"guardian": guardian, "guardian_user": guardian_user,
"student": student, "student_user": student_user,
"enrollment": enrollment, "attendance": attendance, "mark": mark,
}
# -- Guardian sees only their own child --
def test_guardian_search_returns_only_own_children(self):
found = self.env["mc.student"].with_user(self.family_a["guardian_user"]).search([])
self.assertEqual(found, self.family_a["student"])
def test_guardian_cannot_read_another_familys_student(self):
other_student = self.family_b["student"]
with self.assertRaises(AccessError):
other_student.with_user(self.family_a["guardian_user"]).read(["name"])
def test_guardian_cannot_read_another_familys_enrollment(self):
other_enrollment = self.family_b["enrollment"]
with self.assertRaises(AccessError):
other_enrollment.with_user(self.family_a["guardian_user"]).read(["roll_no"])
def test_guardian_cannot_read_another_familys_attendance(self):
other_attendance = self.family_b["attendance"]
with self.assertRaises(AccessError):
other_attendance.with_user(self.family_a["guardian_user"]).read(["state"])
def test_guardian_cannot_read_another_familys_marks(self):
other_mark = self.family_b["mark"]
with self.assertRaises(AccessError):
other_mark.with_user(self.family_a["guardian_user"]).read(["marks_obtained"])
# -- Student sees only themselves --
def test_student_search_returns_only_self(self):
found = self.env["mc.student"].with_user(self.family_a["student_user"]).search([])
self.assertEqual(found, self.family_a["student"])
def test_student_cannot_read_another_students_record(self):
other_student = self.family_b["student"]
with self.assertRaises(AccessError):
other_student.with_user(self.family_a["student_user"]).read(["name"])
def test_student_cannot_read_another_students_marks(self):
other_mark = self.family_b["mark"]
with self.assertRaises(AccessError):
other_mark.with_user(self.family_a["student_user"]).read(["marks_obtained"])
# -- Notices: school-wide visible to everyone, batch-scoped only to that batch --
def test_guardian_sees_school_wide_notice(self):
notice = self.env["mc.notice"].create({
"title": "TEST PORTAL school-wide", "date": "2026-07-01", "body": "Body",
})
found = self.env["mc.notice"].with_user(self.family_a["guardian_user"]).search([
("id", "=", notice.id),
])
self.assertEqual(found, notice)
def test_guardian_does_not_see_other_batchs_notice(self):
notice = self.env["mc.notice"].create({
"title": "TEST PORTAL batch B only", "date": "2026-07-01", "body": "Body",
"batch_id": self.batch_b.id,
})
found = self.env["mc.notice"].with_user(self.family_a["guardian_user"]).search([
("id", "=", notice.id),
])
self.assertFalse(found)
def test_guardian_sees_own_batchs_notice(self):
notice = self.env["mc.notice"].create({
"title": "TEST PORTAL batch A only", "date": "2026-07-01", "body": "Body",
"batch_id": self.batch_a.id,
})
found = self.env["mc.notice"].with_user(self.family_a["guardian_user"]).search([
("id", "=", notice.id),
])
self.assertEqual(found, notice)
class TestPortalHttpAccess(HttpCase):
"""The same boundary as TestPortalAccess, but exercised the way Demo
Scene 5 actually performs it: a real logged-in browser session
hitting the real route, with a real id in the URL - not a
with_user() shortcut. This is "editing the student identifier in
the URL and showing that it is refused" for real.
"""
def setUp(self):
super().setUp()
self.year = self.env["mc.academic.year"].create({
"name": "TEST-PORTAL-HTTP-2026-27",
"date_start": "2026-06-01", "date_end": "2027-04-30",
})
self.program = self.env["mc.program"].create({
"name": "TEST PORTAL HTTP Program", "code": "TEST-PORTAL-HTTP-P1", "sequence_no": 1,
"display_label": "Test Grade",
})
self.batch = self.env["mc.batch"].create({
"name": "TEST PORTAL HTTP Batch", "program_id": self.program.id, "year_id": self.year.id,
})
guardian_a_partner = self.env["res.partner"].create({"name": "HTTP Guardian A"})
self.guardian_a_user = self.env["res.users"].create({
"name": "HTTP Guardian A", "login": "test_http_guardian_a",
"email": "test_http_guardian_a@example.com", "password": "demo1234",
"partner_id": guardian_a_partner.id,
"group_ids": [(6, 0, [self.env.ref("mc_education_base.group_guardian").id])],
})
self.guardian_a = self.env["mc.guardian"].create({
"partner_id": guardian_a_partner.id, "name": "HTTP Guardian A",
})
own_student_partner = self.env["res.partner"].create({"name": "HTTP Student Own"})
self.own_student = self.env["mc.student"].create({
"partner_id": own_student_partner.id, "name": "HTTP Student Own",
})
self.env["mc.student.guardian"].create({
"student_id": self.own_student.id, "guardian_id": self.guardian_a.id,
"relationship": "father", "is_primary": True,
})
self.env["mc.enrollment"].create({
"student_id": self.own_student.id, "program_id": self.program.id,
"batch_id": self.batch.id, "year_id": self.year.id, "state": "active",
})
other_student_partner = self.env["res.partner"].create({"name": "HTTP Student Other"})
self.other_student = self.env["mc.student"].create({
"partner_id": other_student_partner.id, "name": "HTTP Student Other",
})
def test_guardian_can_open_own_childs_dashboard(self):
self.authenticate("test_http_guardian_a", "demo1234")
response = self.url_open("/my/school/student/%s" % self.own_student.id)
self.assertEqual(response.status_code, 200)
def test_guardian_refused_another_childs_dashboard_by_editing_the_url(self):
self.authenticate("test_http_guardian_a", "demo1234")
response = self.url_open("/my/school/student/%s" % self.other_student.id)
self.assertNotEqual(
response.status_code, 200,
"editing the student id in the URL to a child that is not "
"the logged-in guardian's must not return their dashboard",
)
def test_guardian_can_open_every_own_child_subpage(self):
# Loading each page for real is exactly what caught the missing
# mc.batch and hr.employee access earlier - a plain domain-level
# test would not have exercised the template rendering at all.
self.authenticate("test_http_guardian_a", "demo1234")
for path in ("fees", "attendance", "timetable", "results"):
response = self.url_open("/my/school/student/%s/%s" % (self.own_student.id, path))
self.assertEqual(response.status_code, 200, "failed on /%s" % path)
def test_guardian_refused_every_other_child_subpage(self):
self.authenticate("test_http_guardian_a", "demo1234")
for path in ("fees", "attendance", "timetable", "results"):
response = self.url_open("/my/school/student/%s/%s" % (self.other_student.id, path))
self.assertNotEqual(response.status_code, 200, "leaked on /%s" % path)
def test_guardian_can_open_notices_and_school_home(self):
self.authenticate("test_http_guardian_a", "demo1234")
for path in ("/my/school", "/my/school/notices"):
response = self.url_open(path)
self.assertEqual(response.status_code, 200, "failed on %s" % path)

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_mc_notice" name="Notices"
parent="mc_education_base.menu_school_root"
action="action_mc_notice" sequence="18"/>
</odoo>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_notice_list" model="ir.ui.view">
<field name="name">mc.notice.list</field>
<field name="model">mc.notice</field>
<field name="arch" type="xml">
<list string="Notices">
<field name="date"/>
<field name="title"/>
<field name="batch_id"/>
</list>
</field>
</record>
<record id="view_mc_notice_form" model="ir.ui.view">
<field name="name">mc.notice.form</field>
<field name="model">mc.notice</field>
<field name="arch" type="xml">
<form string="Notice">
<sheet>
<div class="oe_title">
<label for="title"/>
<h1><field name="title"/></h1>
</div>
<group>
<field name="date"/>
<field name="batch_id"/>
</group>
<field name="body"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_mc_notice" model="ir.actions.act_window">
<field name="name">Notices</field>
<field name="res_model">mc.notice</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,211 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_my_home_school" name="Portal My Home: School" inherit_id="portal.portal_my_home" priority="20">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="icon" t-value="'/base/static/img/icons/mail_channel.png'"/>
<t t-set="title">My Children</t>
<t t-set="text">Fees, attendance, timetable and results</t>
<t t-set="url" t-value="'/my/school'"/>
<t t-set="placeholder_count" t-value="'children_count'"/>
</t>
</xpath>
</template>
<!-- Shared sub-navigation for every page scoped to one student, and
the "switch child" link back to the chooser - this is the
"child switcher", the single most convincing portal feature per
shared/DEMO_SCRIPT.md. -->
<template id="portal_school_student_nav" name="School Student Sub-nav">
<div class="d-flex flex-wrap gap-2 mb-4">
<a t-attf-href="/my/school/student/#{student.id}" class="btn btn-outline-primary btn-sm">Overview</a>
<a t-attf-href="/my/school/student/#{student.id}/fees" class="btn btn-outline-primary btn-sm">Fees</a>
<a t-attf-href="/my/school/student/#{student.id}/attendance" class="btn btn-outline-primary btn-sm">Attendance</a>
<a t-attf-href="/my/school/student/#{student.id}/timetable" class="btn btn-outline-primary btn-sm">Timetable</a>
<a t-attf-href="/my/school/student/#{student.id}/results" class="btn btn-outline-primary btn-sm">Results</a>
<a href="/my/school" class="btn btn-link btn-sm ms-auto">Switch child</a>
</div>
</template>
<template id="portal_school_children" name="My Children">
<t t-call="portal.portal_layout">
<div class="container">
<h2>My Children</h2>
<div t-if="not children" class="alert alert-warning">No students are linked to your account.</div>
<div class="row g-3">
<div t-foreach="children" t-as="child" class="col-12 col-md-4">
<a t-attf-href="/my/school/student/#{child.id}" class="card text-decoration-none h-100">
<div class="card-body">
<h5 class="card-title" t-esc="child.name"/>
<p class="card-text text-muted" t-esc="child.admission_no"/>
</div>
</a>
</div>
</div>
<p class="mt-4"><a href="/my/school/notices">View notices</a></p>
</div>
</t>
</template>
<template id="portal_school_student_dashboard" name="Student Dashboard">
<t t-call="portal.portal_layout">
<div class="container">
<h2 t-esc="student.name"/>
<t t-call="mc_education_portal.portal_school_student_nav"/>
<table class="table table-borderless w-auto">
<tr><td><strong>Admission No.:</strong></td><td t-esc="student.admission_no"/></tr>
<tr><td><strong>Batch:</strong></td><td t-esc="enrollment.batch_id.name if enrollment else ''"/></tr>
<tr><td><strong>Roll No.:</strong></td><td t-esc="enrollment.roll_no if enrollment else ''"/></tr>
<tr><td><strong>Status:</strong></td><td t-esc="student.status"/></tr>
</table>
</div>
</t>
</template>
<template id="portal_school_student_fees" name="Student Fees">
<t t-call="portal.portal_layout">
<div class="container">
<h2 t-esc="student.name"/>
<t t-call="mc_education_portal.portal_school_student_nav"/>
<div t-if="not invoices" class="alert alert-info">No invoices yet for this student.</div>
<t t-if="invoices" t-call="portal.portal_table">
<thead>
<tr>
<th>Invoice</th>
<th>Date</th>
<th class="text-end">Total</th>
<th class="text-end">Due</th>
<th>Status</th>
</tr>
</thead>
<t t-foreach="invoices" t-as="invoice">
<tr>
<td><a t-att-href="invoice.get_portal_url()" t-esc="invoice.name"/></td>
<td><span t-field="invoice.invoice_date"/></td>
<td class="text-end"><span t-field="invoice.amount_total"/></td>
<td class="text-end"><span t-field="invoice.amount_residual"/></td>
<td><span t-esc="invoice.payment_state"/></td>
</tr>
</t>
</t>
</div>
</t>
</template>
<template id="portal_school_student_attendance" name="Student Attendance">
<t t-call="portal.portal_layout">
<div class="container">
<h2 t-esc="student.name"/>
<t t-call="mc_education_portal.portal_school_student_nav"/>
<div class="d-flex align-items-center justify-content-between mb-3">
<a t-attf-href="/my/school/student/#{student.id}/attendance?month=#{prev_month}" class="btn btn-outline-secondary btn-sm">&#8592; Prev</a>
<h4 class="mb-0"><span t-esc="month_name"/> <span t-esc="year"/></h4>
<a t-attf-href="/my/school/student/#{student.id}/attendance?month=#{next_month}" class="btn btn-outline-secondary btn-sm">Next &#8594;</a>
</div>
<p><strong>Present:</strong> <span t-esc="present_count"/> / <span t-esc="total_count"/> days
(<span t-esc="'%.1f' % percentage"/>%)</p>
<table class="table table-bordered text-center">
<thead>
<tr>
<th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th><th>Sun</th>
</tr>
</thead>
<tbody>
<tr t-foreach="calendar_weeks" t-as="week">
<td t-foreach="week" t-as="day">
<t t-if="day.month == month_num">
<div t-esc="day.day"/>
<span t-if="day in by_date"
t-attf-class="badge #{'text-bg-success' if by_date[day] in ('present', 'late') else 'text-bg-danger' if by_date[day] == 'absent' else 'text-bg-warning'}"
t-esc="by_date[day]"/>
</t>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</template>
<template id="portal_school_student_timetable" name="Student Timetable">
<t t-call="portal.portal_layout">
<div class="container">
<h2 t-esc="student.name"/>
<t t-call="mc_education_portal.portal_school_student_nav"/>
<div t-if="not by_weekday" class="alert alert-info">No timetable configured yet.</div>
<table t-if="by_weekday" class="table table-bordered">
<thead>
<tr>
<th>Weekday</th>
<th>Period</th>
<th>Subject</th>
<th>Teacher</th>
<th>Room</th>
</tr>
</thead>
<tbody>
<t t-foreach="weekday_order" t-as="wd">
<t t-foreach="by_weekday.get(wd, [])" t-as="slot">
<tr>
<td t-esc="weekday_labels[wd]"/>
<td t-esc="slot.period"/>
<td t-esc="slot.subject_id.name"/>
<td t-esc="slot.teacher_id.name"/>
<td t-esc="slot.room_id.name"/>
</tr>
</t>
</t>
</tbody>
</table>
</div>
</t>
</template>
<template id="portal_school_student_results" name="Student Results">
<t t-call="portal.portal_layout">
<div class="container">
<h2 t-esc="student.name"/>
<t t-call="mc_education_portal.portal_school_student_nav"/>
<h4>Term: <span t-esc="term.name if term else 'N/A'"/></h4>
<div t-if="not marks" class="alert alert-info">No marks recorded for this term yet.</div>
<table t-if="marks" class="table table-bordered">
<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>
</tbody>
</table>
<a t-if="marks" t-attf-href="/report/pdf/mc_education_exam.report_card/#{student.id}" class="btn btn-primary mt-3">
Download Report Card
</a>
</div>
</t>
</template>
<template id="portal_school_notices" name="Notices">
<t t-call="portal.portal_layout">
<div class="container">
<h2>Notices</h2>
<div t-if="not notices" class="alert alert-info">No notices right now.</div>
<div t-foreach="notices" t-as="notice" class="card mb-3">
<div class="card-body">
<h5 class="card-title" t-esc="notice.title"/>
<p class="card-subtitle text-muted"><span t-field="notice.date"/></p>
<div t-field="notice.body"/>
</div>
</div>
</div>
</t>
</template>
</odoo>