Compare commits

...

167 Commits

Author SHA1 Message Date
pateljannat
aa20136223 fix: undo status change on livecode 2021-06-28 13:05:20 +05:30
pateljannat
5a7afb3092 fix: added livecode editor in community 2021-06-24 16:38:02 +05:30
Jannat Patel
56d8a72a7d Merge pull request #136 from fossunited/quiz
feat: Quizzes, Youtube Video integration and Other Minor Fixes
2021-06-24 10:34:36 +05:30
pateljannat
f6c11ce52f fix: conflicts 2021-06-24 10:27:01 +05:30
pateljannat
0284c9305c fix: quiz progress and youtube video integration 2021-06-24 10:25:23 +05:30
Jannat Patel
d785fb7562 Merge pull request #127 from fossunited/livecode-cleanup
refactor: removed the portal pages for showing sketches
2021-06-23 13:09:26 +05:30
Anand Chitipothu
9f50af4ebd refactor: removed the portal pages for showing sketches
Moved them to mon_school.
2021-06-23 12:53:35 +05:30
Jannat Patel
4c3645f0d4 Merge pull request #133 from fossunited/mon-fixes-01
Various fixes from mon.school
2021-06-23 11:44:35 +05:30
Anand Chitipothu
20b3ae7d76 fix: error in linking lessons on course page
The course was adding `{{ no such element: community.lms.doctype.lms_course.lms_course.LMSCourse object['query_parameter'] }}`
to the lesson links. Fixed it by setting query_parameter to "".
2021-06-23 10:27:01 +05:30
Anand Chitipothu
f303be4db5 fix: error in find_macros when the input is empty
Added a special case to handle this issue.
2021-06-22 18:12:31 +05:30
Anand Chitipothu
fc1c393f15 feat: allow a student to be mentor of another batch
This is a requirement for mon.school. The students are of the first
batch are now mentors of new batches.
2021-06-22 18:09:21 +05:30
pateljannat
5d96bf544d fix: conflicts 2021-06-22 12:28:12 +05:30
Jannat Patel
5abfa35095 Merge pull request #132 from fossunited/learning-modes
feat: learning modes and batch switching
2021-06-22 12:23:46 +05:30
pateljannat
6c751cdf39 fix: test 2021-06-22 12:17:06 +05:30
pateljannat
2c570ea214 fix: added default value for arguements 2021-06-22 10:48:33 +05:30
pateljannat
ecfcc8a2f7 fix: redirects and urls 2021-06-22 10:45:07 +05:30
pateljannat
3384f974e5 fix: batch switch with query parameters 2021-06-22 10:11:21 +05:30
pateljannat
eb435261fe feat: learning modes 2021-06-18 18:31:10 +05:30
Jannat Patel
dc7eabefb9 Merge pull request #131 from fossunited/minor-fixes
fix: web form, progress ui, title non unique
2021-06-16 13:15:10 +05:30
pateljannat
fed4b5568b fix: web form, progress ui, title non unique 2021-06-16 13:04:45 +05:30
Jannat Patel
aa77c60abd Merge pull request #129 from fossunited/minor-fix
fix: minor issues
2021-06-15 18:46:33 +05:30
pateljannat
9c1506d3c8 fix: minor issues 2021-06-15 18:40:14 +05:30
Jannat Patel
e94c3f27ab Merge pull request #128 from fossunited/ui-fixes
fix: UI fixes
2021-06-15 13:19:03 +05:30
pateljannat
5fa8bdd40c fix: invite request test, removed print statements and unused classes' 2021-06-15 13:09:48 +05:30
pateljannat
17f03aeee7 fix: join batch, removed code revision, redirects for other pages if batch missing 2021-06-15 13:01:57 +05:30
pateljannat
7840512a13 fix: ui, preview, progress, batches 2021-06-14 18:45:46 +05:30
Anand Chitipothu
526ded784b Merge pull request #125 from fossunited/hotfix-exercise-image
fix: fixed error on saving exercises
2021-06-12 21:42:27 +05:30
Anand Chitipothu
6b5ddcd54a fix: fixed error on saving exercises
Removed the image generation when exercise is saved. The library used
for exercises has changed and generating the image doesn't work any
more.
2021-06-12 10:49:27 +05:30
Jannat Patel
c42247db42 Merge pull request #122 from fderyckel/patch-1
frappe wasn't imported
2021-06-10 20:47:05 +05:30
François de Ryckel
8f8d4901ff frappe wasn't imported
error with NameError: name 'frappe' is not defined
2021-06-10 18:04:40 +03:00
pateljannat
f5f3c808d4 Merge branch 'main' of https://github.com/frappe/community into ui-fixes 2021-06-10 13:41:31 +05:30
pateljannat
1e3152e303 fix: ui 2021-06-10 13:41:11 +05:30
Jannat Patel
344661cf83 Merge pull request #121 from fossunited/lesson-markup
Lesson markup
2021-06-10 12:32:16 +05:30
Anand Chitipothu
d9185c0b6b feat: integrated lesson markup
- added PageExtension plugin to inject custom styles scripts in a page
- removed the livecode integration and enabled PageExtension plugins for
  learn page
- also merged the profile_tab.py with plugins.py
- added a utility to find the macros from given text
- updated the before_save of lesson to find exercises using the macros
  and update the exercises as before

Issue #115
2021-06-09 23:58:21 +05:30
Anand Chitipothu
5363fb7eb3 feat: extend markdown to support macros
With this feature, the exercises can be added to the lesson as:

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

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

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

Issue #115
2021-06-09 23:22:00 +05:30
pateljannat
1cb81de5c0 feat: lms quizzes 2021-06-09 13:17:42 +05:30
Jannat Patel
d90a1247f1 Merge pull request #120 from fossunited/profile-tabs
feat: pluggable profile tabs
2021-06-08 14:53:38 +05:30
Anand Chitipothu
ef0c3e4a24 feat: pluggable profile tabs
Added ProfileTab class to represent a profile tab and made the profile
page render the tabs specified in the hook `profile_tabs`. This allows
plugging in new tabs in the profile page without makeing any changes to
the community module.
2021-06-08 10:36:12 +05:30
Anand Chitipothu
3619b136f8 Merge pull request #117 from fossunited/lesson-progress
feat: lesson progress
2021-06-07 11:24:52 +05:30
pateljannat
671b4a0650 fix: api and orm 2021-06-02 20:19:36 +05:30
Anand Chitipothu
586b39c0fd fix: issue with numbering the exercises
The exercises being listed in unpredicted order instead of the order
they were listed in the lesson. The was because the `index_` of the
exercise was never updated. Fixed this by updating the `index_` whenever
a lesson edited. However, the user still need to run reindex exercises
on the course correct the ordering, which wasn't possible earlier.
2021-06-02 17:48:02 +05:30
pateljannat
4fd7af053b fix: tests 2021-06-02 16:47:17 +05:30
pateljannat
5fd1143f76 feat: lesson progress 2021-06-02 13:52:50 +05:30
Jannat Patel
0dc4743556 Merge pull request #116 from fossunited/reindex-exercises
feat: actions to reindex lessons and exercises
2021-06-01 11:40:52 +05:30
Anand Chitipothu
c96a14c972 feat: ignore orphan exercises in the progress
Don't show exercises that are not added to any lesson in the progress.
2021-06-01 08:15:52 +05:30
Anand Chitipothu
400e706be1 feat: update the index of orphan exercises
When an exercise is removed from a lesson, the link to the lesson is
removed from that exercise and the index is reset. This will make sure
the removed exercises won't show up in places like progress.
2021-06-01 05:59:01 +05:30
Anand Chitipothu
a12a52747e feat: show exercise index in the title
Show exercise as "Exercise 2.1: Draw a Circle".
2021-06-01 05:49:45 +05:30
Anand Chitipothu
b9a93bb160 feat: added actions to reindex lessons and exercises
Some lessons gets deleted and some new ones get added in the progress of
course creation and it may happen then some of the lesson index may
become inconsistent.  Also, we would like to maintain an index for the
exercises. To support both of these, added actions to reindex lessons
and exercises to the course doctype.
2021-06-01 05:46:32 +05:30
Jannat Patel
9c65ff8ae6 Merge pull request #113 from fossunited/invite-based-membership
feat: Invite based membership
2021-05-31 13:41:12 +05:30
pateljannat
bb0aa09b4e fix: messages and url 2021-05-31 13:39:31 +05:30
pateljannat
a8752afb3b feat: invite based membership become a member page 2021-05-28 13:53:34 +05:30
pateljannat
327bde870b Merge branch 'main' of https://github.com/frappe/community into invite-based-membership 2021-05-27 17:32:48 +05:30
Jannat Patel
640ead4922 Merge pull request #109 from fossunited/style-fixes
fix: Style fixes
2021-05-27 11:54:08 +05:30
pateljannat
687f7f7f7b fix: minor home page issues 2021-05-27 11:25:05 +05:30
Anand Chitipothu
527a563e4a chore: added "programming" to the hero title 2021-05-27 09:39:07 +05:30
pateljannat
5bc9a7fe37 Merge branch 'style-fixes' of https://github.com/frappe/community into invite-based-membership 2021-05-26 19:10:38 +05:30
pateljannat
24835acd9c fix: jinja 2021-05-26 19:10:08 +05:30
pateljannat
3648b3ab47 Merge branch 'style-fixes' of https://github.com/frappe/community into invite-based-membership 2021-05-26 19:08:38 +05:30
pateljannat
914f8504a0 fix: added class in lms_message 2021-05-26 18:56:57 +05:30
pateljannat
ab8546a121 fix: course outline, discussion, lms batch 2021-05-26 17:16:00 +05:30
pateljannat
f327c6fb10 fix: tests for course description 2021-05-26 12:38:50 +05:30
pateljannat
c7ccefa632 fix: discussion, batch home page, new fields for batches 2021-05-26 12:13:04 +05:30
Anand Chitipothu
823cf4e431 style: fixed word-wrap of output 2021-05-25 16:06:12 +05:30
pateljannat
18f074d8ac fix: ignore user permission for membership 2021-05-24 19:35:26 +05:30
pateljannat
c9185ae68c fix: tabs and learn page 2021-05-24 19:24:07 +05:30
Anand Chitipothu
82fa0fa4d7 fix: error in loading the progress page 2021-05-24 13:46:59 +05:30
Jannat Patel
64752433d2 Merge pull request #106 from fossunited/issue-103
Redirect the learn page to the current lesson of the user
2021-05-24 13:40:50 +05:30
Anand Chitipothu
50856fdfa5 fix: fixed failing test 2021-05-24 13:30:47 +05:30
Anand Chitipothu
cac4f2afef feat: redirect the learn page to the current lesson of the user
The current lesson is maintained in the LMS Batch Membership and that is
updated everytime a lesson page is visited.
2021-05-24 13:07:29 +05:30
Anand Chitipothu
df431165e8 feat: redirect non-members visiting any batch page to the course page 2021-05-24 12:57:01 +05:30
Anand Chitipothu
69125e571f feat: added member_username and current_lesson fields to LMS Batch Membership
And removed member_email field which is a duplicate of member.
2021-05-24 12:43:20 +05:30
Anand Chitipothu
68f7215b95 fix: error in updating LMS Batch membership
The validation was always failing when trying to updating an LMS Batch
Membership document. This was due to a bug in the validation logic that
was considering itself as a duplicate record. This has been fixed.

Also added tests to verify that.
2021-05-24 12:15:16 +05:30
pateljannat
ca42c32f54 Merge branch 'main' of https://github.com/frappe/community into style-fixes 2021-05-24 11:57:53 +05:30
Anand Chitipothu
20adc8079e Merge pull request #105 from fossunited/community-member-to-user-refactor
refactor: Community Member to User refactor
2021-05-24 11:44:45 +05:30
Anand Chitipothu
d55941d4bb Merge branch 'main' into community-member-to-user-refactor 2021-05-24 11:39:20 +05:30
Jannat Patel
6074ee3688 Merge pull request #104 from fossunited/resume-course
Added "Resume Course" button to the course teaser
2021-05-24 11:36:06 +05:30
pateljannat
b3f87ba5b6 fix: removed community course member and references to profile macro 2021-05-24 10:39:04 +05:30
pateljannat
631275e9a8 refactor: course and sidebar cleanup 2021-05-24 10:28:02 +05:30
Anand Chitipothu
8d7963fc60 feat: added "Resume Course" button to course teaser
Closes #102
2021-05-24 09:55:35 +05:30
Anand Chitipothu
38938ac14b feat: added ability to find the batch of a student
Added the course field and member_email fields to LMS Batch Membership
to allow the possibility of querying if a user is a student of a course.

Closes #101
2021-05-24 09:55:13 +05:30
Anand Chitipothu
14f9d4875a chore: added issue template for Feature Request 2021-05-24 08:36:09 +05:30
pateljannat
419a7e666f refactor: tests, mentor request, messages 2021-05-22 20:49:47 +05:30
pateljannat
713dcf178a refactor: patches to fix data, profile dashboard, lms mentor mapping page fixes 2021-05-21 21:40:31 +05:30
pateljannat
637c795321 refactor: moved community member class functions to user override 2021-05-21 16:22:59 +05:30
pateljannat
63d00a46c4 Merge branch 'main' of https://github.com/frappe/community into community-member-to-user-refactor 2021-05-21 13:27:22 +05:30
pateljannat
e991dc5c73 refactor: removed community member doctype 2021-05-21 13:27:15 +05:30
Anand Chitipothu
4a2ecff15d Merge pull request #98 from fossunited/remove-primary-color-from-app
fix: removed primary color from app
2021-05-21 13:26:38 +05:30
Jannat Patel
f8d6b5b949 Merge pull request #99 from fossunited/mentor-dashboard
Refactored batch pages and added batch progress page
2021-05-21 13:25:56 +05:30
Anand Chitipothu
c77835b81f feat: added page to see progress of a batch 2021-05-21 13:13:34 +05:30
Anand Chitipothu
e04bbb633d refactor: moved courses/*/index pages to batch/* 2021-05-21 13:12:52 +05:30
Anand Chitipothu
a2b856aaf8 feat: added image to exercise submission 2021-05-21 13:10:54 +05:30
pateljannat
7a650b46ac fix: removed primary color from app 2021-05-21 11:06:37 +05:30
Jannat Patel
b61ca1d7a2 Merge pull request #97 from fossunited/gitignore-build-file
fix: gitignore dist folder
2021-05-21 10:31:40 +05:30
pateljannat
573019bbcc fix: added build dist folder to gitignore 2021-05-21 10:23:44 +05:30
Anand Chitipothu
632693c9f8 fix: show submitted solution in the exercise 2021-05-20 21:03:24 +05:30
Anand Chitipothu
463aec01f8 fix: permission issue when a student submits an exercise 2021-05-20 21:02:54 +05:30
Anand Chitipothu
e7d116f31c chore: removed obsolete doctype LMS Topic
It has been replaced by Chapter and Lesson.
Moved the section_parser from lms_topic directory to the lms.
2021-05-20 16:52:51 +05:30
Anand Chitipothu
f1b3ee19b6 Merge pull request #93 from fossunited/exercises
Added Exercise and Exercise Submission doctypes
2021-05-20 16:43:29 +05:30
Anand Chitipothu
9cb9fad05c fix: fixed failing tests 2021-05-20 16:24:41 +05:30
Anand Chitipothu
0859afdf34 style: fixed the styles of the sidebar 2021-05-20 15:08:12 +05:30
Anand Chitipothu
6407b24324 feat: added course, batch and lesson to exercise submission
Useful to find all the submissions for a batch/lesson.
2021-05-20 15:07:14 +05:30
Anand Chitipothu
34e993cf86 refactor: added lesson to exercise
usualy to know which lesson an exercise is part of by looking at the
exercise.
2021-05-20 13:27:30 +05:30
Anand Chitipothu
8c889ffb92 chore: switched to new build system for css.
- added community.bundle.less file to create css bundle
- removed the obsolete build.json in favor of new build system
- updated the paths in the books
- removed the imported urls from css files (the new build system doesn't support that)
- removed obsolete lms.css
2021-05-20 13:15:08 +05:30
Anand Chitipothu
a67ad67be1 feat: show image for the exercise
generate the image from the answer and display it along with
description. The image is geneated when the exercise is saved.
2021-05-20 12:09:12 +05:30
Anand Chitipothu
646a7b723f fix: fix broken pagination links 2021-05-19 20:12:35 +05:30
Anand Chitipothu
6f7011ca58 feat: integrated exercises into lessons
- an exercise can now be added to a lesson
- it is rendered using livecode editor with submit button
- remembers the submitted code and shows the submission time

Issue #90
2021-05-19 20:06:20 +05:30
Anand Chitipothu
d61acb552a feat: added Exercise and Exercise Submission doctypes
Also:
- added methods to submit an exercise and get the submission for a user
- added test cases

Issue #90
2021-05-19 20:01:17 +05:30
Jannat Patel
265c78e76e Merge pull request #88 from fossunited/minor-fixes
fix: removed slug field ref from courses page
2021-05-19 13:59:43 +05:30
pateljannat
7d180e141c fix: removed print statememt 2021-05-19 13:19:34 +05:30
pateljannat
29f9141ad8 fix: removed slug field ref from courses page 2021-05-19 13:16:46 +05:30
Anand Chitipothu
e6f58f56e0 Merge pull request #87 from fossunited/remove-branding
refactor: removed the branding and customization for mon school
2021-05-19 10:41:45 +05:30
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
183 changed files with 4388 additions and 2879 deletions

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -36,12 +36,27 @@ jobs:
- name: setup node
uses: actions/setup-node@v2
with:
node-version: '12'
node-version: '14'
check-latest: true
- name: setup cache for bench
uses: actions/cache@v2
with:
path: ~/bench-cache
key: ${{ runner.os }}
- name: install bench
run: pip3 install frappe-bench
run: |
pip3 install frappe-bench
which bench
- 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
working-directory: /home/runner/frappe-bench
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
- name: install community app
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
working-directory: /home/runner/frappe-bench
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
working-directory: /home/runner/frappe-bench
run: bench --site frappe.local run-tests --app community

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@
*.egg-info
*.swp
tags
community/docs/current
community/docs/current
community/public/dist

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2021, Frappe and contributors
// For license information, please see license.txt
frappe.ui.form.on('Community Course Member', {
// refresh: function(frm) {
// }
});

View File

@@ -1,96 +0,0 @@
{
"actions": [],
"autoname": "field:user_name",
"creation": "2021-03-02 11:24:49.612530",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"enabled",
"full_name",
"user_name",
"email",
"short_intro",
"bio",
"photo",
"route"
],
"fields": [
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "full_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Full Name",
"reqd": 1
},
{
"fieldname": "user_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "User Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "email",
"fieldtype": "Data",
"label": "Email",
"options": "Email",
"reqd": 1
},
{
"fieldname": "short_intro",
"fieldtype": "Data",
"label": "Short Intro"
},
{
"fieldname": "bio",
"fieldtype": "Markdown Editor",
"label": "Bio"
},
{
"fieldname": "photo",
"fieldtype": "Attach Image",
"label": "Photo"
},
{
"fieldname": "route",
"fieldtype": "Data",
"label": "Route"
}
],
"has_web_view": 1,
"index_web_pages_for_search": 1,
"is_published_field": "enabled",
"links": [],
"modified": "2021-04-06 11:50:41.551665",
"modified_by": "Administrator",
"module": "Community",
"name": "Community Course Member",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"route": "community-course-member",
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
import re
from frappe.website.website_generator import WebsiteGenerator
from frappe import _
class CommunityCourseMember(WebsiteGenerator):
def get_context(self, context):
context.abbr = ("").join([ s[0] for s in self.full_name.split() ])
return context
def validate(self):
self.validate_user_name()
if not self.route:
self.route = self.user_name
def validate_user_name(self):
if len(self.user_name) < 4:
frappe.throw(_("Username must be atleast 4 characters long."))
if not re.match("^[A-Za-z0-9_]*$", self.user_name):
frappe.throw(_("Username can only contain alphabets, numbers, and underscore."))
self.user_name = self.user_name.lower()
def after_insert(self):
if frappe.db.exists("User", self.email):
user = frappe.get_doc("User", self.email)
else:
user, update_password_link = self.create_user()
self.send_email(update_password_link)
def send_email(self, update_password_link):
args = {
'update_password_link': update_password_link,
'full_name': self.full_name,
}
frappe.sendmail(
recipients=self.email,
sender="Administrator",
subject=_("Set your Password"),
template="community_course_membership",
reference_doctype=self.doctype,
reference_name=self.name,
send_priority=0,
queue_separately=True,
args=args)

View File

@@ -1,25 +0,0 @@
{% extends "templates/web.html" %}
{% block page_content %}
<div class="py-20 row">
{% if photo %}
<div class="col-sm-2 border border-dark">
<img src="{{ photo }}" alt="{{ full_name }}">
</div>
{% else %}
<div class="col-sm-2">
<div class="standard-image" style="font-size: 30px;">{{ abbr }}</div>
</div>
{% endif %}
<div class="col">
<h1>{{ full_name }}</h1>
{% if short_intro %}
<p class="lead"> {{ short_intro }} </p>
{% endif %}
{% if bio %}
<p class="markdown-style"> {{ frappe.utils.md_to_html(bio) }} </p>
{% endif %}
</div>
</div>
{% endblock %}
<!-- this is a sample default web page template -->

View File

@@ -1,4 +0,0 @@
<div>
<a href="{{ doc.route }}">{{ doc.full_name }}</a>
</div>
<!-- this is a sample default list template -->

View File

@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestCommunityCourseMember(unittest.TestCase):
pass

View File

@@ -13,13 +13,13 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "Community Member"
"options": "User"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-02-15 12:03:31.153575",
"modified": "2021-05-21 12:15:51.286478",
"modified_by": "Administrator",
"module": "Community",
"name": "Community Event Volunteer",

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2021, Frappe and contributors
// For license information, please see license.txt
frappe.ui.form.on('Community Member', {
// refresh: function(frm) {
// }
});

View File

@@ -1,155 +0,0 @@
{
"actions": [],
"allow_guest_to_view": 1,
"allow_rename": 1,
"creation": "2021-02-12 15:47:23.591567",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"full_name",
"email",
"enabled",
"column_break_4",
"username",
"email_preference",
"section_break_7",
"bio",
"section_break_9",
"role",
"photo",
"column_break_12",
"short_intro",
"route",
"abbr"
],
"fields": [
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "full_name",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Full Name",
"reqd": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "role",
"fieldtype": "Select",
"label": "Role",
"options": "\nBoard\nDirector\nVolunteer\nSpeaker"
},
{
"allow_in_quick_entry": 1,
"fieldname": "photo",
"fieldtype": "Attach Image",
"label": "Photo"
},
{
"fieldname": "short_intro",
"fieldtype": "Data",
"label": "Short Intro"
},
{
"allow_in_quick_entry": 1,
"fieldname": "bio",
"fieldtype": "Markdown Editor",
"label": "Bio"
},
{
"fieldname": "route",
"fieldtype": "Data",
"label": "Route"
},
{
"fieldname": "email",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "Email",
"options": "Email",
"reqd": 1,
"unique": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "username",
"fieldtype": "Data",
"in_list_view": 1,
"label": "User Name",
"unique": 1
},
{
"fieldname": "email_preference",
"fieldtype": "Select",
"label": "Email preference",
"options": "Email on every Message\nOne Digest Mail per day\nNever"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_9",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"fieldname": "abbr",
"fieldtype": "Data",
"label": "Abbr",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-28 11:22:35.402217",
"modified_by": "Administrator",
"module": "Community",
"name": "Community Member",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"search_fields": "full_name",
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "full_name",
"track_changes": 1
}

View File

@@ -1,81 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
import re
from frappe import _
from frappe.model.document import Document
import random
class CommunityMember(Document):
def validate(self):
self.validate_username()
self.abbr = ("").join([ s[0] for s in self.full_name.split() ])
if self.route != self.username:
self.route = self.username
def validate_username(self):
if not self.username:
self.username = create_username_from_email(self.email)
if self.username:
if len(self.username) < 4:
frappe.throw(_("Username must be atleast 4 characters long."))
if not re.match("^[A-Za-z0-9_]*$", self.username):
frappe.throw(_("Username can only contain alphabets, numbers and underscore."))
self.username = self.username.lower()
def get_course_count(self) -> int:
"""Returns the number of courses authored by this user.
"""
return frappe.db.count(
'LMS Course', {
'owner': self.email
})
def get_batch_count(self) -> int:
"""Returns the number of batches authored by this user.
"""
return frappe.db.count(
'LMS Batch Membership', {
'member': self.name,
'member_type': 'Mentor'
})
def __repr__(self):
return f"<CommunityMember: {self.email}>"
def create_member_from_user(doc, method):
username = doc.username
if ( doc.username and username_exists(doc.username)) or not doc.username:
username = create_username_from_email(doc.email)
elif len(doc.username) < 4:
username = adjust_username(doc.username)
if username_exists(username):
username = username + str(random.randint(0,9))
member = frappe.get_doc({
"doctype": "Community Member",
"full_name": doc.full_name,
"username": username,
"email": doc.email,
"route": doc.username,
"owner": doc.email
})
member.save(ignore_permissions=True)
def username_exists(username):
return frappe.db.exists("Community Member", dict(username=username))
def create_username_from_email(email):
string = email.split("@")[0]
return ''.join(e for e in string if e.isalnum())
def adjust_username(username):
return username.ljust(4, str(random.randint(0,9)))

View File

@@ -1,25 +0,0 @@
{% extends "templates/web.html" %}
{% block page_content %}
<div class="py-20 row">
{% if photo %}
<div class="col-sm-2 border border-dark">
<img src="{{ photo }}" alt="{{ full_name }}">
</div>
{% else %}
<div class="col-sm-2">
<div class="standard-image" style="font-size: 30px;">{{ abbr }}</div>
</div>
{% endif %}
<div class="col">
<h1>{{ full_name }}</h1>
{% if short_intro %}
<p class="lead"> {{ short_intro }} </p>
{% endif %}
{% if bio %}
<p class="markdown-style"> {{ frappe.utils.md_to_html(bio) }} </p>
{% endif %}
</div>
</div>
{% endblock %}
<!-- this is a sample default web page template -->

View File

@@ -1,4 +0,0 @@
<div>
<a href="{{ doc.route }}">{{ doc.title or doc.name }}</a>
</div>
<!-- this is a sample default list template -->

View File

@@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe and Contributors
# See license.txt
from __future__ import unicode_literals
from community.lms.doctype.lms_course.test_lms_course import new_user
import frappe
import unittest
class TestCommunityMember(unittest.TestCase):
@classmethod
def setUpClass(self):
users = ["test_user@example.com","test_user1@example.com"]
for user in users:
if not frappe.db.exists("User", user):
new_user("Test User", user)
def test_member_created_from_user(self):
user = frappe.db.get_value("User","test_user@example.com", ["full_name", "email", "username"], as_dict=True)
self.assertTrue(frappe.db.exists("Community Member", {"username":user.username}))
member = frappe.db.get_value("Community Member",
filters={"email": user.email},
fieldname=["full_name", "email", "owner", "username", "route"],
as_dict=True
)
self.assertEqual(user.full_name, member.full_name)
self.assertEqual(member.owner, user.email)
self.assertEqual(user.username, member.username)
self.assertEqual(member.username, member.route)
def test_members_with_same_name(self):
user1 = frappe.db.get_value("User","test_user@example.com", ["email"], as_dict=True)
user2 = frappe.get_doc("User","test_user1@example.com", ["email"], as_dict=True)
self.assertTrue(frappe.db.exists("Community Member", {"email": user1.email} ))
self.assertTrue(frappe.db.exists("Community Member", {"email": user2.email }))
member1 = frappe.db.get_value("Community Member",
filters={"email": user1.email},
fieldname=["full_name", "email", "owner", "username", "route"],
as_dict=True
)
member2 = frappe.db.get_value("Community Member",
filters={"email": user2.email},
fieldname=["full_name", "email", "owner", "username", "route"],
as_dict=True
)
self.assertEqual(member1.full_name, member2.full_name)
self.assertEqual(member1.email, user1.email)
self.assertEqual(member2.email, user2.email)
self.assertNotEqual(member1.username, member2.username)
def test_username_validations(self):
user = new_user("Tst", "tst@example.com")
self.assertTrue(frappe.db.exists("Community Member", {"email":user.email} ))
member = frappe.db.get_value("Community Member",
filters={"email": user.email},
fieldname=["username"],
as_dict=True
)
self.assertEqual(len(member.username), 4)
frappe.delete_doc("User", user.email)
def test_user_without_username(self):
user = new_user("Test User", "test_user2@example.com")
self.assertTrue(frappe.db.exists("Community Member", {"email":user.email} ))
member = frappe.db.get_value("Community Member",
filters={"email": user.email},
fieldname=["username"],
as_dict=True
)
self.assertTrue(member.username)
frappe.delete_doc("User", user.email)
@classmethod
def tearDownClass(self):
users = ["test_user@example.com","test_user1@example.com"]
for user in users:
if frappe.db.exists("User", user):
frappe.delete_doc("User", user)
if frappe.db.exists("Community Member", {"email": user}):
frappe.delete_doc("Community Member", {"email": user})

View File

@@ -1,16 +1 @@
import frappe
def create_members_from_users():
users = frappe.get_all("User", {"enabled": 1}, ["email"])
for user in users:
if not frappe.db.get_value("Community Member", {"email": user.email}, "name"):
doc = frappe.get_doc("User", {"email": user.email})
username = doc.username if doc.username and len(doc.username) > 3 else ("").join([ s for s in doc.full_name.split() ])
if not frappe.db.exists("Community Member", username):
member = frappe.new_doc("Community Member")
member.full_name = doc.full_name
member.username = username
member.email = doc.email
member.route = username
member.owner = doc.email
member.insert(ignore_permissions=True)

View File

@@ -1,5 +0,0 @@
frappe.ready(function() {
frappe.web_form.after_save = () => {
window.location.href = frappe.web_form.get_value("username")
}
})

View File

@@ -1,100 +0,0 @@
{
"accept_payment": 0,
"allow_comments": 0,
"allow_delete": 0,
"allow_edit": 1,
"allow_incomplete": 0,
"allow_multiple": 0,
"allow_print": 0,
"amount": 0.0,
"amount_based_on_field": 0,
"apply_document_permissions": 0,
"breadcrumbs": "",
"button_label": "Save",
"creation": "2021-03-09 17:34:03.394301",
"doc_type": "Community Member",
"docstatus": 0,
"doctype": "Web Form",
"idx": 0,
"is_standard": 1,
"login_required": 1,
"max_attachment_size": 0,
"modified": "2021-03-22 12:04:22.571655",
"modified_by": "Administrator",
"module": "Community",
"name": "update-profile",
"owner": "Administrator",
"payment_button_label": "Buy Now",
"published": 1,
"route": "edit-profile",
"route_to_success_link": 0,
"show_attachments": 0,
"show_in_grid": 0,
"show_sidebar": 0,
"sidebar_items": [],
"success_message": "Profile updated successfully.",
"success_url": "/",
"title": "Update Profile",
"web_form_fields": [
{
"allow_read_on_all_link_options": 0,
"fieldname": "full_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Full Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 1,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "username",
"fieldtype": "Data",
"hidden": 0,
"label": "User Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 1,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "short_intro",
"fieldtype": "Data",
"hidden": 0,
"label": "Short Intro",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "bio",
"fieldtype": "Data",
"hidden": 0,
"label": "Bio",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "photo",
"fieldtype": "Attach Image",
"hidden": 0,
"label": "Photo",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
}
]
}

View File

@@ -1,7 +0,0 @@
from __future__ import unicode_literals
import frappe
def get_context(context):
# do your magic here
pass

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.user_image %}
<img class="avatar-frame standard-image" style="object-fit: cover;" src="{{ member.user_image }}" 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]}});">
{{ frappe.utils.get_abbr(member.full_name) }}
</span>
{% endif %}
</span>
</a>

View File

@@ -23,7 +23,7 @@
"fieldname": "organizer",
"fieldtype": "Link",
"label": "Organizer",
"options": "Community Member"
"options": "User"
},
{
"fieldname": "year",
@@ -34,7 +34,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-14 11:43:23.515972",
"modified": "2021-05-21 12:22:26.619776",
"modified_by": "Administrator",
"module": "Hackathon",
"name": "Community Hackathon",

View File

@@ -3,13 +3,14 @@
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
import frappe
from frappe.model.document import Document
from frappe import _
class CommunityProjectMember(Document):
def validate(self):
self.validate_if_already_member()
def validate_if_already_member(self):
if frappe.get_all("Community Project Member", {"owner": self.owner}):
frappe.throw(_("You have already applied for the membership of this project."))

View File

@@ -10,15 +10,16 @@ app_icon = "octicon octicon-file-directory"
app_color = "grey"
app_email = "jannat@erpnext.com"
app_license = "AGPL"
# Includes in <head>
# ------------------
# include js, css files in header of desk.html
app_include_css = "/assets/community/css/community.css"
app_include_js = "/assets/community/js/community.js"
# app_include_css = "/assets/community/css/community.css"
# app_include_js = "/assets/community/js/community.js"
# include js, css files in header of web template
web_include_css = "/assets/css/community.css"
web_include_css = "community.bundle.css"
# web_include_css = "/assets/community/css/community.css"
# web_include_js = "/assets/community/js/community.js"
@@ -83,22 +84,17 @@ web_include_css = "/assets/css/community.css"
# ---------------
# Override standard doctype classes
# override_doctype_class = {
# "ToDo": "custom_app.overrides.CustomToDo"
# }
override_doctype_class = {
"User": "community.overrides.user.CustomUser"
}
# Document Events
# ---------------
# Hook on document methods and events
doc_events = {
"User": {
"after_insert": "community.community.doctype.community_member.community_member.create_member_from_user"
},
"LMS Message": {
"after_insert": "community.lms.doctype.lms_message.lms_message.publish_message"
}
}
}
# Scheduled Tasks
# ---------------
@@ -140,11 +136,15 @@ primary_rules = [
{"from_route": "/hackathons/<hackathon>/<project>", "to_route": "hackathons/project"},
{"from_route": "/dashboard", "to_route": ""},
{"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>/schedule", "to_route": "courses/schedule"},
{"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>/about", "to_route": "courses/about"}
{"from_route": "/courses/<course>/home", "to_route": "batch/home"},
{"from_route": "/courses/<course>/learn", "to_route": "batch/learn"},
{"from_route": "/courses/<course>/learn/<int:chapter>.<int:lesson>", "to_route": "batch/learn"},
{"from_route": "/courses/<course>/schedule", "to_route": "batch/schedule"},
{"from_route": "/courses/<course>/members", "to_route": "batch/members"},
{"from_route": "/courses/<course>/discuss", "to_route": "batch/discuss"},
{"from_route": "/courses/<course>/about", "to_route": "batch/about"},
{"from_route": "/courses/<course>/progress", "to_route": "batch/progress"},
{"from_route": "/courses/<course>/join", "to_route": "batch/join"}
]
# Any frappe default URL is blocked by profile-rules, add it here to unblock it
@@ -161,10 +161,11 @@ whitelist = [
"/socket.io",
"/hackathons",
"/dashboard",
"/join-request"
"/join-request",
"/add-a-new-batch",
"/new-sign-up",
"/message"
"/message",
"/about"
]
whitelist_rules = [{"from_route": p, "to_route": p[1:]} for p in whitelist]
@@ -176,3 +177,23 @@ profile_rules = [
website_route_rules = primary_rules + whitelist_rules + profile_rules
update_website_context = 'community.widgets.update_website_context'
## Specify the additional tabs to be included in the user profile page.
## Each entry must be a subclass of community.community.plugins.ProfileTab
# profile_tabs = []
## Specify the extension to be used to control what scripts and stylesheets
## to be included in lesson pages. The specified value must be be a
## subclass of community.community.plugins.PageExtension
# community_lesson_page_extension = None
community_lesson_page_extensions = [
"community.plugins.LiveCodeExtension"
]
## Markdown Macros for Lessons
community_markdown_macro_renderers = {
"Exercise": "community.plugins.exercise_renderer",
"Quiz": "community.plugins.quiz_renderer",
"YouTubeVideo": "community.plugins.youtube_video_renderer",
}

View File

@@ -16,8 +16,32 @@ def autosave_section(section, code):
return {"name": doc.name}
@frappe.whitelist()
def get_section(name):
"""Saves the code edited in one of the sections.
def submit_solution(exercise, code):
"""Submits a solution.
@exerecise: name of the exercise to submit
@code: solution to the exercise
"""
doc = frappe.get_doc("LMS Section", name)
return doc and doc.as_dict()
ex = frappe.get_doc("Exercise", exercise)
if not ex:
return
doc = ex.submit(code)
return {"name": doc.name, "creation": doc.creation}
@frappe.whitelist()
def save_current_lesson(course_name, lesson_name):
"""Saves the current lesson for a student/mentor.
"""
name = frappe.get_value(
doctype="LMS Batch Membership",
filters={
"course": course_name,
"member": frappe.session.user
},
fieldname="name")
if not name:
return
doc = frappe.get_doc("LMS Batch Membership", name)
doc.current_lesson = lesson_name
doc.save(ignore_permissions=True)
return {"current_lesson": doc.current_lesson}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on('LMS Topic', {
frappe.ui.form.on('Exercise', {
// refresh: function(frm) {
// }

View File

@@ -0,0 +1,123 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-05-19 17:43:39.923430",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"title",
"description",
"code",
"answer",
"column_break_4",
"course",
"hints",
"tests",
"image",
"lesson",
"index_",
"index_label"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title"
},
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Course",
"options": "LMS Course"
},
{
"columns": 4,
"fieldname": "description",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Description"
},
{
"columns": 4,
"fieldname": "answer",
"fieldtype": "Code",
"label": "Answer"
},
{
"fieldname": "tests",
"fieldtype": "Code",
"label": "Tests"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"columns": 4,
"fieldname": "hints",
"fieldtype": "Small Text",
"label": "Hints"
},
{
"columns": 4,
"fieldname": "code",
"fieldtype": "Code",
"label": "Code"
},
{
"fieldname": "image",
"fieldtype": "Code",
"label": "Image",
"read_only": 1
},
{
"fieldname": "lesson",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Lesson",
"options": "Lesson"
},
{
"fieldname": "index_",
"fieldtype": "Int",
"label": "Index",
"read_only": 1
},
{
"fieldname": "index_label",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Index Label",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-06-01 05:22:15.656013",
"modified_by": "Administrator",
"module": "LMS",
"name": "Exercise",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"search_fields": "title",
"sort_field": "index_label",
"sort_order": "ASC",
"title_field": "title",
"track_changes": 1
}

View File

@@ -0,0 +1,53 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
# from ..lms_sketch.livecode import livecode_to_svg
class Exercise(Document):
def get_user_submission(self):
"""Returns the latest submission for this user.
"""
user = frappe.session.user
if not user or user == "Guest":
return
result = frappe.get_all('Exercise Submission',
fields="*",
filters={
"owner": user,
"exercise": self.name
},
order_by="creation desc",
page_length=1)
if result:
return result[0]
def submit(self, code):
"""Submits the given code as solution to exercise.
"""
user = frappe.session.user
if not user or user == "Guest":
return
old_submission = self.get_user_submission()
if old_submission and old_submission.solution == code:
return old_submission
course = frappe.get_doc("LMS Course", self.course)
batch = course.get_student_batch(user)
doc = frappe.get_doc(
doctype="Exercise Submission",
exercise=self.name,
exercise_title=self.title,
course=self.course,
lesson=self.lesson,
batch=batch and batch.name,
solution=code)
doc.insert(ignore_permissions=True)
return doc

View File

@@ -0,0 +1,49 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
import frappe
import unittest
class TestExercise(unittest.TestCase):
def setUp(self):
frappe.db.sql('delete from `tabExercise Submission`')
frappe.db.sql('delete from `tabExercise`')
frappe.db.sql('delete from `tabLMS Course`')
def new_exercise(self):
course = frappe.get_doc({
"doctype": "LMS Course",
"name": "test-course",
"title": "Test Course",
"short_introduction": "Test Course",
"description": "Test Course"
})
course.insert()
e = frappe.get_doc({
"doctype": "Exercise",
"name": "test-problem",
"course": course.name,
"title": "Test Problem",
"description": "draw a circle",
"code": "# draw a single cicle",
"answer": (
"# draw a single circle\n" +
"circle(100, 100, 50)")
})
e.insert()
return e
def test_exercise(self):
e = self.new_exercise()
assert e.get_user_submission() is None
def test_exercise_submission(self):
e = self.new_exercise()
submission = e.submit("circle(100, 100, 50)")
assert submission is not None
assert submission.exercise == e.name
assert submission.course == e.course
user_submission = e.get_user_submission()
assert user_submission is not None
assert user_submission.name == submission.name

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on('Exercise Submission', {
// refresh: function(frm) {
// }
});

View File

@@ -0,0 +1,119 @@
{
"actions": [],
"creation": "2021-05-19 11:41:18.108316",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"exercise",
"status",
"batch",
"column_break_4",
"exercise_title",
"course",
"lesson",
"section_break_8",
"solution",
"image",
"test_results",
"comments"
],
"fields": [
{
"fieldname": "exercise",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Exercise",
"options": "Exercise"
},
{
"fetch_from": "exercise.title",
"fieldname": "exercise_title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Exercise Title",
"read_only": 1
},
{
"fetch_from": "exercise.course",
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fieldname": "batch",
"fieldtype": "Link",
"label": "Batch",
"options": "LMS Batch"
},
{
"fetch_from": "exercise.lesson",
"fieldname": "lesson",
"fieldtype": "Link",
"label": "Lesson",
"options": "Lesson"
},
{
"fieldname": "image",
"fieldtype": "Code",
"label": "Image",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Correct\nIncorrect"
},
{
"fieldname": "test_results",
"fieldtype": "Small Text",
"label": "Test Results"
},
{
"fieldname": "comments",
"fieldtype": "Small Text",
"label": "Comments"
},
{
"fieldname": "solution",
"fieldtype": "Code",
"in_list_view": 1,
"label": "Solution"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-06-24 16:22:50.570845",
"modified_by": "Administrator",
"module": "LMS",
"name": "Exercise Submission",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,13 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
from ..lesson.lesson import update_progress
class ExerciseSubmission(Document):
def after_insert(self):
course_details = frappe.get_doc("LMS Course", self.course)
if not (course_details.is_mentor(frappe.session.user) or frappe.flags.in_test):
update_progress(self.lesson)

View File

@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestLMSTopic(unittest.TestCase):
class TestExerciseSubmission(unittest.TestCase):
pass

View File

@@ -46,14 +46,22 @@ class InviteRequest(Document):
@frappe.whitelist(allow_guest=True)
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",
"invite_email": invite_email
"invite_email": invite_email,
"status": "Approved"
}).save(ignore_permissions=True)
return "OK"
except frappe.UniqueValidationError:
frappe.throw(_("Email {0} has already been used to request an invite").format(invite_email))
return "OK"
@frappe.whitelist(allow_guest=True)

View File

@@ -18,7 +18,7 @@ class TestInviteRequest(unittest.TestCase):
filters={"invite_email": "test_invite@example.com"},
fieldname=["invite_email", "status", "signup_email"],
as_dict=True)
self.assertEqual(invite.status, "Pending")
self.assertEqual(invite.status, "Approved")
self.assertEqual(invite.signup_email, None)
def test_create_invite_request_update(self):
@@ -52,15 +52,8 @@ class TestInviteRequest(unittest.TestCase):
self.assertEqual(user.send_welcome_email, 0)
self.assertEqual(user.user_type, "Website User")
member = frappe.db.get_value("Community Member", {"email": "test_invite@example.com"})
self.assertTrue(member)
@classmethod
def tearDownClass(self):
if frappe.db.exists("Community Member", {"email": "test_invite@example.com"}):
frappe.delete_doc("Community Member", {"email": "test_invite@example.com"})
if frappe.db.exists("User", "test_invite@example.com"):
frappe.delete_doc("User", "test_invite@example.com")

View File

@@ -7,8 +7,13 @@
"engine": "InnoDB",
"field_order": [
"chapter",
"lesson_type",
"title"
"include_in_preview",
"column_break_4",
"title",
"index_",
"index_label",
"section_break_6",
"body"
],
"fields": [
{
@@ -18,24 +23,48 @@
"label": "Chapter",
"options": "Chapter"
},
{
"default": "Video",
"fieldname": "lesson_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Lesson Type",
"options": "Video\nText\nQuiz"
},
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title"
},
{
"default": "1",
"fieldname": "index_",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Index"
},
{
"fieldname": "body",
"fieldtype": "Markdown Editor",
"label": "Body"
},
{
"fieldname": "index_label",
"fieldtype": "Data",
"label": "Index Label",
"read_only": 1
},
{
"default": "0",
"fieldname": "include_in_preview",
"fieldtype": "Check",
"label": "Include In Preview"
},
{
"fieldname": "section_break_6",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-05-03 06:51:43.588969",
"modified": "2021-06-23 17:59:52.946515",
"modified_by": "Administrator",
"module": "LMS",
"name": "Lesson",

View File

@@ -3,8 +3,127 @@
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
import frappe
from frappe.model.document import Document
from ...md import markdown_to_html, find_macros
class Lesson(Document):
pass
def before_save(self):
dynamic_documents = ["Exercise", "Quiz"]
for section in dynamic_documents:
self.update_lesson_name_in_document(section)
def update_lesson_name_in_document(self, section):
doctype_map= {
"Exercise": "Exercise",
"Quiz": "LMS Quiz"
}
macros = find_macros(self.body)
documents = [value for name, value in macros if name == section]
index = 1
for name in documents:
e = frappe.get_doc(doctype_map[section], name)
e.lesson = self.name
e.index_ = index
e.save()
index += 1
self.update_orphan_documents(doctype_map[section], documents)
def update_orphan_documents(self, doctype, documents):
"""Updates the documents that were previously part of this lesson,
but not any more.
"""
linked_documents = {row['name'] for row in frappe.get_all(doctype, {"lesson": self.name})}
active_documents = set(documents)
orphan_documents = linked_documents - active_documents
for name in orphan_documents:
ex = frappe.get_doc(doctype, name)
ex.lesson = None
ex.index_ = 0
ex.index_label = ""
ex.save()
def render_html(self):
return markdown_to_html(self.body)
def get_exercises(self):
if not self.body:
return []
macros = find_macros(self.body)
exercises = [value for name, value in macros if name == "Exercise"]
return [frappe.get_doc("Exercise", name) for name in exercises]
def get_progress(self):
return frappe.db.get_value("LMS Course Progress", {"lesson": self.name, "owner": frappe.session.user}, "status")
def get_slugified_class(self):
if self.get_progress():
return ("").join([ s for s in self.get_progress().lower().split() ])
return
@frappe.whitelist()
def save_progress(lesson, course):
if not frappe.db.exists("LMS Batch Membership",
{
"member": frappe.session.user,
"course": course
}):
return
if frappe.db.exists("LMS Course Progress",
{
"lesson": lesson,
"owner": frappe.session.user
}):
return
lesson_details = frappe.get_doc("Lesson", lesson)
dynamic_content = find_macros(lesson_details.body)
status = "Complete"
if dynamic_content:
status = "Partially Complete"
frappe.get_doc({
"doctype": "LMS Course Progress",
"lesson": lesson_details.name,
"status": status
}).save(ignore_permissions=True)
def update_progress(lesson):
user = frappe.session.user
if not all_dynamic_content_submitted(lesson, user):
return
if frappe.db.exists("LMS Course Progress", {"lesson": lesson, "owner": user}):
course_progress = frappe.get_doc("LMS Course Progress", {"lesson": lesson, "owner": user})
course_progress.status = "Complete"
course_progress.save(ignore_permissions=True)
def all_dynamic_content_submitted(lesson, user):
all_exercises_submitted = check_all_exercise_submission(lesson, user)
all_quiz_submitted = check_all_quiz_submitted(lesson, user)
return all_exercises_submitted and all_quiz_submitted
def check_all_exercise_submission(lesson, user):
exercise_names = frappe.get_list("Exercise", {"lesson": lesson}, pluck="name", ignore_permissions=True)
if not len(exercise_names):
return True
query = {
"exercise": ["in", exercise_names],
"owner": user
}
if frappe.db.count("Exercise Submission", query) == len(exercise_names):
return True
return False
def check_all_quiz_submitted(lesson, user):
quizzes = frappe.get_list("LMS Quiz", {"lesson": lesson}, pluck="name", ignore_permissions=True)
if not len(quizzes):
return True
query = {
"quiz": ["in", quizzes],
"owner": user
}
if frappe.db.count("LMS Quiz Submission", query) == len(quizzes):
return True
return False

View File

@@ -1,17 +1,15 @@
{
"actions": [],
"autoname": "field:title",
"creation": "2021-03-18 19:37:34.614796",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"title",
"start_date",
"start_time",
"column_break_3",
"code",
"title",
"sessions_on",
"end_time",
"section_break_5",
@@ -32,18 +30,10 @@
"label": "Course",
"options": "LMS Course"
},
{
"fieldname": "code",
"fieldtype": "Data",
"label": "Code",
"read_only": 1,
"unique": 1
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"unique": 1
"label": "Title"
},
{
"fieldname": "description",
@@ -51,10 +41,12 @@
"label": "Description"
},
{
"default": "Public",
"fieldname": "visibility",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Visibility",
"options": "\nPublic\nUnlisted\nPrivate"
"options": "Public\nUnlisted\nPrivate"
},
{
"fieldname": "membership",
@@ -63,16 +55,19 @@
"options": "\nOpen\nRestricted\nInvite Only\nClosed"
},
{
"default": "Active",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "\nActive\nInactive"
"options": "Active\nInactive"
},
{
"default": "Ready",
"fieldname": "stage",
"fieldtype": "Select",
"label": "Stage",
"options": "\nReady\nIn Progress\nCompleted\nCancelled"
"options": "Ready\nIn Progress\nCompleted\nCancelled"
},
{
"fieldname": "column_break_3",
@@ -80,7 +75,8 @@
},
{
"fieldname": "section_break_5",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Batch Description"
},
{
"fieldname": "column_break_9",
@@ -88,7 +84,8 @@
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Batch Settings"
},
{
"fieldname": "start_date",
@@ -122,7 +119,7 @@
"link_fieldname": "batch"
}
],
"modified": "2021-04-30 09:52:18.941276",
"modified": "2021-06-16 10:51:05.403726",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch",

View File

@@ -5,46 +5,69 @@
from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
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):
def validate(self):
if not self.code:
self.generate_code()
self.validate_if_mentor()
def generate_code(self):
short_code = frappe.db.get_value("LMS Course", self.course, "short_code")
course_batches = frappe.get_all("LMS Batch",{"course":self.course})
self.code = short_code + str(len(course_batches) + 1)
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 get_mentors(self):
mentors = []
memberships = frappe.get_all(
"LMS Batch Membership",
{"batch": self.name, "member_type": "Mentor"},
["member"])
for membership in memberships:
member = frappe.db.get_value("Community Member", membership.member, ["full_name", "photo", "abbr"], as_dict=1)
mentors.append(member)
return mentors
def after_insert(self):
create_membership(batch=self.name, course=self.course, member_type="Mentor")
@frappe.whitelist()
def get_messages(batch):
messages = frappe.get_all("LMS Message", {"batch": batch}, ["*"], order_by="creation")
for message in messages:
message.message = frappe.utils.md_to_html(message.message)
member_email = frappe.db.get_value("Community Member", message.author, ["email"])
if member_email == frappe.session.user:
message.author_name = "You"
message.is_author = True
return messages
def is_member(self, email, member_type=None):
"""Checks if a person is part of a batch.
If member_type is specified, checks if the person is a Student/Mentor.
"""
filters = {
"batch": self.name,
"member": email
}
if member_type:
filters['member_type'] = member_type
return frappe.db.exists("LMS Batch Membership", filters)
def get_messages(self):
messages = frappe.get_all("LMS Message", {"batch": self.name}, ["*"], order_by="creation")
for message in messages:
message.message = frappe.utils.md_to_html(message.message)
if message.author == frappe.session.user:
message.author_name = "You"
message.is_author = True
return messages
def get_membership(self, email):
"""Returns the membership document of given user.
"""
name = frappe.get_value(
doctype="LMS Batch Membership",
filters={
"batch": self.name,
"member": email
},
fieldname="name")
return frappe.get_doc("LMS Batch Membership", name)
def get_current_lesson(self, user):
"""Returns the name of the current lesson for the given user.
"""
membership = self.get_membership(user)
return membership and membership.current_lesson
@frappe.whitelist()
def save_message(message, batch):
doc = frappe.get_doc({
"doctype": "LMS Message",
"batch": batch,
"author": get_member_with_email(),
"author": frappe.session.user,
"message": message
})
doc.save(ignore_permissions=True)

View File

@@ -2,7 +2,13 @@
// For license information, please see license.txt
frappe.ui.form.on('LMS Batch Membership', {
// refresh: function(frm) {
// }
onload: function(frm) {
frm.set_query('member', function(doc) {
return {
filters: {
"ignore_user_type": 1,
}
};
});
},
});

View File

@@ -6,11 +6,14 @@
"engine": "InnoDB",
"field_order": [
"batch",
"role",
"column_break_3",
"member",
"member_name",
"member_type"
"member_username",
"column_break_3",
"course",
"member_type",
"role",
"current_lesson"
],
"fields": [
{
@@ -27,9 +30,10 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member",
"options": "Community Member"
"options": "User"
},
{
"default": "Student",
"fieldname": "member_type",
"fieldtype": "Select",
"in_list_view": 1,
@@ -37,6 +41,7 @@
"options": "\nStudent\nMentor\nStaff"
},
{
"default": "Member",
"fieldname": "role",
"fieldtype": "Select",
"in_standard_filter": 1,
@@ -54,11 +59,32 @@
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fetch_from": "batch.course",
"fieldname": "course",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Course",
"read_only": 1
},
{
"fieldname": "current_lesson",
"fieldtype": "Link",
"label": "Current Lesson",
"options": "Lesson"
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Memeber Username",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-26 12:52:59.826509",
"modified": "2021-06-21 12:10:28.808803",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch Membership",

View File

@@ -14,32 +14,65 @@ class LMSBatchMembership(Document):
self.validate_membership_in_different_batch_same_course()
def validate_membership_in_same_batch(self):
previous_membership = frappe.db.get_value("LMS Batch Membership", {"member": self.member, "batch": self.batch, "name": ["!=", self.name]}, ["member_type","member"], as_dict=1)
filters={
"member": self.member,
"course": self.course,
"name": ["!=", self.name]
}
if self.batch:
filters["batch"] = self.batch
previous_membership = frappe.db.get_value("LMS Batch Membership",
filters,
fieldname=["member_type","member"],
as_dict=1)
if previous_membership:
member_name = frappe.db.get_value("Community Member", self.member, "full_name")
frappe.throw(_("{0} is already a {1} of {2}").format(member_name, previous_membership.member_type, self.batch))
member_name = frappe.db.get_value("User", self.member, "full_name")
course_title = frappe.db.get_value("LMS Course", self.course, "title")
frappe.throw(_("{0} is already a {1} of the course {2}").format(member_name, previous_membership.member_type, course_title))
def validate_membership_in_different_batch_same_course(self):
"""Ensures that a studnet is only part of one batch.
"""
# nothing to worry if the member is not a student
if self.member_type != "Student":
return
course = frappe.db.get_value("LMS Batch", self.batch, "course")
previous_membership = frappe.get_all("LMS Batch Membership", {"member": self.member}, ["batch", "member_type"])
for membership in previous_membership:
batch_course = frappe.db.get_value("LMS Batch", membership.batch, "course")
if batch_course == course and (membership.member_type == "Student" or self.member_type == "Student"):
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))
memberships = frappe.get_all(
"LMS Batch Membership",
filters={
"member": self.member,
"name": ["!=", self.name],
"member_type": "Student",
"course": self.course
},
fields=["batch", "member_type", "name"]
)
if memberships:
membership = memberships[0]
member_name = frappe.db.get_value("User", self.member, "full_name")
frappe.throw(_("{0} is already a Student of {1} course through {2} batch").format(member_name, course, membership.batch))
@frappe.whitelist()
def create_membership(batch, course=None, member=None, member_type="Student", role="Member"):
if not member:
member = frappe.db.get_value("Community Member", {"email": frappe.session.user}, "name")
def create_membership(course, batch=None, member=None, member_type="Student", role="Member"):
frappe.get_doc({
"doctype": "LMS Batch Membership",
"batch": batch,
"course": course,
"role": role,
"member_type": member_type,
"member": member
"member": member or frappe.session.user
}).save(ignore_permissions=True)
if course:
course_slug = frappe.db.get_value("LMS Course", {"title": course}, ["slug"])
return course_slug
return "OK"
@frappe.whitelist()
def update_current_membership(batch, course, member):
all_memberships = frappe.get_all("LMS Batch Membership", {"member": member, "course": course})
for membership in all_memberships:
frappe.db.set_value("LMS Batch Membership", membership.name, "is_current", 0)
current_membership = frappe.get_all("LMS Batch Membership", {"batch": batch, "member": member})
if len(current_membership):
frappe.db.set_value("LMS Batch Membership", current_membership[0].name, "is_current", 1)

View File

@@ -3,8 +3,78 @@
# See license.txt
from __future__ import unicode_literals
# import frappe
import frappe
import unittest
class TestLMSBatchMembership(unittest.TestCase):
pass
def setUp(self):
frappe.db.sql("DELETE FROM `tabLMS Batch Membership`")
frappe.db.sql("DELETE FROM `tabLMS Batch`")
frappe.db.sql('delete from `tabLMS Course Mentor Mapping`')
frappe.db.sql("DELETE FROM `tabLMS Course`")
frappe.db.sql("DELETE FROM `tabUser` where email like '%@test.com'")
def new_course_batch(self):
course = frappe.get_doc({
"doctype": "LMS Course",
"name": "test-course",
"title": "Test Course",
"short_code": "XX",
"short_introduction": "Test Course",
"description": "Test Course"
})
course.insert()
self.new_user("mentor@test.com", "Test Mentor")
# without this, the creating batch will fail
course.add_mentor("mentor@test.com")
frappe.session.user = "mentor@test.com"
batch = frappe.get_doc({
"doctype": "LMS Batch",
"name": "test-batch",
"title": "Test Batch",
"course": course.name
})
batch.insert(ignore_permissions=True)
frappe.session.user = "Administrator"
return course, batch
def new_user(self, email="test@test.com", full_name="Test User"):
user = frappe.get_doc({
"doctype": "User",
"name": email,
"email": email,
"first_name": full_name,
})
user.insert()
return user
def add_membership(self, batch_name, member_name, member_type="Student"):
doc = frappe.get_doc({
"doctype": "LMS Batch Membership",
"batch": batch_name,
"member": member_name,
"member_type": member_type
})
doc.insert()
return doc
def test_membership(self):
course, batch = self.new_course_batch()
member = self.new_user("test01@test.com")
membership = self.add_membership(batch.name, member.name)
assert membership.course == course.name
assert membership.member_name == member.full_name
def test_membership_change_role(self):
course, batch = self.new_course_batch()
member = self.new_user("test01@test.com")
membership = self.add_membership(batch.name, member.name)
# it should be possible to change role
membership.role = "Admin"
membership.save()

View File

@@ -1,16 +1,28 @@
{
"actions": [],
"actions": [
{
"action": "community.lms.doctype.lms_course.lms_course.reindex_lessons",
"action_type": "Server Action",
"group": "Reindex",
"label": "Reindex Lessons"
},
{
"action": "community.lms.doctype.lms_course.lms_course.reindex_exercises",
"action_type": "Server Action",
"group": "Reindex",
"label": "Reindex Exercises"
}
],
"allow_guest_to_view": 1,
"allow_rename": 1,
"autoname": "field:title",
"creation": "2021-03-01 16:49:33.622422",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"title",
"slug",
"is_published",
"disable_self_learning",
"column_break_3",
"short_code",
"video_link",
@@ -30,7 +42,8 @@
{
"fieldname": "description",
"fieldtype": "Markdown Editor",
"label": "Description"
"label": "Description",
"reqd": 1
},
{
"default": "0",
@@ -43,14 +56,6 @@
"fieldtype": "Data",
"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",
"fieldtype": "Column Break"
@@ -67,7 +72,14 @@
{
"fieldname": "short_introduction",
"fieldtype": "Small Text",
"label": "Short Introduction"
"label": "Short Introduction",
"reqd": 1
},
{
"default": "0",
"fieldname": "disable_self_learning",
"fieldtype": "Check",
"label": "Disable Self Learning"
}
],
"index_web_pages_for_search": 1,
@@ -87,9 +99,14 @@
"group": "Mentors",
"link_doctype": "LMS Course Mentor Mapping",
"link_fieldname": "course"
},
{
"group": "Mentors",
"link_doctype": "LMS Mentor Request",
"link_fieldname": "course"
}
],
"modified": "2021-05-03 05:52:30.396824",
"modified": "2021-06-21 11:34:04.552376",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",
@@ -108,7 +125,7 @@
"write": 1
}
],
"search_fields": "slug",
"search_fields": "title",
"sort_field": "creation",
"sort_order": "DESC",
"title_field": "title",

View File

@@ -5,14 +5,21 @@
from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
import json
from ...utils import slugify
from community.query import find, find_all
from frappe.utils import flt
class LMSCourse(Document):
@staticmethod
def find(slug):
"""Returns the course with specified slug.
def find(name):
"""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
def find_all():
@@ -20,30 +27,15 @@ class LMSCourse(Document):
"""
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):
result = frappe.get_all(
'LMS Course',
fields=['slug'])
slugs = set([row['slug'] for row in result])
fields=['name'])
slugs = set([row['name'] for row in result])
return slugify(title, used_slugs=slugs)
def __repr__(self):
return f"<Course#{self.name} {self.slug}>"
def get_topic(self, slug):
"""Returns the topic with given slug in this course as a Document.
"""
result = frappe.get_all(
"LMS Topic",
filters={"course": self.name, "slug": slug})
if result:
row = result[0]
return frappe.get_doc('LMS Topic', row['name'])
return f"<Course#{self.name}>"
def has_mentor(self, email):
"""Checks if this course has a mentor with given email.
@@ -51,21 +43,9 @@ class LMSCourse(Document):
if not email or email == "Guest":
return False
member = self.get_community_member(email)
if not member:
return False
mapping = frappe.get_all("LMS Course Mentor Mapping", {"course": self.name, "mentor": member})
mapping = frappe.get_all("LMS Course Mentor Mapping", {"course": self.name, "mentor": email})
return mapping != []
def get_community_member(self, email):
"""Returns the name of Community Member document for a give user.
"""
try:
return frappe.db.get_value("Community Member", {"email": email}, "name")
except frappe.DoesNotExistError:
return None
def add_mentor(self, email):
"""Adds a new mentor to the course.
"""
@@ -78,14 +58,10 @@ class LMSCourse(Document):
if self.has_mentor(email):
return
member = self.get_community_member(email)
if not member:
return False
doc = frappe.get_doc({
"doctype": "LMS Course Mentor Mapping",
"course": self.name,
"mentor": member
"mentor": email
})
doc.insert()
@@ -95,7 +71,7 @@ class LMSCourse(Document):
course_mentors = []
mentors = frappe.get_all("LMS Course Mentor Mapping", {"course": self.name}, ["mentor"])
for mentor in mentors:
member = frappe.get_doc("Community Member", mentor.mentor)
member = frappe.get_doc("User", mentor.mentor)
# TODO: change this to count query
member.batch_count = len(frappe.get_all("LMS Batch Membership", {"member": member.name, "member_type": "Mentor"}))
course_mentors.append(member)
@@ -106,31 +82,49 @@ class LMSCourse(Document):
"""
if not email:
return False
member = self.get_community_member(email)
return frappe.db.exists({
"doctype": "LMS Course Mentor Mapping",
"course": self.name,
"mentor": member
})
return frappe.db.count("LMS Course Mentor Mapping",
{
"course": self.name,
"mentor": email
})
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
batch_name = frappe.get_value(
doctype="LMS Batch Membership",
filters={
"course": self.name,
"member_type": "Student",
"member": email
},
fieldname="batch")
return batch_name and frappe.get_doc("LMS Batch", batch_name)
def get_instructor(self):
member_name = self.get_community_member(self.owner)
return frappe.get_doc("Community Member", member_name)
return frappe.get_doc("User", self.owner)
def get_chapters(self):
"""Returns all chapters of this course.
"""
# 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="index_")
def get_batch(self, batch_name):
return find("LMS Batch", name=batch_name, course=self.name)
def get_batches(self, mentor=None):
batches = find_all("LMS Batch", course=self.name)
if mentor:
# TODO: optimize this
member = self.get_community_member(email=mentor)
memberships = frappe.db.get_all(
"LMS Batch Membership",
{"member": member},
{"member": mentor},
["batch"])
batch_names = {m.batch for m in memberships}
return [b for b in batches if b.name in batch_names]
@@ -139,24 +133,170 @@ class LMSCourse(Document):
now = frappe.utils.nowdate()
batches = find_all("LMS Batch",
course=self.name,
start_date=[">", now])
start_date=[">", now],
status="Active",
visibility="Public")
return batches
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 get_chapter(self, index):
return find("Chapter", course=self.name, index_=index)
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))
def get_lesson(self, chapter_index, lesson_index):
chapter_name = frappe.get_value(
"Chapter",
{"course": self.name, "index_": chapter_index},
"name")
lesson_name = chapter_name and frappe.get_value(
"Lesson",
{"chapter": chapter_name, "index_": lesson_index},
"name")
return lesson_name and frappe.get_doc("Lesson", lesson_name)
def get_lesson_index(self, lesson_name):
"""Returns the {chapter_index}.{lesson_index} for the lesson.
"""
lesson = frappe.get_doc("Lesson", lesson_name)
chapter = frappe.get_doc("Chapter", lesson.chapter)
return f"{chapter.index_}.{lesson.index_}"
def reindex_lessons(self):
for i, c in enumerate(self.get_chapters(), start=1):
c.index_ = i
c.save()
self._reindex_lessons_in_chapter(c)
def _reindex_lessons_in_chapter(self, c):
for i, lesson in enumerate(c.get_lessons(), start=1):
lesson.index = i
lesson.index_label = f"{c.index_}.{i}"
lesson.save()
def reindex_exercises(self):
for i, c in enumerate(self.get_chapters(), start=1):
if c.index_ != i:
c.index_ = i
c.save()
self._reindex_exercises_in_chapter(c)
def _reindex_exercises_in_chapter(self, c):
i = 1
for lesson in c.get_lessons():
for exercise in lesson.get_exercises():
exercise.index_ = i
exercise.index_label = f"{c.index_}.{i}"
exercise.save()
i += 1
def get_learn_url(self, lesson_number):
if not lesson_number:
return
return f"/courses/{self.name}/learn/{lesson_number}"
def get_membership(self, member, batch=None):
filters = {
"member": member,
"course": self.name
}
if batch:
filters["batch"] = batch
membership = frappe.db.get_value("LMS Batch Membership", filters, ["name","batch", "current_lesson"], as_dict=True)
if membership and membership.batch:
membership.batch_title = frappe.db.get_value("LMS Batch", membership.batch, "title")
return membership
def get_all_memberships(self, member):
all_memberships = frappe.get_all("LMS Batch Membership", {"member": member, "course": self.name}, ["batch"])
for membership in all_memberships:
membership.batch_title = frappe.db.get_value("LMS Batch", membership.batch, "title")
return all_memberships
def get_mentors(self, batch=None):
filters = {
"course": self.name,
"member_type": "Mentor"
}
if batch:
filters["batch"] = batch
memberships = frappe.get_all(
"LMS Batch Membership",
filters,
["member"])
member_names = [m['member'] for m in memberships]
return find_all("User", name=["IN", member_names])
def get_students(self, batch=None):
"""Returns (email, full_name, username) of all the students of this batch as a list of dict.
"""
filters = {
"course": self.name,
"member_type": "Student"
}
if batch:
filters["batch"] = batch
memberships = frappe.get_all(
"LMS Batch Membership",
filters,
["member"])
member_names = [m['member'] for m in memberships]
return find_all("User", name=["IN", member_names])
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):
current = flt(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):
current = flt(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_"],
order_by="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'] = flt("{}.{}".format(chapter_numbers[lesson['chapter']], lesson['index_']))
return lessons
@frappe.whitelist()
def reindex_lessons(doc):
course_data = json.loads(doc)
course = frappe.get_doc("LMS Course", course_data['name'])
course.reindex_lessons()
frappe.msgprint("All lessons in this course have been re-indexed.")
@frappe.whitelist()
def reindex_exercises(doc):
course_data = json.loads(doc)
course = frappe.get_doc("LMS Course", course_data['name'])
course.reindex_exercises()
frappe.msgprint("All exercises in this course have been re-indexed.")

View File

@@ -11,12 +11,13 @@ class TestLMSCourse(unittest.TestCase):
def setUp(self):
frappe.db.sql('delete from `tabLMS Course Mentor Mapping`')
frappe.db.sql('delete from `tabLMS Course`')
frappe.db.sql('delete from `tabCommunity Member`')
def new_course(self, title):
doc = frappe.get_doc({
"doctype": "LMS Course",
"title": title
"title": title,
"short_introduction": title,
"description": title
})
doc.insert()
return doc
@@ -24,8 +25,7 @@ class TestLMSCourse(unittest.TestCase):
def test_new_course(self):
course = self.new_course("Test Course")
assert course.title == "Test Course"
assert course.slug == "test-course"
assert course.get_mentors() == []
assert course.name == "test-course"
def test_find_all(self):
courses = LMSCourse.find_all()
@@ -41,7 +41,7 @@ class TestLMSCourse(unittest.TestCase):
# now we should find one course
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
def _test_add_mentors(self):

View File

@@ -23,7 +23,7 @@
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Mentor",
"options": "Community Member"
"options": "User"
},
{
"fetch_from": "mentor.full_name",
@@ -36,7 +36,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-20 12:46:48.460934",
"modified": "2021-05-21 11:48:43.340315",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course Mentor Mapping",

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on('LMS Course Progress', {
// refresh: function(frm) {
// }
});

View File

@@ -0,0 +1,78 @@
{
"actions": [],
"creation": "2021-05-31 17:20:13.388453",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"status",
"column_break_3",
"lesson",
"chapter",
"course"
],
"fields": [
{
"fetch_from": "chapter.course",
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fetch_from": "lesson.chapter",
"fieldname": "chapter",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Chapter",
"options": "Chapter",
"read_only": 1
},
{
"fieldname": "lesson",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Lesson",
"options": "Lesson"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "Complete\nPartially Complete\nIncomplete"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-06-02 13:05:31.114939",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course Progress",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class CodeRevision(Document):
class LMSCourseProgress(Document):
pass

View File

@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestCodeRevision(unittest.TestCase):
class TestLMSCourseProgress(unittest.TestCase):
pass

View File

@@ -19,7 +19,7 @@
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Member",
"options": "Community Member"
"options": "User"
},
{
"fieldname": "course",
@@ -52,7 +52,7 @@
"fieldname": "reviewed_by",
"fieldtype": "Link",
"label": "Reviewed By",
"options": "Community Member"
"options": "User"
},
{
"fieldname": "comments",
@@ -62,7 +62,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-19 09:27:03.814016",
"modified": "2021-05-21 11:49:12.543502",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Mentor Request",

View File

@@ -8,86 +8,115 @@ from frappe.model.document import Document
from frappe import _
class LMSMentorRequest(Document):
def on_update(self):
if self.has_value_changed('status'):
template = frappe.db.get_single_value('LMS Settings', 'mentor_request_status_update')
if not template:
return
def on_update(self):
if self.has_value_changed('status'):
email_template = frappe.get_doc('Email Template', template)
message = frappe.render_template(email_template.response, {'member_name': self.member_name, 'status': self.status})
subject = _('The status of your application has changed.')
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')
send_email(member_email, [get_course_author(self.course), reviewed_by], subject, message)
elif self.status == 'Withdrawn':
send_email([member_email, get_course_author(self.course)], None, subject, message)
if self.status == "Approved":
self.create_course_mentor_mapping()
if self.status != "Pending":
self.send_status_change_email()
def create_course_mentor_mapping(self):
mapping = frappe.get_doc({
"doctype": "LMS Course Mentor Mapping",
"mentor": self.member,
"course": self.course
})
mapping.save()
def send_creation_email(self):
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': frappe.db.get_value("User", frappe.session.user, "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
})
if self.status == 'Approved' or self.status == 'Rejected':
email_args = {
"recipients": self.member,
"cc": [course_details.owner, self.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": [self.member, 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()
def has_requested(course):
return len(frappe.get_all('LMS Mentor Request',
filters = {
'member': get_member().name,
'course': course,
'status': ['in', ('Pending', 'Approved')]
}
)
return frappe.db.count('LMS Mentor Request',
filters = {
'member': frappe.session.user,
'course': course,
'status': ['in', ('Pending', 'Approved')]
}
)
@frappe.whitelist()
def create_request(course):
if not has_requested(course):
member = get_member()
frappe.get_doc({
'doctype': 'LMS Mentor Request',
'member': member.name,
'course': course,
'status': 'Pending'
}).save(ignore_permissions=True)
send_creation_email(course, member)
return 'OK'
else:
return 'Already Applied'
if not has_requested(course):
request = frappe.get_doc({
'doctype': 'LMS Mentor Request',
'member': frappe.session.user,
'course': course,
'status': 'Pending'
})
request.save(ignore_permissions=True)
request.send_creation_email()
return 'OK'
else:
return 'Already Applied'
@frappe.whitelist()
def cancel_request(course):
request = frappe.get_doc('LMS Mentor Request', {'member': get_member().name, 'course': course, 'status': ['in', ('Pending', 'Approved')]})
request.status = 'Withdrawn'
request.save(ignore_permissions=True)
return 'OK'
def get_member():
try:
return frappe.get_doc('Community Member', {'email': frappe.session.user})
except frappe.DoesNotExistError:
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
)
request = frappe.get_doc('LMS Mentor Request',{
'member': frappe.session.user,
'course': course,
'status': ['in', ('Pending', 'Approved')]
}
)
request.status = 'Withdrawn'
request.save(ignore_permissions=True)
return 'OK'

View File

@@ -26,7 +26,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Author",
"options": "Community Member"
"options": "User"
},
{
"fieldname": "message",
@@ -58,7 +58,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-28 10:17:57.618127",
"modified": "2021-05-21 11:49:34.911479",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Message",
@@ -82,4 +82,4 @@
"sort_order": "DESC",
"title_field": "author",
"track_changes": 1
}
}

View File

@@ -7,98 +7,104 @@ import frappe
from frappe.model.document import Document
from frappe import _
from frappe.utils import add_days, nowdate
from community.www.courses.utils import get_batch_members
class LMSMessage(Document):
""" def after_insert(self):
self.send_email() """
def after_insert(self):
self.publish_message()
#Todo: Adding email preference field for users
#self.send_email()
def publish_message(self):
template = self.get_message_template()
message = frappe._dict({
"author_name": self.author_name,
"message_time": frappe.utils.format_datetime(self.creation, "dd-mm-yyyy HH:mm"),
"message": frappe.utils.md_to_html(self.message)
})
js = """
$(".msger-input").val("");
var template = `{0}`;
var message = {1};
var session_user = ("{2}" == frappe.session.user) ? true : false;
message.author_name = session_user ? "You" : message.author_name
message.is_author = session_user;
template = frappe.render_template(template, {{
"message": message
}})
$(".messages").append(template);
var message_element = document.getElementsByClassName("messages")[0]
message_element.scrollTo(0, message_element.scrollHeight);
""".format(template, message, self.owner)
frappe.publish_realtime(event="eval_js", message=js, after_commit=True)
def get_message_template(self):
return """
<li class="{% if message.is_author %} ours {% endif %}">
<div class="d-flex justify-content-between">
<div class="font-weight-bold">
{{ message.author_name }}
</div>
<small class="">
{{ message.message_time }}
</small>
</div>
<div class="message-para">
{{ message.message }}
</div>
</li>
"""
def send_email(self):
membership = frappe.get_all("LMS Batch Membership", {"batch": self.batch}, ["member"])
for entry in membership:
member = frappe.get_doc("User", entry.member)
if member.name != self.author:
#Todo: wrap sendmail in frappe.enqueue, else messages takes long to display.
frappe.sendmail(
recipients = member.email,
subject = _("New Message on ") + self.batch,
header = _("New Message on ") + self.batch,
template = "lms_message",
args = {
"author": self.author,
"message": frappe.utils.md_to_html(self.message),
"creation": frappe.utils.format_datetime(self.creation, "medium"),
"course": frappe.db.get_value("LMS Batch", self.batch, ["course"])
}
)
def send_email(self):
membership = frappe.get_all("LMS Batch Membership", {"batch": self.batch}, ["member"])
for entry in membership:
member = frappe.get_doc("Community Member", entry.member)
if member.name != self.author and member.email_preference == "Email on every Message":
frappe.sendmail(
recipients = member.email,
subject = _("New Message on ") + self.batch,
header = _("New Message on ") + self.batch,
template = "lms_message",
args = {
"author": self.author,
"message": frappe.utils.md_to_html(self.message),
"creation": frappe.utils.format_datetime(self.creation, "medium"),
"course": frappe.db.get_value("LMS Batch", self.batch, ["course"])
}
)
def send_daily_digest():
emails = frappe._dict()
messages = frappe.get_all("LMS Message", {"creation": [">=", add_days(nowdate(), -1)]}, ["message", "batch", "author", "creation"])
for message in messages:
membership = frappe.get_all("LMS Batch Membership", {"batch": message.batch}, ["member"])
for entry in membership:
member = frappe.db.get_value("Community Member", entry.member, ["name", "email_preference", "email"], as_dict=1)
if member.name != message.author and member.email_preference == "One Digest Mail per day":
if member.name in emails.keys():
emails[member.name]["messages"].append(message)
else:
emails[member.name] = frappe._dict({
"email": member.email,
"messages": [message]
})
for email in emails:
group_by_batch = frappe._dict()
for message in emails[email]["messages"]:
if message.batch in group_by_batch.keys():
group_by_batch[message.batch].append(message)
else:
group_by_batch[message.batch] = [message]
frappe.sendmail(
recipients = frappe.db.get_value("Community Member", email, "email"),
subject = _("Message Digest"),
header = _("Message Digest"),
template = "lms_daily_digest",
args = {
"batches": group_by_batch
},
delayed = False
)
def publish_message(doc, method):
email = frappe.db.get_value("Community Member", doc.author, "email")
template = get_message_template()
message = frappe._dict()
message.author_name = doc.author_name
message.message_time = frappe.utils.pretty_date(doc.creation)
message.message = frappe.utils.md_to_html(doc.message)
js = """
$(".msger-input").val("");
var template = `{0}`;
var message = {1};
var session_user = ("{2}" == frappe.session.user) ? true : false;
message.author_name = session_user ? "You" : message.author_name
message.is_author = session_user;
template = frappe.render_template(template, {{
"message": message
}})
$(".message-section").append(template);
""".format(template, message, email)
frappe.publish_realtime(event="eval_js", message=js, after_commit=True)
def get_message_template():
return """
<div class="discussion {% if message.is_author %} is-author {% endif %}">
<div class="d-flex justify-content-between">
<div class="font-weight-bold">
{{ message.author_name }}
</div>
<div class="text-muted">
{{ message.message_time }}
</div>
</div>
<div class="mt-5">
{{ message.message }}
</div>
</div>
"""
#Todo: Optimize this
emails = frappe._dict()
messages = frappe.get_all("LMS Message", {"creation": [">=", add_days(nowdate(), -1)]}, ["message", "batch", "author", "creation"])
for message in messages:
membership = frappe.get_all("LMS Batch Membership", {"batch": message.batch}, ["member"])
for entry in membership:
member = frappe.db.get_value("User", entry.member, ["name", "email"], as_dict=1)
if member.name != message.author:
if member.name in emails.keys():
emails[member.name]["messages"].append(message)
else:
emails[member.name] = frappe._dict({
"email": member.email,
"messages": [message]
})
for email in emails:
group_by_batch = frappe._dict()
for message in emails[email]["messages"]:
if message.batch in group_by_batch.keys():
group_by_batch[message.batch].append(message)
else:
group_by_batch[message.batch] = [message]
frappe.sendmail(
recipients = frappe.db.get_value("User", email, "email"),
subject = _("Message Digest"),
header = _("Message Digest"),
template = "lms_daily_digest",
args = {
"batches": group_by_batch
},
delayed = False
)

View File

@@ -0,0 +1,36 @@
{
"actions": [],
"creation": "2021-06-07 10:46:10.402684",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"option",
"is_correct"
],
"fields": [
{
"fieldname": "option",
"fieldtype": "Data",
"label": "Option"
},
{
"default": "0",
"fieldname": "is_correct",
"fieldtype": "Check",
"label": "Is Correct"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-06-07 10:48:45.290227",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Option",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSOption(Document):
pass

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on('Code Revision', {
frappe.ui.form.on('LMS Quiz', {
// refresh: function(frm) {
// }

View File

@@ -1,41 +1,41 @@
{
"actions": [],
"creation": "2021-04-07 00:26:28.806520",
"autoname": "field:title",
"creation": "2021-06-07 10:50:17.893625",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"section",
"code",
"author"
"title",
"questions",
"lesson"
],
"fields": [
{
"fieldname": "section",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Section",
"options": "LMS Section"
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"unique": 1
},
{
"fieldname": "code",
"fieldtype": "Code",
"label": "Code"
"fieldname": "questions",
"fieldtype": "Table",
"label": "Questions",
"options": "LMS Quiz Question"
},
{
"fieldname": "author",
"fieldname": "lesson",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Author",
"options": "User"
"label": "Lesson",
"options": "Lesson"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-14 11:26:19.628317",
"modified": "2021-06-23 17:58:57.642873",
"modified_by": "Administrator",
"module": "LMS",
"name": "Code Revision",
"name": "LMS Quiz",
"owner": "Administrator",
"permissions": [
{
@@ -53,6 +53,5 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "section",
"track_changes": 1
}

View File

@@ -0,0 +1,79 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
from community.lms.doctype.lesson.lesson import update_progress
import frappe
from frappe.model.document import Document
import json
from frappe import _
from ..lesson.lesson import update_progress
class LMSQuiz(Document):
def validate(self):
self.validate_correct_answers()
def validate_correct_answers(self):
for question in self.questions:
correct_options = self.get_correct_options(question)
if len(correct_options) > 1:
question.multiple = 1
if not len(correct_options):
frappe.throw(_("At least one answer must be correct for this question: {0}").format(frappe.bold(question.question)))
def get_correct_options(self, question):
correct_option_fields = ["is_correct_1", "is_correct_2", "is_correct_3", "is_correct_4"]
return list(filter(lambda x: question.get(x) == 1, correct_option_fields))
def get_last_submission_details(self):
"""Returns the latest submission for this user.
"""
user = frappe.session.user
if not user or user == "Guest":
return
result = frappe.get_all('LMS Quiz Submission',
fields="*",
filters={
"owner": user,
"quiz": self.name
},
order_by="creation desc",
page_length=1)
if result:
return result[0]
@frappe.whitelist()
def submit(quiz, result):
score = 0
answer_map = {
"is_correct_1": "option_1",
"is_correct_2": "option_2",
"is_correct_3": "option_3",
"is_correct_4": "option_4"
}
result = json.loads(result)
quiz_details = frappe.get_doc("LMS Quiz", quiz)
for response in result:
match = list(filter(lambda x: x.question == response.get("question"), quiz_details.questions))[0]
correct_options = quiz_details.get_correct_options(match)
correct_answers = [ match.get(answer_map[option]) for option in correct_options ]
if response.get("answer") == correct_answers:
response["result"] = "Right"
score += 1
else:
response["result"] = "Wrong"
response["answer"] = ("").join([ ans if idx == len(response.get("answer")) -1 else ans + ", " for idx, ans in enumerate(response.get("answer")) ])
frappe.get_doc({
"doctype": "LMS Quiz Submission",
"quiz": quiz,
"result": result,
"score": score
}).save(ignore_permissions=True)
update_progress(quiz_details.lesson)
return score

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestLMSQuiz(unittest.TestCase):
pass

View File

@@ -0,0 +1,118 @@
{
"actions": [],
"creation": "2021-06-07 10:48:57.994714",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"question",
"options_section",
"option_1",
"is_correct_1",
"section_break_5",
"option_2",
"is_correct_2",
"column_break_4",
"option_3",
"is_correct_3",
"section_break_11",
"option_4",
"is_correct_4",
"multiple"
],
"fields": [
{
"fieldname": "question",
"fieldtype": "Text",
"in_list_view": 1,
"label": "Question",
"reqd": 1
},
{
"fieldname": "option_1",
"fieldtype": "Data",
"label": "Option 1",
"reqd": 1
},
{
"fieldname": "option_2",
"fieldtype": "Data",
"label": "Option 2",
"reqd": 1
},
{
"fieldname": "option_3",
"fieldtype": "Data",
"label": "Option 3"
},
{
"fieldname": "option_4",
"fieldtype": "Data",
"label": "Option 4"
},
{
"default": "0",
"depends_on": "option_1",
"fieldname": "is_correct_1",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"depends_on": "option_2",
"fieldname": "is_correct_2",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"depends_on": "option_3",
"fieldname": "is_correct_3",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"depends_on": "option_4",
"fieldname": "is_correct_4",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"fieldname": "multiple",
"fieldtype": "Check",
"hidden": 1,
"label": "Multiple Correct Answers",
"read_only": 1
},
{
"fieldname": "options_section",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_4",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_5",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_11",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-06-22 16:54:13.133859",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Question",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSQuizQuestion(Document):
pass

View File

@@ -0,0 +1,45 @@
{
"actions": [],
"creation": "2021-06-07 14:19:23.683323",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"question",
"answer",
"result"
],
"fields": [
{
"fieldname": "question",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Question"
},
{
"fieldname": "result",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Result",
"options": "Right\nWrong"
},
{
"fieldname": "answer",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Users Response"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-06-22 18:32:28.813159",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Result",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSQuizResult(Document):
pass

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on('LMS Quiz Submission', {
// refresh: function(frm) {
// }
});

View File

@@ -0,0 +1,55 @@
{
"actions": [],
"creation": "2021-06-07 14:19:54.958989",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"quiz",
"result",
"score"
],
"fields": [
{
"fieldname": "quiz",
"fieldtype": "Link",
"label": "Quiz",
"options": "LMS Quiz"
},
{
"fieldname": "result",
"fieldtype": "Table",
"label": "Result",
"options": "LMS Quiz Result"
},
{
"fieldname": "score",
"fieldtype": "Data",
"label": "Score"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-06-07 14:19:54.958989",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Submission",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSQuizSubmission(Document):
pass

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestLMSQuizSubmission(unittest.TestCase):
pass

View File

@@ -1,60 +0,0 @@
{
"actions": [],
"creation": "2021-03-05 15:10:53.906006",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"label",
"type",
"contents",
"code",
"attrs",
"index"
],
"fields": [
{
"fieldname": "label",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Label"
},
{
"fieldname": "type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Type"
},
{
"fieldname": "contents",
"fieldtype": "Markdown Editor",
"label": "Contents"
},
{
"fieldname": "code",
"fieldtype": "Code",
"label": "Code"
},
{
"fieldname": "attrs",
"fieldtype": "Long Text",
"label": "attrs"
},
{
"fieldname": "index",
"fieldtype": "Int",
"label": "Index"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-03-12 17:56:23.118854",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Section",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -10,6 +10,14 @@ class LMSSection(Document):
def __repr__(self):
return f"<LMSSection {self.label!r}>"
def get_exercise(self):
if self.type == "exercise":
return frappe.get_doc("Exercise", self.id)
def get_quiz(self):
if self.type == "quiz":
return frappe.get_doc("LMS Quiz", self.id)
def get_latest_code_for_user(self):
"""Returns the latest code for the logged in user.
"""

View File

@@ -3,26 +3,102 @@
import websocket
import json
from .svg import SVG
import frappe
from urllib.parse import urlparse
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 get_livecode_url():
doc = frappe.get_cached_doc("LMS Settings")
return doc.livecode_url
def get_livecode_ws_url():
url = urlparse(get_livecode_url())
protocol = "wss" if url.scheme == "https" else "ws"
return protocol + "://" + url.netloc + "/livecode"
def livecode_to_svg(livecode_ws_url, code, *, timeout=3):
"""Renders the code as svg.
"""
print("livecode_to_svg")
ws = websocket.WebSocket()
ws.settimeout(timeout)
ws.connect(livecode_ws_url)
if livecode_ws_url is None:
livecode_ws_url = get_livecode_ws_url()
msg = {
"msgtype": "exec",
"runtime": "python-canvas",
"code": code
}
ws.send(json.dumps(msg))
try:
ws = websocket.WebSocket()
ws.settimeout(timeout)
ws.connect(livecode_ws_url)
messages = _read_messages(ws)
commands = [m['cmd'] for m in messages if m['msgtype'] == 'draw']
img = draw_image(commands)
return img.tostring()
msg = {
"msgtype": "exec",
"runtime": "python",
"code": code,
"files": [
{"filename": "start.py", "contents": START},
{"filename": "sketch.py", "contents": SKETCH},
],
"command": ["python", "start.py"]
}
ws.send(json.dumps(msg))
messages = _read_messages(ws)
commands = [m for m in messages if m['msgtype'] == 'draw']
img = draw_image(commands)
return img.tostring()
except websocket.WebSocketException as e:
frappe.log_error(frappe.get_traceback(), 'livecode_to_svg failed')
def _read_messages(ws):
messages = []
@@ -32,17 +108,19 @@ def _read_messages(ws):
if not msg:
break
messages.append(json.loads(msg))
except websocket.WebSocketTimeoutException:
except websocket.WebSocketTimeoutException as e:
print("Error:", e)
pass
return messages
def draw_image(commands):
img = SVG(width=300, height=300, viewBox="0 0 300 300", fill='none', stroke='black')
for c in commands:
args = c['args']
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':
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':
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

View File

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

View File

@@ -1,89 +0,0 @@
{
"actions": [],
"allow_guest_to_view": 1,
"creation": "2021-03-02 07:20:41.686573",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"title",
"slug",
"preview",
"description",
"order",
"sections"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "description",
"fieldtype": "Markdown Editor",
"label": "Description"
},
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Course",
"options": "LMS Course",
"reqd": 1
},
{
"fieldname": "order",
"fieldtype": "Int",
"label": "Order"
},
{
"fieldname": "preview",
"fieldtype": "Markdown Editor",
"label": "Preview"
},
{
"fieldname": "sections",
"fieldtype": "Table",
"label": "Sections",
"options": "LMS Section"
},
{
"description": "The slug of the topic. Autogenerated from the title if not specified.",
"fieldname": "slug",
"fieldtype": "Data",
"label": "Slug"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-04-06 14:12:48.514062",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Topic",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"search_fields": "title",
"sort_field": "creation",
"sort_order": "ASC",
"title_field": "title",
"track_changes": 1,
"track_seen": 1,
"track_views": 1
}

View File

@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from .section_parser import SectionParser
from ...utils import slugify
class LMSTopic(Document):
def before_save(self):
course = self.get_course()
if not self.slug:
self.slug = self.generate_slug(title=self.title)
sections = SectionParser().parse(self.description or "")
self.sections = [self.make_lms_section(i, s) for i, s in enumerate(sections)]
def get_course(self):
return frappe.get_doc("LMS Course", self.course)
def generate_slug(self, title):
result = frappe.get_all(
'LMS Topic',
filters={'course': self.course},
fields=['slug'])
slugs = set([row['slug'] for row in result])
return slugify(title, used_slugs=slugs)
def get_sections(self):
return sorted(self.sections, key=lambda s: s.index)
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

View File

@@ -1,79 +0,0 @@
"""Utility to split the text in the topic into multiple sections.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import List, Tuple, Dict, Iterator
RE_SECTION = re.compile(r"^\{\{\s(\w+)\s*(?:\((.*)\))?\s*\}\}\s*")
class SectionParser:
def parse(self, text: str) -> Iterator[Section]:
"""Parses given text into sections and return an iterator over sections.
"""
lines = text.splitlines()
marked_lines = self.parse_lines(lines)
return self.group_sections(marked_lines)
def parse_lines(self, lines: List[str]) -> List[Tuple[str, str, str]]:
for line in lines:
m = RE_SECTION.match(line)
if m:
yield m.group(1), self.parse_attrs(m.group(2)), None
else:
yield None, None, line
def parse_attrs(self, attrs_str: str) -> Dict[str, str]:
# XXX-Anand: Hack
code = "dict({})".format(attrs_str or "")
return eval(code)
def group_sections(self, marked_lines) -> Iterator[Section]:
index = 0
def make_section(type='text', id=None, label=None, **attrs):
nonlocal index
index += 1
id = id or f"section-{index}"
label = label or id
return Section(
type=type,
id=id,
label=label,
attrs=attrs)
section = make_section("text")
for mark, attrs, line in marked_lines:
if not mark:
section.append(line)
continue
yield section
if mark == 'end':
section = make_section(type='text')
else:
section = make_section(**attrs)
yield section
@dataclass
class Section:
"""One section of the Topic.
"""
type: str
id: str
label: str
contents: str = ""
attrs: dict = None
def append(self, line):
if not line.endswith("\n"):
line = line + "\n"
self.contents += line
def __repr__(self):
attrs = dict(type=self.type, id=self.id, label=self.label, **self.attrs)
attrs_str = ", ".join(f'{k}="{v}"' for k, v in attrs.items())
return f'<Section({attrs_str})>'

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

@@ -0,0 +1,109 @@
"""
The md module extends markdown to add macros.
Macros can be added to the markdown text in the following format.
{{ MacroName("macro-argument") }}
These macros will be rendered using a pluggable mechanism.
Apps can provide a hook community_markdown_macro_renderers, a
dictionary mapping the macro name to the function that to render
that macro. The function will get the argument passed to the macro
as argument.
"""
import frappe
import re
from bs4 import BeautifulSoup
import markdown
from markdown import Extension
from markdown.inlinepatterns import InlineProcessor
import xml.etree.ElementTree as etree
def markdown_to_html(text):
"""Renders markdown text into html.
"""
return markdown.markdown(text, extensions=['fenced_code', MacroExtension()])
def find_macros(text):
"""Returns all macros in the given text.
>>> find_macros(text)
[
('YouTubeVideo': 'abcd1234')
('Exercise', 'two-circles'),
('Exercise', 'four-circles')
]
"""
if not text:
return []
macros = re.findall(MACRO_RE, text)
# remove the quotes around the argument
return [(name, _remove_quotes(arg)) for name, arg in macros]
def _remove_quotes(value):
"""Removes quotes around a value.
Also strips the whitespace.
>>> _remove_quotes('"hello"')
'hello'
>>> _remove_quotes("'hello'")
'hello'
>>> _remove_quotes("hello")
'hello'
"""
return value.strip(" '\"")
def get_macro_registry():
d = frappe.get_hooks("community_markdown_macro_renderers") or {}
return {name: frappe.get_attr(klass[0]) for name, klass in d.items()}
def render_macro(macro_name, macro_argument):
# stripping the quotes on either side of the argument
macro_argument = _remove_quotes(macro_argument)
registry = get_macro_registry()
if macro_name in registry:
return registry[macro_name](macro_argument)
else:
return f"<p>Unknown macro: {macro_name}</p>"
MACRO_RE = r'{{ *(\w+)\(([^{}]*)\) *}}'
class MacroExtension(Extension):
"""MacroExtension is a markdown extension to support macro syntax.
"""
def extendMarkdown(self, md):
self.md = md
pattern = MacroInlineProcessor(MACRO_RE)
pattern.md = md
md.inlinePatterns.register(pattern, 'macro', 75)
class MacroInlineProcessor(InlineProcessor):
"""MacroInlineProcessor is class that is handles the logic
of how to render each macro occurence in the markdown text.
"""
def handleMatch(self, m, data):
"""Handles each macro match and return rendered contents
for that macro as an etree node.
"""
macro = m.group(1)
arg = m.group(2)
html = render_macro(macro, arg)
html = sanitize_html(str(html))
e = etree.fromstring(html)
return e, m.start(0), m.end(0)
def sanitize_html(html):
"""Sanotize the html using BeautifulSoup.
The markdown processor request the correct markup and crashes on
any broken tags. This makes sures that all those things are fixed
before passing to the etree parser.
"""
soup = BeautifulSoup(html, features="lxml")
nodes = soup.body.children
return "<div>" + "\n".join(str(node) for node in nodes) + "</div>"

View File

@@ -2,4 +2,4 @@
"""
from .doctype.lms_course.lms_course import LMSCourse as Course
from .doctype.lms_sketch.lms_sketch import LMSSketch as Sketch
from .doctype.lms_batch_membership.lms_batch_membership import LMSBatchMembership as Membership

View File

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

View File

@@ -11,7 +11,7 @@
"apply_document_permissions": 0,
"button_label": "Save",
"creation": "2021-04-20 11:37:49.135114",
"custom_css": ".datepicker.active {\n background-color: white;\n}",
"custom_css": ".datepicker.active {\n background-color: white;\n}\n\n[data-doctype=\"Web Form\"] {\n max-width: 720px;\n margin: 6rem auto;\n}",
"doc_type": "LMS Batch",
"docstatus": 0,
"doctype": "Web Form",
@@ -19,7 +19,7 @@
"is_standard": 1,
"login_required": 1,
"max_attachment_size": 0,
"modified": "2021-04-30 11:22:18.188712",
"modified": "2021-06-15 18:49:50.530001",
"modified_by": "Administrator",
"module": "LMS",
"name": "add-a-new-batch",
@@ -39,12 +39,12 @@
"allow_read_on_all_link_options": 0,
"fieldname": "course",
"fieldtype": "Data",
"hidden": 0,
"hidden": 1,
"label": "Course",
"max_length": 0,
"max_value": 0,
"options": "",
"read_only": 1,
"options": "LMS Course",
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
@@ -111,4 +111,4 @@
"show_in_filter": 0
}
]
}
}

View File

@@ -1,3 +0,0 @@
frappe.ready(function() {
// bind events here
})

View File

@@ -1,48 +0,0 @@
{
"accept_payment": 0,
"allow_comments": 0,
"allow_delete": 0,
"allow_edit": 0,
"allow_incomplete": 0,
"allow_multiple": 0,
"allow_print": 0,
"amount": 0.0,
"amount_based_on_field": 0,
"apply_document_permissions": 0,
"button_label": "Save",
"creation": "2021-04-15 13:32:14.171328",
"doc_type": "LMS Batch Membership",
"docstatus": 0,
"doctype": "Web Form",
"idx": 0,
"is_standard": 1,
"login_required": 1,
"max_attachment_size": 0,
"modified": "2021-04-15 13:32:14.171328",
"modified_by": "Administrator",
"module": "LMS",
"name": "join-a-batch",
"owner": "Administrator",
"payment_button_label": "Buy Now",
"published": 1,
"route": "join-a-batch",
"route_to_success_link": 0,
"show_attachments": 0,
"show_in_grid": 0,
"show_sidebar": 0,
"sidebar_items": [],
"success_url": "/join-a-batch",
"title": "Join a Batch",
"web_form_fields": [
{
"allow_read_on_all_link_options": 0,
"fieldtype": "Attach",
"hidden": 0,
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
}
]
}

View File

@@ -1,7 +0,0 @@
from __future__ import unicode_literals
import frappe
def get_context(context):
# do your magic here
pass

View File

@@ -0,0 +1,4 @@
<div class="p-5 batch-header">
<h3>{{batch_name}}</h3>
<div class="text-muted">{{member_count}} members</div>
</div>

View File

@@ -0,0 +1,67 @@
<div class="mt-5">
<a class="anchor_style" href="/courses">Courses</a> /{% if course.is_mentor(frappe.session.user) %} <a
class="anchor_style" href="/courses/{{ course.name }}"> {{ course.title }}</a> {% else %} <span class="text-muted">
{{ course.title }}</span> {% endif %}
{% set all_memberships = course.get_all_memberships(frappe.session.user) %}
{% if membership and membership.batch and all_memberships | length > 1 %}
<a class="pull-right dropdown-item border rounded" style="width: 10rem;" href="#" id="navbarDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ membership.batch_title }}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
{% for data in all_memberships %}
{% if data.batch != membership.batch %}
<a class="dropdown-item switch-batch"
href="/courses/{{ course.name }}/home?batch={{ data.batch }}">{{ data.batch_title }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% if not membership %}
{% set display_class = "hide" %}
{% else %}
{% set display_class = "" %}
{% endif %}
<ul class="nav nav-tabs mt-4">
<li class="nav-item">
<a class="nav-link" id="home" href="/courses/{{course.name}}/home{{ course.query_parameter }}">Home</a>
</li>
<li class="nav-item">
{% set lesson_index = course.get_lesson_index(membership.current_lesson) if membership and membership.current_lesson
else '1.1' %}
<a class="nav-link" id="learn"
href="{{ course.get_learn_url(lesson_index) }}{{ course.query_parameter }}">Lessons</a>
</li>
<!-- <li class="nav-item">
<a class="nav-link" id="schedule" href="/courses/{{course.name}}/schedule">Schedule</a>
</li> -->
<li class="nav-item {{ display_class }}">
<a class="nav-link" id="members" href="/courses/{{course.name}}/members{{ course.query_parameter }}">Members</a>
</li>
<!-- <li class="nav-item {{ display_class }}">
<a class="nav-link" id="discussion" href="/courses/{{course.name}}/discuss">Discussion</a>
</li> -->
<!-- <li class="nav-item">
<a class="nav-link" id="about" href="/courses/{{course.name}}/about">About</a>
</li> -->
{% if membership and membership.batch and course.is_mentor(frappe.session.user) %}
<li class="nav-item">
<a class="nav-link" id="progress" href="/courses/{{course.name}}/progress{{ course.query_parameter }}">Progress</a>
</li>
{% endif %}
</ul>
{% block script %}
<script>
frappe.ready(() => {
var selector = document.querySelector(`a[href="${decodeURIComponent(window.location.pathname)}{{ course.query_parameter }}"]`)
if (selector) {
selector.classList.add('active');
}
else {
$("#learn").addClass('active')
}
})
</script>
{% endblock %}

View File

@@ -1,14 +1,27 @@
<div class="chapter-teaser">
<div class="teaser-body">
<h3 class="chapter-title">{{ chapter.title }}</h3>
<div class="chapter-title mb-5 font-weight-bold"><span class="mr-1">{{index}}.</span> {{ chapter.title }}</div>
<div class="chapter-description">
{{ chapter.description or "" }}
</div>
<div class="chapter-lessons">
{% for lesson in chapter.get_lessons() %}
<div class="lesson-teaser">
{{lesson.title}}
<div class="lesson-teaser">
{% if show_link or lesson.include_in_preview %}
<a class="" href="{{ course.get_learn_url(course.get_lesson_index(lesson.name)) }}{{course.query_parameter}}"
data-course="{{ course.name }}">{{ lesson.title }}</a>
{% else %}
<div class="no-preview" title="This lesson is not available for preview">
<span style="color: #2490ef;">
{{ lesson.title }}
</span>
<i class="fa fa-lock ml-2"></i>
</div>
{% endif %}
{% if show_progress and not course.is_mentor(frappe.session.user) and lesson.get_progress() %}
<span class="ml-5 badge p-2 {{ lesson.get_slugified_class() }}"> {{ lesson.get_progress() }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,7 @@
<div class="mt-5">
<h3> Course Outline </h3>
{% for chapter in course.get_chapters() %}
{{ widgets.ChapterTeaser(index=loop.index, chapter=chapter, course=course, batch=batch, show_link=show_link, show_progress=show_progress)}}
{% endfor %}
</div>

View File

@@ -1,13 +1,19 @@
<div class="course-teaser">
<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">
{{ course.short_introduction or "" }}
</div>
</div>
<div class="course-footer">
{% set batch = course.get_student_batch(frappe.session.user) %}
{% if batch %}
<a class="btn btn-secondary pull-right" href="/courses/{{course.name}}/{{batch.name}}/learn">Resume Course</a>
{% endif %}
<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>

View File

@@ -0,0 +1,18 @@
{% from "www/macros/livecode.html" import LiveCodeEditorJS, LiveCodeEditor with context %}
<div class="exercise">
<h2>Exercise {{exercise.index_label}}: {{ exercise.title }}</h2>
<div class="exercise-description">{{frappe.utils.md_to_html(exercise.description)}}</div>
{% if exercise.image %}
<div class="exercise-image">{{exercise.image}}</div>
{% endif %}
{% set submission = exercise.get_user_submission() %}
{{ LiveCodeEditor(exercise.name,
code=submission.solution if submission else exercise.code,
reset_code=exercise.code,
is_exercise=True,
last_submitted=submission and submission.creation) }}
</div>

View File

@@ -0,0 +1,6 @@
<div class="instructor">
{{ widgets.Avatar(member=instructor, avatar_class="avatar-medium") }}
<a class="ml-1 instructor-title" href="/{{instructor.username}}">{{ instructor.full_name }}</a>
<div class="instructor-subtitle">Course Creator</div>
<!-- <div class="instructor-subtitle">Created {{instructor.get_course_count()}} courses</div> -->
</div>

Some files were not shown because too many files have changed in this diff Show More