diff --git a/school/hooks.py b/school/hooks.py index e3b2b351..73365a81 100644 --- a/school/hooks.py +++ b/school/hooks.py @@ -141,6 +141,12 @@ website_route_rules = [ {"from_route": "/courses//learn/.", "to_route": "batch/learn"}, {"from_route": "/courses//progress", "to_route": "batch/progress"}, {"from_route": "/courses//join", "to_route": "batch/join"}, + {"from_route": "/courses//manage", "to_route": "cohorts"}, + {"from_route": "/courses//cohorts/", "to_route": "cohorts/cohort"}, + {"from_route": "/courses//cohorts//", "to_route": "cohorts/cohort"}, + {"from_route": "/courses//subgroups//", "to_route": "cohorts/subgroup"}, + {"from_route": "/courses//subgroups///", "to_route": "cohorts/subgroup"}, + {"from_route": "/courses//join///", "to_route": "cohorts/join"}, {"from_route": "/users", "to_route": "profiles/profile"} ] diff --git a/school/lms/api.py b/school/lms/api.py index b60d2d13..f6aee823 100644 --- a/school/lms/api.py +++ b/school/lms/api.py @@ -45,3 +45,119 @@ def save_current_lesson(course_name, lesson_name): doc.current_lesson = lesson_name doc.save(ignore_permissions=True) return {"current_lesson": doc.current_lesson} + + +@frappe.whitelist() +def join_cohort(course, cohort, subgroup, invite_code): + """Creates a Cohort Join Request for given user. + """ + course_doc = frappe.get_doc("LMS Course", course) + cohort_doc = course_doc and course_doc.get_cohort(cohort) + subgroup_doc = cohort_doc and cohort_doc.get_subgroup(subgroup) + + if not subgroup_doc or subgroup_doc.invite_code != invite_code: + return { + "ok": False, + "error": "Invalid join link" + } + + data = { + "doctype": "Cohort Join Request", + "cohort": cohort_doc.name, + "subgroup": subgroup_doc.name, + "email": frappe.session.user, + "status": "Pending" + } + # Don't insert duplicate records + if frappe.db.exists(data): + return {"ok": True, "status": "record found"} + else: + doc = frappe.get_doc(data) + doc.insert(ignore_permissions=True) + return {"ok": True, "status": "record created"} + +@frappe.whitelist() +def approve_cohort_join_request(join_request): + r = frappe.get_doc("Cohort Join Request", join_request) + sg = r and frappe.get_doc("Cohort Subgroup", r.subgroup) + if not sg or r.status not in ["Pending", "Accepted"]: + return { + "ok": False, + "error": "Invalid Join Request" + } + if not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles(): + return { + "ok": False, + "error": "Permission Deined" + } + + r.status = "Accepted" + r.save(ignore_permissions=True) + return {"ok": True} + +@frappe.whitelist() +def reject_cohort_join_request(join_request): + r = frappe.get_doc("Cohort Join Request", join_request) + sg = r and frappe.get_doc("Cohort Subgroup", r.subgroup) + if not sg or r.status not in ["Pending", "Rejected"]: + return { + "ok": False, + "error": "Invalid Join Request" + } + if not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles(): + return { + "ok": False, + "error": "Permission Deined" + } + + r.status = "Rejected" + r.save(ignore_permissions=True) + return {"ok": True} + + +@frappe.whitelist() +def undo_reject_cohort_join_request(join_request): + r = frappe.get_doc("Cohort Join Request", join_request) + sg = r and frappe.get_doc("Cohort Subgroup", r.subgroup) + # keeping Pending as well to consider the case of duplicate requests + if not sg or r.status not in ["Pending", "Rejected"]: + return { + "ok": False, + "error": "Invalid Join Request" + } + if not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles(): + return { + "ok": False, + "error": "Permission Deined" + } + + r.status = "Pending" + r.save(ignore_permissions=True) + return {"ok": True} + +@frappe.whitelist() +def add_mentor_to_subgroup(subgroup, email): + try: + sg = frappe.get_doc("Cohort Subgroup", subgroup) + except frappe.DoesNotExistError: + return { + "ok": False, + "error": f"Invalid subgroup: {subgroup}" + } + + if not sg.get_cohort().is_admin(frappe.session.user) and "System Manager" not in frappe.get_roles(): + return { + "ok": False, + "error": "Permission Deined" + } + + try: + user = frappe.get_doc("User", email) + except frappe.DoesNotExistError: + return { + "ok": False, + "error": f"Invalid user: {email}" + } + + sg.add_mentor(email) + return {"ok": True} diff --git a/school/lms/doctype/cohort/__init__.py b/school/lms/doctype/cohort/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/lms/doctype/cohort/cohort.js b/school/lms/doctype/cohort/cohort.js new file mode 100644 index 00000000..002b8b3c --- /dev/null +++ b/school/lms/doctype/cohort/cohort.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, FOSS United and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Cohort', { + // refresh: function(frm) { + + // } +}); diff --git a/school/lms/doctype/cohort/cohort.json b/school/lms/doctype/cohort/cohort.json new file mode 100644 index 00000000..2efa1428 --- /dev/null +++ b/school/lms/doctype/cohort/cohort.json @@ -0,0 +1,131 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{course}/{slug}", + "creation": "2021-11-19 11:45:31.016097", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "course", + "title", + "slug", + "section_break_2", + "instructor", + "status", + "column_break_4", + "begin_date", + "end_date", + "duration", + "section_break_8", + "description", + "pages" + ], + "fields": [ + { + "fieldname": "description", + "fieldtype": "Markdown Editor", + "label": "Description" + }, + { + "fieldname": "instructor", + "fieldtype": "Link", + "label": "Instructor", + "options": "User", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Upcoming\nLive\nCompleted\nCancelled", + "reqd": 1 + }, + { + "fieldname": "begin_date", + "fieldtype": "Date", + "label": "Begin Date" + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date" + }, + { + "fieldname": "duration", + "fieldtype": "Data", + "label": "Duration" + }, + { + "fieldname": "slug", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Slug", + "unique": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_8", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "course", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Course", + "options": "LMS Course", + "reqd": 1 + }, + { + "fieldname": "pages", + "fieldtype": "Table", + "label": "Pages", + "options": "Cohort Web Page" + } + ], + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Links", + "link_doctype": "Cohort Subgroup", + "link_fieldname": "cohort" + } + ], + "modified": "2021-12-16 14:44:25.406301", + "modified_by": "Administrator", + "module": "LMS", + "name": "Cohort", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/school/lms/doctype/cohort/cohort.py b/school/lms/doctype/cohort/cohort.py new file mode 100644 index 00000000..429baac5 --- /dev/null +++ b/school/lms/doctype/cohort/cohort.py @@ -0,0 +1,85 @@ +# Copyright (c) 2021, FOSS United and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + +class Cohort(Document): + def get_url(self): + return f"{frappe.utils.get_url()}/courses/{self.course}/cohorts/{self.slug}" + + def get_subgroups(self, include_counts=False, sort_by=None): + names = frappe.get_all("Cohort Subgroup", filters={"cohort": self.name}, pluck="name") + subgroups = [frappe.get_cached_doc("Cohort Subgroup", name) for name in names] + subgroups = sorted(subgroups, key=lambda sg: sg.title) + + if include_counts: + mentors = self._get_subgroup_counts("Cohort Mentor") + students = self._get_subgroup_counts("LMS Batch Membership") + join_requests = self._get_subgroup_counts("Cohort Join Request", status="Pending") + for s in subgroups: + s.num_mentors = mentors.get(s.name, 0) + s.num_students = students.get(s.name, 0) + s.num_join_requests = join_requests.get(s.name, 0) + + if sort_by: + subgroups.sort(key=lambda sg: getattr(sg, sort_by), reverse=True) + return subgroups + + def _get_subgroup_counts(self, doctype, **kw): + rows = frappe.get_all(doctype, + filters={"cohort": self.name, **kw}, + fields=['subgroup', 'count(*) as count'], + group_by='subgroup') + return {row['subgroup']: row['count'] for row in rows} + + def _get_count(self, doctype, **kw): + filters = {"cohort": self.name, **kw} + return frappe.db.count(doctype, filters=filters) + + def get_page_template(self, slug, scope=None): + p = self.get_page(slug, scope=scope) + return p and p.get_template_html() + + def get_page(self, slug, scope=None): + for p in self.pages: + if p.slug == slug and scope in [p.scope, None]: + return p + + def get_pages(self, scope=None): + return [p for p in self.pages if scope in [p.scope, None]] + + def get_stats(self): + return { + "subgroups": self._get_count("Cohort Subgroup"), + "mentors": self._get_count("Cohort Mentor"), + "students": self._get_count("LMS Batch Membership"), + "join_requests": self._get_count("Cohort Join Request", status="Pending"), + } + + def get_subgroup(self, slug): + q = dict(cohort=self.name, slug=slug) + name = frappe.db.get_value("Cohort Subgroup", q, "name") + return name and frappe.get_doc("Cohort Subgroup", name) + + def get_mentor(self, email): + q = dict(cohort=self.name, email=email) + name = frappe.db.get_value("Cohort Mentor", q, "name") + return name and frappe.get_doc("Cohort Mentor", name) + + def is_mentor(self, email): + q = { + "doctype": "Cohort Mentor", + "cohort": self.name, + "email": email + } + return frappe.db.exists(q) + + def is_admin(self, email): + q = { + "doctype": "Cohort Staff", + "cohort": self.name, + "email": email, + "role": "Admin" + } + return frappe.db.exists(q) diff --git a/school/lms/doctype/cohort/test_cohort.py b/school/lms/doctype/cohort/test_cohort.py new file mode 100644 index 00000000..a7cf10ba --- /dev/null +++ b/school/lms/doctype/cohort/test_cohort.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, FOSS United and Contributors +# See license.txt + +# import frappe +import unittest + +class TestCohort(unittest.TestCase): + pass diff --git a/school/lms/doctype/cohort_join_request/__init__.py b/school/lms/doctype/cohort_join_request/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/lms/doctype/cohort_join_request/cohort_join_request.js b/school/lms/doctype/cohort_join_request/cohort_join_request.js new file mode 100644 index 00000000..8f6cec18 --- /dev/null +++ b/school/lms/doctype/cohort_join_request/cohort_join_request.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, FOSS United and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Cohort Join Request', { + // refresh: function(frm) { + + // } +}); diff --git a/school/lms/doctype/cohort_join_request/cohort_join_request.json b/school/lms/doctype/cohort_join_request/cohort_join_request.json new file mode 100644 index 00000000..8ecd4be3 --- /dev/null +++ b/school/lms/doctype/cohort_join_request/cohort_join_request.json @@ -0,0 +1,76 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-11-19 16:27:41.716509", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "cohort", + "email", + "column_break_3", + "subgroup", + "status" + ], + "fields": [ + { + "fieldname": "cohort", + "fieldtype": "Link", + "label": "Cohort", + "options": "Cohort", + "reqd": 1 + }, + { + "fieldname": "subgroup", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Subgroup", + "options": "Cohort Subgroup", + "reqd": 1 + }, + { + "fieldname": "email", + "fieldtype": "Link", + "in_list_view": 1, + "label": "E-Mail", + "options": "User", + "reqd": 1 + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Pending\nAccepted\nRejected" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-12-16 15:06:03.985221", + "modified_by": "Administrator", + "module": "LMS", + "name": "Cohort Join Request", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/school/lms/doctype/cohort_join_request/cohort_join_request.py b/school/lms/doctype/cohort_join_request/cohort_join_request.py new file mode 100644 index 00000000..95c975f8 --- /dev/null +++ b/school/lms/doctype/cohort_join_request/cohort_join_request.py @@ -0,0 +1,51 @@ +# Copyright (c) 2021, FOSS United and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + +class CohortJoinRequest(Document): + def on_update(self): + if self.status == "Accepted": + self.ensure_student() + + def ensure_student(self): + # case 1 - user is already a member + q = { + "doctype": "LMS Batch Membership", + "cohort": self.cohort, + "subgroup": self.subgroup, + "member": self.email, + "member_type": "Student" + } + if frappe.db.exists(q): + return + + # case 2 - user has signed up for this course, possibly not this cohort + cohort = frappe.get_doc("Cohort", self.cohort) + + q = { + "doctype": "LMS Batch Membership", + "course": cohort.course, + "member": self.email, + "member_type": "Student" + } + name = frappe.db.exists(q) + if name: + doc = frappe.get_doc("LMS Batch Membership", name) + doc.cohort = self.cohort + doc.subgroup = self.subgroup + doc.save(ignore_permissions=True) + else: + # case 3 - user has not signed up for this course yet + data = { + "doctype": "LMS Batch Membership", + "course": cohort.course, + "cohort": self.cohort, + "subgroup": self.subgroup, + "member": self.email, + "member_type": "Student", + "role": "Member" + } + doc = frappe.get_doc(data) + doc.insert(ignore_permissions=True) diff --git a/school/lms/doctype/cohort_join_request/test_cohort_join_request.py b/school/lms/doctype/cohort_join_request/test_cohort_join_request.py new file mode 100644 index 00000000..dd386401 --- /dev/null +++ b/school/lms/doctype/cohort_join_request/test_cohort_join_request.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, FOSS United and Contributors +# See license.txt + +# import frappe +import unittest + +class TestCohortJoinRequest(unittest.TestCase): + pass diff --git a/school/lms/doctype/cohort_mentor/__init__.py b/school/lms/doctype/cohort_mentor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/lms/doctype/cohort_mentor/cohort_mentor.js b/school/lms/doctype/cohort_mentor/cohort_mentor.js new file mode 100644 index 00000000..81f90950 --- /dev/null +++ b/school/lms/doctype/cohort_mentor/cohort_mentor.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, FOSS United and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Cohort Mentor', { + // refresh: function(frm) { + + // } +}); diff --git a/school/lms/doctype/cohort_mentor/cohort_mentor.json b/school/lms/doctype/cohort_mentor/cohort_mentor.json new file mode 100644 index 00000000..42aade7a --- /dev/null +++ b/school/lms/doctype/cohort_mentor/cohort_mentor.json @@ -0,0 +1,73 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-11-19 15:31:47.129156", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "cohort", + "email", + "subgroup", + "course" + ], + "fields": [ + { + "fieldname": "cohort", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Cohort", + "options": "Cohort", + "reqd": 1 + }, + { + "fieldname": "email", + "fieldtype": "Link", + "in_list_view": 1, + "label": "E-mail", + "options": "User", + "reqd": 1 + }, + { + "fieldname": "subgroup", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Primary Subgroup", + "options": "Cohort Subgroup", + "reqd": 1 + }, + { + "fetch_from": "cohort.course", + "fieldname": "course", + "fieldtype": "Link", + "label": "Course", + "options": "LMS Course", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-11-29 16:32:33.235281", + "modified_by": "Administrator", + "module": "LMS", + "name": "Cohort Mentor", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/school/lms/doctype/cohort_mentor/cohort_mentor.py b/school/lms/doctype/cohort_mentor/cohort_mentor.py new file mode 100644 index 00000000..71bf9c4b --- /dev/null +++ b/school/lms/doctype/cohort_mentor/cohort_mentor.py @@ -0,0 +1,12 @@ +# Copyright (c) 2021, FOSS United and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + +class CohortMentor(Document): + def get_subgroup(self): + return frappe.get_doc("Cohort Subgroup", self.subgroup) + + def get_user(self): + return frappe.get_doc("User", self.email) diff --git a/school/lms/doctype/cohort_mentor/test_cohort_mentor.py b/school/lms/doctype/cohort_mentor/test_cohort_mentor.py new file mode 100644 index 00000000..06be484e --- /dev/null +++ b/school/lms/doctype/cohort_mentor/test_cohort_mentor.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, FOSS United and Contributors +# See license.txt + +# import frappe +import unittest + +class TestCohortMentor(unittest.TestCase): + pass diff --git a/school/lms/doctype/cohort_staff/__init__.py b/school/lms/doctype/cohort_staff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/lms/doctype/cohort_staff/cohort_staff.js b/school/lms/doctype/cohort_staff/cohort_staff.js new file mode 100644 index 00000000..8deaab23 --- /dev/null +++ b/school/lms/doctype/cohort_staff/cohort_staff.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, FOSS United and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Cohort Staff', { + // refresh: function(frm) { + + // } +}); diff --git a/school/lms/doctype/cohort_staff/cohort_staff.json b/school/lms/doctype/cohort_staff/cohort_staff.json new file mode 100644 index 00000000..dca4e0f9 --- /dev/null +++ b/school/lms/doctype/cohort_staff/cohort_staff.json @@ -0,0 +1,77 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-11-19 15:35:00.551949", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "cohort", + "course", + "column_break_3", + "email", + "role" + ], + "fields": [ + { + "fieldname": "cohort", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Cohort", + "options": "Cohort", + "reqd": 1 + }, + { + "fieldname": "email", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1 + }, + { + "fieldname": "role", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Role", + "options": "Admin\nManager\nStaff", + "reqd": 1 + }, + { + "fetch_from": "cohort.course", + "fieldname": "course", + "fieldtype": "Link", + "label": "Course", + "options": "LMS Course", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-12-16 15:16:04.042372", + "modified_by": "Administrator", + "module": "LMS", + "name": "Cohort Staff", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/school/lms/doctype/cohort_staff/cohort_staff.py b/school/lms/doctype/cohort_staff/cohort_staff.py new file mode 100644 index 00000000..6febbcc4 --- /dev/null +++ b/school/lms/doctype/cohort_staff/cohort_staff.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, FOSS United and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class CohortStaff(Document): + pass diff --git a/school/lms/doctype/cohort_staff/test_cohort_staff.py b/school/lms/doctype/cohort_staff/test_cohort_staff.py new file mode 100644 index 00000000..c27f423e --- /dev/null +++ b/school/lms/doctype/cohort_staff/test_cohort_staff.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, FOSS United and Contributors +# See license.txt + +# import frappe +import unittest + +class TestCohortStaff(unittest.TestCase): + pass diff --git a/school/lms/doctype/cohort_subgroup/__init__.py b/school/lms/doctype/cohort_subgroup/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/lms/doctype/cohort_subgroup/cohort_subgroup.js b/school/lms/doctype/cohort_subgroup/cohort_subgroup.js new file mode 100644 index 00000000..9c851cf0 --- /dev/null +++ b/school/lms/doctype/cohort_subgroup/cohort_subgroup.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, FOSS United and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Cohort Subgroup', { + // refresh: function(frm) { + + // } +}); diff --git a/school/lms/doctype/cohort_subgroup/cohort_subgroup.json b/school/lms/doctype/cohort_subgroup/cohort_subgroup.json new file mode 100644 index 00000000..06bc00ab --- /dev/null +++ b/school/lms/doctype/cohort_subgroup/cohort_subgroup.json @@ -0,0 +1,104 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{title} ({cohort})", + "creation": "2021-11-19 11:50:27.312434", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "cohort", + "slug", + "title", + "column_break_4", + "invite_code", + "course", + "section_break_7", + "description" + ], + "fields": [ + { + "fieldname": "cohort", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Cohort", + "options": "Cohort", + "reqd": 1 + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "description", + "fieldtype": "Markdown Editor", + "label": "Description" + }, + { + "fieldname": "invite_code", + "fieldtype": "Data", + "label": "Invite Code", + "read_only": 1 + }, + { + "fieldname": "slug", + "fieldtype": "Data", + "label": "Slug", + "reqd": 1 + }, + { + "fetch_from": "cohort.course", + "fieldname": "course", + "fieldtype": "Link", + "label": "Course", + "options": "LMS Course", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_7", + "fieldtype": "Section Break" + } + ], + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Links", + "link_doctype": "Cohort Join Request", + "link_fieldname": "subgroup" + } + ], + "modified": "2021-12-16 15:12:42.504883", + "modified_by": "Administrator", + "module": "LMS", + "name": "Cohort Subgroup", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/school/lms/doctype/cohort_subgroup/cohort_subgroup.py b/school/lms/doctype/cohort_subgroup/cohort_subgroup.py new file mode 100644 index 00000000..dfb810b0 --- /dev/null +++ b/school/lms/doctype/cohort_subgroup/cohort_subgroup.py @@ -0,0 +1,95 @@ +# Copyright (c) 2021, FOSS United and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe.utils import random_string + +class CohortSubgroup(Document): + def before_save(self): + if not self.invite_code: + self.invite_code = random_string(8) + + def get_url(self): + cohort = frappe.get_doc("Cohort", self.cohort) + return f"{frappe.utils.get_url()}/courses/{self.course}/subgroups/{cohort.slug}/{self.slug}" + + def get_invite_link(self): + cohort = frappe.get_doc("Cohort", self.cohort) + return f"{frappe.utils.get_url()}/courses/{self.course}/join/{cohort.slug}/{self.slug}/{self.invite_code}" + + def has_student(self, email): + """Check if given user is a student of this subgroup. + """ + q = { + "doctype": "LMS Batch Membership", + "subgroup": self.name, + "member": email + } + return frappe.db.exists(q) + + def has_join_request(self, email): + """Check if given user is a student of this subgroup. + """ + q = { + "doctype": "Cohort Join Request", + "subgroup": self.name, + "email": email + } + return frappe.db.exists(q) + + def get_join_requests(self, status="Pending"): + q = { + "subgroup": self.name, + "status": status + } + return frappe.get_all("Cohort Join Request", filters=q, fields=["*"], order_by="creation desc") + + def get_mentors(self): + emails = frappe.get_all("Cohort Mentor", filters={"subgroup": self.name}, fields=["email"], pluck='email') + return self._get_users(emails) + + def get_students(self): + emails = frappe.get_all("LMS Batch Membership", + filters={"subgroup": self.name}, + fields=["member"], + pluck='member', + page_length=1000) + return self._get_users(emails) + + def _get_users(self, emails): + users = [frappe.get_cached_doc("User", email) for email in emails] + return sorted(users, key=lambda user: user.full_name) + + def is_mentor(self, email): + q = { + "doctype": "Cohort Mentor", + "subgroup": self.name, + "email": email + } + return frappe.db.exists(q) + + def is_manager(self, email): + """Returns True if the given user is a manager of this subgroup. + + Mentors of the subgroup, admins of the Cohort are considered as managers. + """ + return self.is_mentor(email) or self.get_cohort().is_admin(email) + + def get_cohort(self): + return frappe.get_doc("Cohort", self.cohort) + + def add_mentor(self, email): + d = { + "doctype": "Cohort Mentor", + "subgroup": self.name, + "cohort": self.cohort, + "email": email + } + if frappe.db.exists(d): + return + doc = frappe.get_doc(d) + doc.insert(ignore_permissions=True) + +#def after_doctype_insert(): +# frappe.db.add_unique("Cohort Subgroup", ("cohort", "slug")) diff --git a/school/lms/doctype/cohort_subgroup/test_cohort_subgroup.py b/school/lms/doctype/cohort_subgroup/test_cohort_subgroup.py new file mode 100644 index 00000000..8715a234 --- /dev/null +++ b/school/lms/doctype/cohort_subgroup/test_cohort_subgroup.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, FOSS United and Contributors +# See license.txt + +# import frappe +import unittest + +class TestCohortSubgroup(unittest.TestCase): + pass diff --git a/school/lms/doctype/cohort_web_page/__init__.py b/school/lms/doctype/cohort_web_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/lms/doctype/cohort_web_page/cohort_web_page.json b/school/lms/doctype/cohort_web_page/cohort_web_page.json new file mode 100644 index 00000000..fac05f0e --- /dev/null +++ b/school/lms/doctype/cohort_web_page/cohort_web_page.json @@ -0,0 +1,64 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-12-04 23:28:40.429867", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "slug", + "title", + "template", + "scope", + "required_role" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "template", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Template", + "options": "Web Template", + "reqd": 1 + }, + { + "default": "Cohort", + "fieldname": "scope", + "fieldtype": "Select", + "label": "Scope", + "options": "Cohort\nSubgroup" + }, + { + "default": "Public", + "fieldname": "required_role", + "fieldtype": "Select", + "label": "Required Role", + "options": "Public\nStudent\nMentor\nAdmin" + }, + { + "fieldname": "slug", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Slug", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-12-04 23:33:03.954128", + "modified_by": "Administrator", + "module": "LMS", + "name": "Cohort Web Page", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/school/lms/doctype/cohort_web_page/cohort_web_page.py b/school/lms/doctype/cohort_web_page/cohort_web_page.py new file mode 100644 index 00000000..b290954e --- /dev/null +++ b/school/lms/doctype/cohort_web_page/cohort_web_page.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + +class CohortWebPage(Document): + def get_template_html(self): + return frappe.get_doc("Web Template", self.template).template diff --git a/school/lms/doctype/exercise/exercise.py b/school/lms/doctype/exercise/exercise.py index a7e3308d..faccd1b7 100644 --- a/school/lms/doctype/exercise/exercise.py +++ b/school/lms/doctype/exercise/exercise.py @@ -36,7 +36,7 @@ class Exercise(Document): return old_submission course = frappe.get_doc("LMS Course", self.course) - batch = course.get_student_batch(user) + member = course.get_membership(frappe.session.user) doc = frappe.get_doc( doctype="Exercise Submission", @@ -44,8 +44,9 @@ class Exercise(Document): exercise_title=self.title, course=self.course, lesson=self.lesson, - batch=batch and batch.name, - solution=code) + batch=member.batch, + solution=code, + member=member.name) doc.insert(ignore_permissions=True) return doc diff --git a/school/lms/doctype/exercise/test_exercise.py b/school/lms/doctype/exercise/test_exercise.py index c68f3212..9a4a45fd 100644 --- a/school/lms/doctype/exercise/test_exercise.py +++ b/school/lms/doctype/exercise/test_exercise.py @@ -6,6 +6,7 @@ import unittest class TestExercise(unittest.TestCase): def setUp(self): + frappe.db.sql('delete from `tabLMS Batch Membership`') frappe.db.sql('delete from `tabExercise Submission`') frappe.db.sql('delete from `tabExercise`') frappe.db.sql('delete from `tabLMS Course`') @@ -19,6 +20,12 @@ class TestExercise(unittest.TestCase): "description": "Test Course" }) course.insert() + member = frappe.get_doc({ + "doctype": "LMS Batch Membership", + "course": course.name, + "member": frappe.session.user + }) + member.insert() e = frappe.get_doc({ "doctype": "Exercise", "name": "test-problem", diff --git a/school/lms/doctype/exercise_latest_submission/__init__.py b/school/lms/doctype/exercise_latest_submission/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.js b/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.js new file mode 100644 index 00000000..aea1f03d --- /dev/null +++ b/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Exercise Latest Submission', { + // refresh: function(frm) { + + // } +}); diff --git a/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.json b/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.json new file mode 100644 index 00000000..16ee0a6f --- /dev/null +++ b/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.json @@ -0,0 +1,166 @@ +{ + "actions": [], + "creation": "2021-12-08 17:56:26.049675", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "exercise", + "status", + "batch", + "column_break_4", + "exercise_title", + "course", + "lesson", + "section_break_8", + "solution", + "image", + "test_results", + "comments", + "latest_submission", + "member", + "member_email", + "member_cohort", + "member_subgroup" + ], + "fields": [ + { + "fieldname": "exercise", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Exercise", + "options": "Exercise", + "search_index": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Correct\nIncorrect" + }, + { + "fieldname": "batch", + "fieldtype": "Link", + "label": "Batch", + "options": "LMS Batch" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fetch_from": "exercise.title", + "fieldname": "exercise_title", + "fieldtype": "Data", + "label": "Exercise Title", + "read_only": 1 + }, + { + "fetch_from": "exercise.course", + "fieldname": "course", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Course", + "options": "LMS Course", + "read_only": 1 + }, + { + "fetch_from": "exercise.lesson", + "fieldname": "lesson", + "fieldtype": "Link", + "label": "Lesson", + "options": "Course Lesson" + }, + { + "fieldname": "section_break_8", + "fieldtype": "Section Break" + }, + { + "fetch_from": "latest_submission.solution", + "fieldname": "solution", + "fieldtype": "Code", + "label": "Solution" + }, + { + "fetch_from": "latest_submission.image", + "fieldname": "image", + "fieldtype": "Code", + "label": "Image", + "read_only": 1 + }, + { + "fetch_from": "latest_submission.test_results", + "fieldname": "test_results", + "fieldtype": "Small Text", + "label": "Test Results" + }, + { + "fieldname": "comments", + "fieldtype": "Small Text", + "label": "Comments" + }, + { + "fieldname": "latest_submission", + "fieldtype": "Link", + "label": "Latest Submission", + "options": "Exercise Submission" + }, + { + "fieldname": "member", + "fieldtype": "Link", + "label": "Member", + "options": "LMS Batch Membership" + }, + { + "fetch_from": "member.member", + "fieldname": "member_email", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Member Email", + "options": "User", + "search_index": 1 + }, + { + "fetch_from": "member.cohort", + "fieldname": "member_cohort", + "fieldtype": "Link", + "label": "Member Cohort", + "options": "Cohort", + "search_index": 1 + }, + { + "fetch_from": "member.subgroup", + "fieldname": "member_subgroup", + "fieldtype": "Link", + "label": "Member Subgroup", + "options": "Cohort Subgroup", + "search_index": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-12-08 22:58:46.312861", + "modified_by": "Administrator", + "module": "LMS", + "name": "Exercise Latest Submission", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.py b/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.py new file mode 100644 index 00000000..43a7f5cd --- /dev/null +++ b/school/lms/doctype/exercise_latest_submission/exercise_latest_submission.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class ExerciseLatestSubmission(Document): + pass diff --git a/school/lms/doctype/exercise_latest_submission/test_exercise_latest_submission.py b/school/lms/doctype/exercise_latest_submission/test_exercise_latest_submission.py new file mode 100644 index 00000000..76825ecd --- /dev/null +++ b/school/lms/doctype/exercise_latest_submission/test_exercise_latest_submission.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe and Contributors +# See license.txt + +# import frappe +import unittest + +class TestExerciseLatestSubmission(unittest.TestCase): + pass diff --git a/school/lms/doctype/exercise_submission/exercise_submission.json b/school/lms/doctype/exercise_submission/exercise_submission.json index 229e9de6..def5f1f7 100644 --- a/school/lms/doctype/exercise_submission/exercise_submission.json +++ b/school/lms/doctype/exercise_submission/exercise_submission.json @@ -16,7 +16,8 @@ "solution", "image", "test_results", - "comments" + "comments", + "member" ], "fields": [ { @@ -90,11 +91,17 @@ { "fieldname": "section_break_8", "fieldtype": "Section Break" + }, + { + "fieldname": "member", + "fieldtype": "Link", + "label": "Member", + "options": "LMS Batch Membership" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-09-29 15:27:57.273879", + "modified": "2021-12-08 22:25:05.809376", "modified_by": "Administrator", "module": "LMS", "name": "Exercise Submission", diff --git a/school/lms/doctype/exercise_submission/exercise_submission.py b/school/lms/doctype/exercise_submission/exercise_submission.py index d588281c..7209826f 100644 --- a/school/lms/doctype/exercise_submission/exercise_submission.py +++ b/school/lms/doctype/exercise_submission/exercise_submission.py @@ -5,4 +5,20 @@ import frappe from frappe.model.document import Document class ExerciseSubmission(Document): - pass + def on_update(self): + self.update_latest_submission() + + def update_latest_submission(self): + names = frappe.get_all("Exercise Latest Submission", {"exercise": self.exercise, "member": self.member}) + if names: + doc = frappe.get_doc("Exercise Latest Submission", names[0]) + doc.latest_submission = self.name + doc.save(ignore_permissions=True) + else: + doc = frappe.get_doc({ + "doctype": "Exercise Latest Submission", + "exercise": self.exercise, + "member": self.member, + "latest_submission": self.name + }) + doc.insert(ignore_permissions=True) diff --git a/school/lms/doctype/lms_batch_membership/lms_batch_membership.json b/school/lms/doctype/lms_batch_membership/lms_batch_membership.json index 69720d29..35f74e35 100644 --- a/school/lms/doctype/lms_batch_membership/lms_batch_membership.json +++ b/school/lms/doctype/lms_batch_membership/lms_batch_membership.json @@ -6,15 +6,19 @@ "engine": "InnoDB", "field_order": [ "course", - "member", - "member_name", - "member_username", + "cohort", + "subgroup", "column_break_3", "batch", + "current_lesson", + "role", + "member_section", + "member", "member_type", "progress", - "current_lesson", - "role" + "column_break_12", + "member_name", + "member_username" ], "fields": [ { @@ -88,12 +92,32 @@ "fieldtype": "Data", "label": "Progress", "read_only": 1 + }, + { + "fieldname": "cohort", + "fieldtype": "Link", + "label": "Cohort", + "options": "Cohort" + }, + { + "fieldname": "subgroup", + "fieldtype": "Link", + "label": "Subgroup", + "options": "Cohort Subgroup" + }, + { + "fieldname": "member_section", + "fieldtype": "Section Break", + "label": "Member" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "links": [], - "migration_hash": "fe10c462acf5e727d864305d7ce90e73", - "modified": "2021-10-20 15:10:33.767419", + "modified": "2021-12-16 14:49:25.964853", "modified_by": "Administrator", "module": "LMS", "name": "LMS Batch Membership", diff --git a/school/lms/doctype/lms_course/lms_course.py b/school/lms/doctype/lms_course/lms_course.py index b9993cbe..9562445c 100644 --- a/school/lms/doctype/lms_course/lms_course.py +++ b/school/lms/doctype/lms_course/lms_course.py @@ -210,6 +210,28 @@ class LMSCourse(Document): visibility="Public") return batches + def get_cohorts(self): + return find_all("Cohort", course=self.name, order_by="creation") + + def get_cohort(self, cohort_slug): + name = frappe.get_value("Cohort", {"course": self.name, "slug": cohort_slug}) + return name and frappe.get_doc("Cohort", name) + + def is_cohort_staff(self, user_email): + """Returns True if the user is either a mentor or a staff for one or more active cohorts of this course. + """ + q1 = { + "doctype": "Cohort Staff", + "course": self.name, + "email": user_email + } + q2 = { + "doctype": "Cohort Mentor", + "course": self.name, + "email": user_email + } + return frappe.db.exists(q1) or frappe.db.exists(q2) + def get_lesson_index(self, lesson_name): """Returns the {chapter_index}.{lesson_index} for the lesson. """ diff --git a/school/templates/exercise.html b/school/templates/exercise.html index fca923b7..4af77867 100644 --- a/school/templates/exercise.html +++ b/school/templates/exercise.html @@ -1,5 +1,5 @@
-

Exercise {{exercise.index_label}}: {{ exercise.title }}

+

Exercise {{exercise.index_label}}: {{ exercise.title }}

{{frappe.utils.md_to_html(exercise.description)}}
{% set submission = exercise.get_user_submission() %} diff --git a/school/www/cohorts/__init__.py b/school/www/cohorts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/school/www/cohorts/base.html b/school/www/cohorts/base.html new file mode 100644 index 00000000..f0177dc4 --- /dev/null +++ b/school/www/cohorts/base.html @@ -0,0 +1,34 @@ +{% extends "templates/base.html" %} + +{% macro render_nav(nav) %} + + +{% endmacro %} + +{% block title %}Cohorts{% endblock %} +{% block head_include %} + + +{% endblock %} + + +{% block content %} +
+
+ {{ render_nav(nav | default([])) }} + + {% block page_content %} + Hello, world! + {% endblock %} +
+
+{% endblock %} + + diff --git a/school/www/cohorts/cohort.html b/school/www/cohorts/cohort.html new file mode 100644 index 00000000..007e5d70 --- /dev/null +++ b/school/www/cohorts/cohort.html @@ -0,0 +1,77 @@ +{% extends "www/cohorts/base.html" %} +{% block title %}Manage {{ course.title }}{% endblock %} + +{% block page_content %} +

{{cohort.title}} Cohort

+ +

+ {% set stats = cohort.get_stats() %} + + {{ stats.subgroups }} Subgroups + | {{ stats.mentors }} Mentors + | {{ stats.students }} students + | {{ stats.join_requests }} join requests +

+ +{% if is_mentor %} +
+ {% set sg = mentor.get_subgroup() %} +

You are a mentor of {{sg.title}} subgroup.

+

Visit Your Subgroup →

+
+{% endif %} + + + +
+{% if not page %} + {{ render_subgroups() }} +{% else %} + {{ render_page(page) }} +{% endif %} +
+ +{% endblock %} + +{% macro render_subgroups() %} +
    + {% for sg in cohort.get_subgroups(include_counts=True) %} +
  • + +
    + {{sg.num_mentors}} Mentors + | + {{sg.num_students}} Students + | + {{sg.num_join_requests}} Join Requests +
    +
  • + {% endfor %} +
+{% endmacro %} + +{% macro render_navitem(title, link, page, count=-1) %} + +{% endmacro %} diff --git a/school/www/cohorts/cohort.py b/school/www/cohorts/cohort.py new file mode 100644 index 00000000..64c63757 --- /dev/null +++ b/school/www/cohorts/cohort.py @@ -0,0 +1,32 @@ +import frappe +from . import utils + +def get_context(context): + context.no_cache = 1 + course = utils.get_course() + cohort = course and utils.get_cohort(course, frappe.form_dict["cohort"]) + if not cohort: + context.template = "www/404.html" + return + + user = frappe.session.user + mentor = cohort.get_mentor(user) + is_mentor = mentor is not None + is_admin = cohort.is_admin(user) or "System Manager" in frappe.get_roles() + + utils.add_nav(context, "All Courses", "/courses") + utils.add_nav(context, course.title, "/courses/" + course.name) + utils.add_nav(context, "Cohorts", "/courses/" + course.name + "/manage") + + context.course = course + context.cohort = cohort + context.mentor = mentor + context.is_mentor = is_mentor + context.is_admin = is_admin + context.page = frappe.form_dict.get("page") or "" + context.page_scope = "Cohort" + + # Function to render to custom page given the slug + context.render_page = lambda page: frappe.render_template( + cohort.get_page_template(page, scope="Cohort"), + context) diff --git a/school/www/cohorts/index.html b/school/www/cohorts/index.html new file mode 100644 index 00000000..68009df8 --- /dev/null +++ b/school/www/cohorts/index.html @@ -0,0 +1,38 @@ +{% extends "www/cohorts/base.html" %} +{% block title %}Manage {{ course.title }}{% endblock %} + +{% block page_content %} + {% if cohorts %} +

Cohorts

+
+ {% for cohort in cohorts %} +
+ {{ render_cohort(course, cohort) }} +
+ {% endfor %} +
+ {% else %} +

Permission Denied

+

You don't have permission to manage this course.

+ {% endif %} +{% endblock %} + +{% macro render_cohort(course, cohort) %} +
+
+
{{cohort.title}}
+
{{cohort.begin_date}} - {{cohort.end_date}}
+

+ {% set stats = cohort.get_stats() %} + + {{ stats.subgroups }} Subgroups + | {{ stats.mentors }} Mentors + | {{ stats.students }} students + | {{ stats.join_requests }} join requests +

+ + Manage +
+
+ +{% endmacro %} diff --git a/school/www/cohorts/index.py b/school/www/cohorts/index.py new file mode 100644 index 00000000..2d1f648b --- /dev/null +++ b/school/www/cohorts/index.py @@ -0,0 +1,31 @@ +import frappe +from .utils import get_course, add_nav + +def get_context(context): + context.no_cache = 1 + context.course = get_course() + if frappe.session.user == "Guest": + frappe.local.flags.redirect_location = "/login?redirect-to=" + frappe.request.path + raise frappe.Redirect() + + if not context.course: + context.template = "www/404.html" + return + + context.cohorts = get_cohorts(context.course) + if len(context.cohorts) == 1: + frappe.local.flags.redirect_location = context.cohorts[0].get_url() + raise frappe.Redirect + + add_nav(context, "All Courses", "/courses") + add_nav(context, context.course.title, "/courses/" + context.course.name) + +def get_cohorts(course): + if "System Manager" in frappe.get_roles(): + return course.get_cohorts() + + staff_roles = frappe.get_all("Cohort Staff", filters={"course": course.name}, fields=["cohort"]) + mentor_roles = frappe.get_all("Cohort Mentor", filters={"course": course.name}, fields=["cohort"]) + roles = staff_roles + mentor_roles + names = {role.cohort for role in roles} + return [frappe.get_doc("Cohort", name) for name in names] diff --git a/school/www/cohorts/join.html b/school/www/cohorts/join.html new file mode 100644 index 00000000..34b55c2e --- /dev/null +++ b/school/www/cohorts/join.html @@ -0,0 +1,88 @@ +{% extends "www/cohorts/base.html" %} + +{% block title %}Join Course{% endblock %} + +{% block page_content %} + +

Join Course

+ +

+ Course: {{course.title}} +

+

+ Cohort: {{cohort.title}} +

+

+ Subgroup: {{subgroup.title}} +

+ +{% if frappe.session.user == "Guest" %} + +
+

+ Please login to be able to join the course.

+ +

+ If you don't already have an account, you can sign up for a new account. +

+ Login to continue +
+{% elif subgroup.has_student(frappe.session.user) %} +
+

You are already a student of this course.

+ Start Learning → +
+{% elif subgroup.has_join_request(frappe.session.user) %} +
+

We have received your request to join the course. You'll hear back from us soon.

+
+{% else %} + +Join the course + +{% endif %} + +{% endblock %} + +{% block script %} + + + +{% endblock %} diff --git a/school/www/cohorts/join.py b/school/www/cohorts/join.py new file mode 100644 index 00000000..4b6368a3 --- /dev/null +++ b/school/www/cohorts/join.py @@ -0,0 +1,24 @@ +import frappe +from . import utils + +def get_context(context): + context.no_cache = 1 + + course = utils.get_course(frappe.form_dict["course"]) + cohort = course and utils.get_cohort(course, frappe.form_dict["cohort"]) + subgroup = cohort and utils.get_subgroup(cohort, frappe.form_dict["subgroup"]) + if not subgroup: + context.template = "www/404.html" + return + + invite_code = frappe.form_dict["invite_code"] + if subgroup.invite_code != invite_code: + context.template = "www/404.html" + return + + utils.add_nav(context, "All Courses", "/courses") + utils.add_nav(context, course.title, "/courses/" + course.name) + + context.course = course + context.cohort = cohort + context.subgroup = subgroup diff --git a/school/www/cohorts/subgroup.html b/school/www/cohorts/subgroup.html new file mode 100644 index 00000000..a7d69895 --- /dev/null +++ b/school/www/cohorts/subgroup.html @@ -0,0 +1,264 @@ +{% extends "www/cohorts/base.html" %} +{% block title %} Subgroup {{subgroup.title}} - {{ course.title }} {% endblock %} + +{% block page_content %} +

{{subgroup.title}} Subgroup

+ + +
+ {% if page == "info" %} + {{ render_info() }} + {% elif page == "mentors" %} + {{ render_mentors() }} + {% elif page == "students" %} + {{ render_students() }} + {% elif page == "join-requests" %} + {{ render_join_requests() }} + {% elif page == "admin" %} + {{ render_admin() }} + {% else %} + {{ render_page(page) }} + {% endif %} +
+{% endblock %} + +{% macro render_admin() %} +
+
Add a new mentor
+
+
+ +
+ +
+
+{% endmacro %} + +{% macro render_mentors() %} +
Mentors
+ {% set mentors = subgroup.get_mentors() %} + {% if mentors %} +
+ {% for m in mentors %} + {{ widgets.MemberCard(member=m, show_course_count=False, dimension_class="") }} + {% endfor %} +
+ {% else %} + None found. + {% endif %} +{% endmacro %} + + +{% macro render_students() %} + {% set students = subgroup.get_students() %} + {% if students %} +
+ {% for student in students %} + {{ widgets.MemberCard(member=student, show_course_count=False, dimension_class="") }} + {% endfor %} +
+ {% else %} + None found. + {% endif %} +{% endmacro %} + + +{% macro render_join_requests() %} +
Invite Link
+ {% set link = subgroup.get_invite_link() %} +

{{link}} +
+ Copy to Clipboard +

+ +{% set join_requests = subgroup.get_join_requests() %} +
Pending Requests
+ {% if join_requests %} + + + + + + + + {% for r in join_requests %} + + + + + + + {% endfor %} +
#WhenEmailActions
{{loop.index}}{{r.creation}}{{r.email}} + Approve | Reject
+ {% else %} +

There are no pending join requests.

+ {% endif %} + {% set rejected_requests = subgroup.get_join_requests(status="Rejected") %} + +
Rejected Requests
+ {% if rejected_requests %} + + + + + + + + {% for r in rejected_requests %} + + + + + + + {% endfor %} +
#WhenEmailActions
{{loop.index}}{{r.creation}}{{r.email}} + Undo
+ {% else %} +

There are no rejected requests.

+ {% endif %} +{% endmacro %} + +{% macro render_navitem(title, link, count, active) %} + +{% endmacro %} + + +{% block script %} + + +{% endblock %} + +{% block style %} + +{% endblock %} diff --git a/school/www/cohorts/subgroup.py b/school/www/cohorts/subgroup.py new file mode 100644 index 00000000..4ab8c056 --- /dev/null +++ b/school/www/cohorts/subgroup.py @@ -0,0 +1,67 @@ +import frappe +from . import utils + +def get_context(context): + context.no_cache = 1 + course = utils.get_course() + + cohort = utils.get_cohort(course, frappe.form_dict['cohort']) + subgroup = utils.get_subgroup(cohort, frappe.form_dict['subgroup']) + + if not subgroup: + context.template = "www/404.html" + return + + page = frappe.form_dict.get("page") + is_mentor = subgroup.is_mentor(frappe.session.user) + is_admin = cohort.is_admin(frappe.session.user) or "System Manager" in frappe.get_roles() + + if is_admin: + role = "Admin" + elif is_mentor: + role = "Mentor" + else: + role = "Public" + + pages = [ + ("mentors", ["Admin", "Mentor", "Public"]), + ("students", ["Admin", "Mentor", "Public"]), + ("join-requests", ["Admin", "Mentor"]), + ("admin", ["Admin"]) + ] + pages += [(p.slug, ["Admin", "Mentor"]) for p in cohort.get_pages(scope="Subgroup")] + + page_names = [p for p, roles in pages if role in roles] + + if page not in page_names: + frappe.local.flags.redirect_location = subgroup.get_url() + "/mentors" + raise frappe.Redirect + + utils.add_nav(context, "All Courses", "/courses") + utils.add_nav(context, course.title, f"/courses/{course.name}") + utils.add_nav(context, "Cohorts", f"/courses/{course.name}/manage") + utils.add_nav(context, cohort.title, f"/courses/{course.name}/cohorts/{cohort.slug}") + + context.course = course + context.cohort = cohort + context.subgroup = subgroup + context.stats = get_stats(subgroup) + context.page = page + context.is_admin = is_admin + context.is_mentor = is_mentor + context.page_scope = "Subgroup" + + # Function to render to custom page given the slug + context.render_page = lambda page: frappe.render_template( + cohort.get_page_template(page, scope="Subgroup"), + context) + +def get_stats(subgroup): + return { + "join_requests": len(subgroup.get_join_requests()), + "students": len(subgroup.get_students()), + "mentors": len(subgroup.get_mentors()) + } + +def has_page(cohort, page): + return cohort.get_page(page, scope="Subgroup") diff --git a/school/www/cohorts/utils.py b/school/www/cohorts/utils.py new file mode 100644 index 00000000..a35c7e9f --- /dev/null +++ b/school/www/cohorts/utils.py @@ -0,0 +1,25 @@ +import frappe + +def get_course(course_name=None): + course_name = course_name or frappe.form_dict["course"] + return course_name and get_doc("LMS Course", course_name) + +def get_doc(doctype, name): + try: + return frappe.get_doc(doctype, name) + except frappe.exceptions.DoesNotExistError: + return + +def get_cohort(course, cohort_slug): + name = frappe.get_value("Cohort", {"course": course.name, "slug": cohort_slug}) + return name and frappe.get_doc("Cohort", name) + +def get_subgroup(cohort, subgroup_slug): + name = frappe.get_value("Cohort Subgroup", {"cohort": cohort.name, "slug": subgroup_slug}) + return name and frappe.get_doc("Cohort Subgroup", name) + +def add_nav(context, title, href): + """Adds a breadcrumb to the navigation. + """ + nav = context.setdefault("nav", []) + nav.append({"title": title, "href": href}) diff --git a/school/www/courses/course.html b/school/www/courses/course.html index 364fdeec..9b169569 100644 --- a/school/www/courses/course.html +++ b/school/www/courses/course.html @@ -70,6 +70,14 @@
{% endif %} + + {% if course.is_cohort_staff(frappe.session.user) %} + + Manage the course + + {% endif %}