From 151bc327470d5f7163ed4fa2e7f306e558f3c867 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 11 Sep 2026 13:32:38 -0400 Subject: [PATCH] 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 --- addons/mc_education_portal/__init__.py | 2 + addons/mc_education_portal/__manifest__.py | 28 ++ .../controllers/__init__.py | 1 + .../mc_education_portal/controllers/main.py | 175 +++++++++++++ .../demo/mc_notice_demo.xml | 14 + .../demo/mc_portal_users_demo.xml | 32 +++ addons/mc_education_portal/models/__init__.py | 2 + addons/mc_education_portal/models/mc_batch.py | 10 + .../mc_education_portal/models/mc_notice.py | 22 ++ .../security/ir.model.access.csv | 34 +++ .../security/mc_education_portal_security.xml | 146 +++++++++++ addons/mc_education_portal/tests/__init__.py | 1 + .../tests/test_portal_access.py | 246 ++++++++++++++++++ .../views/mc_education_portal_menus.xml | 6 + .../views/mc_notice_views.xml | 41 +++ .../views/portal_templates.xml | 211 +++++++++++++++ 16 files changed, 971 insertions(+) create mode 100644 addons/mc_education_portal/__init__.py create mode 100644 addons/mc_education_portal/__manifest__.py create mode 100644 addons/mc_education_portal/controllers/__init__.py create mode 100644 addons/mc_education_portal/controllers/main.py create mode 100644 addons/mc_education_portal/demo/mc_notice_demo.xml create mode 100644 addons/mc_education_portal/demo/mc_portal_users_demo.xml create mode 100644 addons/mc_education_portal/models/__init__.py create mode 100644 addons/mc_education_portal/models/mc_batch.py create mode 100644 addons/mc_education_portal/models/mc_notice.py create mode 100644 addons/mc_education_portal/security/ir.model.access.csv create mode 100644 addons/mc_education_portal/security/mc_education_portal_security.xml create mode 100644 addons/mc_education_portal/tests/__init__.py create mode 100644 addons/mc_education_portal/tests/test_portal_access.py create mode 100644 addons/mc_education_portal/views/mc_education_portal_menus.xml create mode 100644 addons/mc_education_portal/views/mc_notice_views.xml create mode 100644 addons/mc_education_portal/views/portal_templates.xml 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 @@ + + + + + + + + + + + + + + + + + + + + +