diff --git a/.gitignore b/.gitignore index 12cd14d6a..8173a958e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,5 @@ __pycache__/ node_modules education/public/frontend education/www/education.html -education/www/student-portal.html +education/www/edu-portal.html ./node_modules \ No newline at end of file diff --git a/education/education/api.py b/education/education/api.py index 694f26e05..b11f2989f 100644 --- a/education/education/api.py +++ b/education/education/api.py @@ -89,7 +89,11 @@ def check_attendance_records_exist(course_schedule=None, student_group=None, dat @frappe.whitelist() def mark_attendance( - students_present, students_absent, course_schedule=None, student_group=None, date=None + students_present, + students_absent, + course_schedule=None, + student_group=None, + date=None, ): """Creates Multiple Attendance Records. @@ -117,12 +121,22 @@ def mark_attendance( for d in present: make_attendance_records( - d["student"], d["student_name"], "Present", course_schedule, student_group, date + d["student"], + d["student_name"], + "Present", + course_schedule, + student_group, + date, ) for d in absent: make_attendance_records( - d["student"], d["student_name"], "Absent", course_schedule, student_group, date + d["student"], + d["student_name"], + "Absent", + course_schedule, + student_group, + date, ) frappe.db.commit() @@ -160,7 +174,6 @@ def make_attendance_records( student_attendance.submit() -@frappe.whitelist() def get_student_guardians(student): """Returns List of Guardians of a Student. @@ -178,6 +191,9 @@ def get_student_group_students(student_group, include_inactive=0): :param student_group: Student Group. """ + if not frappe.has_permission("Student Group", "read", student_group): + raise frappe.PermissionError(_("You are not authorized to access this student group")) + if include_inactive: students = frappe.get_all( "Student Group Student", @@ -298,6 +314,9 @@ def get_assessment_criteria(course): @frappe.whitelist() def get_assessment_students(assessment_plan, student_group): + if not frappe.has_permission("Assessment Result", "read"): + raise frappe.PermissionError("Not Authorized") + student_list = get_student_group_students(student_group) for i, student in enumerate(student_list): result = get_result(student.student, assessment_plan) @@ -306,7 +325,10 @@ def get_assessment_students(assessment_plan, student_group): for d in result.details: student_result.update({d.assessment_criteria: [cstr(d.score), d.grade]}) student_result.update( - {"total_score": [cstr(result.total_score), result.grade], "comment": result.comment} + { + "total_score": [cstr(result.total_score), result.grade], + "comment": result.comment, + } ) student.update( { @@ -334,7 +356,6 @@ def get_assessment_details(assessment_plan): ) -@frappe.whitelist() def get_result(student, assessment_plan): """Returns Submitted Result of given student for specified Assessment Plan @@ -509,7 +530,7 @@ def get_instructors(student_group): @frappe.whitelist() def get_user_info(): if frappe.session.user == "Guest": - frappe.throw("Authentication failed", exc=frappe.AuthenticationError) + frappe.throw(_("Authentication failed"), exc=frappe.AuthenticationError) current_user = frappe.db.get_list( "User", @@ -520,16 +541,50 @@ def get_user_info(): return current_user +@frappe.whitelist() +def get_user_role(): + if frappe.session.user == "Guest": + frappe.throw(_("Authentication failed"), exc=frappe.AuthenticationError) + + roles = frappe.get_roles() + + if "Guardian" in roles: + return "Guardian" + + if "Student" in roles: + return "Student" + + return None + + @frappe.whitelist() def get_student_info(): email = frappe.session.user if email == "Administrator": return - student_info = frappe.db.get_list( - "Student", - fields=["*"], - filters={"user": email}, - )[0] + + student = frappe.db.get_value("Student", {"user": email}, "name") + if not student: + return None + + return get_student_context(student) + + +@frappe.whitelist() +def get_student_context(student): + """Returns the full portal context for a single student. + + Same shape as get_student_info (student record + current_program + + student_groups) but for an explicit student, authorized for the student + themselves or a linked guardian. + + :param student: Student. + """ + check_permission(student, "profile") + + student_info = frappe.db.get_value("Student", student, "*", as_dict=True) + if not student_info: + return None current_program = get_current_enrollment(student_info.name) if current_program: @@ -539,9 +594,81 @@ def get_student_info(): return student_info +@frappe.whitelist() +def get_guardian_info(): + """Returns the Guardian record for the logged-in guardian user.""" + guardian = get_guardian_for_user() + if not guardian: + return None + + return frappe.db.get_value( + "Guardian", + guardian, + [ + "name", + "guardian_name", + "email_address", + "mobile_number", + "image", + "date_of_birth", + "nationality", + "gender", + "blood_group", + "work_address", + "occupation", + ], + as_dict=True, + ) + + +@frappe.whitelist() +def get_guardian_students(): + """Returns the students (wards) linked to the logged-in guardian user.""" + guardian = get_guardian_for_user() + if not guardian: + return [] + + student_guardian = frappe.qb.DocType("Student Guardian") + student = frappe.qb.DocType("Student") + + return ( + frappe.qb.from_(student_guardian) + .inner_join(student) + .on(student.name == student_guardian.parent) + .select( + student_guardian.parent.as_("student"), + student.student_name, + student.image, + student_guardian.relation, + ) + .where(student_guardian.guardian == guardian) + .where(student_guardian.parenttype == "Student") + .run(as_dict=1) + ) + + +@frappe.whitelist() +def get_portal_context(): + """Returns role-aware bootstrap data for the portal SPA. + + For a Student: {role, student}. For a Guardian: {role, guardian, students}. + """ + role = get_user_role() + context = {"role": role} + + if role == "Guardian": + context["guardian"] = get_guardian_info() + context["students"] = get_guardian_students() + elif role == "Student": + context["student"] = get_student_info() + + return context + + @frappe.whitelist() def get_student_programs(student): - # student = 'EDU-STU-2023-00043' + check_permission(student, "programs") + programs = frappe.db.get_list( "Program Enrollment", fields=["program", "name"], @@ -600,6 +727,7 @@ def get_course_schedule_for_student(program_name, student_groups): filters={"program": program_name, "student_group": ["in", student_groups]}, order_by="schedule_date asc", ) + return schedule @@ -664,6 +792,8 @@ def apply_leave_based_on_student_group(leave_data, program_name): @frappe.whitelist() def get_student_invoices(student): + check_permission(student, "invoices") + student_sales_invoices = [] sales_invoice_list = frappe.db.get_list( @@ -760,8 +890,65 @@ def get_school_abbr_logo(): @frappe.whitelist() def get_student_attendance(student, student_group): + check_permission(student, "attendance") + return frappe.db.get_list( "Student Attendance", - filters={"student": student, "student_group": student_group, "docstatus": 1}, + filters={ + "student": student, + "student_group": student_group, + "docstatus": 1, + }, fields=["date", "status", "name"], ) + + +def check_permission(student, resource_type): + user = frappe.session.user + if user == "Administrator": + return + + student_user = frappe.db.get_value("Student", student, "user") + if student_user == user: + return + + if is_guardian_of(student, user): + return + + raise frappe.PermissionError( + _("You are not authorized to access this student's {0}".format(resource_type)) + ) + + +def get_guardian_for_user(user=None): + """Returns the Guardian linked to the given user (defaults to session user). + + Matches on the Guardian `user` link first, then falls back to + `email_address` for guardians invited before a User was linked. + """ + user = user or frappe.session.user + if not user or user in ("Guest", "Administrator"): + return None + + guardian = frappe.db.get_value("Guardian", {"user": user}, "name") + if not guardian: + guardian = frappe.db.get_value("Guardian", {"email_address": user}, "name") + return guardian + + +def is_guardian_of(student, user=None): + """Returns True if the user is a guardian of the given student.""" + guardian = get_guardian_for_user(user) + if not guardian: + return False + + return bool( + frappe.db.exists( + "Student Guardian", + { + "parent": student, + "parenttype": "Student", + "guardian": guardian, + }, + ) + ) diff --git a/education/education/doctype/assessment_result/assessment_result.json b/education/education/doctype/assessment_result/assessment_result.json index d7ece7b13..00e799012 100644 --- a/education/education/doctype/assessment_result/assessment_result.json +++ b/education/education/doctype/assessment_result/assessment_result.json @@ -173,11 +173,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2023-12-19 10:15:59.176904", + "modified": "2026-06-25 13:15:08.962397", "modified_by": "Administrator", "module": "Education", "name": "Assessment Result", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { @@ -203,8 +203,18 @@ "report": 1, "role": "Student", "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", diff --git a/education/education/doctype/course_schedule/course_schedule.json b/education/education/doctype/course_schedule/course_schedule.json index 9dec2f6f5..49a4ee508 100644 --- a/education/education/doctype/course_schedule/course_schedule.json +++ b/education/education/doctype/course_schedule/course_schedule.json @@ -135,7 +135,7 @@ } ], "links": [], - "modified": "2024-02-20 16:02:05.583081", + "modified": "2026-06-25 13:17:59.651523", "modified_by": "Administrator", "module": "Education", "name": "Course Schedule", @@ -162,8 +162,18 @@ "report": 1, "role": "Student", "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], + "row_format": "Dynamic", "sort_field": "schedule_date", "sort_order": "DESC", "states": [], diff --git a/education/education/doctype/guardian/guardian.json b/education/education/doctype/guardian/guardian.json index c17bdb19c..def000646 100644 --- a/education/education/doctype/guardian/guardian.json +++ b/education/education/doctype/guardian/guardian.json @@ -1,580 +1,179 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "EDU-GRD-.YYYY.-.#####", - "beta": 0, "creation": "2016-07-21 15:32:51.163292", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "guardian_name", + "email_address", + "mobile_number", + "alternate_number", + "date_of_birth", + "nationality", + "gender", + "blood_group", + "user", + "column_break_3", + "education", + "occupation", + "designation", + "work_address", + "image", + "section_break_13", + "students", + "section_break_8", + "interests" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "guardian_name", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Guardian Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "email_address", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, "in_global_search": 1, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Email Address", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Email Address" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "mobile_number", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, "in_global_search": 1, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Mobile Number", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Mobile Number" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "alternate_number", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Alternate Number", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Alternate Number" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "date_of_birth", "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Date of Birth", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "no_copy": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "user", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "User Id", - "length": 0, - "no_copy": 0, - "options": "User", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "User" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "education", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Education", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Education" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "occupation", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Occupation", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Occupation" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "designation", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Designation", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Designation" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "work_address", "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Work Address", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Work Address" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "image", "fieldtype": "Attach Image", "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Image", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "no_copy": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "section_break_13", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Guardian Of ", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Guardian Of " }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "students", "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Students", - "length": 0, - "no_copy": 0, "options": "Guardian Student", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "read_only": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "section_break_8", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Guardian Interests", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Guardian Interests" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "interests", "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Interests", - "length": 0, - "no_copy": 0, - "options": "Guardian Interest", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Guardian Interest" + }, + { + "fieldname": "nationality", + "fieldtype": "Data", + "label": "Nationality" + }, + { + "fieldname": "gender", + "fieldtype": "Link", + "label": "Gender", + "options": "Gender" + }, + { + "fieldname": "blood_group", + "fieldtype": "Select", + "label": "Blood Group", + "options": "\nA+\nA-\nB+\nB-\nO+\nO-\nAB+\nAB-" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, "image_field": "image", - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-08-21 16:15:54.050317", + "links": [], + "modified": "2026-06-25 13:21:48.245244", "modified_by": "Administrator", "module": "Education", "name": "Guardian", - "name_case": "", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "Academics User", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "restrict_to_domain": "", + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", - "title_field": "guardian_name", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + "states": [], + "title_field": "guardian_name" } \ No newline at end of file diff --git a/education/education/doctype/guardian/guardian.py b/education/education/doctype/guardian/guardian.py index d6f315cdb..aae67aeed 100644 --- a/education/education/doctype/guardian/guardian.py +++ b/education/education/doctype/guardian/guardian.py @@ -39,6 +39,7 @@ def validate(self): @frappe.whitelist() def invite_guardian(guardian): + frappe.has_permission("Guardian", "write", throw=True) guardian_doc = frappe.get_doc("Guardian", guardian) if not guardian_doc.email_address: frappe.throw(_("Please set Email Address")) @@ -58,6 +59,8 @@ def invite_guardian(guardian): "user_type": "Website User", "send_welcome_email": 1, } - ).insert(ignore_permissions=True) + ) + user.add_roles("Guardian") + user.save(ignore_permissions=True) frappe.msgprint(_("User {0} created").format(getlink("User", user.name))) return user.name diff --git a/education/education/doctype/program/program.json b/education/education/doctype/program/program.json index 795999989..499eec39c 100644 --- a/education/education/doctype/program/program.json +++ b/education/education/doctype/program/program.json @@ -110,7 +110,7 @@ "link_fieldname": "program" } ], - "modified": "2026-02-04 12:17:24.314384", + "modified": "2026-06-25 13:23:47.550553", "modified_by": "Administrator", "module": "Education", "name": "Program", @@ -161,6 +161,15 @@ "report": 1, "role": "Student", "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], "row_format": "Dynamic", @@ -169,4 +178,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} +} \ No newline at end of file diff --git a/education/education/doctype/program_enrollment/program_enrollment.json b/education/education/doctype/program_enrollment/program_enrollment.json index 4c7489e31..6d578bcfb 100644 --- a/education/education/doctype/program_enrollment/program_enrollment.json +++ b/education/education/doctype/program_enrollment/program_enrollment.json @@ -175,11 +175,11 @@ "link_fieldname": "program_enrollment" } ], - "modified": "2024-01-30 15:41:31.016037", + "modified": "2026-06-25 13:24:39.256363", "modified_by": "Administrator", "module": "Education", "name": "Program Enrollment", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { @@ -205,8 +205,18 @@ "report": 1, "role": "Student", "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", diff --git a/education/education/doctype/student/student.json b/education/education/doctype/student/student.json index d4c4d91c9..5988313b9 100644 --- a/education/education/doctype/student/student.json +++ b/education/education/doctype/student/student.json @@ -351,7 +351,7 @@ "link_fieldname": "student" } ], - "modified": "2024-06-07 16:52:48.135292", + "modified": "2026-06-25 13:28:11.149666", "modified_by": "Administrator", "module": "Education", "name": "Student", @@ -383,8 +383,18 @@ "report": 1, "role": "Student", "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], + "row_format": "Dynamic", "show_name_in_global_search": 1, "show_title_field_in_link": 1, "sort_field": "modified", diff --git a/education/education/doctype/student_attendance/student_attendance.json b/education/education/doctype/student_attendance/student_attendance.json index e335434f1..056d9ee77 100644 --- a/education/education/doctype/student_attendance/student_attendance.json +++ b/education/education/doctype/student_attendance/student_attendance.json @@ -115,7 +115,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-06-05 17:10:47.758164", + "modified": "2026-06-25 13:29:15.742415", "modified_by": "Administrator", "module": "Education", "name": "Student Attendance", @@ -144,6 +144,15 @@ "report": 1, "role": "Student", "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], "row_format": "Dynamic", diff --git a/education/education/doctype/student_leave_application/student_leave_application.json b/education/education/doctype/student_leave_application/student_leave_application.json index f73090b0b..ba7516555 100644 --- a/education/education/doctype/student_leave_application/student_leave_application.json +++ b/education/education/doctype/student_leave_application/student_leave_application.json @@ -122,11 +122,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2023-12-19 10:17:31.704531", + "modified": "2026-06-25 13:31:44.505759", "modified_by": "Administrator", "module": "Education", "name": "Student Leave Application", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { @@ -166,9 +166,19 @@ "report": 1, "role": "Student", "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guardian", + "share": 1 } ], "quick_entry": 1, + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", diff --git a/education/hooks.py b/education/hooks.py index b1cded084..417389a46 100644 --- a/education/hooks.py +++ b/education/hooks.py @@ -45,7 +45,7 @@ website_route_rules = [ {"from_route": "/admissions", "to_route": "Student Admission"}, - {"from_route": "/student-portal/", "to_route": "student-portal"}, + {"from_route": "/edu-portal/", "to_route": "edu-portal"}, ] treeviews = ["Assessment Group"] diff --git a/education/install.py b/education/install.py index 57c800105..e14e84bb9 100644 --- a/education/install.py +++ b/education/install.py @@ -16,7 +16,11 @@ def after_install(): def setup_fixtures(): records = [ # Party Type Records - {"doctype": "Party Type", "party_type": "Student", "account_type": "Receivable"}, + { + "doctype": "Party Type", + "party_type": "Student", + "account_type": "Receivable", + }, # Item Group Records {"doctype": "Item Group", "item_group_name": "Fee Component"}, # Customer Group Records @@ -41,6 +45,11 @@ def create_student_role(): frappe.get_doc({"doctype": "Role", "role_name": "Student", "desk_access": 0}).save() +def create_guardian_role(): + if not frappe.db.exists("Role", "Guardian"): + frappe.get_doc({"doctype": "Role", "role_name": "Guardian", "desk_access": 0}).save() + + def create_invoice_permissions(): add_permission("Sales Invoice", "Student", 0) diff --git a/education/patches.txt b/education/patches.txt index 94048e205..a978917d9 100644 --- a/education/patches.txt +++ b/education/patches.txt @@ -15,4 +15,5 @@ education.patches.v15_0.sales_order_student_field education.patches.v15_0.fee_schedule_status_update #28-03-2024 education.patches.v15_0.create_fee_component_item_group education.patches.v15_0.create_student_customer_group -education.patches.v15_0.create_custom_permissions \ No newline at end of file +education.patches.v15_0.create_custom_permissions +education.patches.v15_0.create_guardian_role diff --git a/education/patches/v15_0/create_guardian_role.py b/education/patches/v15_0/create_guardian_role.py new file mode 100644 index 000000000..c17810699 --- /dev/null +++ b/education/patches/v15_0/create_guardian_role.py @@ -0,0 +1,6 @@ +import frappe + + +def execute(): + if not frappe.db.exists("Role", "Guardian"): + frappe.get_doc({"doctype": "Role", "role_name": "Guardian", "desk_access": 0}).save() diff --git a/education/www/student_portal.py b/education/www/edu_portal.py similarity index 100% rename from education/www/student_portal.py rename to education/www/edu_portal.py diff --git a/frontend/package.json b/frontend/package.json index 014bf4979..6a4bcfcf5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "dev": "vite", "build": "vite build --base=/assets/education/frontend/ && yarn copy-html-entry", "preview": "vite preview", - "copy-html-entry": "cp ../education/public/frontend/index.html ../education/www/student-portal.html" + "copy-html-entry": "cp ../education/public/frontend/index.html ../education/www/edu-portal.html" }, "dependencies": { "dayjs": "^1.11.10", diff --git a/frontend/src/components/Navbar.vue b/frontend/src/components/Navbar.vue index 1d4384e76..3285f8b58 100644 --- a/frontend/src/components/Navbar.vue +++ b/frontend/src/components/Navbar.vue @@ -7,7 +7,7 @@ {{ currentRoute }} -
+
@@ -71,11 +71,56 @@ import { Dialog, Avatar, FeatherIcon } from 'frappe-ui' import { inject } from 'vue' import { studentStore } from '@/stores/student' +import { portalStore } from '@/stores/portal' const { getStudentInfo } = studentStore() +const { isStudent, getGuardianInfo } = portalStore() const showProfileDialog = inject('showProfileDialog') const studentInfo = getStudentInfo().value +const guardianInfo = getGuardianInfo().value +let profileInfo = {} + +function standardizeProfileInfo(info) { + if (isStudent) { + return { + full_name: info.student_name, + image: info.image, + email_id: info.student_email_id, + mobile_number: info.student_mobile_number, + joining_date: info.joining_date, + date_of_birth: info.date_of_birth, + blood_group: info.blood_group, + gender: info.gender, + nationality: info.nationality, + address_line_1: info.address_line_1, + address_line_2: info.address_line_2, + city: info.city, + pincode: info.pincode, + state: info.state, + country: info.country, + } + } + + return { + full_name: guardianInfo.guardian_name, + image: guardianInfo.image, + email_id: guardianInfo.email_address, + mobile_number: guardianInfo.mobile_number, + occupation: guardianInfo.occupation, + date_of_birth: guardianInfo.date_of_birth, + address_line_1: guardianInfo.work_address, + blood_group: guardianInfo.blood_group, + gender: guardianInfo.gender, + nationality: guardianInfo.nationality, + } +} + +if (isStudent) { + profileInfo = standardizeProfileInfo(studentInfo) +} else { + profileInfo = standardizeProfileInfo(guardianInfo) +} const infoFormat = [ { @@ -83,25 +128,25 @@ const infoFormat = [ fields: [ { label: 'Mobile Number', - value: studentInfo.student_mobile_number, + value: profileInfo.mobile_number, }, { label: 'Joining Date', - value: studentInfo.joining_date, + value: profileInfo.joining_date, }, { label: 'Date of Birth', - value: studentInfo.date_of_birth, + value: profileInfo.date_of_birth, }, { label: 'Address', value: [ - studentInfo?.address_line_1, - studentInfo?.address_line_2, - studentInfo?.city, - studentInfo?.pincode, - studentInfo?.state, - studentInfo?.country, + profileInfo?.address_line_1, + profileInfo?.address_line_2, + profileInfo?.city, + profileInfo?.pincode, + profileInfo?.state, + profileInfo?.country, ] .map((item) => item?.trim()) .filter(Boolean) @@ -114,17 +159,24 @@ const infoFormat = [ fields: [ { label: 'Blood Group', - value: studentInfo.blood_group, + value: profileInfo.blood_group, }, { label: 'Gender', - value: studentInfo.gender, + value: profileInfo.gender, }, { label: 'Nationality', - value: studentInfo.nationality, + value: profileInfo.nationality, }, ], }, ] + +if (!isStudent) { + infoFormat[0].fields.splice(1, 1, { + label: 'Occupation', + value: profileInfo.occupation, + }) +} diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index 702820edc..e8497ef32 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -11,7 +11,10 @@ !educationSettings.loading && educationSettings.data " /> -
+
+ +
diff --git a/frontend/src/components/SidebarLink.vue b/frontend/src/components/SidebarLink.vue index 97a6612af..39be19ecd 100644 --- a/frontend/src/components/SidebarLink.vue +++ b/frontend/src/components/SidebarLink.vue @@ -33,6 +33,7 @@ import { Tooltip } from 'frappe-ui' import { computed } from 'vue' import { useRouter } from 'vue-router' +import { portalStore } from '@/stores/portal' const router = useRouter() const props = defineProps({ @@ -51,9 +52,18 @@ const props = defineProps({ type: Boolean, default: false, }, + isRedirect: { + type: Boolean, + default: false, + }, }) +const { clearActiveStudent } = portalStore() + function handleClick() { + if (props.isRedirect) { + clearActiveStudent() + } router.push(props.to) } diff --git a/frontend/src/pages/Students.vue b/frontend/src/pages/Students.vue new file mode 100644 index 000000000..b04fd66f5 --- /dev/null +++ b/frontend/src/pages/Students.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 3152f1965..4540b1b94 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -2,9 +2,15 @@ import { createRouter, createWebHistory } from 'vue-router' import { usersStore } from '@/stores/user' import { sessionStore } from '@/stores/session' import { studentStore } from '@/stores/student' +import { portalStore } from '@/stores/portal' const routes = [ { path: '/', redirect: '/schedule' }, + { + path: '/students', + name: 'Students', + component: () => import('@/pages/Students.vue'), + }, { path: '/schedule', name: 'Schedule', @@ -32,24 +38,45 @@ const routes = [ ] let router = createRouter({ - history: createWebHistory('/student-portal'), + history: createWebHistory('/edu-portal'), routes, }) -router.beforeEach(async (to, from) => { - const { isLoggedIn, user: sessionUser } = sessionStore() +router.beforeEach(async (to) => { + const { isLoggedIn } = sessionStore() const { user } = usersStore() - const { student } = studentStore() + const portal = portalStore() + const student = studentStore() if (!isLoggedIn) { window.location.href = '/login' - return await next(false) + return false } if (user.data.length === 0) { await user.reload() } - await student.reload() + + if (!portal.role) { + await portal.context.reload() + } + + if (!portal.role) { + window.location.href = '/app' + return false + } + + // Guardians must pick a student before any student-scoped page. + if (portal.isGuardian && !portal.activeStudentId && to.path !== '/students') { + return '/students' + } + + // The cards landing page is guardian-only. + if (!portal.isGuardian && to.path === '/students') { + return '/schedule' + } + + await student.loadStudent() }) export default router diff --git a/frontend/src/stores/portal.js b/frontend/src/stores/portal.js new file mode 100644 index 000000000..57186ea63 --- /dev/null +++ b/frontend/src/stores/portal.js @@ -0,0 +1,68 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { createResource } from 'frappe-ui' +import { useStorage } from '@vueuse/core' + +export const portalStore = defineStore('education-portal', () => { + const role = ref(null) + const guardianInfo = ref({}) + const students = ref([]) + + const isGuardian = computed(() => role.value === 'Guardian') + const isStudent = computed(() => role.value === 'Student') + + // Persisted so a refresh keeps the guardian on the same student + const activeStudentId = useStorage('education-active_student', null) + + const context = createResource({ + url: 'education.education.api.get_portal_context', + onSuccess: (data) => { + if (!data) return + role.value = data.role + if (data.role === 'Guardian') { + guardianInfo.value = data.guardian || {} + students.value = data.students || [] + const studentIds = students.value.map((s) => s.student) + if ( + activeStudentId.value && + !studentIds.includes(activeStudentId.value) + ) { + activeStudentId.value = null + } + } else { + guardianInfo.value = {} + students.value = [] + // A student always acts on their own record. + activeStudentId.value = null + } + }, + onError(err) { + console.warn(err) + }, + }) + + function setActiveStudent(studentId) { + activeStudentId.value = studentId + } + + function clearActiveStudent() { + activeStudentId.value = null + } + + function getGuardianInfo() { + return guardianInfo + } + + return { + role, + guardianInfo, + students, + activeStudentId, + isGuardian, + isStudent, + context, + setActiveStudent, + clearActiveStudent, + getGuardianInfo, + } +}) diff --git a/frontend/src/stores/student.js b/frontend/src/stores/student.js index 7cf1b31f9..dc1a29963 100644 --- a/frontend/src/stores/student.js +++ b/frontend/src/stores/student.js @@ -1,36 +1,63 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { createResource } from 'frappe-ui' +import { portalStore } from '@/stores/portal' export const studentStore = defineStore('education-student', () => { const studentInfo = ref({}) const currentProgram = ref({}) const studentGroups = ref([]) - const student = createResource({ + const portal = portalStore() + + function setInfo(info) { + if (!info) { + studentInfo.value = {} + currentProgram.value = {} + studentGroups.value = [] + return + } + currentProgram.value = info.current_program || {} + studentGroups.value = info.student_groups || [] + const rest = { ...info } + delete rest.current_program + delete rest.student_groups + studentInfo.value = rest + } + + // Logged-in student viewing their own record. + const selfStudent = createResource({ url: 'education.education.api.get_student_info', - onSuccess(info) { - if (!info) { - window.location.href = '/app' - } - currentProgram.value = info.current_program - // remove current_program from info - delete info.current_program - studentGroups.value = info.student_groups - delete info.student_groups - studentInfo.value = info - }, - onError(err) { - console.error(err) + onSuccess: setInfo, + onError: (err) => console.warn(err), + }) + + // Guardian viewing the currently selected student + const guardianStudent = createResource({ + url: 'education.education.api.get_student_context', + makeParams() { + return { student: portal.activeStudentId } }, + onSuccess: setInfo, + onError: (err) => console.warn(err), }) - // const s = createDocumentResource({ - // doctype:"Student", - // whitelist: { - // 'get_student_info': get_student_info - // } - // }) + async function loadStudent() { + if (portal.isGuardian) { + if (!portal.activeStudentId) { + setInfo(null) + return + } + return guardianStudent.reload() + } + if (portal.isStudent) { + return selfStudent.reload() + } + } + + // Backwards-compatible interface: the router and session store call + // student.reload() to (re)load the active student's context. + const student = { reload: loadStudent, fetch: loadStudent } function getStudentInfo() { return studentInfo @@ -38,7 +65,6 @@ export const studentStore = defineStore('education-student', () => { function getCurrentProgram() { return currentProgram } - function getStudentGroups() { return studentGroups } @@ -48,6 +74,7 @@ export const studentStore = defineStore('education-student', () => { studentInfo, currentProgram, studentGroups, + loadStudent, getStudentInfo, getCurrentProgram, getStudentGroups,