diff --git a/community/hooks.py b/community/hooks.py index 0aec4b15..1b6f2151 100644 --- a/community/hooks.py +++ b/community/hooks.py @@ -15,11 +15,11 @@ app_license = "AGPL" # ------------------ # include js, css files in header of desk.html -app_include_css = "/assets/community/css/community.css" -app_include_js = "/assets/community/js/community.js" +# app_include_css = "/assets/community/css/community.css" +# app_include_js = "/assets/community/js/community.js" # include js, css files in header of web template -web_include_css = "/assets/css/community.css" +web_include_css = "community.bundle.css" # web_include_css = "/assets/community/css/community.css" # web_include_js = "/assets/community/js/community.js" diff --git a/community/lms/api.py b/community/lms/api.py index ab69ede4..3a7bd1b0 100644 --- a/community/lms/api.py +++ b/community/lms/api.py @@ -21,3 +21,16 @@ def get_section(name): """ doc = frappe.get_doc("LMS Section", name) return doc and doc.as_dict() + +@frappe.whitelist() +def submit_solution(exercise, code): + """Submits a solution. + + @exerecise: name of the exercise to submit + @code: solution to the exercise + """ + ex = frappe.get_doc("Exercise", exercise) + if not ex: + return + doc = ex.submit(code) + return {"name": doc.name, "creation": doc.creation} diff --git a/community/lms/doctype/exercise/__init__.py b/community/lms/doctype/exercise/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/lms/doctype/exercise/exercise.js b/community/lms/doctype/exercise/exercise.js new file mode 100644 index 00000000..740634b4 --- /dev/null +++ b/community/lms/doctype/exercise/exercise.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, FOSS United and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Exercise', { + // refresh: function(frm) { + + // } +}); diff --git a/community/lms/doctype/exercise/exercise.json b/community/lms/doctype/exercise/exercise.json new file mode 100644 index 00000000..33c7151e --- /dev/null +++ b/community/lms/doctype/exercise/exercise.json @@ -0,0 +1,106 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-05-19 17:43:39.923430", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "description", + "code", + "answer", + "column_break_4", + "course", + "hints", + "tests", + "image", + "lesson" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title" + }, + { + "fieldname": "course", + "fieldtype": "Link", + "label": "Course", + "options": "LMS Course" + }, + { + "columns": 4, + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description" + }, + { + "columns": 4, + "fieldname": "answer", + "fieldtype": "Code", + "label": "Answer" + }, + { + "fieldname": "tests", + "fieldtype": "Code", + "label": "Tests" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "columns": 4, + "fieldname": "hints", + "fieldtype": "Small Text", + "label": "Hints" + }, + { + "columns": 4, + "fieldname": "code", + "fieldtype": "Code", + "label": "Code" + }, + { + "fieldname": "image", + "fieldtype": "Code", + "label": "Image", + "read_only": 1 + }, + { + "fieldname": "lesson", + "fieldtype": "Link", + "label": "Lesson", + "options": "Lesson" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-05-20 13:23:12.340928", + "modified_by": "Administrator", + "module": "LMS", + "name": "Exercise", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "title", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "title", + "track_changes": 1 +} \ No newline at end of file diff --git a/community/lms/doctype/exercise/exercise.py b/community/lms/doctype/exercise/exercise.py new file mode 100644 index 00000000..313165cf --- /dev/null +++ b/community/lms/doctype/exercise/exercise.py @@ -0,0 +1,54 @@ +# Copyright (c) 2021, FOSS United and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from ..lms_sketch.livecode import livecode_to_svg + +class Exercise(Document): + def before_save(self): + self.image = livecode_to_svg(None, self.answer) + + def get_user_submission(self): + """Returns the latest submission for this user. + """ + user = frappe.session.user + if not user or user == "Guest": + return + + result = frappe.get_all('Exercise Submission', + fields="*", + filters={ + "owner": user, + "exercise": self.name + }, + order_by="creation desc", + page_length=1) + if result: + return result[0] + + def submit(self, code): + """Submits the given code as solution to exercise. + """ + user = frappe.session.user + if not user or user == "Guest": + return + + old_submission = self.get_user_submission() + if old_submission and old_submission.solution == code: + return old_submission + + course = frappe.get_doc("LMS Course", self.course) + batch = course.get_student_batch(user) + + doc = frappe.get_doc( + doctype="Exercise Submission", + exercise=self.name, + exercise_title=self.title, + course=self.course, + lesson=self.lesson, + batch=batch and batch.name, + solution=code) + doc.insert() + return doc + diff --git a/community/lms/doctype/exercise/test_exercise.py b/community/lms/doctype/exercise/test_exercise.py new file mode 100644 index 00000000..8ae68e96 --- /dev/null +++ b/community/lms/doctype/exercise/test_exercise.py @@ -0,0 +1,47 @@ +# Copyright (c) 2021, FOSS United and Contributors +# See license.txt + +import frappe +import unittest + +class TestExercise(unittest.TestCase): + def setUp(self): + frappe.db.sql('delete from `tabExercise Submission`') + frappe.db.sql('delete from `tabExercise`') + frappe.db.sql('delete from `tabLMS Course`') + + def new_exercise(self): + course = frappe.get_doc({ + "doctype": "LMS Course", + "name": "test-course", + "title": "Test Course" + }) + course.insert() + e = frappe.get_doc({ + "doctype": "Exercise", + "name": "test-problem", + "course": course.name, + "title": "Test Problem", + "description": "draw a circle", + "code": "# draw a single cicle", + "answer": ( + "# draw a single circle\n" + + "circle(100, 100, 50)") + }) + e.insert() + return e + + def test_exercise(self): + e = self.new_exercise() + assert e.get_user_submission() is None + + def test_exercise_submission(self): + e = self.new_exercise() + submission = e.submit("circle(100, 100, 50)") + assert submission is not None + assert submission.exercise == e.name + assert submission.course == e.course + + user_submission = e.get_user_submission() + assert user_submission is not None + assert user_submission.name == submission.name diff --git a/community/lms/doctype/exercise_submission/__init__.py b/community/lms/doctype/exercise_submission/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/lms/doctype/exercise_submission/exercise_submission.js b/community/lms/doctype/exercise_submission/exercise_submission.js new file mode 100644 index 00000000..5f1d8399 --- /dev/null +++ b/community/lms/doctype/exercise_submission/exercise_submission.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, FOSS United and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Exercise Submission', { + // refresh: function(frm) { + + // } +}); diff --git a/community/lms/doctype/exercise_submission/exercise_submission.json b/community/lms/doctype/exercise_submission/exercise_submission.json new file mode 100644 index 00000000..de7541ea --- /dev/null +++ b/community/lms/doctype/exercise_submission/exercise_submission.json @@ -0,0 +1,83 @@ +{ + "actions": [], + "creation": "2021-05-19 11:41:18.108316", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "exercise", + "solution", + "exercise_title", + "course", + "batch", + "lesson" + ], + "fields": [ + { + "fieldname": "exercise", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Exercise", + "options": "Exercise" + }, + { + "fieldname": "solution", + "fieldtype": "Code", + "in_list_view": 1, + "label": "Solution" + }, + { + "fetch_from": "exercise.title", + "fieldname": "exercise_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Exercise Title", + "read_only": 1 + }, + { + "fetch_from": "exercise.course", + "fieldname": "course", + "fieldtype": "Link", + "label": "Course", + "options": "LMS Course", + "read_only": 1 + }, + { + "fieldname": "batch", + "fieldtype": "Link", + "label": "Batch", + "options": "LMS Batch" + }, + { + "fetch_from": "exercise.lesson", + "fieldname": "lesson", + "fieldtype": "Link", + "label": "Lesson", + "options": "Lesson" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-05-20 13:30:16.349278", + "modified_by": "Administrator", + "module": "LMS", + "name": "Exercise 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/community/lms/doctype/exercise_submission/exercise_submission.py b/community/lms/doctype/exercise_submission/exercise_submission.py new file mode 100644 index 00000000..fd631eb8 --- /dev/null +++ b/community/lms/doctype/exercise_submission/exercise_submission.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 ExerciseSubmission(Document): + pass diff --git a/community/lms/doctype/exercise_submission/test_exercise_submission.py b/community/lms/doctype/exercise_submission/test_exercise_submission.py new file mode 100644 index 00000000..eed851a5 --- /dev/null +++ b/community/lms/doctype/exercise_submission/test_exercise_submission.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, FOSS United and Contributors +# See license.txt + +# import frappe +import unittest + +class TestExerciseSubmission(unittest.TestCase): + pass diff --git a/community/lms/doctype/lesson/lesson.py b/community/lms/doctype/lesson/lesson.py index b1105240..c544f774 100644 --- a/community/lms/doctype/lesson/lesson.py +++ b/community/lms/doctype/lesson/lesson.py @@ -11,6 +11,11 @@ class Lesson(Document): def before_save(self): sections = SectionParser().parse(self.body or "") self.sections = [self.make_lms_section(i, s) for i, s in enumerate(sections)] + for s in self.sections: + if s.type == "exercise": + e = s.get_exercise() + e.lesson = self.name + e.save() def get_sections(self): return sorted(self.get('sections'), key=lambda s: s.index) @@ -18,6 +23,7 @@ class Lesson(Document): def make_lms_section(self, index, section): s = frappe.new_doc('LMS Section', parent_doc=self, parentfield='sections') s.type = section.type + s.id = section.id s.label = section.label s.contents = section.contents s.index = index diff --git a/community/lms/doctype/lms_section/lms_section.json b/community/lms/doctype/lms_section/lms_section.json index 4a275d32..3b056485 100644 --- a/community/lms/doctype/lms_section/lms_section.json +++ b/community/lms/doctype/lms_section/lms_section.json @@ -9,7 +9,8 @@ "contents", "code", "attrs", - "index" + "index", + "id" ], "fields": [ { @@ -43,12 +44,17 @@ "fieldname": "index", "fieldtype": "Int", "label": "Index" + }, + { + "fieldname": "id", + "fieldtype": "Data", + "label": "id" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-03-12 17:56:23.118854", + "modified": "2021-05-19 18:55:26.019625", "modified_by": "Administrator", "module": "LMS", "name": "LMS Section", diff --git a/community/lms/doctype/lms_section/lms_section.py b/community/lms/doctype/lms_section/lms_section.py index f51fec69..65c33f46 100644 --- a/community/lms/doctype/lms_section/lms_section.py +++ b/community/lms/doctype/lms_section/lms_section.py @@ -10,6 +10,10 @@ class LMSSection(Document): def __repr__(self): return f"" + def get_exercise(self): + if self.type == "exercise": + return frappe.get_doc("Exercise", self.id) + def get_latest_code_for_user(self): """Returns the latest code for the logged in user. """ diff --git a/community/lms/doctype/lms_sketch/livecode.py b/community/lms/doctype/lms_sketch/livecode.py index 8ea05ddd..ab5d8dc5 100644 --- a/community/lms/doctype/lms_sketch/livecode.py +++ b/community/lms/doctype/lms_sketch/livecode.py @@ -4,6 +4,7 @@ import websocket import json from .svg import SVG import frappe +from urllib.parse import urlparse # Files to pass to livecode server # The same code is part of livecode-canvas.js @@ -60,9 +61,21 @@ def clear(): clear() ''' +def get_livecode_url(): + doc = frappe.get_cached_doc("LMS Settings") + return doc.livecode_url + +def get_livecode_ws_url(): + url = urlparse(get_livecode_url()) + protocol = "wss" if url.scheme == "https" else "ws" + return protocol + "://" + url.netloc + "/livecode" + def livecode_to_svg(livecode_ws_url, code, *, timeout=3): """Renders the code as svg. """ + if livecode_ws_url is None: + livecode_ws_url = get_livecode_ws_url() + try: ws = websocket.WebSocket() ws.settimeout(timeout) diff --git a/community/lms/widgets/Exercise.html b/community/lms/widgets/Exercise.html new file mode 100644 index 00000000..b43410b4 --- /dev/null +++ b/community/lms/widgets/Exercise.html @@ -0,0 +1,18 @@ +{% from "www/macros/livecode.html" import LiveCodeEditorJS, LiveCodeEditor with context %} + +
+

{{ exercise.title }}

+
{{frappe.utils.md_to_html(exercise.description)}}
+ + {% if exercise.image %} +
{{exercise.image}}
+ {% endif %} + + {% set submission = exercise.get_user_submission() %} + + {{ LiveCodeEditor(exercise.name, + code=exercise.code, + reset_code=exercise.code, + is_exercise=True, + last_submitted=submission and submission.creation) }} +
diff --git a/community/public/build.json b/community/public/build.json deleted file mode 100644 index 06a54a29..00000000 --- a/community/public/build.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "css/lms.css": [ - "public/css/lms.css" - ], - "css/community.css": [ - "public/css/style.css", - "public/css/vars.css", - "public/css/style.less" - ], - "js/livecode-canvas.js": [ - "public/js/livecode-canvas.js" - ] - -} diff --git a/community/public/css/community.bundle.less b/community/public/css/community.bundle.less new file mode 100644 index 00000000..66652c93 --- /dev/null +++ b/community/public/css/community.bundle.less @@ -0,0 +1,4 @@ + +@import "./style.css"; +@import "./vars.css"; +@import "./style.less"; diff --git a/community/public/css/lms.css b/community/public/css/lms.css deleted file mode 100644 index 266295d0..00000000 --- a/community/public/css/lms.css +++ /dev/null @@ -1,26 +0,0 @@ - -.heading { - background: #eee; - padding: 10px; - clear: both; - color: #212529; - border: 1px solid #ddd; -} - -.sketch-header h1 { - font-size: 1.5em; - margin-bottom: 0px; -} - -.sketch-header { - margin-bottom: 1em; -} - -.sketch-card .sketch-title a { - font-weight: bold; - color: inherit; -} - -.hidden { - display: none; -} diff --git a/community/public/css/style.css b/community/public/css/style.css index 1462ebf4..8c3fb588 100644 --- a/community/public/css/style.css +++ b/community/public/css/style.css @@ -1,5 +1,3 @@ -@import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css"); -@import url("https://use.fontawesome.com/releases/v5.13.0/css/all.css"); :root { --c1: #fefae0; @@ -26,7 +24,7 @@ --send-message: var(--c7); --received-message: var(--c8); - --primary-color: #08B74F; + --primary-color: #08B74F !important; } body { diff --git a/community/public/css/style.less b/community/public/css/style.less index b0800ff3..e92d02ef 100644 --- a/community/public/css/style.less +++ b/community/public/css/style.less @@ -1,9 +1,4 @@ @primary-color: #08B74F; -@import url('https://rsms.me/inter/inter.css'); - -body { - font-family: "Inter", sans-serif; -} h2 { margin: 20px 0px; @@ -306,3 +301,14 @@ section.lightgray { .lesson-page { margin: 20px 0px; } + +.lesson-pagination { + clear: both; +} + +.exercise-image svg { + width: 200px; + height: 200px; + border: 1px solid #ddd; + margin-bottom: 20px; +} diff --git a/community/www/courses/about/index.html b/community/www/courses/about/index.html index 99eb138f..46bf0e13 100644 --- a/community/www/courses/about/index.html +++ b/community/www/courses/about/index.html @@ -6,6 +6,8 @@ {% block head_include %} + + {% endblock %} {% block content %} diff --git a/community/www/courses/discuss/index.html b/community/www/courses/discuss/index.html index b4e19f86..73270d8e 100644 --- a/community/www/courses/discuss/index.html +++ b/community/www/courses/discuss/index.html @@ -6,6 +6,8 @@ {% block head_include %} + + {% endblock %} {% block content %} diff --git a/community/www/courses/learn/index.html b/community/www/courses/learn/index.html index 4804f508..db3a4549 100644 --- a/community/www/courses/learn/index.html +++ b/community/www/courses/learn/index.html @@ -9,6 +9,7 @@ + @@ -26,14 +27,7 @@
-
- {% if prev_url %} - ← Prev - {% endif %} - {% if next_url %} - Next → - {% endif %} -
+ {{ pagination(prev_url, next_url) }}

{{ lesson.title }}

@@ -43,10 +37,8 @@
{% endfor %} - + {{ pagination(prev_url, next_url) }} +
{% endblock %} @@ -55,8 +47,14 @@ {% macro render_section(s) %} {% if s.type == "text" %} {{ render_section_text(s) }} - {% elif s.type == "example" or s.type == "code" or s.type == "exercise" %} - {{ LiveCodeEditor(s.name, s.get_latest_code_for_user(), s.type=="exercise", "2 hours ago") }} + {% elif s.type == "example" or s.type == "code" %} + {{ LiveCodeEditor(s.name, + code=s.get_latest_code_for_user(), + reset_code=s.contents, + is_exercise=False) + }} + {% elif s.type == "exercise" %} + {{ widgets.Exercise(exercise=s.get_exercise())}} {% else %}
Unknown section type: {{s.type}}
{% endif %} @@ -70,6 +68,18 @@ {% endmacro %} +{% macro pagination(prev_url, next_url) %} +
+ {% if prev_url %} + ← Prev + {% endif %} + {% if next_url %} + Next → + {% endif %} +
+
+{% endmacro %} + {%- block script %} {{ super() }} {{ LiveCodeEditorJS() }} diff --git a/community/www/courses/members/index.html b/community/www/courses/members/index.html index 618ce2ac..bc7ce6f1 100644 --- a/community/www/courses/members/index.html +++ b/community/www/courses/members/index.html @@ -7,6 +7,7 @@ {% block head_include %} + {% endblock %} {% block content %} diff --git a/community/www/courses/schedule/index.html b/community/www/courses/schedule/index.html index 8f3cff12..f78a9397 100644 --- a/community/www/courses/schedule/index.html +++ b/community/www/courses/schedule/index.html @@ -4,6 +4,7 @@ {% block head_include %} + {% endblock %} {% block content %} diff --git a/community/www/macros/livecode.html b/community/www/macros/livecode.html index 94502e53..a4ec142f 100644 --- a/community/www/macros/livecode.html +++ b/community/www/macros/livecode.html @@ -24,7 +24,7 @@ {% endmacro %} -{% macro LiveCodeEditor(name, code, is_exercise, last_submitted) %} +{% macro LiveCodeEditor(name, code, reset_code, is_exercise=False, last_submitted=None) %}
@@ -34,10 +34,13 @@ {% if is_exercise %} {% if last_submitted %} - Submitted on {{last_submitted}} + {% endif %} {% endif %}
+
+
{{reset_code}}
+
@@ -59,20 +62,67 @@ {% macro LiveCodeEditorJS(name, code) %} + + + + + + + + {% endmacro %} diff --git a/community/www/macros/sidebar.html b/community/www/macros/sidebar.html index d0922924..c36ab8ee 100644 --- a/community/www/macros/sidebar.html +++ b/community/www/macros/sidebar.html @@ -1,12 +1,12 @@ {% macro Sidebar(course, batch_code) %} -{% endmacro %} \ No newline at end of file +{% endmacro %}