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 @@ + + + + + + + + + + + + + + + + + + + + +