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 150c88bc..809562ac 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 new file mode 100644 index 00000000..b256eb72 --- /dev/null +++ b/community/lms/md.py @@ -0,0 +1,107 @@ +""" +The md module extends markdown to add macros. + +Macros can be added to the markdown text in the following format. + + {{ MacroName("macro-argument") }} + +These macros will be rendered using a pluggable mechanism. + +Apps can provide a hook community_markdown_macro_renderers, a +dictionary mapping the macro name to the function that to render +that macro. The function will get the argument passed to the macro +as argument. +""" + +import frappe +import re +from bs4 import BeautifulSoup +import markdown +from markdown import Extension +from markdown.inlinepatterns import InlineProcessor +import xml.etree.ElementTree as etree + +def markdown_to_html(text): + """Renders markdown text into html. + """ + 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 = _remove_quotes(macro_argument) + + registry = get_macro_registry() + if macro_name in registry: + return registry[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 + pattern = MacroInlineProcessor(MACRO_RE) + pattern.md = md + md.inlinePatterns.register(pattern, 'macro', 75) + +class MacroInlineProcessor(InlineProcessor): + """MacroInlineProcessor is class that is handles the logic + of how to render each macro occurence in the markdown text. + """ + def handleMatch(self, m, data): + """Handles each macro match and return rendered contents + for that macro as an etree node. + """ + macro = m.group(1) + arg = m.group(2) + html = render_macro(macro, arg) + html = sanitize_html(str(html)) + e = etree.fromstring(html) + return e, m.start(0), m.end(0) + +def sanitize_html(html): + """Sanotize the html using BeautifulSoup. + + The markdown processor request the correct markup and crashes on + any broken tags. This makes sures that all those things are fixed + before passing to the etree parser. + """ + soup = BeautifulSoup(html, features="lxml") + nodes = soup.body.children + return "
" + "\n".join(str(node) for node in nodes) + "
" 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 08ccb05e..ceb5fa36 100644 --- a/community/www/batch/learn.py +++ b/community/www/batch/learn.py @@ -30,7 +30,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: @@ -45,4 +45,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 diff --git a/requirements.txt b/requirements.txt index 900c38db..293d21a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ frappe websocket_client +markdown +beautifulsoup4 +lxml