Compare commits

..

3 Commits

Author SHA1 Message Date
Anand Chitipothu
d9185c0b6b 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
2021-06-09 23:58:21 +05:30
Anand Chitipothu
5363fb7eb3 feat: extend markdown to support macros
With this feature, the exercises can be added to the lesson as:

    {{ Exercise("two-circles") }}

This also added fenced_code extension that allows adding id and classes
to code blocks.

This uses Python-Markdown library instead of Markdown2 that is used
everywhere in Frappe. The Python-Markdown is more easily extensible than
Markdown2.

Issue #115
2021-06-09 23:22:00 +05:30
Jannat Patel
d90a1247f1 Merge pull request #120 from fossunited/profile-tabs
feat: pluggable profile tabs
2021-06-08 14:53:38 +05:30
8 changed files with 223 additions and 90 deletions

View File

@@ -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()

View File

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

View File

@@ -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')

107
community/lms/md.py Normal file
View File

@@ -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"<p>Unknown macro: {macro_name}</p>"
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 "<div>" + "\n".join(str(node) for node in nodes) + "</div>"

66
community/plugins.py Normal file
View File

@@ -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 <head> 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()

View File

@@ -9,16 +9,13 @@
</style>
<link rel="stylesheet" href="/assets/frappe/css/font-awesome.css">
<link rel="stylesheet" href="{{ livecode_url }}/static/codemirror/lib/codemirror.css">
<link rel="stylesheet" href="/assets/css/lms.css">
<link rel="stylesheet" href="/assets/frappe/css/hljs-night-owl.css">
<script src="{{ livecode_url }}/static/codemirror/lib/codemirror.js"></script>
<script src="{{ livecode_url }}/static/codemirror/mode/python/python.js"></script>
<script src="{{ livecode_url }}/static/codemirror/keymap/sublime.js"></script>
{% for ext in page_extensions %}
{{ ext.render_header() }}
{% endfor %}
<script src="{{ livecode_url }}/static/codemirror/addon/edit/matchbrackets.js"></script>
<script src="{{ livecode_url }}/static/codemirror/addon/comment/comment.js"></script>
{% endblock %}
@@ -30,11 +27,7 @@
<h2 class="title {% if course.is_mentor(frappe.session.user) %} is_mentor {% endif %}" data-name="{{ lesson.name }}" data-batch="{{ batch.name }}">{{ lesson.title }}</h2>
{% for s in lesson.get_sections() %}
<div class="section section-{{ s.type }}">
{{ render_section(s) }}
</div>
{% endfor %}
{{ lesson.render_html() }}
{{ pagination(prev_chap, prev_url, next_chap, next_url) }}
</div>
@@ -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 %}
<div>Unknown section type: {{s.type}}</div>
{% endif %}
{% endmacro %}
{% macro render_section_text(s) %}
<div class="row">
<div class="col-md-9">
{{ frappe.utils.md_to_html(s.contents) }}
</div>
</div>
{% endmacro %}
{% macro pagination(prev_chap, prev_url, next_chap, next_url) %}
<div class="lesson-pagination">
{% if prev_url %}
@@ -84,8 +53,6 @@
{%- block script %}
{{ super() }}
{{ LiveCodeEditorJS() }}
<script type="text/javascript">
$(function() {
var batch_name = "{{ batch.name }}";
@@ -98,4 +65,8 @@
})
</script>
{% for ext in page_extensions %}
{{ ext.render_footer() }}
{% endfor %}
{%- endblock %}

View File

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

View File

@@ -1,2 +1,5 @@
frappe
websocket_client
markdown
beautifulsoup4
lxml