From d9185c0b6b84d857174b5227ee114e30ddd884e5 Mon Sep 17 00:00:00 2001 From: Anand Chitipothu Date: Wed, 9 Jun 2021 23:49:18 +0530 Subject: [PATCH] feat: integrated lesson markup - added PageExtension plugin to inject custom styles scripts in a page - removed the livecode integration and enabled PageExtension plugins for learn page - also merged the profile_tab.py with plugins.py - added a utility to find the macros from given text - updated the before_save of lesson to find exercises using the macros and update the exercises as before Issue #115 --- community/community/profile_tab.py | 38 --------------- community/hooks.py | 12 +++++ community/lms/doctype/lesson/lesson.py | 34 ++++++++----- community/lms/md.py | 35 ++++++++++++-- community/plugins.py | 66 ++++++++++++++++++++++++++ community/www/batch/learn.html | 45 ++++-------------- community/www/batch/learn.py | 8 +++- 7 files changed, 145 insertions(+), 93 deletions(-) delete mode 100644 community/community/profile_tab.py create mode 100644 community/plugins.py diff --git a/community/community/profile_tab.py b/community/community/profile_tab.py deleted file mode 100644 index 62e54dcb..00000000 --- a/community/community/profile_tab.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -The profile_tab module provides a pluggable way to add tabs to user -profiles. - -This is achieved by specifying the profile_tabs in the hooks. - - profile_tabs = [ - 'myapp.myapp.profile_tabs.SketchesTab' - ] - -When a profile page is rendered, these classes specified in the -profile_hooks are instanciated with the user as argument and used to -render the tabs. -""" - -class ProfileTab: - """Base class for profile tabs. - - Every subclass of ProfileTab must implement two methods: - - get_title() - - render() - """ - def __init__(self, user): - self.user = user - - def get_title(self): - """Returns the title of the tab. - - Every subclass must implement this. - """ - raise NotImplementedError() - - def render(self): - """Renders the contents of the tab as HTML. - - Every subclass must implement this. - """ - raise NotImplementedError() diff --git a/community/hooks.py b/community/hooks.py index e6916c0b..ea739a8f 100644 --- a/community/hooks.py +++ b/community/hooks.py @@ -176,3 +176,15 @@ profile_rules = [ website_route_rules = primary_rules + whitelist_rules + profile_rules update_website_context = 'community.widgets.update_website_context' + +## Specify the additional tabs to be included in the user profile page. +## Each entry must be a subclass of community.community.plugins.ProfileTab +# profile_tabs = [] + +## Specify the extension to be used to control what scripts and stylesheets +## to be included in lesson pages. The specified value must be be a +## subclass of community.community.plugins.PageExtension +# community_lesson_page_extension = None + +## Markdown Macros for Lessons +# community_markdown_macro_renderers = {"Exercise": "myapp.mymodule.plugins.render_exercise"} diff --git a/community/lms/doctype/lesson/lesson.py b/community/lms/doctype/lesson/lesson.py index ef1f9825..52d8bf28 100644 --- a/community/lms/doctype/lesson/lesson.py +++ b/community/lms/doctype/lesson/lesson.py @@ -6,28 +6,28 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document from ...section_parser import SectionParser +from ...md import markdown_to_html, find_macros 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)] + macros = find_macros(self.body) + exercises = [value for name, value in macros if name == "Exercise"] index = 1 - for s in self.sections: - if s.type == "exercise": - e = s.get_exercise() - e.lesson = self.name - e.index_ = index - e.save() - index += 1 - self.update_orphan_exercises() + for name in exercises: + e = frappe.get_doc("Exercise", name) + e.lesson = self.name + e.index_ = index + e.save() + index += 1 + self.update_orphan_exercises(exercises) - def update_orphan_exercises(self): + def update_orphan_exercises(self, active_exercises): """Updates the exercises that were previously part of this lesson, but not any more. """ linked_exercises = {row['name'] for row in frappe.get_all('Exercise', {"lesson": self.name})} - active_exercises = {s.id for s in self.get("sections") if s.type=="exercise"} + active_exercises = set(active_exercises) orphan_exercises = linked_exercises - active_exercises for name in orphan_exercises: ex = frappe.get_doc("Exercise", name) @@ -36,11 +36,19 @@ class Lesson(Document): ex.index_label = "" ex.save() + def render_html(self): + return markdown_to_html(self.body) + def get_sections(self): return sorted(self.get('sections'), key=lambda s: s.index) def get_exercises(self): - return [frappe.get_doc("Exercise", s.id) for s in self.get("sections") if s.type=="exercise"] + if not self.body: + return [] + + macros = find_macros(self.body) + exercises = [value for name, value in macros if name == "Exercise"] + return [frappe.get_doc("Exercise", name) for name in exercises] def make_lms_section(self, index, section): s = frappe.new_doc('LMS Section', parent_doc=self, parentfield='sections') diff --git a/community/lms/md.py b/community/lms/md.py index 55f2fe12..b256eb72 100644 --- a/community/lms/md.py +++ b/community/lms/md.py @@ -26,13 +26,42 @@ def markdown_to_html(text): """ return markdown.markdown(text, extensions=['fenced_code', MacroExtension()]) +def find_macros(text): + """Returns all macros in the given text. + + >>> find_macros(text) + [ + ('YouTubeVideo': 'abcd1234') + ('Exercise', 'two-circles'), + ('Exercise', 'four-circles') + ] + """ + macros = re.findall(MACRO_RE, text) + # remove the quotes around the argument + return [(name, _remove_quotes(arg)) for name, arg in macros] + +def _remove_quotes(value): + """Removes quotes around a value. + + Also strips the whitespace. + + >>> _remove_quotes('"hello"') + 'hello' + >>> _remove_quotes("'hello'") + 'hello' + >>> _remove_quotes("hello") + 'hello' + """ + return value.strip(" '\"") + + def get_macro_registry(): d = frappe.get_hooks("community_markdown_macro_renderers") or {} return {name: frappe.get_attr(klass[0]) for name, klass in d.items()} def render_macro(macro_name, macro_argument): # stripping the quotes on either side of the argument - macro_argument = macro_argument.strip(" '\"") + macro_argument = _remove_quotes(macro_argument) registry = get_macro_registry() if macro_name in registry: @@ -40,13 +69,13 @@ def render_macro(macro_name, macro_argument): else: return f"

Unknown macro: {macro_name}

" +MACRO_RE = r'{{ *(\w+)\(([^{}]*)\) *}}' + class MacroExtension(Extension): """MacroExtension is a markdown extension to support macro syntax. """ def extendMarkdown(self, md): self.md = md - - MACRO_RE = r'{{ *(\w+)\(([^{}]*)\) *}}' pattern = MacroInlineProcessor(MACRO_RE) pattern.md = md md.inlinePatterns.register(pattern, 'macro', 75) diff --git a/community/plugins.py b/community/plugins.py new file mode 100644 index 00000000..58fb2765 --- /dev/null +++ b/community/plugins.py @@ -0,0 +1,66 @@ +""" +The plugins module provides various plugins to change the default +behaviour some parts of the community app. + +A site specify what plugins to use using appropriate entries in the frappe +hooks, written in the `hooks.py`. + +This module exposes two plugins: ProfileTab and PageExtension. + +The ProfileTab is used to specify any additional tabs to be displayed +on the profile page of the user. + +The PageExtension is used to load additinal stylesheets and scripts to +be loaded in a webpage. +""" + +class PageExtension: + """PageExtension is a plugin to inject custom styles and scripts + into a web page. + + The subclasses should overwrite the `render_header()` and + `render_footer()` methods to inject whatever styles/scripts into + the webpage. + """ + + def render_header(self): + """Returns the HTML snippet to be included in the head section + of the web page. + + Typically used to include the stylesheets and javascripts to be + included in the of the webpage. + """ + return "" + + def render_footer(self): + """Returns the HTML snippet to be included in the body tag at + the end of web page. + + Typically used to include javascripts that need to be executed + after the page is loaded. + """ + return "" + +class ProfileTab: + """Base class for profile tabs. + + Every subclass of ProfileTab must implement two methods: + - get_title() + - render() + """ + def __init__(self, user): + self.user = user + + def get_title(self): + """Returns the title of the tab. + + Every subclass must implement this. + """ + raise NotImplementedError() + + def render(self): + """Renders the contents of the tab as HTML. + + Every subclass must implement this. + """ + raise NotImplementedError() diff --git a/community/www/batch/learn.html b/community/www/batch/learn.html index d0271c61..17a12ff2 100644 --- a/community/www/batch/learn.html +++ b/community/www/batch/learn.html @@ -9,16 +9,13 @@ - - - - +{% for ext in page_extensions %} + {{ ext.render_header() }} +{% endfor %} - - {% endblock %} @@ -30,11 +27,7 @@

{{ lesson.title }}

- {% for s in lesson.get_sections() %} -
- {{ render_section(s) }} -
- {% endfor %} + {{ lesson.render_html() }} {{ pagination(prev_chap, prev_url, next_chap, next_url) }} @@ -42,30 +35,6 @@ {% endblock %} -{% macro render_section(s) %} -{% if s.type == "text" %} -{{ render_section_text(s) }} -{% 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 %} -{% endmacro %} - -{% macro render_section_text(s) %} -
-
- {{ frappe.utils.md_to_html(s.contents) }} -
-
-{% endmacro %} - {% macro pagination(prev_chap, prev_url, next_chap, next_url) %}
{% if prev_url %} @@ -84,8 +53,6 @@ {%- block script %} {{ super() }} -{{ LiveCodeEditorJS() }} - +{% for ext in page_extensions %} + {{ ext.render_footer() }} +{% endfor %} + {%- endblock %} diff --git a/community/www/batch/learn.py b/community/www/batch/learn.py index 4f031eab..d0d4f38e 100644 --- a/community/www/batch/learn.py +++ b/community/www/batch/learn.py @@ -28,7 +28,7 @@ def get_context(context): context.next_url = context.batch.get_learn_url(next_) context.prev_url = context.batch.get_learn_url(prev_) - + context.page_extensions = get_page_extensions() def get_chapter_title(course_name, lesson_number): if not lesson_number: @@ -42,4 +42,8 @@ def get_lesson_index(course, batch, user): lesson = batch.get_current_lesson(user) return lesson and course.get_lesson_index(lesson) - +def get_page_extensions(): + default_value = ["community.community.plugins.PageExtension"] + classnames = frappe.get_hooks("community_lesson_page_extensions") or default_value + extensions = [frappe.get_attr(name)() for name in classnames] + return extensions