Serves Demo Scene 5 in full, including the specific requirement to
demonstrate a refused access attempt live on the call. Every route
that takes a student id resolves it through _get_authorized_student(),
which does no manual comparison of its own - it relies on mc.student's
own record rules (a real mc.student.guardian link for guardians,
partner_id for students, never group membership alone, per CLAUDE.md
sec 3's "Guardian access is by relationship, not by role") to filter
a plain search(), and turns an empty result into an explicit
AccessError. The child switcher - "the single most convincing single
feature in the demo" per the spec - is just that same search with no
id filter, returning exactly the caller's own children.
Verified with real HTTP requests (HttpCase), not with_user()
shortcuts alone: authenticated as a real guardian, opened their own
child's dashboard and all four sub-pages (fees/attendance/timetable/
results) and got 200 on every one, then requested another family's
child by id and got 403 with the exact refusal message, on every one
of those same five routes. That is "editing the student identifier in
the URL and showing that it is refused" performed for real inside the
test, not asserted from a domain expression.
That same real-request testing earned its keep twice over, catching
two gaps a with_user()-only test would have missed entirely:
- mc.batch had never been portal-reachable before this module and
had no access row at all for guardian/student - a legitimate
own-child dashboard load failed with a raw ACL error, not even a
record-rule denial. Added mc.batch rules scoped the same way as
everything else (via mc.batch.enrollment_ids, a new reverse field
this module adds).
- Rendering the timetable page touches mc.subject, mc.room, mc.exam,
mc.academic.year/term and hr.employee through related-field reads,
each of which triggers its own ACL check independent of whatever
rule scopes mc.timetable.slot itself. Granted broad (unrestricted)
guardian/student read on the non-sensitive reference models
(subject/room/exam/year/term names - catalog data, not per-student
data, same reasoning already applied to Teacher/Staff/Accountant
in earlier modules); used sudo() instead for hr.employee
specifically, since employee records carry real HR data no
blanket portal grant should touch, with a comment noting the
student was already authorized by that point in the request.
Fees needs no new rule at all: mc_education_fees deliberately bills
the primary guardian's own partner_id (a design decision made back in
O3, for exactly this reason), so stock account+portal's own
partner_id-scoped visibility already covers it, and group_guardian/
group_student already imply base.group_portal from O1.
mc.notice is a new model - shared/DOMAIN_MODEL.md lists it as an
entity Track O needs but no module spec in CLAUDE.md sec 5 ever gave
it a field table; added here since it is specifically an O7 view
requirement, scoped by the same batch-or-school-wide pattern as
everything else.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
8.1 KiB
Python
176 lines
8.1 KiB
Python
import calendar
|
|
import datetime
|
|
from collections import defaultdict
|
|
|
|
from odoo import _
|
|
from odoo.exceptions import AccessError
|
|
from odoo.http import request, route
|
|
from odoo.addons.portal.controllers.portal import CustomerPortal
|
|
|
|
|
|
class SchoolPortal(CustomerPortal):
|
|
|
|
def _prepare_home_portal_values(self, counters):
|
|
values = super()._prepare_home_portal_values(counters)
|
|
if "children_count" in counters:
|
|
values["children_count"] = self._get_my_children_count()
|
|
return values
|
|
|
|
def _get_my_children_count(self):
|
|
# No sudo: mc.student's own record rules already scope this to
|
|
# the students the current user is entitled to see. See
|
|
# security/mc_education_portal_security.xml.
|
|
return request.env["mc.student"].search_count([])
|
|
|
|
def _get_authorized_student(self, student_id):
|
|
"""The standing rule (CLAUDE.md sec 3) made concrete: every route
|
|
below that takes a student id calls this first. mc.student's
|
|
record rules (guardian: a real mc.student.guardian link; student:
|
|
their own partner_id) already scope what a plain, non-sudo search
|
|
can find - an id the caller is not entitled to simply is not
|
|
found here, and that is turned into an explicit AccessError
|
|
instead of silently 404ing, so "editing the student identifier in
|
|
the URL" fails loudly and demonstrably (shared/DEMO_SCRIPT.md
|
|
Scene 5).
|
|
"""
|
|
try:
|
|
student_id = int(student_id)
|
|
except (TypeError, ValueError):
|
|
raise AccessError(_("Invalid student reference."))
|
|
student = request.env["mc.student"].search([("id", "=", student_id)])
|
|
if not student:
|
|
raise AccessError(_("You are not allowed to access this student's records."))
|
|
return student
|
|
|
|
@route(["/my/school"], type="http", auth="user", website=True)
|
|
def school_home(self, **kw):
|
|
children = request.env["mc.student"].search([])
|
|
return request.render("mc_education_portal.portal_school_children", {
|
|
"children": children,
|
|
"page_name": "school",
|
|
})
|
|
|
|
@route(["/my/school/student/<int:student_id>"], type="http", auth="user", website=True)
|
|
def school_student_dashboard(self, student_id, **kw):
|
|
student = self._get_authorized_student(student_id)
|
|
enrollment = request.env["mc.enrollment"].search([
|
|
("student_id", "=", student.id), ("state", "=", "active"),
|
|
], limit=1)
|
|
return request.render("mc_education_portal.portal_school_student_dashboard", {
|
|
"student": student,
|
|
"enrollment": enrollment,
|
|
"page_name": "school_student",
|
|
})
|
|
|
|
@route(["/my/school/student/<int:student_id>/fees"], type="http", auth="user", website=True)
|
|
def school_student_fees(self, student_id, **kw):
|
|
student = self._get_authorized_student(student_id)
|
|
# account.move is not scoped by a rule this module adds - it is
|
|
# already portal-safe via stock account+portal, since every
|
|
# invoice this app creates is billed to the guardian's own
|
|
# partner_id (see mc_education_fees). mc_student_id is only used
|
|
# here to pick the right subset for an already-authorized caller,
|
|
# never as the access check itself.
|
|
invoices = request.env["account.move"].search([
|
|
("mc_student_id", "=", student.id), ("move_type", "=", "out_invoice"),
|
|
])
|
|
return request.render("mc_education_portal.portal_school_student_fees", {
|
|
"student": student,
|
|
"invoices": invoices,
|
|
"page_name": "school_student",
|
|
})
|
|
|
|
@route(["/my/school/student/<int:student_id>/attendance"], type="http", auth="user", website=True)
|
|
def school_student_attendance(self, student_id, month=None, **kw):
|
|
student = self._get_authorized_student(student_id)
|
|
if month:
|
|
year_str, month_str = month.split("-")
|
|
year, month_num = int(year_str), int(month_str)
|
|
else:
|
|
today = datetime.date.today()
|
|
year, month_num = today.year, today.month
|
|
|
|
first_day = datetime.date(year, month_num, 1)
|
|
last_day_num = calendar.monthrange(year, month_num)[1]
|
|
last_day = datetime.date(year, month_num, last_day_num)
|
|
|
|
records = request.env["mc.attendance"].search([
|
|
("student_id", "=", student.id),
|
|
("date", ">=", first_day), ("date", "<=", last_day),
|
|
])
|
|
by_date = {r.date: r.state for r in records}
|
|
|
|
present_count = len([s for s in by_date.values() if s in ("present", "late")])
|
|
total_count = len(by_date)
|
|
percentage = (present_count / total_count * 100) if total_count else 0.0
|
|
|
|
prev_month = first_day - datetime.timedelta(days=1)
|
|
next_month = last_day + datetime.timedelta(days=1)
|
|
|
|
return request.render("mc_education_portal.portal_school_student_attendance", {
|
|
"student": student,
|
|
"year": year,
|
|
"month_num": month_num,
|
|
"month_name": calendar.month_name[month_num],
|
|
"calendar_weeks": calendar.Calendar(firstweekday=0).monthdatescalendar(year, month_num),
|
|
"by_date": by_date,
|
|
"current_month_first_day": first_day,
|
|
"present_count": present_count,
|
|
"total_count": total_count,
|
|
"percentage": percentage,
|
|
"prev_month": "%04d-%02d" % (prev_month.year, prev_month.month),
|
|
"next_month": "%04d-%02d" % (next_month.year, next_month.month),
|
|
"page_name": "school_student",
|
|
})
|
|
|
|
@route(["/my/school/student/<int:student_id>/timetable"], type="http", auth="user", website=True)
|
|
def school_student_timetable(self, student_id, **kw):
|
|
student = self._get_authorized_student(student_id)
|
|
enrollment = request.env["mc.enrollment"].search([
|
|
("student_id", "=", student.id), ("state", "=", "active"),
|
|
], limit=1)
|
|
Slot = request.env["mc.timetable.slot"]
|
|
slots = Slot
|
|
if enrollment:
|
|
slots = Slot.search([("batch_id", "=", enrollment.batch_id.id)])
|
|
weekday_selection = Slot._fields["weekday"].selection
|
|
by_weekday = defaultdict(list)
|
|
for slot in slots.sorted("period"):
|
|
# sudo() only for the teacher's *name* on an already-authorized
|
|
# student's timetable: hr.employee carries real HR data
|
|
# (address, phone, ...) that guardian/student must never get
|
|
# a blanket grant on, but "who teaches this period" is exactly
|
|
# what a timetable communicates and student_id was already
|
|
# checked above via _get_authorized_student.
|
|
by_weekday[slot.weekday].append(slot.sudo())
|
|
return request.render("mc_education_portal.portal_school_student_timetable", {
|
|
"student": student,
|
|
"weekday_order": [w[0] for w in weekday_selection],
|
|
"weekday_labels": dict(weekday_selection),
|
|
"by_weekday": by_weekday,
|
|
"page_name": "school_student",
|
|
})
|
|
|
|
@route(["/my/school/student/<int:student_id>/results"], type="http", auth="user", website=True)
|
|
def school_student_results(self, student_id, **kw):
|
|
student = self._get_authorized_student(student_id)
|
|
term = student._get_report_card_term()
|
|
marks = student._get_report_card_marks(term) if term else request.env["mc.mark"]
|
|
return request.render("mc_education_portal.portal_school_student_results", {
|
|
"student": student,
|
|
"term": term,
|
|
"marks": marks,
|
|
"page_name": "school_student",
|
|
})
|
|
|
|
@route(["/my/school/notices"], type="http", auth="user", website=True)
|
|
def school_notices(self, **kw):
|
|
# No sudo: mc.notice's own record rules already scope this to
|
|
# school-wide notices plus the current user's own children's
|
|
# batches. See security/mc_education_portal_security.xml.
|
|
notices = request.env["mc.notice"].search([], order="date desc")
|
|
return request.render("mc_education_portal.portal_school_notices", {
|
|
"notices": notices,
|
|
"page_name": "school",
|
|
})
|