Compare commits

...

56 Commits

Author SHA1 Message Date
Anand Chitipothu
9e0476fd00 chore: upgraded node to v14 in github actions
- the new changes to frappe required node v14
- also added a build setup before runing tests to make sure the assets are built
2021-05-19 10:05:59 +05:30
Anand Chitipothu
d9ea02667d refactor: removed the branding and customization for mon school
They have been moved to a new mon_school app
2021-05-18 21:25:29 +05:30
Anand Chitipothu
bdabf32124 Merge pull request #86 from fossunited/learn
Implemented learning section for batches
2021-05-14 15:36:44 +05:30
Anand Chitipothu
8b657f2f40 feat: added next/prev links to learn pages 2021-05-14 15:29:44 +05:30
Anand Chitipothu
49b41749e8 feat: added learn page
- added sections to the lesson to handle multiple sesions like examples and exercises
- added livecode integration to lesson pages
- autosave and submiting the answers is not done yet
2021-05-14 12:11:45 +05:30
Anand Chitipothu
1cf57c4823 feat: redirect the course page to learn page when the visitor is a student of a batch 2021-05-14 12:06:37 +05:30
Anand Chitipothu
5cfb72a731 style: tweaks
- made hero h1 black
- fixed the styles of lesson teasers
2021-05-08 13:44:34 +05:30
Anand Chitipothu
49d5ca4292 fix: added index to lesson doctype to fix the display order 2021-05-08 13:44:34 +05:30
Anand Chitipothu
a3e53efcc1 fix: fixed course description on the course page
The small intro was being shown in its place.
2021-05-08 13:44:34 +05:30
Anand Chitipothu
be7814b4fe Merge pull request #81 from fossunited/flow-fixes
fix: username and email validations
2021-05-07 18:53:58 +05:30
Anand Chitipothu
c40ab9a726 style: style tweaks 2021-05-07 18:32:35 +05:30
Anand Chitipothu
64cf14ed92 fix: fail gracefully when livecode_to_svg crashes
That seems to be happening in some cases and couldn't really figure out
the reason. Handling the error to gracefully to show an empty image in
those cases.
2021-05-07 18:32:34 +05:30
Anand Chitipothu
dbaa896fcc fix: fixed the issue of users unable to save sketches
Inserting new sketches is failing because the web user role doesn't have
permission to create sketches. Fixed it by adding
ignore_permission=True.
2021-05-07 18:32:34 +05:30
pateljannat
911c85bfc8 fix: redirect after login 2021-05-07 18:27:18 +05:30
pateljannat
5edceb2562 fix: photo from user 2021-05-07 17:14:33 +05:30
Anand Chitipothu
5d14dce320 Merge pull request #80 from fossunited/add-new-batch-form-enhancements
fix: Add new batch form enhancements
2021-05-07 16:53:21 +05:30
Anand Chitipothu
eec9e57dd2 fix: fixed thumbnail generation from livecode
Due to the recent changes to livecode, the code to generate svg from
code got broken. Fixed that now.
2021-05-07 16:48:52 +05:30
Anand Chitipothu
503b922074 style: improved the sketch page 2021-05-07 16:48:25 +05:30
pateljannat
d4c19932d5 fix: replaced slug with name and removed whitelist 2021-05-07 16:28:12 +05:30
Anand Chitipothu
3c8cffc5ad fix: fixed the issue of unable to create sketches.
The livecode API has been generalized and there was some
backwackward-incompatible changes in that proces. Added
livecode-canvas.js with the required options to fix the issue.
2021-05-07 13:59:38 +05:30
pateljannat
b3c67a3f34 fix: username and email validations 2021-05-07 13:47:33 +05:30
Anand Chitipothu
84b4833fed style: tweaked the styles of profile page 2021-05-07 12:33:43 +05:30
pateljannat
28ef7e5def fix: conflicts 2021-05-07 12:08:51 +05:30
pateljannat
9981baa13b fix: add new batch form enhancements 2021-05-07 12:04:11 +05:30
Anand Chitipothu
c764aa6c20 fix: fixed the error in saving a new course 2021-05-07 05:18:06 +05:30
Anand Chitipothu
bc11730697 style: showing the message as alert elements on the course page 2021-05-06 21:05:36 +05:30
Anand Chitipothu
92c4a86e8b refactor: fixed the accidentally removed code in RequestInvite 2021-05-06 21:05:08 +05:30
Anand Chitipothu
358724bf1c style: fixed the hero section on mobile
The email textbox was becoming too small.
2021-05-06 20:53:14 +05:30
Anand Chitipothu
bb80d988d7 refactor: using SketchTeaser widget to show sketch in the profile page 2021-05-06 20:43:04 +05:30
Anand Chitipothu
0ad03a3fb5 style: made the avatar and name of the person a link 2021-05-06 20:41:52 +05:30
Anand Chitipothu
3382de0ecb refactor: removed slug from course page. 2021-05-06 15:47:47 +05:30
Anand Chitipothu
08bb7b4490 Merge pull request #73 from fossunited/mentor-request-email-templates
fix: mentor request flow and emails #30, #69, #70
2021-05-06 15:39:58 +05:30
Anand Chitipothu
15203f6bcc Merge branch 'main' into mentor-request-email-templates 2021-05-06 15:38:53 +05:30
Anand Chitipothu
cbfb0d6761 Merge pull request #76 from fossunited/course-page
Refactored the course page
2021-05-06 13:55:42 +05:30
Anand Chitipothu
343aa50f78 Merge branch 'main' into course-page 2021-05-06 13:50:12 +05:30
Anand Chitipothu
5a70687067 refactor: removed the slug using the course name as part of url 2021-05-06 13:42:45 +05:30
Anand Chitipothu
a0921f7380 Merge pull request #77 from fossunited/widget-avatar
feat: Widget avatar
2021-05-06 13:26:49 +05:30
pateljannat
11cc03849d fix: displaying course name in mentor email 2021-05-06 13:23:22 +05:30
pateljannat
413aeaccb1 feat: avatar widget 2021-05-06 12:17:28 +05:30
Anand Chitipothu
88457a82ac refactor: learn page 2021-05-06 07:05:27 +05:30
Anand Chitipothu
761f36519e refactor: refactored the about page 2021-05-06 07:05:17 +05:30
Anand Chitipothu
dc5b637ada refactor: fixed the course page 2021-05-06 06:47:09 +05:30
pateljannat
5e5395658e Merge branch 'main' of https://github.com/frappe/community into mentor-request-email-templates 2021-05-05 16:32:37 +05:30
pateljannat
69b3f366f4 feat: widget file added 2021-05-05 16:32:21 +05:30
Anand Chitipothu
da902d23f7 Merge pull request #71 from fossunited/footer
Fix footer
2021-05-05 16:30:45 +05:30
Anand Chitipothu
c2be23a902 fix: fixed the pickle error on installing community 2021-05-05 15:58:28 +05:30
Anand Chitipothu
da771d7830 [actions] added action to ache bench 2021-05-05 15:57:41 +05:30
pateljannat
8a242a69fb fix: mentor request flow and emails #30, #69, #70 2021-05-05 15:45:20 +05:30
Anand Chitipothu
041bed7e9d Added sketches to the nav-bar on install 2021-05-05 12:56:27 +05:30
Anand Chitipothu
e330f45adc style: fixed the footer 2021-05-05 12:53:24 +05:30
Anand Chitipothu
370d3a321b Merge pull request #68 from fossunited/invite-flow-fixes
fix: invite flow issues
2021-05-04 23:23:40 +05:30
Anand Chitipothu
138fba66ae Merge branch 'main' of git://github.com/khalby786/community into khalby786-main 2021-05-04 23:21:21 +05:30
Anand Chitipothu
82faaed15d feat: set app name and logo on install and disable signup
- set app_name and app_logo_url on install
- disabled signup
2021-05-04 23:19:50 +05:30
Anand Chitipothu
b58a685e7a Merge pull request #64 from fossunited/miscellaneous-fixes
fix: Miscellaneous fixes
2021-05-04 19:18:17 +05:30
pateljannat
18c0fb0da5 fix: #65 and #66 2021-05-04 17:41:54 +05:30
Khaleel Gibran
67d3ec75c8 feat: design homepage based on figma design 2021-05-01 18:51:44 +05:30
53 changed files with 1321 additions and 567 deletions

View File

@@ -36,12 +36,27 @@ jobs:
- name: setup node - name: setup node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: '12' node-version: '14'
check-latest: true check-latest: true
- name: setup cache for bench
uses: actions/cache@v2
with:
path: ~/bench-cache
key: ${{ runner.os }}
- name: install bench - name: install bench
run: pip3 install frappe-bench run: |
pip3 install frappe-bench
which bench
- name: bench init - name: bench init
run: bench init ~/frappe-bench --skip-redis-config-generation run: |
if [ -d ~/bench-cache/bench.tgz ]
then
(cd && tar xzf ~/bench-cache/bench.tgz)
else
bench init ~/frappe-bench --skip-redis-config-generation
mkdir -p ~/bench-cache
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
fi
- name: add community app to bench - name: add community app to bench
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench get-app community $GITHUB_WORKSPACE run: bench get-app community $GITHUB_WORKSPACE
@@ -50,10 +65,13 @@ jobs:
run: bench new-site --mariadb-root-password root --admin-password admin frappe.local run: bench new-site --mariadb-root-password root --admin-password admin frappe.local
- name: install community app - name: install community app
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench --site frappe.local install-app community run: bench --verbose --site frappe.local install-app community
- name: allow tests - name: allow tests
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench --site frappe.local set-config allow_tests true run: bench --site frappe.local set-config allow_tests true
- name: bench build
working-directory: /home/runner/frappe-bench
run: bench --site frappe.local build
- name: run tests - name: run tests
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench --site frappe.local run-tests --app community run: bench --site frappe.local run-tests --app community

View File

@@ -8,6 +8,8 @@ import re
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
import random import random
from frappe.utils import cint
import hashlib
class CommunityMember(Document): class CommunityMember(Document):
@@ -45,6 +47,27 @@ class CommunityMember(Document):
'member_type': 'Mentor' 'member_type': 'Mentor'
}) })
def get_photo_url(self):
return frappe.db.get_value("User", self.email, ["user_image"])
def get_palette(self):
palette = [
['--orange-avatar-bg', '--orange-avatar-color'],
['--pink-avatar-bg', '--pink-avatar-color'],
['--blue-avatar-bg', '--blue-avatar-color'],
['--green-avatar-bg', '--green-avatar-color'],
['--dark-green-avatar-bg', '--dark-green-avatar-color'],
['--red-avatar-bg', '--red-avatar-color'],
['--yellow-avatar-bg', '--yellow-avatar-color'],
['--purple-avatar-bg', '--purple-avatar-color'],
['--gray-avatar-bg', '--gray-avatar-color0']
]
encoded_name = str(self.full_name).encode("utf-8")
hash_name = hashlib.md5(encoded_name).hexdigest()
idx = cint((int(hash_name[4:6], 16) + 1) / 5.33)
return palette[idx % 8]
def __repr__(self): def __repr__(self):
return f"<CommunityMember: {self.email}>" return f"<CommunityMember: {self.email}>"
@@ -54,7 +77,7 @@ def create_member_from_user(doc, method):
if ( doc.username and username_exists(doc.username)) or not doc.username: if ( doc.username and username_exists(doc.username)) or not doc.username:
username = create_username_from_email(doc.email) username = create_username_from_email(doc.email)
elif len(doc.username) < 4: elif len(doc.username) < 4 and doc.send_welcome_email == 1:
username = adjust_username(doc.username) username = adjust_username(doc.username)
if username_exists(username): if username_exists(username):

View File

@@ -0,0 +1,14 @@
{% set color = member.get_palette() %}
<a href="/{{member.username}}">
<span class="avatar {{ avatar_class }}" title="{{ member.full_name }}">
{% if member.get_photo_url() %}
<img class="avatar-frame standard-image" src="{{ member.get_photo_url() }}" title="{{ member.full_name }}">
</img>
{% else %}
<span class="avatar-frame standard-image" title="{{ member.full_name }}"
style="background-color: var({{color[0]}}); color: var({{color[1]}});">
{{ member.abbr }}
</span>
{% endif %}
</a>
</span>

View File

@@ -10,6 +10,7 @@ app_icon = "octicon octicon-file-directory"
app_color = "grey" app_color = "grey"
app_email = "jannat@erpnext.com" app_email = "jannat@erpnext.com"
app_license = "AGPL" app_license = "AGPL"
# Includes in <head> # Includes in <head>
# ------------------ # ------------------
@@ -141,6 +142,7 @@ primary_rules = [
{"from_route": "/dashboard", "to_route": ""}, {"from_route": "/dashboard", "to_route": ""},
{"from_route": "/add-a-new-batch", "to_route": "add-a-new-batch"}, {"from_route": "/add-a-new-batch", "to_route": "add-a-new-batch"},
{"from_route": "/courses/<course>/<batch>/learn", "to_route": "courses/learn"}, {"from_route": "/courses/<course>/<batch>/learn", "to_route": "courses/learn"},
{"from_route": "/courses/<course>/<batch>/learn/<int:chapter>.<int:lesson>", "to_route": "courses/learn"},
{"from_route": "/courses/<course>/<batch>/schedule", "to_route": "courses/schedule"}, {"from_route": "/courses/<course>/<batch>/schedule", "to_route": "courses/schedule"},
{"from_route": "/courses/<course>/<batch>/members", "to_route": "courses/members"}, {"from_route": "/courses/<course>/<batch>/members", "to_route": "courses/members"},
{"from_route": "/courses/<course>/<batch>/discuss", "to_route": "courses/discuss"}, {"from_route": "/courses/<course>/<batch>/discuss", "to_route": "courses/discuss"},

View File

@@ -9,7 +9,8 @@
"course", "course",
"title", "title",
"description", "description",
"locked" "locked",
"index_"
], ],
"fields": [ "fields": [
{ {
@@ -35,6 +36,12 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Course", "label": "Course",
"options": "LMS Course" "options": "LMS Course"
},
{
"default": "1",
"fieldname": "index_",
"fieldtype": "Int",
"label": "Index"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
@@ -45,7 +52,7 @@
"link_fieldname": "chapter" "link_fieldname": "chapter"
} }
], ],
"modified": "2021-05-03 06:52:10.894328", "modified": "2021-05-13 21:05:20.531890",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Chapter", "name": "Chapter",

View File

@@ -10,5 +10,6 @@ class Chapter(Document):
def get_lessons(self): def get_lessons(self):
rows = frappe.db.get_all("Lesson", rows = frappe.db.get_all("Lesson",
filters={"chapter": self.name}, filters={"chapter": self.name},
fields='*') fields='*',
order_by="index_")
return [frappe.get_doc(dict(row, doctype='Lesson')) for row in rows] return [frappe.get_doc(dict(row, doctype='Lesson')) for row in rows]

View File

@@ -46,14 +46,21 @@ class InviteRequest(Document):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def create_invite_request(invite_email): def create_invite_request(invite_email):
try:
frappe.get_doc({ if not frappe.utils.validate_email_address(invite_email):
return "invalid email"
if frappe.db.exists("User", invite_email):
return "user"
if frappe.db.exists("Invite Request", {"invite_email": invite_email}):
return "invite"
frappe.get_doc({
"doctype": "Invite Request", "doctype": "Invite Request",
"invite_email": invite_email "invite_email": invite_email
}).save(ignore_permissions=True) }).save(ignore_permissions=True)
return "OK" return "OK"
except frappe.UniqueValidationError:
frappe.throw(_("Email {0} has already been used to request an invite").format(invite_email))
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)

View File

@@ -8,7 +8,10 @@
"field_order": [ "field_order": [
"chapter", "chapter",
"lesson_type", "lesson_type",
"title" "title",
"index_",
"body",
"sections"
], ],
"fields": [ "fields": [
{ {
@@ -31,11 +34,28 @@
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Title" "label": "Title"
},
{
"default": "1",
"fieldname": "index_",
"fieldtype": "Int",
"label": "Index"
},
{
"fieldname": "body",
"fieldtype": "Markdown Editor",
"label": "Body"
},
{
"fieldname": "sections",
"fieldtype": "Table",
"label": "Sections",
"options": "LMS Section"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-05-03 06:51:43.588969", "modified": "2021-05-13 20:03:51.510605",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Lesson", "name": "Lesson",

View File

@@ -3,8 +3,37 @@
# For license information, please see license.txt # For license information, please see license.txt
from __future__ import unicode_literals from __future__ import unicode_literals
# import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from ..lms_topic.section_parser import SectionParser
class Lesson(Document): class Lesson(Document):
pass def before_save(self):
sections = SectionParser().parse(self.body or "")
self.sections = [self.make_lms_section(i, s) for i, s in enumerate(sections)]
def get_sections(self):
return sorted(self.get('sections'), key=lambda s: s.index)
def make_lms_section(self, index, section):
s = frappe.new_doc('LMS Section', parent_doc=self, parentfield='sections')
s.type = section.type
s.label = section.label
s.contents = section.contents
s.index = index
return s
def get_next(self):
"""Returns the number for the next lesson.
The return value would be like 1.2, 2.1 etc.
It will be None if there is no next lesson.
"""
def get_prev(self):
"""Returns the number for the prev lesson.
The return value would be like 1.2, 2.1 etc.
It will be None if there is no next lesson.
"""

View File

@@ -1,6 +1,5 @@
{ {
"actions": [], "actions": [],
"autoname": "field:title",
"creation": "2021-03-18 19:37:34.614796", "creation": "2021-03-18 19:37:34.614796",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
@@ -51,10 +50,12 @@
"label": "Description" "label": "Description"
}, },
{ {
"default": "Public",
"fieldname": "visibility", "fieldname": "visibility",
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1,
"label": "Visibility", "label": "Visibility",
"options": "\nPublic\nUnlisted\nPrivate" "options": "Public\nUnlisted\nPrivate"
}, },
{ {
"fieldname": "membership", "fieldname": "membership",
@@ -63,16 +64,19 @@
"options": "\nOpen\nRestricted\nInvite Only\nClosed" "options": "\nOpen\nRestricted\nInvite Only\nClosed"
}, },
{ {
"default": "Active",
"fieldname": "status", "fieldname": "status",
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1,
"label": "Status", "label": "Status",
"options": "\nActive\nInactive" "options": "Active\nInactive"
}, },
{ {
"default": "Ready",
"fieldname": "stage", "fieldname": "stage",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Stage", "label": "Stage",
"options": "\nReady\nIn Progress\nCompleted\nCancelled" "options": "Ready\nIn Progress\nCompleted\nCancelled"
}, },
{ {
"fieldname": "column_break_3", "fieldname": "column_break_3",
@@ -122,7 +126,7 @@
"link_fieldname": "batch" "link_fieldname": "batch"
} }
], ],
"modified": "2021-04-30 09:52:18.941276", "modified": "2021-05-06 05:46:38.469120",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",

View File

@@ -6,27 +6,44 @@ from __future__ import unicode_literals
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from community.www.courses.utils import get_member_with_email from community.www.courses.utils import get_member_with_email
from frappe import _
from community.lms.doctype.lms_batch_membership.lms_batch_membership import create_membership
from community.query import find, find_all
class LMSBatch(Document): class LMSBatch(Document):
def validate(self): def validate(self):
self.validate_if_mentor()
if not self.code: if not self.code:
self.generate_code() self.generate_code()
def validate_if_mentor(self):
course = frappe.get_doc("LMS Course", self.course)
if not course.is_mentor(frappe.session.user):
frappe.throw(_("You are not a mentor of the course {0}").format(course.title))
def after_insert(self):
create_membership(batch=self.name, member_type="Mentor")
def generate_code(self): def generate_code(self):
short_code = frappe.db.get_value("LMS Course", self.course, "short_code") short_code = frappe.db.get_value("LMS Course", self.course, "short_code")
course_batches = frappe.get_all("LMS Batch",{"course":self.course}) course_batches = frappe.get_all("LMS Batch",{"course":self.course})
self.code = short_code + str(len(course_batches) + 1) self.code = short_code + str(len(course_batches) + 1)
def get_mentors(self): def get_mentors(self):
mentors = []
memberships = frappe.get_all( memberships = frappe.get_all(
"LMS Batch Membership", "LMS Batch Membership",
{"batch": self.name, "member_type": "Mentor"}, {"batch": self.name, "member_type": "Mentor"},
["member"]) ["member"])
for membership in memberships: member_names = [m['member'] for m in memberships]
member = frappe.db.get_value("Community Member", membership.member, ["full_name", "photo", "abbr"], as_dict=1) return find_all("Community Member", name=["IN", member_names])
mentors.append(member)
return mentors def is_member(self, email):
"""Checks if a person is part of a batch.
"""
member = find("Community Member", email=email)
return member and frappe.db.exists(
"LMS Batch Membership",
{"batch": self.name, "member": member.name})
@frappe.whitelist() @frappe.whitelist()
def get_messages(batch): def get_messages(batch):

View File

@@ -28,8 +28,7 @@ class LMSBatchMembership(Document):
member_name = frappe.db.get_value("Community Member", self.member, "full_name") member_name = frappe.db.get_value("Community Member", self.member, "full_name")
frappe.throw(_("{0} is already a {1} of {2} course through {3} batch").format(member_name, membership.member_type, course, membership.batch)) frappe.throw(_("{0} is already a {1} of {2} course through {3} batch").format(member_name, membership.member_type, course, membership.batch))
@frappe.whitelist() def create_membership(batch, member=None, member_type="Student", role="Member"):
def create_membership(batch, course=None, member=None, member_type="Student", role="Member"):
if not member: if not member:
member = frappe.db.get_value("Community Member", {"email": frappe.session.user}, "name") member = frappe.db.get_value("Community Member", {"email": frappe.session.user}, "name")
frappe.get_doc({ frappe.get_doc({
@@ -39,7 +38,4 @@ def create_membership(batch, course=None, member=None, member_type="Student", ro
"member_type": member_type, "member_type": member_type,
"member": member "member": member
}).save(ignore_permissions=True) }).save(ignore_permissions=True)
if course:
course_slug = frappe.db.get_value("LMS Course", {"title": course}, ["slug"])
return course_slug
return "OK" return "OK"

View File

@@ -2,14 +2,12 @@
"actions": [], "actions": [],
"allow_guest_to_view": 1, "allow_guest_to_view": 1,
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:title",
"creation": "2021-03-01 16:49:33.622422", "creation": "2021-03-01 16:49:33.622422",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"title", "title",
"slug",
"is_published", "is_published",
"column_break_3", "column_break_3",
"short_code", "short_code",
@@ -43,14 +41,6 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Short Code" "label": "Short Code"
}, },
{
"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
},
{ {
"fieldname": "column_break_3", "fieldname": "column_break_3",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@@ -87,9 +77,14 @@
"group": "Mentors", "group": "Mentors",
"link_doctype": "LMS Course Mentor Mapping", "link_doctype": "LMS Course Mentor Mapping",
"link_fieldname": "course" "link_fieldname": "course"
},
{
"group": "Mentors",
"link_doctype": "LMS Mentor Request",
"link_fieldname": "course"
} }
], ],
"modified": "2021-05-03 05:52:30.396824", "modified": "2021-05-06 13:37:03.318829",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Course", "name": "LMS Course",
@@ -108,7 +103,7 @@
"write": 1 "write": 1
} }
], ],
"search_fields": "slug", "search_fields": "title",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"title_field": "title", "title_field": "title",

View File

@@ -6,13 +6,18 @@ from __future__ import unicode_literals
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from ...utils import slugify from ...utils import slugify
from community.query import find, find_all
class LMSCourse(Document): class LMSCourse(Document):
@staticmethod @staticmethod
def find(slug): def find(name):
"""Returns the course with specified slug. """Returns the course with specified name.
""" """
return find("LMS Course", is_published=True, slug=slug) return find("LMS Course", is_published=True, name=name)
def autoname(self):
if not self.name:
self.name = self.generate_slug(title=self.title)
@staticmethod @staticmethod
def find_all(): def find_all():
@@ -20,19 +25,15 @@ class LMSCourse(Document):
""" """
return find_all("LMS Course", is_published=True) return find_all("LMS Course", is_published=True)
def before_save(self):
if not self.slug:
self.slug = self.generate_slug(title=self.title)
def generate_slug(self, title): def generate_slug(self, title):
result = frappe.get_all( result = frappe.get_all(
'LMS Course', 'LMS Course',
fields=['slug']) fields=['name'])
slugs = set([row['slug'] for row in result]) slugs = set([row['name'] for row in result])
return slugify(title, used_slugs=slugs) return slugify(title, used_slugs=slugs)
def __repr__(self): def __repr__(self):
return f"<Course#{self.name} {self.slug}>" return f"<Course#{self.name}>"
def get_topic(self, slug): def get_topic(self, slug):
"""Returns the topic with given slug in this course as a Document. """Returns the topic with given slug in this course as a Document.
@@ -113,6 +114,35 @@ class LMSCourse(Document):
"mentor": member "mentor": member
}) })
def get_student_batch(self, email):
"""Returns the batch the given student is part of.
Returns None if the student is not part of any batch.
"""
if not email:
return False
member = self.get_community_member(email)
result = frappe.db.get_all(
"LMS Batch Membership",
filters={
"member": member,
"member_type": "Student",
},
fields=['batch']
)
batches = [row['batch'] for row in result]
# filter the batches that are for this course
result = frappe.db.get_all(
"LMS Batch",
filters={
"course": self.name,
"name": ["IN", batches]
})
batches = [row['name'] for row in result]
if batches:
return frappe.get_doc("LMS Batch", batches[0])
def get_instructor(self): def get_instructor(self):
member_name = self.get_community_member(self.owner) member_name = self.get_community_member(self.owner)
return frappe.get_doc("Community Member", member_name) return frappe.get_doc("Community Member", member_name)
@@ -123,6 +153,9 @@ class LMSCourse(Document):
# TODO: chapters should have a way to specify the order # TODO: chapters should have a way to specify the order
return find_all("Chapter", course=self.name, order_by="creation") return find_all("Chapter", course=self.name, order_by="creation")
def get_batch(self, batch_name):
return find("LMS Batch", name=batch_name, course=self.name)
def get_batches(self, mentor=None): def get_batches(self, mentor=None):
batches = find_all("LMS Batch", course=self.name) batches = find_all("LMS Batch", course=self.name)
if mentor: if mentor:
@@ -139,24 +172,64 @@ class LMSCourse(Document):
now = frappe.utils.nowdate() now = frappe.utils.nowdate()
batches = find_all("LMS Batch", batches = find_all("LMS Batch",
course=self.name, course=self.name,
start_date=[">", now]) start_date=[">", now],
status="Active",
visibility="Public")
return batches return batches
def find_all(doctype, order_by=None, **filters): def get_chapter(self, index):
"""Queries the database for documents of a doctype matching given filters. return find("Chapter", course=self.name, index_=index)
"""
rows = frappe.db.get_all(doctype,
filters=filters,
fields='*',
order_by=order_by)
return [frappe.get_doc(dict(row, doctype=doctype)) for row in rows]
def find(doctype, **filters): def get_lesson(self, chapter_index, lesson_index):
"""Queries the database for a document of given doctype matching given filters. chapter_name = frappe.get_value(
""" "Chapter",
rows = frappe.db.get_all(doctype, {"course": self.name, "index_": chapter_index},
filters=filters, "name")
fields='*') lesson_name = chapter_name and frappe.get_value(
if rows: "Lesson",
row = rows[0] {"chapter": chapter_name, "index_": lesson_index},
return frappe.get_doc(dict(row, doctype=doctype)) "name")
return lesson_name and frappe.get_doc("Lesson", lesson_name)
def get_outline(self):
return CourseOutline(self)
class CourseOutline:
def __init__(self, course):
self.course = course
self.chapters = self.get_chapters()
self.lessons = self.get_lessons()
def get_next(self, current):
numbers = sorted(lesson['number'] for lesson in self.lessons)
try:
index = numbers.index(current)
return numbers[index+1]
except IndexError:
return None
def get_prev(self, current):
numbers = sorted(lesson['number'] for lesson in self.lessons)
try:
index = numbers.index(current)
if index == 0:
return None
return numbers[index-1]
except IndexError:
return None
def get_chapters(self):
return frappe.db.get_all("Chapter",
filters={"course": self.course.name},
fields=["name", "title", "index_"])
def get_lessons(self):
chapters = [c['name'] for c in self.chapters]
lessons = frappe.db.get_all("Lesson",
filters={"chapter": ["IN", chapters]},
fields=["name", "title", "chapter", "index_"])
chapter_numbers = {c['name']: c['index_'] for c in self.chapters}
for lesson in lessons:
lesson['number'] = "{}.{}".format(chapter_numbers[lesson['chapter']], lesson['index_'])
return lessons

View File

@@ -24,7 +24,7 @@ class TestLMSCourse(unittest.TestCase):
def test_new_course(self): def test_new_course(self):
course = self.new_course("Test Course") course = self.new_course("Test Course")
assert course.title == "Test Course" assert course.title == "Test Course"
assert course.slug == "test-course" assert course.name == "test-course"
assert course.get_mentors() == [] assert course.get_mentors() == []
def test_find_all(self): def test_find_all(self):
@@ -41,7 +41,7 @@ class TestLMSCourse(unittest.TestCase):
# now we should find one course # now we should find one course
courses = LMSCourse.find_all() courses = LMSCourse.find_all()
assert [c.slug for c in courses] == [course.slug] assert [c.name for c in courses] == [course.name]
# disabled this test as it is failing # disabled this test as it is failing
def _test_add_mentors(self): def _test_add_mentors(self):

View File

@@ -8,86 +8,119 @@ from frappe.model.document import Document
from frappe import _ from frappe import _
class LMSMentorRequest(Document): class LMSMentorRequest(Document):
def on_update(self): def on_update(self):
if self.has_value_changed('status'): if self.has_value_changed('status'):
template = frappe.db.get_single_value('LMS Settings', 'mentor_request_status_update')
if not template:
return
email_template = frappe.get_doc('Email Template', template) if self.status == "Approved":
message = frappe.render_template(email_template.response, {'member_name': self.member_name, 'status': self.status}) self.create_course_mentor_mapping()
subject = _('The status of your application has changed.')
member_email = frappe.db.get_value("Community Member", self.member, "email") if self.status != "Pending":
self.send_status_change_email()
if self.status == 'Approved' or self.status == 'Rejected':
reviewed_by = frappe.db.get_value('Community Member', self.reviewed_by, 'email') def create_course_mentor_mapping(self):
send_email(member_email, [get_course_author(self.course), reviewed_by], subject, message) mapping = frappe.get_doc({
"doctype": "LMS Course Mentor Mapping",
elif self.status == 'Withdrawn': "mentor": self.member,
send_email([member_email, get_course_author(self.course)], None, subject, message) "course": self.course
})
mapping.save()
def send_creation_email(self, member):
email_template = self.get_email_template('mentor_request_creation')
if not email_template:
return
course_details = frappe.db.get_value("LMS Course", self.course, ["owner", "slug", "title"], as_dict=True)
message = frappe.render_template(email_template.response,
{
'member_name': member.full_name,
'course_url': '/courses/' + course_details.slug,
'course': course_details.title
})
email_args = {
"recipients": [frappe.session.user, course_details.owner],
"subject": email_template.subject,
"header": email_template.subject,
"message": message
}
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
def send_status_change_email(self):
email_template = self.get_email_template('mentor_request_status_update')
if not email_template:
return
course_details = frappe.db.get_value("LMS Course", self.course, ["owner", "title"], as_dict=True)
message = frappe.render_template(email_template.response,
{
'member_name': self.member_name,
'status': self.status,
'course': course_details.title
})
member_email = frappe.db.get_value("Community Member", self.member, "email")
if self.status == 'Approved' or self.status == 'Rejected':
reviewed_by = frappe.db.get_value('Community Member', self.reviewed_by, 'email')
email_args = {
"recipients": member_email,
"cc": [course_details.owner, reviewed_by],
"subject": email_template.subject,
"header": email_template.subject,
"message": message
}
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
elif self.status == 'Withdrawn':
email_args = {
"recipients": [member_email, course_details.owner],
"subject": email_template.subject,
"header": email_template.subject,
"message": message
}
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
def get_email_template(self, template_name):
template = frappe.db.get_single_value('LMS Settings', template_name)
if template:
return frappe.get_doc('Email Template', template)
@frappe.whitelist() @frappe.whitelist()
def has_requested(course): def has_requested(course):
return len(frappe.get_all('LMS Mentor Request', return frappe.db.count('LMS Mentor Request',
filters = { filters = {
'member': get_member().name, 'member': get_member().name,
'course': course, 'course': course,
'status': ['in', ('Pending', 'Approved')] 'status': ['in', ('Pending', 'Approved')]
} }
)
) )
@frappe.whitelist() @frappe.whitelist()
def create_request(course): def create_request(course):
if not has_requested(course): if not has_requested(course):
member = get_member() member = get_member()
frappe.get_doc({ request = frappe.get_doc({
'doctype': 'LMS Mentor Request', 'doctype': 'LMS Mentor Request',
'member': member.name, 'member': member.name,
'course': course, 'course': course,
'status': 'Pending' 'status': 'Pending'
}).save(ignore_permissions=True) })
send_creation_email(course, member) request.save(ignore_permissions=True)
return 'OK' request.send_creation_email(member)
else: return 'OK'
return 'Already Applied'
else:
return 'Already Applied'
@frappe.whitelist() @frappe.whitelist()
def cancel_request(course): def cancel_request(course):
request = frappe.get_doc('LMS Mentor Request', {'member': get_member().name, 'course': course, 'status': ['in', ('Pending', 'Approved')]}) request = frappe.get_doc('LMS Mentor Request', {'member': get_member().name, 'course': course, 'status': ['in', ('Pending', 'Approved')]})
request.status = 'Withdrawn' request.status = 'Withdrawn'
request.save(ignore_permissions=True) request.save(ignore_permissions=True)
return 'OK' return 'OK'
def get_member(): def get_member():
try: try:
return frappe.get_doc('Community Member', {'email': frappe.session.user}) return frappe.get_doc('Community Member', {'email': frappe.session.user})
except frappe.DoesNotExistError: except frappe.DoesNotExistError:
return return
def get_course_author(course):
return frappe.db.get_value('LMS Course', course, 'owner')
def send_creation_email(course, member):
template = frappe.db.get_single_value('LMS Settings', 'mentor_request_creation')
if not template:
return
email_template = frappe.get_doc('Email Template', template)
member_name = member.full_name
message = frappe.render_template(email_template.response, {'member_name': member_name})
subject = _('Request for Mentorship')
send_email([frappe.session.user, get_course_author(course)], None, subject, message)
def send_email(recipients, cc=None, subject=None, message=None, template=None, args=None):
frappe.sendmail(
recipients = recipients,
cc = cc,
sender = frappe.db.get_single_value('LMS Settings', 'email_sender'),
subject = subject,
send_priority = 0,
queue_separately = True,
message = message,
template=template,
args=args
)

View File

@@ -3,26 +3,89 @@
import websocket import websocket
import json import json
from .svg import SVG from .svg import SVG
import frappe
def livecode_to_svg(livecode_ws_url, code, *, timeout=1): # Files to pass to livecode server
# The same code is part of livecode-canvas.js
# TODO: generate livecode-canvas.js from this file
START = '''
import sketch
code = open("main.py").read()
env = dict(sketch.__dict__)
exec(code, env)
'''
SKETCH = '''
import json
def sendmsg(msgtype, function, args):
"""Sends a message to the frontend.
The frontend will receive the specified message whenever
this function is called. The frontend can decide to some
action on each of these messages.
"""
msg = dict(msgtype=msgtype, function=function, args=args)
print("--MSG--", json.dumps(msg))
def _draw(func, **kwargs):
sendmsg(msgtype="draw", function=func, args=kwargs)
def circle(x, y, d):
"""Draws a circle of diameter d with center (x, y).
"""
_draw("circle", x=x, y=y, d=d)
def line(x1, y1, x2, y2):
"""Draws a line from point (x1, y1) to point (x2, y2).
"""
_draw("line", x1=x1, y1=y1, x2=x2, y2=y2)
def rect(x, y, w, h):
"""Draws a rectangle on the canvas.
Parameters
----------
x: x coordinate of the top-left corner of the rectangle
y: y coordinate of the top-left corner of the rectangle
w: width of the rectangle
h: height of the rectangle
"""
_draw("rect", x=x, y=y, w=w, h=h)
def clear():
_draw("clear")
# clear the canvas on start
clear()
'''
def livecode_to_svg(livecode_ws_url, code, *, timeout=3):
"""Renders the code as svg. """Renders the code as svg.
""" """
print("livecode_to_svg") try:
ws = websocket.WebSocket() ws = websocket.WebSocket()
ws.settimeout(timeout) ws.settimeout(timeout)
ws.connect(livecode_ws_url) ws.connect(livecode_ws_url)
msg = { msg = {
"msgtype": "exec", "msgtype": "exec",
"runtime": "python-canvas", "runtime": "python",
"code": code "code": code,
} "files": [
ws.send(json.dumps(msg)) {"filename": "start.py", "contents": START},
{"filename": "sketch.py", "contents": SKETCH},
],
"command": ["python", "start.py"]
}
ws.send(json.dumps(msg))
messages = _read_messages(ws) messages = _read_messages(ws)
commands = [m['cmd'] for m in messages if m['msgtype'] == 'draw'] commands = [m for m in messages if m['msgtype'] == 'draw']
img = draw_image(commands) img = draw_image(commands)
return img.tostring() return img.tostring()
except websocket.WebSocketException as e:
frappe.log_error(frappe.get_traceback(), 'livecode_to_svg failed')
def _read_messages(ws): def _read_messages(ws):
messages = [] messages = []
@@ -32,17 +95,19 @@ def _read_messages(ws):
if not msg: if not msg:
break break
messages.append(json.loads(msg)) messages.append(json.loads(msg))
except websocket.WebSocketTimeoutException: except websocket.WebSocketTimeoutException as e:
print("Error:", e)
pass pass
return messages return messages
def draw_image(commands): def draw_image(commands):
img = SVG(width=300, height=300, viewBox="0 0 300 300", fill='none', stroke='black') img = SVG(width=300, height=300, viewBox="0 0 300 300", fill='none', stroke='black')
for c in commands: for c in commands:
args = c['args']
if c['function'] == 'circle': if c['function'] == 'circle':
img.circle(cx=c['x'], cy=c['y'], r=c['d']/2) img.circle(cx=args['x'], cy=args['y'], r=args['d']/2)
elif c['function'] == 'line': elif c['function'] == 'line':
img.line(x1=c['x1'], y1=c['y1'], x2=c['x2'], y2=c['y2']) img.line(x1=args['x1'], y1=args['y1'], x2=args['x2'], y2=args['y2'])
elif c['function'] == 'rect': elif c['function'] == 'rect':
img.rect(x=c['x'], y=c['y'], width=c['w'], height=c['h']) img.rect(x=args['x'], y=args['y'], width=args['w'], height=args['h'])
return img return img

View File

@@ -9,6 +9,11 @@ import frappe
from frappe.model.document import Document from frappe.model.document import Document
from . import livecode from . import livecode
DEFAULT_IMAGE = """
<svg viewBox="0 0 300 300" width="300" xmlns="http://www.w3.org/2000/svg">
</svg>
"""
class LMSSketch(Document): class LMSSketch(Document):
@property @property
def sketch_id(self): def sketch_id(self):
@@ -48,8 +53,9 @@ class LMSSketch(Document):
else: else:
ws_url = self.get_livecode_ws_url() ws_url = self.get_livecode_ws_url()
value = livecode.livecode_to_svg(ws_url, self.code) value = livecode.livecode_to_svg(ws_url, self.code)
cache.set(key, value) if value:
return value cache.set(key, value)
return value or DEFAULT_IMAGE
@staticmethod @staticmethod
def get_recent_sketches(limit=100, owner=None): def get_recent_sketches(limit=100, owner=None):
@@ -77,7 +83,7 @@ def save_sketch(name, title, code):
doc.title = title doc.title = title
doc.code = code doc.code = code
doc.runtime = 'python-canvas' doc.runtime = 'python-canvas'
doc.insert() doc.insert(ignore_permissions=True)
status = "created" status = "created"
else: else:
doc = frappe.get_doc("LMS Sketch", name) doc = frappe.get_doc("LMS Sketch", name)

View File

@@ -1,4 +1,9 @@
"""Utility to split the text in the topic into multiple sections. """Utility to split the text in the topic into multiple sections.
{{ section(type="example", id="foo") }}
circle(100, 100, 50)
{{ end }}
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass

View File

@@ -1,19 +1,14 @@
frappe.ready(function () { frappe.ready(function () {
frappe.web_form.after_save = () => { frappe.web_form.after_save = () => {
let data = frappe.web_form.get_values(); let data = frappe.web_form.get_values();
frappe.call({ let slug = new URLSearchParams(window.location.search).get("slug")
"method": "community.lms.doctype.lms_batch_membership.lms_batch_membership.create_membership", frappe.msgprint({
"args": { message: __("Batch {0} has been successfully created!", [data.title]),
"batch": data.title, clear: true
"member_type": "Mentor", });
"course": data.course setTimeout(function () {
}, window.location.href = `courses/${slug}`;
"callback": (data) => { }, 2000);
if (data.message) {
window.location.href = `courses/${data.message}`
}
}
})
} }
frappe.web_form.validate = () => { frappe.web_form.validate = () => {
@@ -21,8 +16,13 @@ frappe.ready(function () {
let time_format = sysdefaults && sysdefaults.time_format ? sysdefaults.time_format : 'HH:mm:ss'; let time_format = sysdefaults && sysdefaults.time_format ? sysdefaults.time_format : 'HH:mm:ss';
let data = frappe.web_form.get_values(); let data = frappe.web_form.get_values();
data.start_time = moment(data.start_time,time_format).format(time_format) data.start_time = moment(data.start_time, time_format).format(time_format)
data.end_time = moment(data.end_time,time_format).format(time_format) data.end_time = moment(data.end_time, time_format).format(time_format)
if (data.start_date < frappe.datetime.nowdate()) {
frappe.msgprint(__('Start date cannot be a past date.'))
return false;
}
if (!frappe.datetime.validate(data.start_time) || !frappe.datetime.validate(data.end_time)) { if (!frappe.datetime.validate(data.start_time) || !frappe.datetime.validate(data.end_time)) {
frappe.msgprint(__('Invalid Start or End Time.')); frappe.msgprint(__('Invalid Start or End Time.'));
@@ -34,10 +34,6 @@ frappe.ready(function () {
return false; return false;
} }
if (data.start_date < date.nowdate()) {
frappe.msgprint(__('Start date cannot be a past date.'))
return false;
}
return true; return true;
}; };
}) })

View File

@@ -1,6 +1,6 @@
<div class="chapter-teaser"> <div class="chapter-teaser">
<div class="teaser-body"> <div class="teaser-body">
<h3 class="chapter-title">{{ chapter.title }}</h3> <h3 class="chapter-title"><span class="chapter-number">{{index}}</span> {{ chapter.title }}</h3>
<div class="chapter-description"> <div class="chapter-description">
{{ chapter.description or "" }} {{ chapter.description or "" }}
</div> </div>

View File

@@ -1,13 +1,15 @@
<div class="course-teaser"> <div class="course-teaser">
<div class="course-body"> <div class="course-body">
<h3 class="course-title"><a href="/courses/{{ course.slug }}">{{ course.title }}</a></h3> <h3 class="course-title"><a href="/courses/{{ course.name }}">{{ course.title }}</a></h3>
<div class="course-intro"> <div class="course-intro">
{{ course.short_introduction or "" }} {{ course.short_introduction or "" }}
</div> </div>
</div> </div>
<div class="course-footer"> <div class="course-footer">
<div class="course-author"> <div class="course-author">
{{ course.get_instructor().full_name }} {% with author = course.get_instructor() %}
{{ widgets.Avatar(member=author, avatar_class="avatar-medium") }} <a href="/{{author.username}}">{{ author.full_name }}</a>
{% endwith %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,25 +1,64 @@
<form id="invite-request-form"> <form id="invite-request-form">
<input class="form-control field-width mr-5" id="invite_email" type="email" placeholder="Email Address"> <div class="row">
<a type="submit" id="submit-invite-request" class="btn btn-primary btn-lg" href="#" role="button">Request Invite</a> <div class="col-md">
<input class="form-control w-100 mr-5 mb-5 mt-2" id="invite_email" type="email" placeholder="Email Address">
</div>
<div class="col-md">
<a type="submit" id="submit-invite-request" class="btn btn-primary btn-lg" role="button">Request
Invite</a>
</div>
</div>
</form> </form>
<script> <script>
frappe.ready(() => { frappe.ready(() => {
$("#submit-invite-request").click(function () { $("#submit-invite-request").click(function () {
frappe.call({ var invite_email = $("#invite_email").val()
method: "community.lms.doctype.invite_request.invite_request.create_invite_request", frappe.call({
args: { method: "community.lms.doctype.invite_request.invite_request.create_invite_request",
invite_email: $("#invite_email").val() args: {
}, invite_email: invite_email
callback: (data) => { },
if (data.message == "OK") { callback: (data) => {
$("#invite-request-form").hide(); if (data.message == "invalid email") {
var message = `<div> $(".email-validation") && $(".email-validation").remove();
<p class="lead">Thanks for your interest in Mon School. We have recorded your interest and we will get back to you shortly.</p> if (invite_email) {
</div>`; var message = `<div>
$(".jumbotron").append(message); <small class="email-validation" style="color: red;">${invite_email} is not a valid email address.</small>
</div>`;
}
else {
var message = `<div>
<small class="email-validation" style="color: red;">Please enter an email address.</small>
</div>`;
}
$("#invite-request-form").append(message);
}
else {
$("#invite-request-form").hide();
if (data.message == "OK") {
var message = `<div>
<p class="lead alert alert-secondary">Thanks for your interest in Mon School. We have recorded your interest and we will get back to you shortly.</p>
</div>`;
}
else if (data.message == "invite") {
var message = `<div>
<p class="lead alert alert-secondary">Email ${invite_email} has already been used to request an invitation.</p>
</div>`;
}
else if (data.message == "user") {
var message = `<div>
<p class="lead alert alert-secondary">Looks like there is already an account with email ${invite_email}. Would you like to <a href="/login">login</a>?</p>
</div>`;
}
$(".jumbotron").append(message);
}
} }
} })
}) })
}) })
})
</script> </script>

View File

@@ -9,7 +9,8 @@
<a href="sketches/{{sketch.sketch_id}}">{{sketch.title}}</a> <a href="sketches/{{sketch.sketch_id}}">{{sketch.title}}</a>
</div> </div>
<div class="sketch-author"> <div class="sketch-author">
by {{sketch.get_owner().full_name}} {% set owner = sketch.get_owner() %}
by <a href="/{{owner.username}}">{{owner.full_name}}</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
community.patches.set_email_preferences community.patches.set_email_preferences
community.patches.change_name_for_community_members community.patches.change_name_for_community_members
community.patches.save_abbr_for_community_members community.patches.save_abbr_for_community_members
community.patches.create_mentor_request_email_templates

View File

@@ -0,0 +1,31 @@
from __future__ import unicode_literals
import frappe, os
from frappe import _
def execute():
frappe.reload_doc("email", "doctype", "email_template")
base_path = frappe.get_app_path("community", "templates", "emails")
if not frappe.db.exists("Email Template", _('Mentor Request Creation Template')):
response = frappe.read_file(os.path.join(base_path, "mentor_request_creation_email.html"))
frappe.get_doc({
'doctype': 'Email Template',
'name': _("Mentor Request Creation Template"),
'response': response,
'subject': _('Request for Mentorship'),
'owner': frappe.session.user
}).insert(ignore_permissions=True)
frappe.db.set_value("LMS Settings", None, "mentor_request_creation", _('Mentor Request Creation Template'))
if not frappe.db.exists("Email Template", _('Mentor Request Status Update Template')):
response = frappe.read_file(os.path.join(base_path, "mentor_request_status_update_email.html"))
frappe.get_doc({
'doctype': 'Email Template',
'name': _("Mentor Request Status Update Template"),
'response': response,
'subject': _('The status of your application has changed.'),
'owner': frappe.session.user
}).insert(ignore_permissions=True)
frappe.db.set_value("LMS Settings", None, "mentor_request_status_update", _('Mentor Request Status Update Template'))

View File

@@ -3,8 +3,12 @@
"public/css/lms.css" "public/css/lms.css"
], ],
"css/community.css": [ "css/community.css": [
"public/css/vars.css",
"public/css/style.css", "public/css/style.css",
"public/css/vars.css",
"public/css/style.less" "public/css/style.less"
],
"js/livecode-canvas.js": [
"public/js/livecode-canvas.js"
] ]
} }

View File

@@ -1,57 +1,4 @@
.livecode-editor-large .canvas-wrapper {
position: relative;
padding: 10px;
}
.livecode-editor-large canvas {
position: relative;
z-index: 0;
border: 1px solid #ddd;
height: 300px;
width: 300px;
}
.livecode-editor-large .code {
width: 100%;
padding: 5px;
min-height: 330px;
resize: none;
}
.livecode-editor-large .output {
padding: 5px;
}
.livecode-editor-large .CodeMirror {
height: 320px;
}
.canvas-editor canvas {
position: relative;
z-index: 0;
border: 1px solid #ddd;
}
.canvas-wrapper .output {
position: absolute;
z-index: 1;
width: 100%;
left: 0px;
top: 0px;
background-color: rgba(255, 255, 255, 0);
margin: 15px;
max-height: 200px;
}
.canvas-editor .code {
width: 100%;
padding: 5px;
/* min-height: 330px; */
resize: none;
}
/* .canvas-editor .output {
padding: 5px;
} */
.heading { .heading {
background: #eee; background: #eee;
padding: 10px; padding: 10px;
@@ -60,21 +7,6 @@
border: 1px solid #ddd; border: 1px solid #ddd;
} }
.livecode-editor-large h2 {
font-size: 1.2em;
text-transform: uppercase;
margin: 0px;
font-weight: normal;
}
.livecode-editor-large .run {
float: right;
}
.livecode-editor-large .col-sm {
border: 1px solid #ddd;
}
.sketch-header h1 { .sketch-header h1 {
font-size: 1.5em; font-size: 1.5em;
margin-bottom: 0px; margin-bottom: 0px;
@@ -89,48 +21,6 @@
color: inherit; color: inherit;
} }
.canvas-editor .CodeMirror {
background: #ffe;
border: 1px solid #eed;
margin-bottom: 10px;
border-radius: 10px;
height: 100%;
}
canvas {
border: 2px solid #eee;
}
@media (min-width: 768px) {
.canvas-wrapper {
margin-bottom: -200px;
}
}
@media (max-width: 768px) {
.canvas-wrapper {
padding-top: 20px;
margin-left: 2em;
}
.canvas-wrapper .output {
left: 2em;
}
}
.hidden { .hidden {
display: none; display: none;
} }
.livecode-controls {
margin-left: 2em;
}
.livecode-controls a {
margin-left: 10px;
color: #666;
}
.canvas-editor {
margin: 10px 0px;
}

View File

@@ -32,78 +32,10 @@
body { body {
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
background: white;
} }
.course-header {
margin-top: 20px;
padding: 20px;
background: var(--header-bg);
color: var(--header-color);
border-radius: 10px;
}
.course-header h1 { /* .course-details {
color: inherit;
}
.course-type {
text-transform: uppercase;
font-size: 1.0em;
color: var(--tag-color);
}
.sidebar {
background: var(--sidebar-bg);
margin: 20px 0px;
border-radius: 10px;
padding: 1px 20px 20px 20px;
color: var(--text-color);
}
.sidebar h3 {
margin-top: 20px;
color: var(--c2);
}
.sidebar-batch {
background: var(--sidebar-bg);
color: var(--text-color);
position: fixed;
left: 0;
height: 100%;
}
.sidebar-batch a {
padding: 16px 8px 8px 16px;
display: block;
}
.instructor {
padding: 10px;
}
.instructor-title {
font-weight: bold;
}
.instructor-subtitle {
font-size: 0.8em;
color: var(--text-color);
}
.sidebar .notice {
padding: 10px;
border-radius: 10px;
border: 1px dashed var(--text-color);
}
.sidebar .notice a {
color: inherit;
text-decoration: underline;
}
.course-details {
margin: 20px 0px; margin: 20px 0px;
} }
@@ -112,7 +44,7 @@ body {
font-size: 1.4em; font-size: 1.4em;
font-weight: bold; font-weight: bold;
margin: 20px 0px 10px 0px; margin: 20px 0px 10px 0px;
} } */
.chapter-plan { .chapter-plan {
border-radius: 10px; border-radius: 10px;
@@ -127,7 +59,7 @@ body {
font-weight: bold; font-weight: bold;
} }
.chapter-number { /* .chapter-number {
background: var(--text-color); background: var(--text-color);
color: white; color: white;
border-radius: 50%; border-radius: 50%;
@@ -140,12 +72,12 @@ body {
.chapter-description { .chapter-description {
margin: 20px 0px; margin: 20px 0px;
} } */
.lessons { .lessons {
padding-left: 20px; padding-left: 20px;
} }
.lesson { .lessons .lesson {
margin: 5px 0px; margin: 5px 0px;
font-weight: bold; font-weight: bold;
} }
@@ -235,7 +167,7 @@ img.profile-photo {
} }
.msger-inputarea { .msger-inputarea {
position: fixed; position: absolute;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
display: flex; display: flex;
@@ -291,3 +223,9 @@ img.profile-photo {
display: inline-block; display: inline-block;
width: 95%; width: 95%;
} }
.display-4 {
color: #2D005A;
font-weight: 600;
line-height: 51px;
}

View File

@@ -1,9 +1,19 @@
@primary-color: #08B74F; @primary-color: #08B74F;
@import url('https://rsms.me/inter/inter.css');
body {
font-family: "Inter", sans-serif;
}
h2 {
margin: 20px 0px;
color: black;
}
.teaser { .teaser {
background: white; background: white;
border-radius: 10px; border-radius: 9px;
border: 1px solid #ddd; border: 1px solid #C4C4C4;
.teaser-body { .teaser-body {
padding: 20px; padding: 20px;
@@ -16,6 +26,8 @@
.sketch-teaser { .sketch-teaser {
.teaser(); .teaser();
width: 220px; width: 220px;
margin-bottom: 30px;
margin-top: 30px;
svg { svg {
width: 200px; width: 200px;
@@ -25,8 +37,9 @@
padding: 10px; padding: 10px;
} }
.sketch-footer { .sketch-footer {
border-top: 1px solid#C4C4C4;
padding: 10px; padding: 10px;
background: #eee; background: #F6F6F6;
border-radius: 0px 0px 10px 10px; border-radius: 0px 0px 10px 10px;
} }
} }
@@ -34,6 +47,8 @@
.course-teaser { .course-teaser {
.teaser(); .teaser();
color: #444; color: #444;
margin-bottom: 20px;
margin-top: 20px;
h3, h4 { h3, h4 {
color: black; color: black;
@@ -64,10 +79,13 @@ section {
section h2 { section h2 {
margin-bottom: 40px; margin-bottom: 40px;
font-size: 48px;
line-height: 58px;
font-weight: bold;
} }
section.lightgray { section.lightgray {
background: #f8f8f8; background: #F6F6F6;
} }
#hero .jumbotron { #hero .jumbotron {
@@ -89,3 +107,202 @@ section.lightgray {
width: 40%; width: 40%;
display: inline-block; display: inline-block;
} }
.footer-grouped-links {
display: none;
}
.footer-info {
border-top: 0px;
margin-top: 0px;
.footer-col-right {
padding-top: 1.8rem;
}
}
.web-footer {
border-top: 1px solid #E2E6E9;
padding: 0px;
padding: 2rem 0px;
margin-top: 2rem;
}
.course-type {
text-transform: uppercase;
font-size: 1.0em;
font-weight: bold;
color: var(--tag-color);
}
.course-header {
margin-top: 20px;
}
/*
.course-header {
margin-top: 20px;
padding: 20px;
background: var(--header-bg);
color: var(--header-color);
border-radius: 9px;
}
.course-author-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 20px;
}
.course-header h1 {
color: inherit;
}
*/
// .gray-section {
// background:#F6F6F6;
// border: 1px solid #C4C4C4;
// padding: 20px;
// margin: 20px 0px;
// }
.instructor-title {
font-weight: bold;
color: black;
}
.instructor-subtitle {
font-size: 0.8em;
color: var(--text-color);
}
// .mentors-wrapper {
// .gray-section();
// }
.chapter-number {
background: var(--text-color);
color: white;
border-radius: 50%;
height: 24px;
min-width: 24px;
align-items: center;
padding: 5px 8px 2px 8px;
margin-right: 5px;
}
.sidebar {
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border);
margin: 20px 0px;
border-radius: 10px;
padding: 1px 20px 20px 20px;
}
.sidebar h3 {
margin-top: 20px;
color: black;
}
.sidebar-batch {
background: var(--sidebar-bg);
color: var(--text-color);
position: fixed;
left: 0;
height: 100%;
}
.sidebar-batch a {
padding: 16px 8px 8px 16px;
display: block;
}
.sidebar .notice {
margin-top: 10px;
padding: 10px;
border-radius: 10px;
border: 1px dashed var(--text-color);
}
.sidebar .notice a {
color: inherit;
text-decoration: underline;
}
// LiveCode editor
.livecode-editor {
.CodeMirror {
border: 1px solid #ddd;
background: #ffe;
height: auto;
}
.CodeMirror-scroll {
max-height: 310px;
min-height: 310px;
}
.controls {
padding: 10px 0px;
}
canvas {
border: 5px solid #ddd;
position: relative;
z-index: 0;
}
.output {
position: absolute;
z-index: 1;
width: 300px;
left: 0px;
top: 0px;
background-color: rgba(255, 255, 255, 0);
max-height: 300px;
white-space: pre-wrap;
margin: 0px;
margin-left: 20px;
padding: 4px;
color: #888;
}
@media (max-width: 768px) {
.canvas-wrapper {
padding-top: 10px;
}
.code-wrapper {
min-height: 50px;
}
.CodeMirror {
min-height: 50px;
}
}
}
.sketch-header {
input#sketch-title {
font-weight: bold;
}
}
.chapter-description {
margin-bottom: 10px;
}
.lesson-teaser {
font-weight: bold;
color: black;
padding-left: 20px;
}
#hero h1 {
color: black !important;
}
.lesson-page {
margin: 20px 0px;
}

View File

@@ -1,4 +1,7 @@
/* Define all your css variables here. */ /* Define all your css variables here. */
:root { :root {
--primary-color: #08B74F; --primary-color: #08B74F;
--tag-color: #737373;
--sidebar-bg: #F6F6F6;
--sidebar-border: #C4C4C4;
} }

View File

@@ -0,0 +1,106 @@
function getLiveCodeOptions() {
var START = `
import sketch
code = open("main.py").read()
env = dict(sketch.__dict__)
exec(code, env)
`
var SKETCH = `
import json
def sendmsg(msgtype, function, args):
"""Sends a message to the frontend.
The frontend will receive the specified message whenever
this function is called. The frontend can decide to some
action on each of these messages.
"""
msg = dict(msgtype=msgtype, function=function, args=args)
print("--MSG--", json.dumps(msg))
def _draw(func, **kwargs):
sendmsg(msgtype="draw", function=func, args=kwargs)
def circle(x, y, d):
"""Draws a circle of diameter d with center (x, y).
"""
_draw("circle", x=x, y=y, d=d)
def line(x1, y1, x2, y2):
"""Draws a line from point (x1, y1) to point (x2, y2).
"""
_draw("line", x1=x1, y1=y1, x2=x2, y2=y2)
def rect(x, y, w, h):
"""Draws a rectangle on the canvas.
Parameters
----------
x: x coordinate of the top-left corner of the rectangle
y: y coordinate of the top-left corner of the rectangle
w: width of the rectangle
h: height of the rectangle
"""
_draw("rect", x=x, y=y, w=w, h=h)
def clear():
_draw("clear")
# clear the canvas on start
clear()
`
const CANVAS_FUNCTIONS = {
circle: function(ctx, args) {
ctx.beginPath();
ctx.arc(args.x, args.y, args.d/2, 0, 2*Math.PI);
ctx.stroke();
},
line: function(ctx, args) {
ctx.beginPath();
ctx.moveTo(args.x1, args.y1);
ctx.lineTo(args.x2, args.y2);
ctx.stroke();
},
rect: function(ctx, args) {
ctx.beginPath();
ctx.rect(args.x, args.y, args.w, args.h);
ctx.stroke();
},
clear: function(ctx, args) {
var width = 300;
var height = 300;
ctx.clearRect(0, 0, width, height);
}
}
function drawOnCanvas(canvasElement, funcName, args) {
var ctx = canvasElement.getContext('2d');
var func = CANVAS_FUNCTIONS[funcName];
var scalex = canvasElement.width/300;
var scaley = canvasElement.height/300;
ctx.save();
ctx.scale(scalex, scaley);
func(ctx, args);
ctx.restore();
}
return {
runtime: "python",
files: [
{filename: "start.py", contents: START},
{filename: "sketch.py", contents: SKETCH},
],
command: ["python", "start.py"],
codemirror: true,
onMessage: {
draw: function(editor, msg) {
const canvasElement = editor.parent.querySelector("canvas");
drawOnCanvas(canvasElement, msg.function, msg.args);
}
}
}
}

22
community/query.py Normal file
View File

@@ -0,0 +1,22 @@
"""Utilities to find docs.
"""
import frappe
def find_all(doctype, order_by=None, **filters):
"""Queries the database for documents of a doctype matching given filters.
"""
rows = frappe.db.get_all(doctype,
filters=filters,
fields='*',
order_by=order_by)
return [frappe.get_doc(dict(row, doctype=doctype)) for row in rows]
def find(doctype, **filters):
"""Queries the database for a document of given doctype matching given filters.
"""
rows = frappe.db.get_all(doctype,
filters=filters,
fields='*')
if rows:
row = rows[0]
return frappe.get_doc(dict(row, doctype=doctype))

View File

@@ -0,0 +1,9 @@
<div>
<p>Dear {{ member_name }},</p>
<br>
<p>You've applied to become a mentor for this course. Your request is currently under review.</p>
<p>If you are not any more interested to mentor the course {{ course }}, you can <a href="{{ course_url }}">cancel your application</a>.</p>
<br>
<p>Thanks and Regards,</p>
<p>Team Community.</p>
</div>

View File

@@ -0,0 +1,8 @@
<div>
<p>Dear {{ member_name }},</p>
<br>
<p>Your request to join us as a mentor for the course {{ course }} has been {{ status }}.</p>
<br>
<p>Thanks and Regards,</p>
<p>Team Community.</p>
</div>

View File

@@ -14,7 +14,8 @@ from frappe.utils.jinja import get_jenv
# When {{widgets.SomeWidget()}} is called, it looks for # When {{widgets.SomeWidget()}} is called, it looks for
# widgets/SomeWidgets.html in each of these modules. # widgets/SomeWidgets.html in each of these modules.
MODULES = [ MODULES = [
"lms" "lms",
"community"
] ]
def update_website_context(context): def update_website_context(context):
@@ -35,8 +36,9 @@ class Widgets:
'<div>Hello, World!</div>' '<div>Hello, World!</div>'
""" """
def __getattr__(self, name): def __getattr__(self, name):
widget_globals = {"widgets": self}
if not name.startswith("__"): if not name.startswith("__"):
return Widget(name) return Widget(name, widget_globals)
else: else:
raise AttributeError(name) raise AttributeError(name)
@@ -50,11 +52,13 @@ class Widget:
>>> w(name="World!") >>> w(name="World!")
'<div>Hello, World!</div>' '<div>Hello, World!</div>'
""" """
def __init__(self, name): def __init__(self, name, widget_globals={}):
self.widget_globals = widget_globals
self.name = name self.name = name
def __call__(self, **kwargs): def __call__(self, **kwargs):
# the widget could be in any of the modules # the widget could be in any of the modules
paths = [f"{module}/widgets/{self.name}.html" for module in MODULES] paths = [f"{module}/widgets/{self.name}.html" for module in MODULES]
env = get_jenv() env = get_jenv()
kwargs.update(self.widget_globals)
return env.get_or_select_template(paths).render(kwargs) return env.get_or_select_template(paths).render(kwargs)

View File

@@ -9,19 +9,16 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ Sidebar(course_slug, batch_code) }} {{ Sidebar(course.name, batch.name) }}
<div class="container"> <div class="container">
{{ CourseBasicDetail(course)}} {{ CourseBasicDetail(course)}}
{{ InstructorsSection(instructor) }} {{ InstructorsSection(course.get_instructor()) }}
{% if batch.description %} {{ BatchDetails(batch)}}
{{ BatchDetails(batch.description) }}
{% endif %}
{{ MentorsSection(mentors, True, course.name) }}
</div> </div>
{% endblock %} {% endblock %}
{% macro CourseBasicDetail(course) %} {% macro CourseBasicDetail(course) %}
<h2>{{course.name}}</h2> <h2>{{course.title}}</h2>
<div class="course-description"> <div class="course-description">
{{course.short_introduction}} {{course.short_introduction}}
</div> </div>
@@ -36,11 +33,23 @@
<div>{{frappe.utils.md_to_html(course.description)}}</div> <div>{{frappe.utils.md_to_html(course.description)}}</div>
{% endmacro %} {% endmacro %}
{% macro BatchDetails(description) %} {% macro BatchDetails(batch) %}
<div class="mt-5"> <h2>About the Batch</h2>
<h3>About the Batch</h3>
<div> <div class="batch">
{{ frappe.utils.md_to_html(description) }} <div class="batch-details">
</div> <div>Session every {{batch.sessions_on}}</div>
<div>{{frappe.utils.format_time(batch.start_time, "short")}} -
{{frappe.utils.format_time(batch.end_time, "short")}}</div>
<div>Starting {{frappe.utils.format_date(batch.start_date, "medium")}}</div>
<div class="course-type" style="color: #888; padding: 10px 0px;">mentors</div>
{% for m in batch.get_mentors() %}
<div>
{{ widgets.Avatar(member=m, avatar_class="avatar-medium" ) }}
<span class="instructor-title">{{m.full_name}}</span>
</div>
{% endfor %}
</div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -1,21 +1,21 @@
import frappe import frappe
from community.www.courses.utils import redirect_if_not_a_member, get_course, get_instructor, get_batch from community.lms.models import Course
def get_context(context): def get_context(context):
context.no_cache = 1 context.no_cache = 1
context.course_slug = frappe.form_dict["course"]
context.course = get_course(context.course_slug)
context.batch_code = frappe.form_dict["batch"]
redirect_if_not_a_member(context.course_slug, context.batch_code)
context.instructor = get_instructor(context.course.owner) course_name = frappe.form_dict["course"]
context.batch = get_batch(context.batch_code) batch_name = frappe.form_dict["batch"]
context.mentors = get_mentors(context.batch.name)
def get_mentors(batch): course = Course.find(course_name)
mentors = [] if not course:
memberships = frappe.get_all("LMS Batch Membership", {"batch": batch, "member_type": "Mentor"}, ["member"]) context.template = "www/404.html"
for membership in memberships: return
member = frappe.get_doc("Community Member", membership.member)
mentors.append(member) batch = course.get_batch(batch_name)
return mentors if not batch:
frappe.local.flags.redirect_location = "/courses/" + course_name
raise frappe.Redirect
context.course = course
context.batch = batch

View File

@@ -11,22 +11,23 @@
<div class="course-header"> <div class="course-header">
<div class="course-type">course</div> <div class="course-type">course</div>
<h1 id="course-title" data-course="{{course.name}}">{{course.title}}</h1> <h1 id="course-title" data-course="{{course.name}}">{{course.title}}</h1>
<div class="course-short-intro">{{ course.short_introduction }}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-9 col-md-12"> <div class="col-lg-8 col-md-12">
<div class="course-details"> <div class="course-details">
{{ CourseVideo(course) }}
{{ CourseDescription(course) }} {{ CourseDescription(course) }}
{{ BatchSection(course) }} {{ BatchSection(course) }}
{{ CourseOutline(course) }} {{ CourseOutline(course) }}
</div> </div>
</div> </div>
<div class="col-lg-4 col-md-12">
<div class="col-lg-3 col-md-12">
<div class="sidebar"> <div class="sidebar">
{{ InstructorsSection(course.get_instructor()) }} {{ InstructorsSection(course.get_instructor()) }}
</div> </div>
<div class="sidebar"> <div class="sidebar">
{{ MentorsSection(course.get_mentors(), course.is_mentor(frappe.session.user), course.name) }} {{ MentorsSection(course.get_mentors(), course.is_mentor(frappe.session.user), course.name) }}
</div> </div>
@@ -35,14 +36,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% macro CourseVideo(course) %}
{% macro CourseDescription(course) %}
<h2>Course Description</h2>
<div class="course-description">
{{ course.short_introduction }}
</div>
{% if course.video_link %} {% if course.video_link %}
<div class="preview-video"> <div class="preview-video">
<iframe <iframe
@@ -57,6 +51,14 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro CourseDescription(course) %}
<h2>Course Description</h2>
<div class="course-description">
{{ frappe.utils.md_to_html(course.description) }}
</div>
{% endmacro %}
{% macro BatchSection(course) %} {% macro BatchSection(course) %}
{% if course.is_mentor(frappe.session.user) %} {% if course.is_mentor(frappe.session.user) %}
{{ BatchSectionForMentors(course, course.get_batches(mentor=frappe.session.user)) }} {{ BatchSectionForMentors(course, course.get_batches(mentor=frappe.session.user)) }}
@@ -76,9 +78,7 @@
{% for m in batch.get_mentors() %} {% for m in batch.get_mentors() %}
<div> <div>
{% if m.photo_url %} {{ widgets.Avatar(member=m, avatar_class="avatar-medium" ) }}
<img class="profile-photo" src="{{m.photo_url}}">
{% endif %}
<span class="instructor-title">{{m.full_name}}</span> <span class="instructor-title">{{m.full_name}}</span>
</div> </div>
{% endfor %} {% endfor %}
@@ -86,9 +86,10 @@
<div class="cta"> <div class="cta">
<div class=""> <div class="">
{% if can_manage %} {% if can_manage %}
<button >Manage</button> <a href="/courses/{{course.name}}/{{batch.name}}/about" class="btn btn-secondary">Manage</a>
{% else %} {% else %}
<button class="join-batch" data-batch="{{ batch.name | urlencode }}">Join this Batch</button> <button class="join-batch" data-batch="{{ batch.name | urlencode }}"
data-course="{{ course.name | urlencode }}">Join this Batch</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -99,7 +100,7 @@
<h2>Your Batches</h2> <h2>Your Batches</h2>
{% if mentor_batches %} {% if mentor_batches %}
<div class="message"> <div class="alert alert-secondary">
You are a mentor for this course. Manage your batches or create a new batch from here. You are a mentor for this course. Manage your batches or create a new batch from here.
</div> </div>
@@ -111,11 +112,11 @@
{% endfor %} {% endfor %}
</div> </div>
<a class="btn btn-primary add-batch margin-bottom" href="/add-a-new-batch?new=1&course={{course.title}}">Add a new batch</a> <a class="btn btn-primary add-batch margin-bottom" href="/add-a-new-batch?new=1&course={{course.title}}&slug={{course.name}}">Add a new batch</a>
{% else %} {% else %}
<div class="mentor_message"> <div class="mentor_message">
<p> You are a mentor for this course. </p> <p> You are a mentor for this course. </p>
<a class="btn btn-primary" href="/add-a-new-batch?new=1&course={{course.title}}" >Create your first batch</a> <a class="btn btn-primary" href="/add-a-new-batch?new=1&course={{course.title}}&slug={{course.name}}" >Create your first batch</a>
</div> </div>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
@@ -136,6 +137,6 @@
<h2>Course Outline</h2> <h2>Course Outline</h2>
{% for chapter in course.get_chapters() %} {% for chapter in course.get_chapters() %}
{{ widgets.ChapterTeaser(chapter=chapter)}} {{ widgets.ChapterTeaser(index=loop.index, chapter=chapter)}}
{% endfor %} {% endfor %}
{% endmacro %} {% endmacro %}

View File

@@ -6,7 +6,7 @@ frappe.ready(() => {
course: decodeURIComponent($("#course-title").attr("data-course")), course: decodeURIComponent($("#course-title").attr("data-course")),
}, },
'callback': (data) => { 'callback': (data) => {
if (data.message) { if (data.message > 0) {
$("#mentor-request").addClass("hide"); $("#mentor-request").addClass("hide");
$("#already-applied").removeClass("hide") $("#already-applied").removeClass("hide")
} }
@@ -17,7 +17,7 @@ frappe.ready(() => {
$("#apply-now").click((e) => { $("#apply-now").click((e) => {
e.preventDefault(); e.preventDefault();
if (frappe.session.user == "Guest") { if (frappe.session.user == "Guest") {
window.location.href = "/login"; window.location.href = `/login?redirect-to=/courses/${$(e.currentTarget).attr("data-course")}`;
return; return;
} }
frappe.call({ frappe.call({
@@ -53,7 +53,7 @@ frappe.ready(() => {
$(".join-batch").click((e) => { $(".join-batch").click((e) => {
e.preventDefault() e.preventDefault()
if (frappe.session.user == "Guest") { if (frappe.session.user == "Guest") {
window.location.href = "/login"; window.location.href = `/login?redirect-to=/courses/${$(e.currentTarget).attr("data-course")}`;
return; return;
} }
batch = decodeURIComponent($(e.currentTarget).attr("data-batch")) batch = decodeURIComponent($(e.currentTarget).attr("data-batch"))
@@ -70,4 +70,3 @@ frappe.ready(() => {
}) })
}) })
}) })

View File

@@ -7,15 +7,20 @@ def get_context(context):
context.no_cache = 1 context.no_cache = 1
try: try:
course_slug = frappe.form_dict["course"] course_name = frappe.form_dict["course"]
except KeyError: except KeyError:
frappe.local.flags.redirect_location = "/courses" frappe.local.flags.redirect_location = "/courses"
raise frappe.Redirect raise frappe.Redirect
course = Course.find(course_slug) course = Course.find(course_name)
if course is None: if course is None:
frappe.local.flags.redirect_location = "/courses" frappe.local.flags.redirect_location = "/courses"
raise frappe.Redirect raise frappe.Redirect
context.course = course context.course = course
batch = course.get_student_batch(frappe.session.user)
if batch:
frappe.local.flags.redirect_location = f"/courses/{course.name}/{batch.name}/learn"
raise frappe.Redirect

View File

@@ -14,10 +14,12 @@
<div class="batch-header"> <div class="batch-header">
{{ BatchHearder(course.name, member_count) }} {{ BatchHearder(course.name, member_count) }}
</div> </div>
<div class="message-section"> <div class="messages">
{{ Messages(messages) }} <div class="message-section">
{{ Messages(messages) }}
</div>
{{ TextArea() }}
</div> </div>
{{ TextArea() }}
</div> </div>
{% endblock %} {% endblock %}
@@ -44,4 +46,4 @@
<input type="text" class="msger-input" placeholder="Write your message..."> <input type="text" class="msger-input" placeholder="Write your message...">
<button type="submit" class="msger-send-btn" data-batch="{{batch.name | urlencode }}">Send</button> <button type="submit" class="msger-send-btn" data-batch="{{batch.name | urlencode }}">Send</button>
</form> </form>
{% endmacro %} {% endmacro %}

View File

@@ -26,11 +26,11 @@
{% macro course_card(course) %} {% macro course_card(course) %}
<div class="card mb-5 w-100"> <div class="card mb-5 w-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"><a href="/courses/{{course.slug}}">{{course.title}}</a></h5> <h5 class="card-title"><a href="/courses/{{course.name}}">{{course.title}}</a></h5>
{% if course.description %} {% if course.description %}
<p class="card-text">{{ frappe.utils.md_to_html(course.description[:250]) }}</p> <p class="card-text">{{ frappe.utils.md_to_html(course.description[:250]) }}</p>
{% endif %} {% endif %}
<a href="/courses/{{course.slug}}" class="card-link">See more &rarr;</a> <a href="/courses/{{course.name}}" class="card-link">See more &rarr;</a>
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -1,13 +1,122 @@
{% extends "templates/base.html" %} {% extends "templates/base.html" %}
{% from "www/macros/sidebar.html" import Sidebar %} {% from "www/macros/sidebar.html" import Sidebar %}
{% block title %}Learn{% endblock %} {% from "www/macros/livecode.html" import LiveCodeEditorJS, LiveCodeEditor with context %}
{% block title %}{{ lesson.title }}{% endblock %}
{% block head_include %} {% block head_include %}
<meta name="description" content="Courses" /> <meta name="description" content="{{lesson.title}} - {{course.title}}" />
<meta name="keywords" content="" /> <meta name="keywords" content="{{lesson.title}} - {{course.title}}" />
<style>
</style>
<link rel="stylesheet" href="{{ livecode_url }}/static/codemirror/lib/codemirror.css">
<link rel="stylesheet" href="/assets/css/lms.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>
<script src="{{ livecode_url }}/static/codemirror/addon/edit/matchbrackets.js"></script>
<script src="{{ livecode_url }}/static/codemirror/addon/comment/comment.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ Sidebar(course, batch_code) }} {{ Sidebar(course.name, batch.name) }}
<div class="container"> <div class="container">
<div class="lesson-page">
<div class="lesson-pagination">
{% if prev_url %}
<a href="{{prev_url}}" class="btn">&larr; Prev</a>
{% endif %}
{% if next_url %}
<a href="{{next_url}}" class="btn pull-right">Next &rarr;</a>
{% endif %}
</div>
<h2>{{ lesson.title }}</h2>
{% for s in lesson.get_sections() %}
<div class="section section-{{ s.type }}">
{{ render_section(s) }}
</div>
{% endfor %}
<div class="lesson-pagination">
<a href="#" class="btn">&larr; Prev</a>
<a href="#" class="btn pull-right">Next &rarr;</a>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% macro render_section(s) %}
{% if s.type == "text" %}
{{ render_section_text(s) }}
{% elif s.type == "example" or s.type == "code" or s.type == "exercise" %}
{{ LiveCodeEditor(s.name, s.get_latest_code_for_user(), s.type=="exercise", "2 hours ago") }}
{% 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 %}
{%- block script %}
{{ super() }}
{{ LiveCodeEditorJS() }}
<!-- <script type="text/javascript">
$(function() {
var editorLookup = {};
$(".canvas-editor").each((i, e) => {
var data = $(e).data();
var editor = new LiveCodeEditor(e, {
runtime: "python-canvas",
base_url: "{{ livecode_url }}",
codemirror: true,
userdata: data,
autosave: function(editor, code) {
// can't autosave when user is Guest
if (frappe.session.user == "Guest") {
return;
}
var data = editor.options.userdata;
var code = editor.codemirror.doc.getValue();
// console.log("autosaving...")
frappe.call("community.lms.api.autosave_section", {
section: data.section,
code: code
}).then((r) => {
// TODO: verify
})
}
})
editorLookup[data.section] = editor;
})
$(".canvas-editor .reset").each((i, e) => {
$(e).on("click", function(event) {
var data = $(this).parents(".canvas-editor").data();
var section = data.section;
frappe.call("community.lms.api.get_section", {
name: section
}).then(r => {
var editor = editorLookup[data.section];
editor.codemirror.doc.setValue(r.message.contents);
})
})
})
})
</script> -->
{%- endblock %}

View File

@@ -1,8 +1,46 @@
import frappe import frappe
from community.www.courses.utils import redirect_if_not_a_member from community.lms.models import Course
def get_context(context): def get_context(context):
context.no_cache = 1 context.no_cache = 1
context.course = frappe.form_dict["course"]
context.batch_code = frappe.form_dict["batch"] course_name = frappe.form_dict["course"]
redirect_if_not_a_member(context.course, context.batch_code) batch_name = frappe.form_dict["batch"]
chapter_index = frappe.form_dict.get("chapter")
lesson_index = frappe.form_dict.get("lesson")
lesson_number = f"{chapter_index}.{lesson_index}"
course = Course.find(course_name)
if not course:
context.template = "www/404.html"
return
batch = course.get_batch(batch_name)
if not batch:
frappe.local.flags.redirect_location = "/courses/" + course_name
raise frappe.Redirect
if not chapter_index or not lesson_index:
frappe.local.flags.redirect_location = f"/courses/{course_name}/{batch_name}/learn/1.1"
raise frappe.Redirect
context.course = course
context.batch = batch
context.lesson = course.get_lesson(chapter_index, lesson_index)
context.lesson_index = lesson_index
context.chapter_index = chapter_index
context.livecode_url = get_livecode_url()
outline = course.get_outline()
next_ = outline.get_next(lesson_number)
prev_ = outline.get_prev(lesson_number)
context.next_url = get_learn_url(course_name, batch_name, next_)
context.prev_url = get_learn_url(course_name, batch_name, prev_)
def get_learn_url(course_name, batch_name, lesson_number):
if not lesson_number:
return
return f"/courses/{course_name}/{batch_name}/learn/{lesson_number}"
def get_livecode_url():
return frappe.db.get_single_value("LMS Settings", "livecode_url")

View File

@@ -14,12 +14,14 @@ def get_member_with_name(name):
def get_batch(code): def get_batch(code):
try: try:
return frappe.db.get_value("LMS Batch", {"code": code}, ["name", "description"], as_dict=True) print("get_batch", code)
return frappe.db.get_value("LMS Batch", {"name": code}, ["name", "description"], as_dict=True)
except frappe.DoesNotExistError: except frappe.DoesNotExistError:
print("Error: notfound")
return return
def is_member_of_batch(batch_code): def is_member_of_batch(batch_code):
membership = frappe.get_all("LMS Batch Membership", {"batch": get_batch(batch_code).name, "member": get_member_with_email()}) membership = frappe.get_all("LMS Batch Membership", {"batch": batch_code, "member": get_member_with_email()})
if len(membership): if len(membership):
return True return True
return False return False
@@ -29,9 +31,9 @@ def redirect_if_not_a_member(course,batch_code):
frappe.local.flags.redirect_location = "/courses/" + course frappe.local.flags.redirect_location = "/courses/" + course
raise frappe.Redirect raise frappe.Redirect
def get_course(slug): def get_course(name):
try: try:
return frappe.get_doc("LMS Course", {"slug": slug}) return frappe.get_doc("LMS Course", {"name": name})
except frappe.DoesNotExistError: except frappe.DoesNotExistError:
return return
@@ -54,4 +56,4 @@ def get_batch_members(batch):
if membership.member_type == "Mentor": if membership.member_type == "Mentor":
member.is_mentor = True member.is_mentor = True
members.append(member) members.append(member)
return members return members

View File

@@ -182,8 +182,8 @@
{% for course in courses %} {% for course in courses %}
<div class="dashboard__course"> <div class="dashboard__course">
<div class="dashboard__courseHeader"> <div class="dashboard__courseHeader">
<a class="text-decoration-none" target="_blank" href="/courses/{{course.slug}}"> <a class="text-decoration-none" target="_blank" href="/courses/{{course.name}}">
<h5 class="w-75">{{ course.name }}</h5> <h5 class="w-75">{{ course.title }}</h5>
</a> </a>
{% if course.member_type %} {% if course.member_type %}
<div class="dashboard__badge"> <div class="dashboard__badge">

View File

@@ -10,7 +10,7 @@
<section id="hero"> <section id="hero">
<div class="container"> <div class="container">
<div class="jumbotron"> <div class="jumbotron">
<h1 class="display-4">Guided online courses, with a mentor at your back.</h1> <h1 class="display-4">Guided online courses, with a <br />mentor at your back.</h1>
<p class="lead">Hands-on online courses designed by experts, delivered by passionate mentors.</p> <p class="lead">Hands-on online courses designed by experts, delivered by passionate mentors.</p>
{{ widgets.RequestInvite() }} {{ widgets.RequestInvite() }}
</div> </div>

View File

@@ -1,31 +1,31 @@
{% macro InstructorsSection(instructor) %} {% macro InstructorsSection(instructor) %}
<h3>Instructor</h3> <h3>Instructor</h3>
<div class="instructor"> <div class="instructor">
<div class="instructor-title">{{instructor.full_name}}</div> <div class="instructor-title">{{instructor.full_name}}</div>
<div class="instructor-subtitle">Created {{instructor.get_course_count()}} courses</div> <div class="instructor-subtitle">Created {{instructor.get_course_count()}} courses</div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro MentorsSection(mentors, is_mentor, course_name) %} {% macro MentorsSection(mentors, is_mentor, course_name) %}
<h3>Mentors</h3> <h3>Mentors</h3>
{% for m in mentors %} {% for m in mentors %}
<div class="instructor"> <div class="instructor">
<div class="instructor-title">{{m.full_name}}</div> <div class="instructor-title">{{m.full_name}}</div>
<div class="instructor-subtitle">Mentored {{m.get_batch_count()}} batches</div> <div class="instructor-subtitle">Mentored {{m.get_batch_count()}} batches</div>
</div> </div>
{% endfor %} {% endfor %}
{% if not is_mentor %} {% if not is_mentor %}
<div id="mentor-request" class="notice"> <div id="mentor-request" class="notice">
Interested to become a mentor? Interested to become a mentor?
<div><a id="apply-now" data-course="{{course_name | urlencode}}" href="">Apply Now!</a></div> <div><a id="apply-now" data-course="{{course_name | urlencode}}" href="">Apply Now!</a></div>
</div> </div>
<div id="already-applied" class="notice hide"> <div id="already-applied" class="notice hide">
You've applied to become a mentor for this course. Your request is currently under review. You've applied to become a mentor for this course. Your request is currently under review.
If you are not any more interested to mentor this course, you can <a id="cancel-request" data-course="{{course_name | urlencode}}" href="">cancel your application</a>. If you are not any more interested to mentor this course, you can <a id="cancel-request" data-course="{{course_name | urlencode}}" href="">cancel your application</a>.
</div> </div>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View File

@@ -1,63 +1,75 @@
{% macro LiveCodeEditorLarge(name, code) %} {% macro LiveCodeEditorLarge(name, code) %}
<div class="livecode-editor livecode-editor-large row no-gutters" id="editor-{{name}}"> <div class="livecode-editor livecode-editor-large" id="editor-{{name}}">
<div class="col-sm"> <div class="row">
<div class="heading"> <div class="col-lg-8 col-md-6">
<button class="run">Run</button> <div class="controls">
<h2>Editor</h2> <button class="run">Run</button>
</div>
</div> </div>
<textarea class="code">{{code}}</textarea>
</div> </div>
<div class="col-sm"> <div class="code-editor">
<div class="heading"> <div class="row">
<h2>Output</h2> <div class="col-lg-8 col-md-6">
</div> <div class="code-wrapper">
<div class="canvas-wrapper"> <textarea class="code">{{code}}</textarea>
<canvas class="canvas" width="300" height="300"></canvas> </div>
<pre class="output"></pre> </div>
<div class="col-lg-4 col-md-6 canvas-wrapper">
<canvas width="300" height="300"></canvas>
<pre class="output"></pre>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro LiveCodeEditor(name, code) %} {% macro LiveCodeEditor(name, code, is_exercise, last_submitted) %}
<div class="livecode-editor canvas-editor" id="editor-{{name}}" <div class="livecode-editor livecode-editor-inline" id="editor-{{name}}">
data-section="{{name}}">
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-lg-8 col-md-6">
<div> <div class="controls">
<textarea class="code">{{code}}</textarea> <button class="run">Run</button>
<div class="livecode-controls"> <button class="reset">Reset</button>
<button type="button" class="run">Run</button> {% if is_exercise %}
<a href="javascript:;" class="reset">Reset</a> <button class="submit pull-right btn-primary">Submit</button>
<a href="javascript:;" class="clear">Clear</a> {% if last_submitted %}
<span class="pull-right" style="padding-right: 10px;">Submitted <span class="human-time" data-timestamp="{{last_submitted}}">on {{last_submitted}}</span></span>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="code-editor">
<div class="canvas-wrapper"> <div class="row">
<canvas class="canvas" width="150" height="150"></canvas> <div class="col-lg-8 col-md-6">
<pre class="output"></pre> <div class="code-wrapper">
<textarea class="code">{{code}}</textarea>
</div>
</div>
<div class="col-lg-4 col-md-6 canvas-wrapper">
<canvas width="300" height="300"></canvas>
<pre class="output"></pre>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro LiveCodeEditorJS(name, code) %} {% macro LiveCodeEditorJS(name, code) %}
<script type="text/javascript" src="{{ livecode_url }}/static/livecode.js"></script> <script type="text/javascript" src="{{ livecode_url }}/static/livecode.js"></script>
<script type="text/javascript" src="/assets/community/js/livecode-canvas.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var livecodeEditors = []; var livecodeEditors = [];
$(function() { $(function() {
$(".livecode-editor").each((i, e) => { $(".livecode-editor").each((i, e) => {
var editor = new LiveCodeEditor(e, { var editor = new LiveCodeEditor(e, {
runtime: "python-canvas",
base_url: "{{ livecode_url }}", base_url: "{{ livecode_url }}",
codemirror: true ...getLiveCodeOptions()
}) })
livecodeEditors.push(editor); livecodeEditors.push(editor);
}) })

View File

@@ -55,7 +55,6 @@
.dashboard__details { .dashboard__details {
padding-top: 2rem; padding-top: 2rem;
width: 80%;
} }
.dashboard__course { .dashboard__course {
@@ -83,12 +82,18 @@
.dashboard__description { .dashboard__description {
height: 100px; height: 100px;
} }
@media (max-width: 900px) {
.dashboard__parent {
flex-direction: column;
}
}
</style> </style>
{% endblock %} {% endblock %}
{% block page_content %} {% block page_content %}
<section>
<div class="dashboard__parent"> <div class="dashboard__parent">
<div> <div class="dashboard__photo">
{{ Profile(member.photo, member.full_name, member.abbr, "large")}} {{ Profile(member.photo, member.full_name, member.abbr, "large")}}
</div> </div>
<div class="dashboard__details"> <div class="dashboard__details">
@@ -106,40 +111,23 @@
<div> <div>
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane fade py-4 show active" role="tabpanel" id="home"> <div class="tab-pane fade py-4 show active" role="tabpanel" id="home">
<div class='container'> <div class="row">
<div class="row row-cols-1 row-cols-xl-5 row-cols-lg-4 row-cols-md-3 row-cols-sm-2"> {% if sketches %}
{% if sketches %} {% for sketch in sketches %}
{% for sketch in sketches %} <div class="col-md-4 col-sm-6">
<div class="col m-4"> {{ widgets.SketchTeaser(sketch=sketch) }}
<div class="card sketch-card" style="width: 200px;">
<div class="card-img-top">
<a href="/sketches/{{sketch.sketch_id}}">
{{ sketch.to_svg() }}
</a>
</div>
<div class="card-footer">
<div class="sketch-title">
<a href="sketches/{{sketch.sketch_id}}">{{sketch.title}}</a>
</div>
<div class="sketch-author">
by {{sketch.get_owner_name()}}
</div>
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div> </div>
{% if not sketches %} {% endfor %}
<p class="text-center">{{member.full_name}} has not created any skecth yet.</p> {% endif %}
{% endif %}
</div> </div>
{% if not sketches %}
<p class="text-center">{{member.full_name}} has not created any skecth yet.</p>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section>
{% endblock %} {% endblock %}
<!-- this is a sample default web page template --> <!-- this is a sample default web page template -->

View File

@@ -13,7 +13,7 @@
<div class='container pb-5'> <div class='container pb-5'>
<h1>Recent Sketches</h1> <h1>Recent Sketches</h1>
<a href="/sketches/new">Create a New Sketch</a> <a href="/sketches/new" class="btn btn-primary">Create a New Sketch</a>
</div> </div>
<div class='container'> <div class='container'>
<div class="row"> <div class="row">

View File

@@ -30,14 +30,18 @@
<div class="sketch-header"> <div class="sketch-header">
{% if editable %} {% if editable %}
<input type="button" class="pull-right" id="sketch-save" value="Save"/> <div class="form-row">
<h1 class="sketch-title"> <div class="col-lg-8 col-md-6">
<input type="text" name="title" id="sketch-title" value="{{ sketch.title }}" /> <input type="text" id="sketch-title" name="title" class="form-control" value="{{ sketch.title }}">
</h1> </div>
<div class="col-lg-4 col-md-6">
<button type="submit" id="sketch-save" class="btn-save btn btn-primary">Save</button>
</div>
</div>
{% else %} {% else %}
<h1 class="sketch-title">{{sketch.title}}</h1> <h1 class="sketch-title">{{sketch.title}}</h1>
<div class="sketch-owner-wrapper">By <span class="sketch-owner">{{sketch.get_owner_name()}}</span></div>
{% endif %} {% endif %}
<div class="sketch-owner-wrapper">By <span class="sketch-owner">{{sketch.get_owner_name()}}</span></div>
</div> </div>
{% if sketch.is_new() and not editable %} {% if sketch.is_new() and not editable %}
@@ -49,7 +53,6 @@
<div class="sketch-editor"> <div class="sketch-editor">
{{LiveCodeEditorLarge(sketch.name, sketch.code) }} {{LiveCodeEditorLarge(sketch.name, sketch.code) }}
</div> </div>
</section>
{% endblock %} {% endblock %}
{%- block script %} {%- block script %}