Compare commits
3 Commits
profile-ta
...
lesson-mar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9185c0b6b | ||
|
|
5363fb7eb3 | ||
|
|
d90a1247f1 |
@@ -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()
|
|
||||||
@@ -176,3 +176,15 @@ profile_rules = [
|
|||||||
website_route_rules = primary_rules + whitelist_rules + profile_rules
|
website_route_rules = primary_rules + whitelist_rules + profile_rules
|
||||||
|
|
||||||
update_website_context = 'community.widgets.update_website_context'
|
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"}
|
||||||
|
|||||||
@@ -6,28 +6,28 @@ from __future__ import unicode_literals
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from ...section_parser import SectionParser
|
from ...section_parser import SectionParser
|
||||||
|
from ...md import markdown_to_html, find_macros
|
||||||
|
|
||||||
class Lesson(Document):
|
class Lesson(Document):
|
||||||
def before_save(self):
|
def before_save(self):
|
||||||
sections = SectionParser().parse(self.body or "")
|
macros = find_macros(self.body)
|
||||||
self.sections = [self.make_lms_section(i, s) for i, s in enumerate(sections)]
|
exercises = [value for name, value in macros if name == "Exercise"]
|
||||||
|
|
||||||
index = 1
|
index = 1
|
||||||
for s in self.sections:
|
for name in exercises:
|
||||||
if s.type == "exercise":
|
e = frappe.get_doc("Exercise", name)
|
||||||
e = s.get_exercise()
|
|
||||||
e.lesson = self.name
|
e.lesson = self.name
|
||||||
e.index_ = index
|
e.index_ = index
|
||||||
e.save()
|
e.save()
|
||||||
index += 1
|
index += 1
|
||||||
self.update_orphan_exercises()
|
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,
|
"""Updates the exercises that were previously part of this lesson,
|
||||||
but not any more.
|
but not any more.
|
||||||
"""
|
"""
|
||||||
linked_exercises = {row['name'] for row in frappe.get_all('Exercise', {"lesson": self.name})}
|
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
|
orphan_exercises = linked_exercises - active_exercises
|
||||||
for name in orphan_exercises:
|
for name in orphan_exercises:
|
||||||
ex = frappe.get_doc("Exercise", name)
|
ex = frappe.get_doc("Exercise", name)
|
||||||
@@ -36,11 +36,19 @@ class Lesson(Document):
|
|||||||
ex.index_label = ""
|
ex.index_label = ""
|
||||||
ex.save()
|
ex.save()
|
||||||
|
|
||||||
|
def render_html(self):
|
||||||
|
return markdown_to_html(self.body)
|
||||||
|
|
||||||
def get_sections(self):
|
def get_sections(self):
|
||||||
return sorted(self.get('sections'), key=lambda s: s.index)
|
return sorted(self.get('sections'), key=lambda s: s.index)
|
||||||
|
|
||||||
def get_exercises(self):
|
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):
|
def make_lms_section(self, index, section):
|
||||||
s = frappe.new_doc('LMS Section', parent_doc=self, parentfield='sections')
|
s = frappe.new_doc('LMS Section', parent_doc=self, parentfield='sections')
|
||||||
|
|||||||
107
community/lms/md.py
Normal file
107
community/lms/md.py
Normal 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
66
community/plugins.py
Normal 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()
|
||||||
@@ -9,16 +9,13 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/assets/frappe/css/font-awesome.css">
|
<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/css/lms.css">
|
||||||
<link rel="stylesheet" href="/assets/frappe/css/hljs-night-owl.css">
|
<link rel="stylesheet" href="/assets/frappe/css/hljs-night-owl.css">
|
||||||
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/lib/codemirror.js"></script>
|
{% for ext in page_extensions %}
|
||||||
<script src="{{ livecode_url }}/static/codemirror/mode/python/python.js"></script>
|
{{ ext.render_header() }}
|
||||||
<script src="{{ livecode_url }}/static/codemirror/keymap/sublime.js"></script>
|
{% endfor %}
|
||||||
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/addon/edit/matchbrackets.js"></script>
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/addon/comment/comment.js"></script>
|
|
||||||
{% endblock %}
|
{% 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>
|
<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() %}
|
{{ lesson.render_html() }}
|
||||||
<div class="section section-{{ s.type }}">
|
|
||||||
{{ render_section(s) }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{{ pagination(prev_chap, prev_url, next_chap, next_url) }}
|
{{ pagination(prev_chap, prev_url, next_chap, next_url) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -42,30 +35,6 @@
|
|||||||
{% endblock %}
|
{% 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) %}
|
{% macro pagination(prev_chap, prev_url, next_chap, next_url) %}
|
||||||
<div class="lesson-pagination">
|
<div class="lesson-pagination">
|
||||||
{% if prev_url %}
|
{% if prev_url %}
|
||||||
@@ -84,8 +53,6 @@
|
|||||||
|
|
||||||
{%- block script %}
|
{%- block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
{{ LiveCodeEditorJS() }}
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$(function() {
|
$(function() {
|
||||||
var batch_name = "{{ batch.name }}";
|
var batch_name = "{{ batch.name }}";
|
||||||
@@ -98,4 +65,8 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{% for ext in page_extensions %}
|
||||||
|
{{ ext.render_footer() }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def get_context(context):
|
|||||||
context.next_url = context.batch.get_learn_url(next_)
|
context.next_url = context.batch.get_learn_url(next_)
|
||||||
context.prev_url = context.batch.get_learn_url(prev_)
|
context.prev_url = context.batch.get_learn_url(prev_)
|
||||||
|
|
||||||
|
context.page_extensions = get_page_extensions()
|
||||||
|
|
||||||
def get_chapter_title(course_name, lesson_number):
|
def get_chapter_title(course_name, lesson_number):
|
||||||
if not lesson_number:
|
if not lesson_number:
|
||||||
@@ -42,4 +42,8 @@ def get_lesson_index(course, batch, user):
|
|||||||
lesson = batch.get_current_lesson(user)
|
lesson = batch.get_current_lesson(user)
|
||||||
return lesson and course.get_lesson_index(lesson)
|
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
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
frappe
|
frappe
|
||||||
websocket_client
|
websocket_client
|
||||||
|
markdown
|
||||||
|
beautifulsoup4
|
||||||
|
lxml
|
||||||
|
|||||||
Reference in New Issue
Block a user