diff --git a/addons/mc_education_portal/__init__.py b/addons/mc_education_portal/__init__.py
new file mode 100644
index 0000000..f7209b1
--- /dev/null
+++ b/addons/mc_education_portal/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import controllers
diff --git a/addons/mc_education_portal/__manifest__.py b/addons/mc_education_portal/__manifest__.py
new file mode 100644
index 0000000..724ea9e
--- /dev/null
+++ b/addons/mc_education_portal/__manifest__.py
@@ -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,
+}
diff --git a/addons/mc_education_portal/controllers/__init__.py b/addons/mc_education_portal/controllers/__init__.py
new file mode 100644
index 0000000..12a7e52
--- /dev/null
+++ b/addons/mc_education_portal/controllers/__init__.py
@@ -0,0 +1 @@
+from . import main
diff --git a/addons/mc_education_portal/controllers/main.py b/addons/mc_education_portal/controllers/main.py
new file mode 100644
index 0000000..34a9b56
--- /dev/null
+++ b/addons/mc_education_portal/controllers/main.py
@@ -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/"], 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//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//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//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//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",
+ })
diff --git a/addons/mc_education_portal/demo/mc_notice_demo.xml b/addons/mc_education_portal/demo/mc_notice_demo.xml
new file mode 100644
index 0000000..967f4e8
--- /dev/null
+++ b/addons/mc_education_portal/demo/mc_notice_demo.xml
@@ -0,0 +1,14 @@
+
+
+
+ Term 2 begins October 1st
+ 2026-09-20
+ Term 2 classes begin on October 1, 2026. Fee installments for Term 2 are due by October 15.
]]>
+
+
+ Grade 8-A: Term 2 exam schedule published
+ 2026-09-25
+
+ The Term 2 exam schedule for Grade 8-A has been published. Please check the Results section closer to the exam dates.]]>
+
+
diff --git a/addons/mc_education_portal/demo/mc_portal_users_demo.xml b/addons/mc_education_portal/demo/mc_portal_users_demo.xml
new file mode 100644
index 0000000..2a2cf40
--- /dev/null
+++ b/addons/mc_education_portal/demo/mc_portal_users_demo.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ Meera Krishnan
+ parent@demo.school
+ parent@demo.school
+ demo1234
+
+
+
+
+
+ Aditya Krishnan
+ student@demo.school
+ student@demo.school
+ demo1234
+
+
+
+
diff --git a/addons/mc_education_portal/models/__init__.py b/addons/mc_education_portal/models/__init__.py
new file mode 100644
index 0000000..070752c
--- /dev/null
+++ b/addons/mc_education_portal/models/__init__.py
@@ -0,0 +1,2 @@
+from . import mc_batch
+from . import mc_notice
diff --git a/addons/mc_education_portal/models/mc_batch.py b/addons/mc_education_portal/models/mc_batch.py
new file mode 100644
index 0000000..f61fafb
--- /dev/null
+++ b/addons/mc_education_portal/models/mc_batch.py
@@ -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")
diff --git a/addons/mc_education_portal/models/mc_notice.py b/addons/mc_education_portal/models/mc_notice.py
new file mode 100644
index 0000000..229d5f5
--- /dev/null
+++ b/addons/mc_education_portal/models/mc_notice.py
@@ -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,
+ )
diff --git a/addons/mc_education_portal/security/ir.model.access.csv b/addons/mc_education_portal/security/ir.model.access.csv
new file mode 100644
index 0000000..f341c25
--- /dev/null
+++ b/addons/mc_education_portal/security/ir.model.access.csv
@@ -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
diff --git a/addons/mc_education_portal/security/mc_education_portal_security.xml b/addons/mc_education_portal/security/mc_education_portal_security.xml
new file mode 100644
index 0000000..0388b5c
--- /dev/null
+++ b/addons/mc_education_portal/security/mc_education_portal_security.xml
@@ -0,0 +1,146 @@
+
+
+
+
+
+ Student: guardian sees own children only
+
+ [('guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Student: sees own record only
+
+ [('partner_id', '=', user.partner_id.id)]
+
+
+
+
+ Guardian: sees own record only
+
+ [('partner_id', '=', user.partner_id.id)]
+
+
+
+ Student: sees own guardians only
+
+ [('student_link_ids.student_id.partner_id', '=', user.partner_id.id)]
+
+
+
+
+ Student-Guardian link: guardian sees own links only
+
+ [('guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Student-Guardian link: student sees own links only
+
+ [('student_id.partner_id', '=', user.partner_id.id)]
+
+
+
+
+ Enrollment: guardian sees own children's enrollments only
+
+ [('student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Enrollment: student sees own enrollments only
+
+ [('student_id.partner_id', '=', user.partner_id.id)]
+
+
+
+
+
+ Batch: guardian sees own children's batches only
+
+ [('enrollment_ids.student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Batch: student sees own batch only
+
+ [('enrollment_ids.student_id.partner_id', '=', user.partner_id.id)]
+
+
+
+
+ Attendance: guardian sees own children's attendance only
+
+ [('student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Attendance: student sees own attendance only
+
+ [('student_id.partner_id', '=', user.partner_id.id)]
+
+
+
+
+ Mark: guardian sees own children's marks only
+
+ [('student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Mark: student sees own marks only
+
+ [('student_id.partner_id', '=', user.partner_id.id)]
+
+
+
+
+ Timetable: guardian sees own children's batches only
+
+ [('batch_id.enrollment_ids.student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Timetable: student sees own batch only
+
+ [('batch_id.enrollment_ids.student_id.partner_id', '=', user.partner_id.id)]
+
+
+
+
+ Notice: guardian sees school-wide + own children's batch notices
+
+ ['|', ('batch_id', '=', False), ('batch_id.enrollment_ids.student_id.guardian_link_ids.guardian_id.partner_id', '=', user.partner_id.id)]
+
+
+
+ Notice: student sees school-wide + own batch notices
+
+ ['|', ('batch_id', '=', False), ('batch_id.enrollment_ids.student_id.partner_id', '=', user.partner_id.id)]
+
+
+
diff --git a/addons/mc_education_portal/tests/__init__.py b/addons/mc_education_portal/tests/__init__.py
new file mode 100644
index 0000000..d50829c
--- /dev/null
+++ b/addons/mc_education_portal/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_portal_access
diff --git a/addons/mc_education_portal/tests/test_portal_access.py b/addons/mc_education_portal/tests/test_portal_access.py
new file mode 100644
index 0000000..925ef39
--- /dev/null
+++ b/addons/mc_education_portal/tests/test_portal_access.py
@@ -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)
diff --git a/addons/mc_education_portal/views/mc_education_portal_menus.xml b/addons/mc_education_portal/views/mc_education_portal_menus.xml
new file mode 100644
index 0000000..254821e
--- /dev/null
+++ b/addons/mc_education_portal/views/mc_education_portal_menus.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/addons/mc_education_portal/views/mc_notice_views.xml b/addons/mc_education_portal/views/mc_notice_views.xml
new file mode 100644
index 0000000..f755702
--- /dev/null
+++ b/addons/mc_education_portal/views/mc_notice_views.xml
@@ -0,0 +1,41 @@
+
+
+
+ mc.notice.list
+ mc.notice
+
+
+
+
+
+
+
+
+
+
+ mc.notice.form
+ mc.notice
+
+
+
+
+
+
+ Notices
+ mc.notice
+ list,form
+
+
diff --git a/addons/mc_education_portal/views/portal_templates.xml b/addons/mc_education_portal/views/portal_templates.xml
new file mode 100644
index 0000000..eb2c252
--- /dev/null
+++ b/addons/mc_education_portal/views/portal_templates.xml
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+ My Children
+ Fees, attendance, timetable and results
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
My Children
+
No students are linked to your account.
+
+
View notices
+
+
+
+
+
+
+
+
+
+
+ | Admission No.: | |
+ | Batch: | |
+ | Roll No.: | |
+ | Status: | |
+
+
+
+
+
+
+
+
+
+
+
No invoices yet for this student.
+
+
+
+ | Invoice |
+ Date |
+ Total |
+ Due |
+ Status |
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Present: / days
+ (%)
+
+
+
+ | Mon | Tue | Wed | Thu | Fri | Sat | Sun |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
No timetable configured yet.
+
+
+
+ | Weekday |
+ Period |
+ Subject |
+ Teacher |
+ Room |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Term:
+
No marks recorded for this term yet.
+
+
+
+ | Subject |
+ Max Marks |
+ Marks Obtained |
+ Grade |
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+ Download Report Card
+
+
+
+
+
+
+
+
+
Notices
+
No notices right now.
+
+
+
+
+