diff --git a/community/hooks.py b/community/hooks.py index 0836b34c..7669e2fe 100644 --- a/community/hooks.py +++ b/community/hooks.py @@ -91,7 +91,7 @@ app_include_js = "/assets/community/js/community.js" # Hook on document methods and events doc_events = { - "User": { + "User": { "after_insert": "community.community.doctype.community_member.community_member.create_member_from_user" } } @@ -127,3 +127,31 @@ scheduler_events = { # # auto_cancel_exempted_doctypes = ["Auto Repeat"] +from .routing import install_regex_converter +install_regex_converter() + +# Add all simple route rules here +primary_rules = [ + {"from_route": "/sketches", "to_route": "sketches"}, + {"from_route": "/sketches/", "to_route": "sketches/sketch"}, + {"from_route": "/courses", "to_route": "courses"}, + {"from_route": "/courses/", "to_route": "courses/course"}, + {"from_route": "/courses//", "to_route": "courses/topic"}, + {"from_route": "/courses//", "to_route": "courses/topic"} +] + +# Any frappe default URL is blocked by profile-rules, add it here to unblock it +whitelist = [ + "/login", + "/update-password", + "/update-profile", + "/third-party-apps" +] +whitelist_rules = [{"from_route": p, "to_route": p[1:]} for p in whitelist] + +# regex rule to match all profiles +profile_rules = [ + {"from_route": "/", "to_route": "profiles/profile"}, +] + +website_route_rules = primary_rules + whitelist_rules + profile_rules diff --git a/community/lms/doctype/lms_course/lms_course.json b/community/lms/doctype/lms_course/lms_course.json index 5478d5c1..4a558f78 100644 --- a/community/lms/doctype/lms_course/lms_course.json +++ b/community/lms/doctype/lms_course/lms_course.json @@ -9,6 +9,7 @@ "engine": "InnoDB", "field_order": [ "title", + "slug", "description", "section_break_3", "is_published", @@ -47,12 +48,20 @@ { "fieldname": "column_break_5", "fieldtype": "Column Break" + }, + { + "description": "The slug of the course. Autogenerated from the title if not specified.", + "fieldname": "slug", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Slug", + "unique": 1 } ], "index_web_pages_for_search": 1, "is_published_field": "is_published", "links": [], - "modified": "2021-03-19 15:44:47.411705", + "modified": "2021-04-06 15:33:08.870313", "modified_by": "Administrator", "module": "LMS", "name": "LMS Course", @@ -71,7 +80,7 @@ "write": 1 } ], - "search_fields": "title", + "search_fields": "slug", "sort_field": "creation", "sort_order": "DESC", "title_field": "title", diff --git a/community/lms/doctype/lms_course/lms_course.py b/community/lms/doctype/lms_course/lms_course.py index 9882d82f..eaa33c90 100644 --- a/community/lms/doctype/lms_course/lms_course.py +++ b/community/lms/doctype/lms_course/lms_course.py @@ -3,8 +3,29 @@ # For license information, please see license.txt from __future__ import unicode_literals -# import frappe +import frappe from frappe.model.document import Document +from ...utils import slugify class LMSCourse(Document): - pass + def before_save(self): + if not self.slug: + self.slug = self.generate_slug(title=self.title) + + def generate_slug(self, title): + result = frappe.get_all( + 'LMS Course', + fields=['slug']) + slugs = set([row['slug'] for row in result]) + return slugify(title, used_slugs=slugs) + + def get_topic(self, slug): + """Returns the topic with given slug in this course as a Document. + """ + result = frappe.get_all( + "LMS Topic", + filters={"course": self.name, "slug": slug}) + + if result: + row = result[0] + return frappe.get_doc('LMS Topic', row['name']) diff --git a/community/lms/doctype/lms_sketch/lms_sketch.py b/community/lms/doctype/lms_sketch/lms_sketch.py index 21257e49..2f0120fe 100644 --- a/community/lms/doctype/lms_sketch/lms_sketch.py +++ b/community/lms/doctype/lms_sketch/lms_sketch.py @@ -13,6 +13,14 @@ class LMSSketch(Document): def get_owner_name(self): return get_userinfo(self.owner)['full_name'] + @property + def sketch_id(self): + """Returns the numeric part of the name. + + For example, the skech_id will be "123" for sketch with name "SKETCH-123". + """ + return self.name.replace("SKETCH-", "") + def get_livecode_url(self): doc = frappe.get_cached_doc("LMS Settings") return doc.livecode_url diff --git a/community/lms/doctype/lms_topic/lms_topic.json b/community/lms/doctype/lms_topic/lms_topic.json index 52d00479..81cc6477 100644 --- a/community/lms/doctype/lms_topic/lms_topic.json +++ b/community/lms/doctype/lms_topic/lms_topic.json @@ -1,15 +1,15 @@ { "actions": [], "allow_guest_to_view": 1, - "autoname": "format:{title}", "creation": "2021-03-02 07:20:41.686573", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "course", - "preview", "title", + "slug", + "preview", "description", "order", "sections" @@ -50,11 +50,17 @@ "fieldtype": "Table", "label": "Sections", "options": "LMS Section" + }, + { + "description": "The slug of the topic. Autogenerated from the title if not specified.", + "fieldname": "slug", + "fieldtype": "Data", + "label": "Slug" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-05 17:08:55.580189", + "modified": "2021-04-06 14:12:48.514062", "modified_by": "Administrator", "module": "LMS", "name": "LMS Topic", diff --git a/community/lms/doctype/lms_topic/lms_topic.py b/community/lms/doctype/lms_topic/lms_topic.py index acf5fe4e..fec5e261 100644 --- a/community/lms/doctype/lms_topic/lms_topic.py +++ b/community/lms/doctype/lms_topic/lms_topic.py @@ -6,12 +6,28 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document from .section_parser import SectionParser +from ...utils import slugify class LMSTopic(Document): def before_save(self): - sections = SectionParser().parse(self.description) + course = self.get_course() + if not self.slug: + self.slug = self.generate_slug(title=self.title) + + sections = SectionParser().parse(self.description or "") self.sections = [self.make_lms_section(i, s) for i, s in enumerate(sections)] + def get_course(self): + return frappe.get_doc("LMS Course", self.course) + + def generate_slug(self, title): + result = frappe.get_all( + 'LMS Topic', + filters={'course': self.course}, + fields=['slug']) + slugs = set([row['slug'] for row in result]) + return slugify(title, used_slugs=slugs) + def get_sections(self): return sorted(self.sections, key=lambda s: s.index) @@ -22,3 +38,6 @@ class LMSTopic(Document): s.contents = section.contents s.index = index return s + + + diff --git a/community/lms/test_utils.py b/community/lms/test_utils.py new file mode 100644 index 00000000..6bb342e8 --- /dev/null +++ b/community/lms/test_utils.py @@ -0,0 +1,17 @@ +import unittest +from .utils import slugify + +class TestSlugify(unittest.TestCase): + def test_simple(self): + self.assertEquals(slugify("hello-world"), "hello-world") + self.assertEquals(slugify("Hello World"), "hello-world") + self.assertEquals(slugify("Hello, World!"), "hello-world") + + def test_duplicates(self): + self.assertEquals( + slugify("Hello World", ['hello-world']), + "hello-world-2") + + self.assertEquals( + slugify("Hello World", ['hello-world', 'hello-world-2']), + "hello-world-3") diff --git a/community/lms/utils.py b/community/lms/utils.py new file mode 100644 index 00000000..9ea8ae5c --- /dev/null +++ b/community/lms/utils.py @@ -0,0 +1,30 @@ +import re + +RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+") + +def slugify(title, used_slugs=[]): + """Converts title to a slug. + + If a list of used slugs is specified, it will make sure the generated slug + is not one of them. + + >>> slugify("Hello World!") + 'hello-world' + >>> slugify("Hello World!", ['hello-world']) + 'hello-world-2' + >>> slugify("Hello World!", ['hello-world', 'hello-world-2']) + 'hello-world-3' + """ + slug = RE_SLUG_NOTALLOWED.sub('-', title.lower()).strip('-') + used_slugs = set(used_slugs) + + if slug not in used_slugs: + return slug + + count = 2 + while True: + new_slug = f"{slug}-{count}" + if new_slug not in used_slugs: + return new_slug + count = count+1 + diff --git a/community/routing.py b/community/routing.py new file mode 100644 index 00000000..26ed052b --- /dev/null +++ b/community/routing.py @@ -0,0 +1,25 @@ +"""Utilities for making custom routing. +""" + +from werkzeug.routing import BaseConverter, Map +from werkzeug.datastructures import ImmutableDict + +class RegexConverter(BaseConverter): + """werkzeug converter that supports custom regular expression. + + The `install_regex_converter` function must be called before using + regex converter in rules. + """ + def __init__(self, map, regex): + super().__init__(map) + self.regex = regex + +def install_regex_converter(): + """Installs the RegexConvetor to the default converters supported by werkzeug. + + This allows specifing rules using regex. For example: + + /profiles/ + """ + default_converters = dict(Map.default_converters, regex=RegexConverter) + Map.default_converters = ImmutableDict(default_converters) diff --git a/community/www/courses/course.html b/community/www/courses/course.html index e4190705..00f68acf 100644 --- a/community/www/courses/course.html +++ b/community/www/courses/course.html @@ -1,6 +1,5 @@ {% extends "templates/base.html" %} {% block title %}{{ 'Courses' }}{% endblock %} -{% from "www/courses/macros/card.html" import course_card, topic_card %} {% block head_include %} @@ -34,16 +33,16 @@ aria-selected="false">Discussions {% endif %} - +
-
{{ frappe.utils.md_to_html(course.description) }}
+
{{ frappe.utils.md_to_html(course.description) }}
{% for topic in course.topics %}
-
{{topic.title}}
+
{{topic.title}}
{{topic.preview | markdown }}
{% endfor %} diff --git a/community/www/courses/course.py b/community/www/courses/course.py index d9f6e5f6..32396a18 100644 --- a/community/www/courses/course.py +++ b/community/www/courses/course.py @@ -16,14 +16,15 @@ def get_context(context): context.current_batch = context.memberships[0].batch context.author = context.memberships[0].member -def get_course(name): - course = frappe.db.get_value('LMS Course', name, - ['name', 'title', 'description'], as_dict=1) +def get_course(slug): + course = frappe.db.get_value('LMS Course', {"slug": slug}, + ['name', 'slug', 'title', 'description'], as_dict=1) + course['topics'] = frappe.db.get_all('LMS Topic', filters={ - 'course': name + 'course': course['name'] }, - fields=['name', 'title', 'preview'], + fields=['name', 'slug', 'title', 'preview'], order_by='creation' ) return course diff --git a/community/www/courses/index.html b/community/www/courses/index.html index 052c5921..bed70209 100644 --- a/community/www/courses/index.html +++ b/community/www/courses/index.html @@ -1,24 +1,34 @@ {% extends "templates/base.html" %} {% block title %}{{ 'Courses' }}{% endblock %} -{% from "www/courses/macros/card.html" import course_card %} {% block head_include %} - - - + + + {% endblock %} {% block content %}
-
-

{{ 'Courses' }}

-
-
-
- {% for course in courses %} - {{ course_card(course) }} - {% endfor %} -
-
+
+

{{ 'Courses' }}

+
+
+
+ {% for course in courses %} + {{ course_card(course) }} + {% endfor %} +
+
-{% endblock %} \ No newline at end of file +{% endblock %} + + +{% macro course_card(course) %} +
+
+
{{course.title}}
+

{{ frappe.utils.md_to_html(course.description[:250]) }}

+ See more → +
+
+{% endmacro %} diff --git a/community/www/courses/index.py b/community/www/courses/index.py index 49b1864a..a4d6feea 100644 --- a/community/www/courses/index.py +++ b/community/www/courses/index.py @@ -7,6 +7,6 @@ def get_context(context): def get_courses(): courses = frappe.get_all( "LMS Course", - fields=['name', 'title', 'description'] + fields=['name', 'slug', 'title', 'description'] ) return courses diff --git a/community/www/courses/macros/card.html b/community/www/courses/macros/card.html deleted file mode 100644 index 49b3ffff..00000000 --- a/community/www/courses/macros/card.html +++ /dev/null @@ -1,19 +0,0 @@ -{% macro course_card(course) %} -
-
-
{{course.title}}
-

{{ frappe.utils.md_to_html(course.description[:250]) }}

- See more → -
-
-{% endmacro %} - -{% macro topic_card(course, topic) %} -
-
-
{{topic.title}}
-

{{topic.description}}

-
-
-{% endmacro %} - diff --git a/community/www/courses/topic.html b/community/www/courses/topic.html index 4d632bc0..67df39dc 100644 --- a/community/www/courses/topic.html +++ b/community/www/courses/topic.html @@ -24,7 +24,7 @@ diff --git a/community/www/courses/topic.py b/community/www/courses/topic.py index 855cabc8..7abaabcb 100644 --- a/community/www/courses/topic.py +++ b/community/www/courses/topic.py @@ -2,36 +2,32 @@ import frappe def get_context(context): context.no_cache = 1 - course_name = get_queryparam("course", '/courses') - context.course = get_course(course_name) - topic_name = get_queryparam("topic", '/courses?course=' + course_name) - context.topic = get_topic(course_name, topic_name) + try: + course_slug = frappe.form_dict['course'] + topic_slug = frappe.form_dict['topic'] + except KeyError: + context.template = 'www/404.html' + return + + course = get_course(course_slug) + topic = course and course.get_topic(topic_slug) + + if not topic: + context.template = 'www/404.html' + return + + context.course = course + context.topic = topic context.livecode_url = get_livecode_url() +def notfound(context): + context.template = 'www/404.html' + def get_livecode_url(): doc = frappe.get_doc("LMS Settings") return doc.livecode_url -def get_queryparam(name, redirect_when_not_found): - try: - return frappe.form_dict[name] - except KeyError: - frappe.local.flags.redirect_location = redirect_when_not_found - raise frappe.Redirect - -def get_course(name): - try: - course = frappe.get_doc('LMS Course', name) - except frappe.exceptions.DoesNotExistError: - raise frappe.NotFound - return course - -def get_topic(course_name, topic_name): - try: - topic = frappe.get_doc('LMS Topic', topic_name) - except frappe.exceptions.DoesNotExistError: - raise frappe.NotFound - if topic.course != course_name: - raise frappe.NotFound - return topic +def get_course(slug): + course = frappe.db.get_value('LMS Course', {"slug": slug}, ["name"], as_dict=1) + return course and frappe.get_doc('LMS Course', course['name']) diff --git a/community/www/profiles/profile.html b/community/www/profiles/profile.html new file mode 100644 index 00000000..5a1887b0 --- /dev/null +++ b/community/www/profiles/profile.html @@ -0,0 +1,25 @@ +{% extends "templates/web.html" %} +{% block page_content %} +
+ {% if user.photo %} +
+ {{ user.full_name }} +
+ {% else %} +
+
{{ user.abbr }}
+
+ {% endif %} +
+

{{ user.full_name }}

+ {% if user.short_intro %} +

{{ user.short_intro }}

+ {% endif %} + {% if user.bio %} +

{{ frappe.utils.md_to_html(user.bio) }}

+ {% endif %} +
+
+{% endblock %} + + diff --git a/community/www/profiles/profile.py b/community/www/profiles/profile.py new file mode 100644 index 00000000..0f9f76e0 --- /dev/null +++ b/community/www/profiles/profile.py @@ -0,0 +1,18 @@ +import frappe + +def get_context(context): + context.no_cache = 1 + + username = frappe.form_dict.get('username') + user = username and get_user(username) + if not user: + context.template = "www/404.html" + + user.abbr = "".join([s[0] for s in user.full_name.split()]) + context.user = user + +def get_user(username): + try: + return frappe.get_doc("Community Member", username) + except frappe.DoesNotExistError: + return diff --git a/community/www/sketches/index.html b/community/www/sketches/index.html index e99ff675..dd1a61de 100644 --- a/community/www/sketches/index.html +++ b/community/www/sketches/index.html @@ -13,7 +13,7 @@
@@ -21,13 +21,13 @@