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 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 "