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", })