Compare commits

..

232 Commits

Author SHA1 Message Date
Frappe PR Bot
ed8baf3327 chore(release): Bumped to Version 2.21.0 2025-01-22 07:09:58 +00:00
Jannat Patel
15dd4c4350 Merge pull request #1261 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-22 10:53:00 +05:30
Jannat Patel
c986089e77 chore: Persian translations 2025-01-21 16:41:39 +05:30
Jannat Patel
17dc77f061 chore: Turkish translations 2025-01-21 16:41:38 +05:30
Jannat Patel
189f353de0 chore: Esperanto translations 2025-01-20 16:43:19 +05:30
Jannat Patel
845e7174f0 chore: Bosnian translations 2025-01-20 16:43:18 +05:30
Jannat Patel
8c6e4ad3ee chore: Persian translations 2025-01-20 16:43:17 +05:30
Jannat Patel
5dfddc890c chore: Chinese Simplified translations 2025-01-20 16:43:15 +05:30
Jannat Patel
1ebabc23d3 chore: Turkish translations 2025-01-20 16:43:14 +05:30
Jannat Patel
1bf8c1c763 chore: Swedish translations 2025-01-20 16:43:12 +05:30
Jannat Patel
c5a59b6370 chore: Russian translations 2025-01-20 16:43:11 +05:30
Jannat Patel
4a5a777478 chore: Polish translations 2025-01-20 16:43:09 +05:30
Jannat Patel
4fd7dcd5b2 chore: Hungarian translations 2025-01-20 16:43:08 +05:30
Jannat Patel
55920d9e3f chore: German translations 2025-01-20 16:43:07 +05:30
Jannat Patel
6d0c3c9cd8 chore: Arabic translations 2025-01-20 16:43:05 +05:30
Jannat Patel
7b20c3fe03 chore: Spanish translations 2025-01-20 16:43:04 +05:30
Jannat Patel
efbe35c836 chore: French translations 2025-01-20 16:43:02 +05:30
Jannat Patel
e591cd74ab Merge pull request #1260 from frappe/pot_develop_2025-01-17
chore: update POT file
2025-01-20 09:57:38 +05:30
Jannat Patel
669b9c73be Merge pull request #1257 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-20 09:57:26 +05:30
frappe-pr-bot
52e1dd6d33 chore: update POT file 2025-01-17 16:04:20 +00:00
Jannat Patel
828e195b81 Merge pull request #1259 from pateljannat/issues-69
perf: misc performance improvements
2025-01-17 17:57:28 +05:30
Jannat Patel
145342bb72 perf: misc performance improvements 2025-01-17 17:17:02 +05:30
Jannat Patel
58abfd004d Merge pull request #1256 from pateljannat/issues-68
fix: changed the naming for certificate and job opportunity
2025-01-17 14:30:56 +05:30
Jannat Patel
9dc8322270 fix: don't check assignment submission status if doc is new 2025-01-17 14:24:14 +05:30
Jannat Patel
4f0a6a7d57 chore: removed print statement 2025-01-17 14:18:07 +05:30
Jannat Patel
2fb8ae00b9 chore: Chinese Simplified translations 2025-01-17 14:11:26 +05:30
Jannat Patel
63da1e384d fix: changed the naming for certificate and job opportunity 2025-01-17 13:00:35 +05:30
Jannat Patel
34685ebdb2 Merge pull request #1255 from pateljannat/batch-url
refactor: changed batch naming to be a slug of the title
2025-01-17 10:54:31 +05:30
Jannat Patel
215ae941e1 Merge pull request #1254 from pateljannat/jobs-page-responsive
fix: improved jobs page ui
2025-01-17 10:36:48 +05:30
Jannat Patel
9d1211e872 fix: changed batch naming to be a slug of the title 2025-01-17 10:35:26 +05:30
Jannat Patel
cd4f2b1039 fix: clarified the posting date 2025-01-17 10:21:49 +05:30
Jannat Patel
9881b7b498 fix: improved jobs page ui 2025-01-17 10:15:58 +05:30
Jannat Patel
28a687f6bf Merge pull request #1252 from pateljannat/refactor-certified-participants-page
refactor: improved ui and performance for certified participants page
2025-01-16 17:01:28 +05:30
Jannat Patel
bd43ed0e88 fix: responsive design for certified participants page 2025-01-16 16:49:03 +05:30
Jannat Patel
17b59ce4e5 refactor: improved ui and performance for certified participants page 2025-01-16 16:39:48 +05:30
Jannat Patel
7acc1864c8 Merge pull request #1251 from pateljannat/issues-67
fix: misc issues
2025-01-16 13:07:23 +05:30
Jannat Patel
5a6fdfcbc3 fix: simplfied logic to filter current day batches 2025-01-16 12:57:13 +05:30
Jannat Patel
23d465d4a1 fix: batch enrolled filter logic 2025-01-16 12:52:17 +05:30
Jannat Patel
27ae014fcb fix: course is no longer mandatory to generate a certificate 2025-01-16 12:35:13 +05:30
Jannat Patel
b4c7338b76 fix: batch listing for current day batches 2025-01-16 11:43:56 +05:30
Jannat Patel
0d1464c5e9 Merge pull request #1249 from pateljannat/batches-responsive
fix: batch list responsive cards
2025-01-15 16:30:03 +05:30
Jannat Patel
f4421d362c fix: batch list responsive cards 2025-01-15 16:15:04 +05:30
Jannat Patel
5c8378f2d4 fix: changed sorting order of batch list 2025-01-15 12:31:23 +05:30
Jannat Patel
8401e86acb feat: batch tabs for moderators 2025-01-15 11:17:07 +05:30
Frappe PR Bot
e16101813c chore(release): Bumped to Version 2.20.0 2025-01-15 05:39:34 +00:00
Jannat Patel
bbd3ac6451 Merge pull request #1246 from pateljannat/batch-refactor
refactor: improved performance and ui batch list
2025-01-15 10:57:11 +05:30
Jannat Patel
c6a26e5260 fix: amount rounding issue 2025-01-14 18:45:57 +05:30
Jannat Patel
a87fda6b84 Merge pull request #1245 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-14 17:48:38 +05:30
Jannat Patel
b42c635cdb refactor: improved performance and ui batch list 2025-01-14 17:41:46 +05:30
Jannat Patel
a9c6b71e19 chore: Persian translations 2025-01-14 12:05:13 +05:30
Jannat Patel
282441e0e7 chore: Swedish translations 2025-01-14 12:05:10 +05:30
Jannat Patel
6020d5f5c2 Merge pull request #1244 from pateljannat/issues-65
fix: removed delivery parameter from batch feedback
2025-01-14 11:59:53 +05:30
Jannat Patel
9a395cbda0 Merge pull request #1243 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-14 11:29:40 +05:30
Jannat Patel
61e41180dd fix: removed delivery parameter from batch feedback 2025-01-14 11:29:13 +05:30
Jannat Patel
26bde996ac chore: Esperanto translations 2025-01-13 12:01:25 +05:30
Jannat Patel
6f78ac06c2 chore: Bosnian translations 2025-01-13 12:01:24 +05:30
Jannat Patel
8e498f4fbe chore: Persian translations 2025-01-13 12:01:22 +05:30
Jannat Patel
8105e606c9 chore: Chinese Simplified translations 2025-01-13 12:01:21 +05:30
Jannat Patel
7df6e5fe64 chore: Turkish translations 2025-01-13 12:01:19 +05:30
Jannat Patel
909c9b446b chore: Swedish translations 2025-01-13 12:01:18 +05:30
Jannat Patel
29639d59c3 chore: Russian translations 2025-01-13 12:01:16 +05:30
Jannat Patel
a13dac6dd4 chore: Polish translations 2025-01-13 12:01:15 +05:30
Jannat Patel
31257e588f chore: Hungarian translations 2025-01-13 12:01:13 +05:30
Jannat Patel
52ab419040 chore: German translations 2025-01-13 12:01:12 +05:30
Jannat Patel
7dbc35977f chore: Arabic translations 2025-01-13 12:01:10 +05:30
Jannat Patel
ce9aafadd9 chore: Spanish translations 2025-01-13 12:01:08 +05:30
Jannat Patel
13da79488f chore: French translations 2025-01-13 12:01:07 +05:30
Jannat Patel
2c999e2037 Merge pull request #1242 from frappe/pot_develop_2025-01-10
chore: update POT file
2025-01-13 11:47:52 +05:30
Jannat Patel
c096c176e3 Merge pull request #1241 from pateljannat/batch-feedback
feat: batch feedback
2025-01-13 11:47:00 +05:30
Jannat Patel
8fe0b62bb3 feat: batch feedback for moderators 2025-01-13 11:31:18 +05:30
frappe-pr-bot
e3b53efd2c chore: update POT file 2025-01-10 16:04:43 +00:00
Jannat Patel
2ecb93e925 feat: show submitted feedback as readonly 2025-01-10 19:05:59 +05:30
Jannat Patel
5d14d6f1aa chore: merged conflicts 2025-01-10 11:03:44 +05:30
Jannat Patel
4869bba7bb Merge pull request #1239 from pateljannat/issues-64
fix: made course list responsive for bigger screen sizes
2025-01-09 18:53:00 +05:30
Jannat Patel
ecc12d783a fix: list and table formatting in lesson 2025-01-09 17:07:57 +05:30
Jannat Patel
54b7f811f7 fix: made course list responsive for bigger screen sizes 2025-01-09 12:24:21 +05:30
Frappe PR Bot
bb6e97992b chore(release): Bumped to Version 2.19.0 2025-01-08 14:21:07 +00:00
Jannat Patel
64fac451f3 Merge pull request #1236 from FahidLatheef/fix/days_diff_function_name
fix: fixed typo in spelling in frappe.utils.date_diff import
2025-01-08 12:43:10 +05:30
Jannat Patel
e45b33a809 feat: batch feedback 2025-01-08 11:22:07 +05:30
Jannat Patel
eb6b72515e Merge pull request #1235 from FahidLatheef/fix/assignment-popup-on-edit-quiz
fix: fix issue where assignment form is popped up on add quiz button in Lesson Edit form
2025-01-08 10:45:42 +05:30
Fahid Latheef A
0550d3aea3 fix: fixed typo in spelling in frappe.utils.date_diff import 2025-01-07 21:07:23 +05:30
Fahid Latheef A
f6577acbff refactor: fixed linting issue 2025-01-07 20:41:54 +05:30
Fahid Latheef A
09c494f38a Added quiz type prop for AssessmentPlugin component 2025-01-07 20:29:07 +05:30
Fahid Latheef A
6c600d747e Added assignement type prop for AssessmentPlugin component 2025-01-07 20:27:39 +05:30
Jannat Patel
9dcfc347d9 Merge pull request #1234 from pateljannat/batch-dashboard-23
feat: student activities display in a heatmap
2025-01-07 19:31:20 +05:30
Jannat Patel
fb40b627fc feat: show student progress heatmap on moderators dashboard 2025-01-07 18:15:59 +05:30
Jannat Patel
c597f96375 Merge pull request #1233 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-07 10:27:39 +05:30
Jannat Patel
f1961ab614 chore: Esperanto translations 2025-01-07 09:39:40 +05:30
Jannat Patel
c2c7b7b250 chore: Bosnian translations 2025-01-07 09:39:38 +05:30
Jannat Patel
c20c272f8e chore: Persian translations 2025-01-07 09:39:37 +05:30
Jannat Patel
85e4115306 chore: Chinese Simplified translations 2025-01-07 09:39:36 +05:30
Jannat Patel
10c2bc589a chore: Turkish translations 2025-01-07 09:39:34 +05:30
Jannat Patel
a30244cb4a chore: Swedish translations 2025-01-07 09:39:33 +05:30
Jannat Patel
5691fcdca4 chore: Russian translations 2025-01-07 09:39:31 +05:30
Jannat Patel
f5848207e2 chore: Polish translations 2025-01-07 09:39:30 +05:30
Jannat Patel
ad224161d8 chore: Hungarian translations 2025-01-07 09:39:28 +05:30
Jannat Patel
5837a1ffab chore: German translations 2025-01-07 09:39:27 +05:30
Jannat Patel
1cfd7cdb98 chore: Arabic translations 2025-01-07 09:39:25 +05:30
Jannat Patel
56a4aa2a3f chore: Spanish translations 2025-01-07 09:39:24 +05:30
Jannat Patel
d91d2ded77 chore: French translations 2025-01-07 09:39:22 +05:30
Jannat Patel
6a48d44b14 Merge pull request #1232 from pateljannat/issues-63
fix: misc issues
2025-01-06 16:25:21 +05:30
Jannat Patel
31c5d423d0 fix: misc issues 2025-01-06 16:00:48 +05:30
Jannat Patel
79177b5f5b feat: students heatmap 2025-01-06 15:42:44 +05:30
Jannat Patel
74658b2054 Merge pull request #1231 from pateljannat/refactor-batch-list
refactor: fetch minimal information for batch cards
2025-01-06 12:44:28 +05:30
Jannat Patel
052fffccef refactor: badge page data 2025-01-06 12:36:44 +05:30
Jannat Patel
bd2b558154 refactor: fetch minimal information for batch cards 2025-01-06 12:05:29 +05:30
Jannat Patel
65ee6b62ea Merge pull request #1230 from pateljannat/issues-62
refactor: duration field in quiz should be in minutes
2025-01-06 11:24:13 +05:30
Jannat Patel
26266a22e8 fix: add description to indicate that duration should be in minutes 2025-01-06 11:02:46 +05:30
Jannat Patel
e52ca63075 refactor: duration field in quiz should be in minutes 2025-01-06 11:01:01 +05:30
Jannat Patel
4d8b2eb5b4 Merge pull request #1229 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-06 10:41:42 +05:30
Jannat Patel
2d81a1ce31 Merge pull request #1227 from frappe/pot_develop_2025-01-03
chore: update POT file
2025-01-06 10:41:30 +05:30
Jannat Patel
052a85fbc0 chore: Swedish translations 2025-01-06 09:43:51 +05:30
frappe-pr-bot
fa0e84c671 chore: update POT file 2025-01-03 16:04:23 +00:00
Jannat Patel
4759736571 Merge pull request #1226 from pateljannat/issues-61
fix: misc batch issues
2025-01-03 17:57:42 +05:30
Jannat Patel
f77686feaa fix: misc batch issues 2025-01-03 17:37:22 +05:30
Frappe PR Bot
34548b93f4 chore(release): Bumped to Version 2.18.0 2025-01-02 14:31:37 +00:00
Jannat Patel
f438d33f75 Merge pull request #1224 from pateljannat/issues-60
fix: quiz api issue
2025-01-02 20:00:36 +05:30
Jannat Patel
be1c0de4c6 fix: quiz api issue 2025-01-02 19:27:35 +05:30
Jannat Patel
ae5ea9a8aa Merge pull request #1223 from pateljannat/assignments-in-courses
feat: assignments in courses
2025-01-02 15:45:32 +05:30
Jannat Patel
eeb7fb1f78 fix: correct path for assignment plugin 2025-01-02 15:32:43 +05:30
Jannat Patel
3f32d5bb3b feat: notification to student on submission update 2025-01-02 15:22:32 +05:30
Jannat Patel
12019ca37d Merge pull request #1219 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-02 15:20:15 +05:30
Jannat Patel
4d133b2f99 fix: assignment dirty state and comments view to student 2025-01-02 14:53:38 +05:30
Jannat Patel
e733226b0c chore: Persian translations 2025-01-01 09:00:19 +05:30
Jannat Patel
2ed583a0c3 fix: assignment submission ux improvements 2024-12-31 23:06:55 +05:30
Jannat Patel
048cee654e fix: mark lesson progress when quiz and assignment are submitted 2024-12-31 13:15:25 +05:30
Jannat Patel
1293294593 feat: assignment in lesson 2024-12-31 12:20:01 +05:30
Jannat Patel
a1947a3106 Merge pull request #1215 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-12-31 12:00:34 +05:30
Jannat Patel
eff6cd6bbe chore: Esperanto translations 2024-12-31 08:59:24 +05:30
Jannat Patel
d784ac5699 chore: Bosnian translations 2024-12-31 08:59:23 +05:30
Jannat Patel
9acad5157b chore: Persian translations 2024-12-31 08:59:21 +05:30
Jannat Patel
94459efa3f chore: Chinese Simplified translations 2024-12-31 08:59:20 +05:30
Jannat Patel
e88bc6a5ce chore: Turkish translations 2024-12-31 08:59:19 +05:30
Jannat Patel
55a7ab54e9 chore: Swedish translations 2024-12-31 08:59:17 +05:30
Jannat Patel
0c324c87cc chore: Russian translations 2024-12-31 08:59:16 +05:30
Jannat Patel
31e8befa11 chore: Polish translations 2024-12-31 08:59:14 +05:30
Jannat Patel
86ab7a6d97 chore: Hungarian translations 2024-12-31 08:59:13 +05:30
Jannat Patel
14bdfb2d98 chore: German translations 2024-12-31 08:59:11 +05:30
Jannat Patel
0036e585da chore: Arabic translations 2024-12-31 08:59:10 +05:30
Jannat Patel
cba2343fc0 chore: Spanish translations 2024-12-31 08:59:08 +05:30
Jannat Patel
864eebce2f chore: French translations 2024-12-31 08:59:07 +05:30
Jannat Patel
156d36fb5e chore: merged conflicts 2024-12-30 18:21:47 +05:30
Jannat Patel
068718aa8a Merge pull request #1214 from pateljannat/issues-59
fix: progress issue in batches
2024-12-30 18:08:09 +05:30
Jannat Patel
10219abfd6 fix: progress issue in batches 2024-12-30 17:51:14 +05:30
Jannat Patel
2ec231a3d0 Merge pull request #1213 from frappe/pot_develop_2024-12-27
chore: update POT file
2024-12-30 11:34:01 +05:30
frappe-pr-bot
78f29b3aff chore: update POT file 2024-12-27 16:04:15 +00:00
Jannat Patel
7f768e81f4 feat: assignment grading 2024-12-26 18:16:46 +05:30
Jannat Patel
aa1460eda1 Merge pull request #1211 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-12-26 14:57:32 +05:30
Jannat Patel
85f85063ac feat: assignment submission list 2024-12-26 11:28:32 +05:30
Jannat Patel
0a7ce3c5d8 chore: Esperanto translations 2024-12-25 07:47:58 +05:30
Jannat Patel
8468d0e3db chore: Bosnian translations 2024-12-25 07:47:56 +05:30
Jannat Patel
059ac27f0b chore: Persian translations 2024-12-25 07:47:55 +05:30
Jannat Patel
a96f8836b1 chore: Chinese Simplified translations 2024-12-25 07:47:54 +05:30
Jannat Patel
4018116136 chore: Turkish translations 2024-12-25 07:47:52 +05:30
Jannat Patel
aa083c8a40 chore: Swedish translations 2024-12-25 07:47:51 +05:30
Jannat Patel
8752243e9c chore: Russian translations 2024-12-25 07:47:50 +05:30
Jannat Patel
1d028e81c4 chore: Polish translations 2024-12-25 07:47:48 +05:30
Jannat Patel
2752d3e42c chore: Hungarian translations 2024-12-25 07:47:47 +05:30
Jannat Patel
aa074ef762 chore: German translations 2024-12-25 07:47:45 +05:30
Jannat Patel
bae75cd2f6 chore: Arabic translations 2024-12-25 07:47:44 +05:30
Jannat Patel
81a714b5a2 chore: Spanish translations 2024-12-25 07:47:41 +05:30
Jannat Patel
10cd44c22f chore: French translations 2024-12-25 07:47:40 +05:30
Jannat Patel
a44f59c362 feat: assignments list and form 2024-12-24 21:48:45 +05:30
Jannat Patel
8d372fcab4 Merge pull request #1204 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-12-24 10:52:10 +05:30
Jannat Patel
97d6c518b5 Merge pull request #1203 from frappe/pot_develop_2024-12-20
chore: update POT file
2024-12-24 10:52:00 +05:30
Jannat Patel
f331c48e1d Merge pull request #1201 from pateljannat/batch-dashboard-2
feat: batch student progress modal
2024-12-23 18:36:44 +05:30
Jannat Patel
9d0b10058d fix: show dashboard to evaluators too 2024-12-23 17:39:37 +05:30
Jannat Patel
4ccd3ba71e fix: legends 2024-12-23 17:19:48 +05:30
Jannat Patel
7a6f5a868c Merge branch 'develop' of https://github.com/frappe/lms into batch-dashboard-2 2024-12-23 12:49:32 +05:30
Jannat Patel
0fae11d031 docs: updated self hosting steps in README 2024-12-23 12:46:02 +05:30
Jannat Patel
8a9725c990 ci: updated the credentials for building docker image 2024-12-23 12:29:21 +05:30
Jannat Patel
d0189b0e3a ci: updated the credentials for building docker image 2024-12-23 12:28:31 +05:30
Jannat Patel
c6853cc95e Merge pull request #1208 from pateljannat/issues-58
ci: added back arch for building docker image
2024-12-23 12:14:36 +05:30
Jannat Patel
f28f37fb2c ci: added back arch for building docker image 2024-12-23 12:14:00 +05:30
Jannat Patel
7dbbe9dba4 Merge pull request #1206 from pateljannat/issues-57
fix: markdown embed and paste issue
2024-12-23 11:49:55 +05:30
Jannat Patel
b625d9b099 fix: markdown embed and paste issue 2024-12-23 11:33:09 +05:30
Jannat Patel
a85c81a4b4 chore: Bosnian translations 2024-12-23 07:45:35 +05:30
Jannat Patel
1677a4a32b chore: Persian translations 2024-12-23 07:45:33 +05:30
Jannat Patel
776d46f5a2 chore: Chinese Simplified translations 2024-12-23 07:45:32 +05:30
Jannat Patel
6384eeaa13 chore: Turkish translations 2024-12-23 07:45:30 +05:30
Jannat Patel
fdc0befcee chore: Russian translations 2024-12-23 07:45:27 +05:30
Jannat Patel
f2c28eb695 chore: Polish translations 2024-12-23 07:45:26 +05:30
Jannat Patel
4095916991 chore: Hungarian translations 2024-12-23 07:45:25 +05:30
Jannat Patel
551703364a chore: German translations 2024-12-23 07:45:23 +05:30
Jannat Patel
4a2fae023c chore: Arabic translations 2024-12-23 07:45:22 +05:30
Jannat Patel
fca206120e chore: Spanish translations 2024-12-23 07:45:21 +05:30
Jannat Patel
65b2199065 chore: French translations 2024-12-23 07:45:19 +05:30
Jannat Patel
9d03a52bf9 chore: Swedish translations 2024-12-21 07:21:24 +05:30
frappe-pr-bot
c8aa44dfcb chore: update POT file 2024-12-20 16:04:18 +00:00
Jannat Patel
7fcbe85ab9 Merge pull request #1202 from pateljannat/docker-production-image
ci: container image for production setup
2024-12-20 13:50:19 +05:30
Jannat Patel
de0dea7df8 ci: container image for production setup 2024-12-20 13:27:07 +05:30
Jannat Patel
43cf7d04b8 feat: batch dashboard for instructors 2024-12-20 13:12:40 +05:30
Jannat Patel
4d18580482 feat: batch student progress modal 2024-12-19 23:00:28 +05:30
Frappe PR Bot
b48e007ea8 chore(release): Bumped to Version 2.17.0 2024-12-18 14:51:51 +00:00
Jannat Patel
d5e8973866 Merge pull request #1196 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-12-18 14:18:51 +05:30
Jannat Patel
a8c530f98c chore: Esperanto translations 2024-12-18 06:07:10 +05:30
Jannat Patel
47769ccd62 Merge pull request #1195 from pateljannat/issues-56
feat: load more in quiz list
2024-12-17 18:33:39 +05:30
Jannat Patel
bfc1d9a0a8 feat: load more in quiz list 2024-12-17 17:48:49 +05:30
Jannat Patel
824484e608 Merge pull request #1194 from pateljannat/issues-55
fix: markdown parser link issue
2024-12-17 16:57:31 +05:30
Jannat Patel
d3f7baae4c fix: markdown parser link issue 2024-12-17 16:35:30 +05:30
Jannat Patel
8d961e9b71 Merge pull request #1193 from pateljannat/issues-54
feat: load more in quiz submissions
2024-12-17 12:48:53 +05:30
Jannat Patel
f22855920c Merge pull request #1192 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-12-17 12:24:18 +05:30
Jannat Patel
18728e3519 Merge pull request #1186 from frappe/pot_develop_2024-12-13
chore: update POT file
2024-12-17 12:24:06 +05:30
Jannat Patel
65dc2838d3 feat: load more in quiz submissions 2024-12-17 12:23:44 +05:30
Jannat Patel
be930ce076 chore: Persian translations 2024-12-17 05:37:38 +05:30
Jannat Patel
1ea47a008c Merge pull request #1191 from pateljannat/scormcontent-issue
fix: scormcontent package load issue
2024-12-16 19:25:32 +05:30
Jannat Patel
e0169cff79 fix: scormcontent package load issue 2024-12-16 19:12:15 +05:30
Jannat Patel
7c53ac10e2 Merge pull request #1189 from pateljannat/lesson-md-parser
feat: markdown parser for lessons
2024-12-16 18:29:45 +05:30
Jannat Patel
212e0de6e9 chore: resolved conflicts 2024-12-16 18:13:49 +05:30
Jannat Patel
8e74384b5a Merge branch 'develop' of https://github.com/frappe/lms into lesson-md-parser 2024-12-16 18:12:17 +05:30
Jannat Patel
86e7e68ce1 chore: removed unused packages 2024-12-16 18:12:13 +05:30
Jannat Patel
a77999dbb6 Merge pull request #1190 from pateljannat/quiz-marks-issue
fix: delete quiz and submission before deleting course
2024-12-16 18:10:23 +05:30
Jannat Patel
3288fb0f06 chore: replace mariadb-client-10.6 with mariadb-client for ui tests 2024-12-16 17:56:57 +05:30
Jannat Patel
a81b384f90 fix: mariadb dependency installation 2024-12-16 17:30:00 +05:30
Jannat Patel
75c11d3fcc fix: course category was not reflecting on course form 2024-12-16 17:21:12 +05:30
Jannat Patel
51a6cc035c fix: delete quiz and submission before deleting course 2024-12-16 17:14:30 +05:30
Jannat Patel
ae8008d05c chore: bumped up mariadb image version 2024-12-16 17:00:55 +05:30
Jannat Patel
7f44177986 feat: markdown parser for links and lists 2024-12-16 16:41:55 +05:30
Jannat Patel
d88aaedf3f Merge branch 'develop' of https://github.com/frappe/lms into lesson-md-parser 2024-12-16 16:40:32 +05:30
frappe-pr-bot
802d4ccb0b chore: update POT file 2024-12-13 16:04:40 +00:00
Jannat Patel
76a84c7f5d Merge pull request #1183 from pateljannat/batch-dashboard
feat: show student course and assessment progress on batch page
2024-12-13 12:11:59 +05:30
Jannat Patel
40aefba203 fix: styling of batch list headers 2024-12-13 12:03:00 +05:30
Jannat Patel
6cdfb822b4 fix: batch time issue 2024-12-13 11:45:54 +05:30
Jannat Patel
fdacab66f7 feat: show student course and assessment progress on batch page 2024-12-13 10:44:56 +05:30
Jannat Patel
5cc12e71df Merge pull request #1182 from pateljannat/fix-readme-2
docs: updated readme header, footer and screenshots
2024-12-12 16:33:24 +05:30
Jannat Patel
f5e5fa2f36 docs: fixed screenshot captions 2024-12-12 16:18:54 +05:30
Jannat Patel
6022b83b8c docs: removed extra space under screenshots 2024-12-12 16:06:30 +05:30
Jannat Patel
a01b1657cc docs: added captions to README screenshots 2024-12-12 16:05:39 +05:30
Jannat Patel
6b785bd0e6 docs: updated readme header, footer and screenshots 2024-12-12 15:59:57 +05:30
Jannat Patel
0beffc3083 Merge pull request #1181 from pateljannat/fix-readme
fix: readme
2024-12-11 12:49:37 +05:30
Jannat Patel
d345d09b13 fix: readme 2024-12-11 12:49:11 +05:30
Jannat Patel
38e1eb8fc7 feat: markdown parser for lessons 2024-12-11 11:57:35 +05:30
117 changed files with 10435 additions and 4592 deletions

BIN
.github/batch.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
.github/batches.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 912 KiB

View File

@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client-10.6
sudo apt-get install libcups2-dev redis-server mariadb-client
install_wkhtmltopdf() {
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb

BIN
.github/hero.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 842 KiB

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
.github/quiz.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

64
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Build Container Image
on:
workflow_dispatch:
push:
branches:
- main
tags:
- "*"
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
permissions:
packages: write
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/${{ matrix.arch }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set Branch
run: |
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
- name: Set Image Tag
run: |
echo "IMAGE_TAG=stable" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
repository: frappe/frappe_docker
path: builds
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: builds
file: builds/images/layered/Containerfile
tags: >
ghcr.io/${{ github.repository }}:${{ github.ref_name }},
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"

View File

@@ -24,7 +24,7 @@ jobs:
services:
mariadb:
image: mariadb:10.6
image: mariadb:10.8
env:
MARIADB_ROOT_PASSWORD: 123
ports:

View File

@@ -1,11 +1,10 @@
<div align="center" markdown="1">
<img src=".github/lms-logo.png" alt="Frappe Learning logo" width="100"/>
<img src=".github/lms-logo.png" alt="Frappe Learning logo" width="80" height="80"/>
<h1>Frappe Learning</h1>
**Easy to use, open source, Learning Management System**
![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/lms)
![Tests](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/vandxn/main&style=flat&logo=cypress)
</div>
@@ -24,10 +23,10 @@
## Frappe Learning
Frappe Learning is an easy-to-use learning system that helps you bring structure to your content.
## Motivation
### Motivation
In 2021, we were looking for a Learning Management System to launch [Mon.School](https://mon.school) for FOSS United. We checked out Moodle, but it didnt feel right. The forms were unnecessarily lengthy and the UI was confusing. It shouldn't be this hard to create a course right? So I started making a learning system for Mon.School which soon became a product in itself. The aim is to have a simple platform that anyone can use to launch a course of their own and make knowledge sharing easier.
## Key Features
### Key Features
- **Structured Learning**: Design a course with a 3-level hierarchy, where your courses have chapters and you can group your lessons within these chapters. This ensures that the context of the lesson is set by the chapter.
@@ -37,24 +36,42 @@ In 2021, we were looking for a Learning Management System to launch [Mon.School]
- **Getting Certified**: Once a learner has completed the course or batch, you can grant them a certificate. The app provides an inbuilt certificate template. You can use this or else create a template of your own and use that instead.
### Batches to group learners
<details>
<summary>View Screenshots</summary>
![Batch](.github/batches.png)
### Quiz to evaluate them
![Batch](.github/batch.png)
<div align="center">
<sub>
Create batches to group your learners
</sub>
</div>
<br>
![Quiz](.github/quiz.png)
<div align="center">
<sub>
Evaluate their knowledge by quizzes
</sub>
</div>
<br>
### Certificate to authenticate their knowledge
![Cerficicate](.github/certificate.png)
<div align="center">
<sub>
Autenticate their work with certification
</sub>
</div>
</details>
## Under the Hood
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework written in Python and Javascript. The framework provides a robust foundation for building web applications, including a database abstraction layer, user authentication, and a REST API.
### Under the Hood
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. The Frappe UI library provides a variety of components that can be used to build single-page applications on top of the Frappe Framework.
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework.
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
## Production Setup
@@ -89,15 +106,15 @@ wget https://frappe.io/easy-install.py
python3 ./easy-install.py deploy \
--project=learning_prod_setup \
--email=your_email.example.com \
--image=ghcr.io/frappe/learning \
--image=ghcr.io/frappe/lms \
--version=stable \
--app=learning \
--app=lms \
--sitename subdomain.domain.tld
```
Replace the following parameters with your values:
- `your_email.example.com`: Your email address
- `subdomain.domain.tld`: Your domain name where Insights will be hosted
- `subdomain.domain.tld`: Your domain name where Learning will be hosted
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
@@ -113,16 +130,16 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
cd frappe-learning
# Download the docker-compose file
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/insights/develop/docker/docker-compose.yml
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/lms/develop/docker/docker-compose.yml
# Download the setup script
wget -O init.sh https://raw.githubusercontent.com/frappe/insights/develop/docker/init.sh
wget -O init.sh https://raw.githubusercontent.com/frappe/lms/develop/docker/init.sh
**Step 2**: Run the container and daemonize it
docker compose up -d
**Step 3**: The site [http://lms.localhost:8000/insights](http://lms.localhost:8000/lms) should now be available. The default credentials are:
**Step 3**: The site [http://lms.localhost:8000/lms](http://lms.localhost:8000/lms) should now be available. The default credentials are:
- Username: Administrator
- Password: admin
@@ -134,7 +151,7 @@ To setup the repository locally follow the steps mentioned below:
1. Start the server by running `bench start`
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
1. Get the Insights app. Run `bench get-app https://github.com/frappe/lms`
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
1. Run `bench --site learning.test install-app lms`.
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
@@ -145,7 +162,8 @@ To setup the repository locally follow the steps mentioned below:
- [Documentation](https://docs.frappe.io/learning)
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
<h2></h2>
<br>
<br>
<div align="center" style="padding-top: 0.75rem;">
<a href="https://frappe.io" target="_blank">
<picture>

View File

@@ -2,7 +2,7 @@ version: "3.7"
name: lms
services:
mariadb:
image: mariadb:10.6
image: mariadb:10.8
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<link rel="icon" href="{{ favicon or '/assets/lms/frontend/favicon.png' }}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frappe Learning</title>
<meta name="title" content="{{ meta.title }}" />

View File

@@ -20,6 +20,7 @@
"@editorjs/simple-image": "^1.6.0",
"@editorjs/table": "^2.4.2",
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6",
@@ -35,6 +36,7 @@
"vue-chartjs": "^5.3.0",
"vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0",
"vuedraggable": "4.1.0"
},
"devDependencies": {

View File

@@ -185,6 +185,17 @@ const addQuizzes = () => {
}
}
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
activeFor: ['Assignments', 'AssignmentForm'],
})
}
}
const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
@@ -247,8 +258,9 @@ watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addQuizzes()
addPrograms()
addQuizzes()
addAssignments()
}
})

View File

@@ -0,0 +1,75 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div v-if="type == 'quiz'" class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div v-else class="text-lg font-semibold">
{{ __('Add an assignment to your lesson') }}
</div>
<div>
<Link
v-if="type == 'quiz'"
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToForm()"
/>
<Link
v-else
v-model="assignment"
doctype="LMS Assignment"
:label="__('Select an assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addAssessment()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const assignment = ref(null)
const props = defineProps({
type: {
type: String,
required: true,
},
onAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addAssessment = () => {
props.onAddition(props.type == 'quiz' ? quiz.value : assignment.value)
show.value = false
}
const redirectToForm = () => {
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
else window.open('/lms/assignments/new', '_blank')
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold mb-4">
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold">
{{ __('Assessments') }}
</div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
@@ -11,7 +11,7 @@
{{ __('Add') }}
</Button>
</div>
<div v-if="assessments.data?.length">
<div v-if="assessments.data?.length" class="text-sm">
<ListView
:columns="getAssessmentColumns()"
:rows="assessments.data"
@@ -19,6 +19,7 @@
:options="{
showTooltip: false,
getRowRoute: (row) => getRowRoute(row),
selectable: user.data?.is_student ? false : true,
}"
>
<ListHeader
@@ -38,7 +39,18 @@
<ListRow :row="row" v-for="row in assessments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
<div v-if="column.key == 'assessment_type'">
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
</div>
<div v-else-if="isNaN(row[column.key])">
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
@@ -80,6 +92,7 @@ import {
ListSelectBanner,
createResource,
Button,
Badge,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
@@ -145,7 +158,7 @@ const getRowRoute = (row) => {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
assignmentID: row.assessment_name,
submissionName: row.submission.name,
},
}
@@ -153,7 +166,7 @@ const getRowRoute = (row) => {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
assignmentID: row.assessment_name,
submissionName: 'new',
},
}
@@ -177,20 +190,33 @@ const getAssessmentColumns = () => {
{
label: 'Assessment',
key: 'title',
width: '25rem',
},
{
label: 'Type',
key: 'assessment_type',
width: '15rem',
},
]
if (!user.data?.is_moderator) {
columns.push({
label: 'Status/Score',
label: 'Status/Percentage',
key: 'status',
align: 'center',
align: 'left',
width: '10rem',
})
}
return columns
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>

View File

@@ -0,0 +1,448 @@
<template>
<div
v-if="assignment.data"
class="grid grid-cols-[68%,32%] h-full"
:class="{ 'border rounded-lg': !showTitle }"
>
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
<div v-if="showTitle" class="text-lg font-semibold mb-5">
<div v-if="submissionName === 'new'">
{{ __('Submission by') }} {{ user.data?.full_name }}
</div>
<div v-else>
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
</div>
</div>
<div class="text-sm text-gray-600 font-medium mb-2">
{{ __('Question') }}:
</div>
<div
v-html="assignment.data.question"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
></div>
</div>
<div class="flex flex-col">
<div class="p-5">
<div class="flex items-center justify-between mb-4">
<div class="font-semibold">
{{ __('Submission') }}
</div>
<div class="flex items-center space-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Badge
v-else-if="submissionResource.doc?.status"
:theme="statusTheme"
size="lg"
>
{{ submissionResource.doc?.status }}
</Badge>
<Button variant="solid" @click="submitAssignment()">
{{ __('Save') }}
</Button>
</div>
</div>
<div
v-if="
submissionName != 'new' &&
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
submissionResource.doc?.owner == user.data?.name
"
class="bg-blue-100 p-3 rounded-md leading-5 text-sm mb-4"
>
{{ __("You've successfully submitted the assignment.") }}
{{
__(
"Once the moderator grades your submission, you'll find the details here."
)
}}
{{ __('Feel free to make edits to your submission if needed.') }}
</div>
<div v-if="showUploader()">
<div class="text-xs text-gray-600 mt-1 mb-2">
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
</div>
<FileUploader
v-if="!submissionFile"
:fileTypes="getType()"
:validateFile="validateFile"
@success="(file) => saveSubmission(file)"
>
<template #default="{ uploading, progress, openFileSelector }">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload File')
}}
</Button>
</template>
</FileUploader>
<div v-else>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<a
:href="submissionFile.file_url"
target="_blank"
class="flex flex-col cursor-pointer !no-underline"
>
<span>
{{ submissionFile.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(submissionFile.file_size) }}
</span>
</a>
<X
v-if="canModifyAssignment"
@click="removeSubmission()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<div v-else-if="assignment.data.type == 'URL'">
<div class="text-xs text-gray-600 mb-1">
{{ __('Enter a URL') }}
</div>
<FormControl
v-model="answer"
type="text"
:readonly="!canModifyAssignment"
/>
</div>
<div v-else>
<div class="text-sm mb-4">
{{ __('Write your answer here') }}
</div>
<TextEditor
:content="answer"
@change="(val) => (answer = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div
v-if="
user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments
"
class="mt-8 p-3 bg-blue-100 rounded-md"
>
<div class="text-sm text-gray-600 font-medium mb-2">
{{ __('Comments by Evaluator') }}:
</div>
<div class="leading-5">
{{ submissionResource.doc.comments }}
</div>
</div>
<!-- Grading -->
<div v-if="canGradeSubmission" class="mt-8 space-y-4">
<div class="font-semibold mb-2">
{{ __('Grading') }}
</div>
<FormControl
v-if="submissionResource.doc"
v-model="submissionResource.doc.status"
:label="__('Grade')"
type="select"
:options="submissionStatusOptions"
/>
<FormControl
v-if="submissionResource.doc"
v-model="submissionResource.doc.comments"
:label="__('Comments')"
type="textarea"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
Badge,
Button,
call,
createResource,
createDocumentResource,
FileUploader,
FormControl,
TextEditor,
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { showToast, getFileSize } from '@/utils'
import { useRouter } from 'vue-router'
const submissionFile = ref(null)
const answer = ref(null)
const router = useRouter()
const user = inject('$user')
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
const isDirty = ref(false)
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
submissionName: {
type: String,
default: 'new',
},
})
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
submitAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createResource({
url: 'frappe.client.get',
params: {
doctype: 'LMS Assignment',
name: props.assignmentID,
},
auto: true,
onSuccess(data) {
if (props.submissionName != 'new') {
submissionResource.reload()
}
},
})
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (showUploader()) {
doc.assignment_attachment = submissionFile.value.file_url
} else {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
submissionFile.value = data
},
})
const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
auto: false,
cache: [user.data?.name, props.assignmentID],
})
watch(submissionResource, () => {
if (submissionResource.doc) {
if (submissionResource.doc.assignment_attachment) {
imageResource.reload({
image: submissionResource.doc.assignment_attachment,
})
}
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (showUploader() && !submissionFile.value) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
} else {
isDirty.value = false
}
}
})
watch(submissionFile, () => {
if (props.submissionName == 'new' && submissionFile.value) {
isDirty.value = true
}
})
const submitAssignment = () => {
if (props.submissionName != 'new') {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
},
{
onSuccess(data) {
showToast(__('Success'), __('Changes saved successfully'), 'check')
},
}
)
} else {
addNewSubmission()
}
}
const addNewSubmission = () => {
newSubmission.submit(
{},
{
onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check')
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
})
} else {
markLessonProgress()
router.go()
}
submissionResource.name = data.name
submissionResource.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const saveSubmission = (file) => {
submissionFile.value = file
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
let courseName = router.currentRoute.value.params.courseName
let chapterNumber = router.currentRoute.value.params.chapterNumber
let lessonNumber = router.currentRoute.value.params.lessonNumber
call('lms.lms.api.mark_lesson_progress', {
course: courseName,
chapter_number: chapterNumber,
lesson_number: lessonNumber,
})
}
}
const getType = () => {
const type = assignment.data?.type
if (type == 'Image') {
return ['image/*']
} else if (type == 'Document') {
return [
'.doc',
'.docx',
'.xml',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
} else if (type == 'PDF') {
return ['.pdf']
}
}
const validateFile = (file) => {
let type = assignment.data?.type
let extension = file.name.split('.').pop().toLowerCase()
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
} else if (
type == 'Document' &&
!['doc', 'docx', 'xml'].includes(extension)
) {
return 'Only document file is allowed.'
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
return 'Only PDF file is allowed.'
}
}
const removeSubmission = () => {
submissionFile.value = null
}
const canGradeSubmission = computed(() => {
return (
(user.data?.is_moderator ||
user.data?.is_evaluator ||
user.data?.is_instructor) &&
props.submissionName != 'new' &&
router.currentRoute.value.name == 'AssignmentSubmission'
)
})
const canModifyAssignment = computed(() => {
return (
!submissionResource.doc ||
(submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded')
)
})
const submissionStatusOptions = computed(() => {
return [
{ label: 'Not Graded', value: 'Not Graded' },
{ label: 'Pass', value: 'Pass' },
{ label: 'Fail', value: 'Fail' },
]
})
const statusTheme = computed(() => {
if (!submissionResource.doc) {
return 'orange'
} else if (submissionResource.doc.status == 'Pass') {
return 'green'
} else if (submissionResource.doc.status == 'Not Graded') {
return 'blue'
} else {
return 'red'
}
})
const showUploader = () => {
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<Assignment
v-if="user.data && submission.data"
:assignmentID="assignmentID"
:submissionName="submission.data?.name || 'new'"
/>
<div v-else class="border rounded-md text-center py-20">
<div>
{{ __('Please login to access the assignment.') }}
</div>
<Button @click="redirectToLogin()" class="mt-2">
<span>
{{ __('Login') }}
</span>
</Button>
</div>
</template>
<script setup>
import { inject, watch } from 'vue'
import { Button, createResource } from 'frappe-ui'
import Assignment from '@/components/Assignment.vue'
const user = inject('$user')
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
})
const submission = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'LMS Assignment Submission',
fieldname: 'name',
filters: {
assignment: props.assignmentID,
member: user.data?.name,
},
}
},
auto: true,
})
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold">
<div class="text-lg font-semibold">
{{ __('Courses') }}
</div>
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
@@ -118,13 +118,13 @@ const getCoursesColumns = () => {
},
{
label: 'Lessons',
key: 'lesson_count',
key: 'lessons',
align: 'right',
},
{
label: 'Enrollments',
align: 'right',
key: 'enrollment_count',
key: 'enrollments',
},
]
}

View File

@@ -1,17 +1,18 @@
<template>
<div>
<div class="space-y-10">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
:isStudent="isStudent"
/>
<Assessments :batch="batch.data.name" />
<StudentHeatmap />
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const props = defineProps({
batch: {

View File

@@ -0,0 +1,239 @@
<template>
<div v-if="user.data?.is_student">
<div
v-if="feedbackList.data?.length"
class="bg-blue-100 text-blue-700 p-2 rounded-md mb-5"
>
{{ __('Thank you for providing your feedback!') }}
</div>
<div v-else class="flex justify-between items-center mb-5">
<div class="text-lg font-semibold">
{{ __('Help Us Improve') }}
</div>
<Button @click="submitFeedback()">
{{ __('Submit') }}
</Button>
</div>
<div class="space-y-8">
<div class="flex items-center justify-between">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
:readonly="readOnly"
/>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="7"
:readonly="readOnly"
/>
</div>
</div>
<div v-else-if="feedbackList.data?.length">
<div class="text-lg font-semibold mb-5">
{{ __('Average of Feedback Received') }}
</div>
<div class="flex items-center justify-between mb-10">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
:label="__(convertToTitleCase(key))"
:readonly="true"
/>
</div>
<div class="text-lg font-semibold mb-5">
{{ __('All Feedback') }}
</div>
<ListView
:columns="feedbackColumns"
:rows="feedbackList.data"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList.data"
class="group cursor-pointer"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
<div v-else class="text-sm italic text-center text-gray-700 mt-5">
{{ __('No feedback received yet.') }}
</div>
</template>
<script setup>
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import {
Avatar,
Button,
createListResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false)
const average = reactive({})
const feedback = reactive({})
const props = defineProps({
batch: {
type: String,
required: true,
},
})
onMounted(() => {
let filters = {
batch: props.batch,
}
if (user.data?.is_student) {
filters['member'] = user.data?.name
}
feedbackList.update({
filters: filters,
})
feedbackList.reload()
})
const feedbackList = createListResource({
doctype: 'LMS Batch Feedback',
filters: {
batch: props.batch,
},
fields: [
'content',
'instructors',
'value',
'feedback',
'name',
'member',
'member_name',
'member_image',
],
cache: ['feedbackList', props.batch, user.data?.name],
})
watch(
() => feedbackList.data,
() => {
if (feedbackList.data.length) {
let data = feedbackList.data
readOnly.value = true
ratingKeys.forEach((key) => {
average[key] = 0
})
data.forEach((row) => {
Object.keys(row).forEach((key) => {
if (ratingKeys.includes(key)) row[key] = row[key] * 5
feedback[key] = row[key]
})
ratingKeys.forEach((key) => {
average[key] += row[key]
})
})
Object.keys(average).forEach((key) => {
average[key] = average[key] / data.length
})
}
}
)
const submitFeedback = () => {
ratingKeys.forEach((key) => {
feedback[key] = feedback[key] / 5
})
feedbackList.insert.submit(
{
member: user.data?.name,
batch: props.batch,
...feedback,
},
{
onSuccess: () => {
feedbackList.reload()
},
}
)
}
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script>

View File

@@ -1,80 +1,199 @@
<template>
<Button class="float-right mb-3" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Students') }}
</div>
<div v-if="students.data?.length">
<ListView
:columns="getStudentColumns()"
:rows="students.data"
row-key="name"
:options="{ showTooltip: false }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
<div class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-gray-600">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-3 gap-5 mb-8">
<div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3">
<User class="w-5 h-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ students.data?.length }}
</span>
<span class="text-gray-700">
{{ __('Students') }}
</span>
</div>
</div>
<div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3">
<BookOpen class="w-5 h-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ batch.courses?.length }}
</span>
<span class="text-gray-700">
{{ __('Courses') }}
</span>
</div>
</div>
<div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3">
<ShieldCheck class="w-5 h-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ assessmentCount }}
</span>
<span class="text-gray-700">
{{ __('Assessments') }}
</span>
</div>
</div>
</div>
<div v-if="showProgressChart" class="mb-8">
<div class="text-gray-600 font-medium">
{{ __('Progress') }}
</div>
<ApexChart
:options="chartOptions"
:series="chartData"
type="bar"
height="200"
/>
<div
class="flex items-center justify-center text-sm text-gray-700 space-x-4"
>
<ListHeaderItem :item="item" v-for="item in getStudentColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in students.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.green[600] }"
></div>
<div>
{{ __('Courses') }}
</div>
</div>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.blue[600] }"
></div>
<div>
{{ __('Assessments') }}
</div>
</div>
</div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-gray-600 font-medium">
{{ __('Students') }}
</div>
<Button @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('There are no students in this batch.') }}
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
:columns="getStudentColumns()"
:rows="students.data"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in getStudentColumns()"
:title="item.label"
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('There are no students in this batch.') }}
</div>
</div>
<StudentModal
:batch="props.batch"
:batch="props.batch.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
/>
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/>
</template>
<script setup>
import {
Avatar,
Button,
createResource,
FeatherIcon,
ListHeader,
ListHeaderItem,
ListSelectBanner,
@@ -82,60 +201,86 @@ import {
ListRows,
ListView,
ListRowItem,
Avatar,
Button,
} from 'frappe-ui'
import { Trash2, Plus } from 'lucide-vue-next'
import { ref } from 'vue'
import {
BookOpen,
Clipboard,
Plus,
ShieldCheck,
Trash2,
User,
} from 'lucide-vue-next'
import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme'
const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const chartData = ref(null)
const chartOptions = ref(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const props = defineProps({
batch: {
type: String,
type: Object,
default: null,
},
})
const students = createResource({
url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch],
cache: ['students', props.batch.name],
params: {
batch: props.batch,
batch: props.batch?.name,
},
auto: true,
onSuccess(data) {
chartData.value = getChartData()
showProgressChart.value = true
},
})
const getStudentColumns = () => {
return [
let columns = [
{
label: 'Full Name',
key: 'full_name',
width: 2,
width: '20rem',
icon: 'user',
},
{
label: 'Courses Done',
key: 'courses_completed',
align: 'center',
},
{
label: 'Assessments Done',
key: 'assessments_completed',
align: 'center',
label: 'Progress',
key: 'progress',
width: '15rem',
icon: 'activity',
},
{
label: 'Last Active',
key: 'last_active',
width: '10rem',
align: 'center',
icon: 'clock',
},
]
return columns
}
const openStudentModal = () => {
showStudentModal.value = true
}
const openStudentProgressModal = (row) => {
showStudentProgressModal.value = true
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
@@ -160,4 +305,106 @@ const removeStudents = (selections, unselectAll) => {
}
)
}
const getChartData = () => {
let categories = {}
Object.keys(students.data?.[0].courses).forEach((course) => {
categories[course] = {
value: 0,
type: 'course',
label: course,
}
})
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
categories[assessment] = {
value: 0,
type: 'assessment',
label: assessment,
}
})
students.data.forEach((student) => {
Object.keys(student.courses).forEach((course) => {
if (student.courses[course] === 100) {
categories[course].value += 1
}
})
Object.keys(student.assessments).forEach((assessment) => {
if (student.assessments[assessment] === 100) {
categories[assessment].value += 1
}
})
})
chartOptions.value = getChartOptions(categories)
return [
{
name: __('Completed by Students'),
data: Object.values(categories).map((item) => item.value),
},
]
}
const getChartOptions = (categories) => {
const courseColor = theme.colors.green[700]
const assessmentColor = theme.colors.blue[700]
const maxY =
students.data?.length % 5
? students.data?.length + (5 - (students.data?.length % 5))
: students.data?.length
return {
chart: {
type: 'bar',
toolbar: {
show: false,
},
},
plotOptions: {
bar: {
distributed: true,
borderRadius: 3,
borderRadiusApplication: 'end',
horizontal: true,
barHeight: '40%',
},
},
colors: Object.values(categories).map((item) =>
item.type === 'course' ? courseColor : assessmentColor
),
xaxis: {
categories: Object.values(categories).map((item) => item.label),
labels: {
style: {
fontSize: '10px',
},
rotate: 0,
formatter: function (value) {
return value.length > 30 ? `${value.substring(0, 30)}...` : value
},
},
},
yaxis: {
max: maxY,
min: 0,
stepSize: 10,
tickAmount: maxY / 5,
/* reversed: true */
},
}
}
watch(students, () => {
if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
}
})
</script>
<style>
.apexcharts-legend {
display: none !important;
}
</style>

View File

@@ -29,8 +29,8 @@
<slot name="item-label" v-bind="{ active, selected, option }" />
</template>
<template v-if="attrs.onCreate" #footer="{ value, close }">
<div>
<template #footer="{ value, close }">
<div v-if="attrs.onCreate">
<Button
variant="ghost"
class="w-full !justify-start"
@@ -42,6 +42,18 @@
</template>
</Button>
</div>
<div>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Clear')"
@click="() => clearValue(close)"
>
<template #prefix>
<X class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</template>
</Autocomplete>
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
@@ -52,7 +64,7 @@
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import { watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import { Plus, X } from 'lucide-vue-next'
import { useAttrs, computed, ref } from 'vue'
const props = defineProps({
@@ -75,9 +87,7 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs)
const value = computed({
@@ -131,7 +141,7 @@ const options = createResource({
},
})
function reload(val) {
const reload = (val) => {
options.update({
params: {
txt: val,
@@ -142,6 +152,11 @@ function reload(val) {
options.reload()
}
const clearValue = (close) => {
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
close()
}
const labelClasses = computed(() => {
return [
{

View File

@@ -59,7 +59,7 @@
<div v-if="course.status != 'Approved'">
<Badge
variant="solid"
variant="subtle"
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
size="sm"
>

View File

@@ -87,25 +87,29 @@
</span>
</Button>
</router-link>
<div class="mt-8 mb-4 font-medium">
{{ __('This course has:') }}
</div>
<div class="flex items-center mb-3">
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ course.data.lessons }} {{ __('Lessons') }}
</span>
</div>
<div class="flex items-center mb-3">
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ formatAmount(course.data.enrollments) }}
{{ __('Enrolled Students') }}
</span>
</div>
<div class="flex items-center">
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
<div class="space-y-4">
<div class="mt-8 font-medium">
{{ __('This course has:') }}
</div>
<div class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ course.data.lessons }} {{ __('Lessons') }}
</span>
</div>
<div class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ formatAmount(course.data.enrollments) }}
{{ __('Enrolled Students') }}
</span>
</div>
<div v-if="parseInt(course.data.rating) > 0" class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
<span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }}
</span>
</div>
</div>
</div>
</div>

View File

@@ -71,6 +71,7 @@
</div>
<TextEditor
v-if="renderEditor"
class="mt-5"
:content="newReply"
:mentions="mentionUsers"
@@ -94,7 +95,7 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, computed } from 'vue'
import { ref, inject, onMounted } from 'vue'
import { createToast } from '../utils'
const showTopics = defineModel('showTopics')
@@ -102,6 +103,8 @@ const newReply = ref('')
const socket = inject('$socket')
const user = inject('$user')
const allUsers = inject('$allUsers')
const mentionUsers = ref([])
const renderEditor = ref(false)
const props = defineProps({
topic: {
@@ -124,6 +127,7 @@ onMounted(() => {
socket.on('delete_message', (data) => {
replies.reload()
})
fetchMentionUsers()
})
const replies = createResource({
@@ -150,15 +154,26 @@ const newReplyResource = createResource({
},
})
const mentionUsers = computed(() => {
let users = Object.values(allUsers.data).map((user) => {
return {
value: user.name,
label: user.full_name,
}
})
return users
})
const fetchMentionUsers = () => {
if (user.data?.is_student) {
renderEditor.value = true
} else {
allUsers.reload(
{},
{
onSuccess(data) {
mentionUsers.value = Object.values(data).map((user) => {
return {
value: user.name,
label: user.full_name,
}
})
renderEditor.value = true
},
}
)
}
}
const postReply = () => {
newReplyResource.submit(

View File

@@ -1,71 +1,41 @@
<template>
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
<div class="flex w-3/5 md:w-2/5">
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain mr-4"
:alt="job.company_name"
/>
<div>
<div class="font-medium mb-1">
<div class="flex space-x-4 border rounded-md p-2">
<Avatar :image="job.company_logo" :label="job.job_title" size="2xl" />
<div class="flex flex-col space-y-2 flex-1">
<div class="flex items-center justify-between">
<span class="font-semibold">
{{ job.job_title }}
</div>
<div class="text-gray-700">
{{ job.company_name }}
</div>
</div>
</div>
<div class="flex justify-end w-1/5 text-gray-700">
{{ job.location.replace(',', '').split(' ')[0] }}
</div>
<div
class="flex justify-end w-1/5 text-gray-700 text-right hidden md:block"
>
{{ job.type }}
</div>
<div class="flex justify-end w-1/5 text-sm text-gray-700 text-right">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</div>
</div>
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
<div class="flex justify-between">
<div>
<div class="text-xl font-semibold mb-2">
{{ job.job_title }}
</div>
<div>
{{ __("posted by") }}
<span class="font-medium">
{{ job.company_name }}
</span>
</div>
</div>
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain"
/>
</div>
<div class="flex justify-between mt-8">
<div class="flex items-center">
<Badge :label="job.type" theme="green" size="lg" class="mr-4"/>
<Badge :label="job.location" theme="gray" size="lg">
<template #prefix>
<MapPin class="h-4 w-4 stroke-1.5" />
</template>
</Badge>
</div>
<div>
<span class="font-medium">
{{ dayjs(job.creation).format('DD MMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Building2 class="w-4 h-4 stroke-1.5 text-gray-600" />
<span>
{{ job.company_name }}
</span>
</div>
<div class="flex items-center space-x-2">
<MapPin class="w-4 h-4 stroke-1.5 text-gray-600" />
<span>
{{ job.location }}
</span>
</div>
<div class="flex items-center space-x-2">
<Shapes class="w-4 h-4 stroke-1.5 text-gray-600" />
<span>
{{ job.type }}
</span>
</div>
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5 text-gray-600" />
<span> {{ __('posted') }} {{ dayjs(job.creation).fromNow() }} </span>
</div>
</div>
</div> -->
</div>
</template>
<script setup>
import { MapPin } from 'lucide-vue-next'
import { Badge } from 'frappe-ui'
import { Building2, Calendar, MapPin, Shapes } from 'lucide-vue-next'
import { inject } from 'vue'
import { Avatar } from 'frappe-ui'
const dayjs = inject('$dayjs')
const props = defineProps({

View File

@@ -1,5 +1,20 @@
<template>
<div class="space-y-5">
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@@ -56,21 +71,6 @@
}}
</div>
</div>
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
)
}}
</div>
</div>
</div>
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
</template>

View File

@@ -15,45 +15,55 @@
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
class="flex flex-col border rounded-md h-full text-gray-700 p-3"
>
<div class="font-semibold text-gray-900 text-lg mb-4">
<div class="font-semibold text-gray-900 text-lg mb-1">
{{ cls.title }}
</div>
<div class="leading-5 text-gray-700 text-sm mb-4">
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
<span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-5">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(cls.time) }}
</span>
</div>
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
<div class="space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }}
</span>
</div>
<div
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
class="flex items-center space-x-2 text-gray-900 mt-auto"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
v-if="cls.date <= dayjs().format('YYYY-MM-DD')"
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<div v-else class="flex items-center space-x-2 text-yellow-700">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('This class has ended') }}
</span>
</div>
</div>
</div>
</div>
@@ -68,7 +78,7 @@
</template>
<script setup>
import { createListResource, Button } from 'frappe-ui'
import { Plus, Clock, Calendar, Video, Monitor } from 'lucide-vue-next'
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
import { inject } from 'vue'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import { ref } from 'vue'
@@ -107,3 +117,15 @@ const openLiveClassModal = () => {
showLiveClassModal.value = true
}
</script>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.5rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold">
{{ student.full_name }}
</div>
<Badge :theme="student.progress === 100 ? 'green' : 'red'">
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-gray-700">
{{ student.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<div class="space-y-2 text-sm">
<div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1">
{{ __('Assessment') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="assessment in Object.keys(student.assessments)"
class="flex items-center text-gray-700 font-medium"
>
<span class="flex-1">
{{ assessment }}
</span>
<span v-if="isAssignment(student.assessments[assessment])">
<Badge :theme="getStatusTheme(student.assessments[assessment])">
{{ student.assessments[assessment] }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment] }}
</span>
</div>
</div>
<!-- Courses -->
<div class="space-y-2 text-sm">
<div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-gray-700 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div>
<!-- Heatmap -->
<StudentHeatmap :member="student.email" :base_days="120" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel()
const props = defineProps({
student: {
type: Object,
default: null,
},
})
const isAssignment = (value) => {
return isNaN(value)
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>

View File

@@ -17,12 +17,6 @@
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="select"
v-model="details.course"
:label="__('Course')"
:options="getCourses()"
/>
<Link
v-model="details.evaluator"
:label="__('Evaluator')"
@@ -38,6 +32,12 @@
v-model="details.expiry_date"
:label="__('Expiry Date')"
/>
<FormControl
type="select"
v-model="details.course"
:label="__('Course')"
:options="getCourses()"
/>
<Link
v-model="details.template"
:label="__('Template')"
@@ -94,7 +94,7 @@ const createCertificate = createResource({
template: details.template,
published: details.published,
course: values.course,
batch: values.batch,
batch_name: values.batch,
member: values.member,
evaluator: details.evaluator,
},

View File

@@ -96,7 +96,7 @@ import {
} from 'frappe-ui'
import { reactive, watch, defineModel } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast } from '@/utils'
import { getFileSize, showToast, escapeHTML } from '@/utils'
const reloadProfile = defineModel('reloadProfile')
@@ -131,6 +131,7 @@ const imageResource = createResource({
const updateProfile = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
profile.bio = escapeHTML(profile.bio)
return {
doctype: 'User',
name: props.profile.data.name,

View File

@@ -26,7 +26,7 @@ const props = defineProps({
required: true,
},
title: {
type: String,
type: [String, null],
required: true,
},
})

View File

@@ -28,6 +28,7 @@
import { Dialog, createResource } from 'frappe-ui'
import { ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const students = defineModel('reloadStudents')
const student = ref()
@@ -61,8 +62,11 @@ const addStudent = (close) => {
{
onSuccess() {
students.value.reload()
close()
student.value = null
close()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)

View File

@@ -1,24 +1,44 @@
<template>
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
<div
class="bg-gray-900 h-1 rounded-full"
:style="{ width: progressBarWidth }"
></div>
</div>
<Tooltip :text="`${props.progress}%`">
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
<div
class="bg-gray-900 rounded-full"
:class="progressBarHeight"
:style="{ width: progressBarWidth }"
></div>
</div>
</Tooltip>
</template>
<script setup>
import { computed } from 'vue'
import { Tooltip } from 'frappe-ui'
const props = defineProps({
progress: {
type: Number,
default: 0,
},
size: {
type: String,
default: 'sm',
},
})
const progressBarWidth = computed(() => {
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
return `${formattedPercentage}%`
})
const progressBarHeight = computed(() => {
if (props.size === 'sm') {
return 'h-1'
}
if (props.size === 'md') {
return 'h-2'
}
if (props.size === 'lg') {
return 'h-3'
}
})
</script>

View File

@@ -118,15 +118,17 @@
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
@change="markAnswer(index)"
/>
<div
v-else-if="quiz.data.show_answers"
v-for="(answer, idx) in showAnswers"
>
<div v-if="index - 1 == idx">
<CheckCircle v-if="answer" class="w-4 h-4 text-green-500" />
<CheckCircle
v-if="answer == 1"
class="w-4 h-4 text-green-500"
/>
<MinusCircle
v-else-if="questionDetails.data[`is_correct_${index}`]"
v-else-if="answer == 2"
class="w-4 h-4 text-green-500"
/>
<XCircle
@@ -271,6 +273,7 @@
import {
Badge,
Button,
call,
createResource,
ListView,
TextEditor,
@@ -280,6 +283,7 @@ import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
@@ -291,6 +295,7 @@ let questions = reactive([])
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const router = useRouter()
const props = defineProps({
quizName: {
@@ -496,8 +501,8 @@ const checkAnswer = () => {
selectedOptions.forEach((option, index) => {
if (option) {
showAnswers[index] = option && data[index]
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
showAnswers[index] = 0
} else if (data[index] == 2) {
showAnswers[index] = 2
} else {
showAnswers[index] = undefined
}
@@ -560,6 +565,7 @@ const createSubmission = () => {
{},
{
onSuccess(data) {
markLessonProgress()
if (quiz.data && quiz.data.max_attempts) attempts.reload()
if (quiz.data.duration) clearInterval(timerInterval)
},
@@ -583,6 +589,16 @@ const getInstructions = (question) => {
else return __('Type your answer')
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
call('lms.lms.api.mark_lesson_progress', {
course: router.currentRoute.value.params.courseName,
chapter_number: router.currentRoute.value.params.chapterNumber,
lesson_number: router.currentRoute.value.params.lessonNumber,
})
}
}
const getSubmissionColumns = () => {
return [
{

View File

@@ -1,58 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div>
<Link
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToQuizForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addQuiz()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const props = defineProps({
onQuizAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addQuiz = () => {
props.onQuizAddition(quiz.value)
show.value = false
}
const redirectToQuizForm = () => {
window.open('/lms/quizzes/new', '_blank')
}
</script>

View File

@@ -0,0 +1,138 @@
<template>
<div v-if="heatmap.data">
<div class="text-lg font-semibold mb-2">
{{ heatmap.data.total_activities }}
{{
heatmap.data.total_activities > 1 ? __('activities') : __('activity')
}}
{{ __('in the last') }}
{{ heatmap.data.weeks }}
{{ __('weeks') }}
</div>
<ApexChart :options="chartOptions" :series="chartSeries" height="240" />
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme'
const user = inject('$user')
const labels = ref([])
const memberName = ref(null)
const props = defineProps({
member: {
type: String,
},
base_days: {
type: Number,
default: 200,
},
})
onMounted(() => {
memberName.value = props.member || user.data?.name
})
const heatmap = createResource({
url: 'lms.lms.api.get_heatmap_data',
makeParams(values) {
return {
member: values.member,
base_days: props.base_days,
}
},
auto: false,
cache: ['heatmap', memberName.value],
})
watch(memberName, (newVal) => {
heatmap.reload(
{
member: newVal,
},
{
onSuccess(data) {
labels.value = data.labels
},
}
)
})
const chartOptions = computed(() => {
return {
chart: {
type: 'heatmap',
toolbar: {
show: false,
},
},
highlightOnHover: false,
grid: {
show: false,
},
plotOptions: {
heatmap: {
radius: 8,
shadeIntensity: 0.2,
enableShades: true,
colorScale: {
ranges: [
{ from: 0, to: 0, color: theme.colors.gray[400] },
{ from: 1, to: 5, color: theme.colors.green[200] },
{ from: 6, to: 15, color: theme.colors.green[500] },
{ from: 16, to: 30, color: theme.colors.green[700] },
{ from: 31, to: 100, color: theme.colors.green[800] },
],
},
},
},
dataLabels: {
enabled: false,
},
xaxis: {
type: 'category',
categories: labels.value,
position: 'top',
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
type: 'category',
categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
reversed: true,
tooltip: {
enabled: false,
},
},
tooltip: {
custom: ({ series, seriesIndex, dataPointIndex, w }) => {
return `<div class="text-xs bg-gray-900 text-white font-medium p-1">
<div class="text-center">${heatmap.data.heatmap_data[seriesIndex].data[dataPointIndex].label}</div>
</div>`
},
},
}
})
const chartSeries = computed(() => {
if (!heatmap.data) return []
let series = heatmap.data.heatmap_data.map((row) => {
return {
name: row.name,
data: row.data.map((value) => value.count),
}
})
return series
})
</script>

View File

@@ -1,10 +1,12 @@
<template>
<div class="mb-10">
<Button v-if="isStudent" @click="openEvalModal" class="float-right">
{{ __('Schedule Evaluation') }}
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Upcoming Evaluations') }}
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold">
{{ __('Upcoming Evaluations') }}
</div>
<Button @click="openEvalModal">
{{ __('Schedule Evaluation') }}
</Button>
</div>
<div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-2 gap-4">
@@ -67,10 +69,6 @@ const props = defineProps({
type: Array,
default: [],
},
isStudent: {
type: Boolean,
default: false,
},
endDate: {
type: String,
default: null,

View File

@@ -1,5 +1,4 @@
import './index.css'
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
@@ -8,15 +7,8 @@ import dayjs from '@/utils/dayjs'
import { createDialog } from '@/utils/dialogs'
import translationPlugin from './translation'
import { usersStore } from './stores/user'
import { sessionStore } from './stores/session'
import { initSocket } from './socket'
import {
FrappeUI,
setConfig,
frappeRequest,
resourcesPlugin,
pageMetaPlugin,
} from 'frappe-ui'
import { FrappeUI, setConfig, frappeRequest, pageMetaPlugin } from 'frappe-ui'
let pinia = createPinia()
let app = createApp(App)
@@ -32,8 +24,6 @@ app.provide('$socket', initSocket())
app.mount('#app')
const { userResource, allUsers } = usersStore()
let { isLoggedIn } = sessionStore()
app.provide('$user', userResource)
app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource

View File

@@ -0,0 +1,191 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="assignment.doc?.name"
:to="{
name: 'AssignmentSubmissionList',
query: {
assignmentID: assignment.doc.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<div class="font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl
v-model="model.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="model.type"
type="select"
:options="assignmentOptions"
:label="__('Type')"
:required="true"
/>
</div>
<div>
<div class="text-xs text-gray-600 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="model.question"
@change="(val) => (model.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
createDocumentResource,
createResource,
FormControl,
TextEditor,
} from 'frappe-ui'
import {
computed,
inject,
onMounted,
onBeforeUnmount,
reactive,
watch,
} from 'vue'
import { showToast } from '@/utils'
import { useRouter } from 'vue-router'
const user = inject('$user')
const router = useRouter()
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
})
const model = reactive({
title: '',
type: 'PDF',
question: '',
})
onMounted(() => {
if (
props.assignmentID == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.assignmentID !== 'new') {
assignment.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
saveAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createDocumentResource({
doctype: 'LMS Assignment',
name: props.assignmentID,
auto: false,
})
const newAssignment = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assignment',
...values,
},
}
},
onSuccess(data) {
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
})
const saveAssignment = () => {
if (props.assignmentID == 'new') {
newAssignment.submit({
...model,
})
} else {
assignment.setValue.submit(
{
...model,
},
{
onSuccess(data) {
showToast(__('Success'), __('Assignment saved successfully'), 'check')
assignment.reload()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
}
watch(assignment, () => {
Object.keys(assignment.doc).forEach((key) => {
model[key] = assignment.doc[key]
})
})
const breadcrumbs = computed(() => [
{
label: __('Assignments'),
route: { name: 'Assignments' },
},
{
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
},
])
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
</script>

View File

@@ -3,137 +3,20 @@
class="flex justify-between sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<Button variant="solid" @click="submitAssignment()">
{{ __('Save') }}
</Button>
</header>
<div class="container py-5">
<div
v-if="submissionResource.data"
class="bg-blue-100 p-2 rounded-md leading-5 text-sm italic"
>
{{ __("You've successfully submitted the assignment.") }}
{{
__(
"Once the moderator grades your submission, you'll find the details here."
)
}}
{{ __('Feel free to make edits to your submission if needed.') }}
</div>
<div v-if="assignment.data">
<div>
<div class="text-xl font-semibold hidden">
{{ __('Question') }}
</div>
<div class="text-sm mt-1 hidden">
{{
__('Read the question carefully before attempting the assignment.')
}}
</div>
<div
v-html="assignment.data.question"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
></div>
</div>
<div class="">
<div class="text-xl font-semibold mt-10">
{{ __('Submission') }}
</div>
<div v-if="showUploader()">
<div class="text-sm mt-1 mb-4">
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
</div>
<FileUploader
v-if="!submissionFile"
:fileTypes="getType()"
:validateFile="validateFile"
@success="(file) => saveSubmission(file)"
>
<template
#default="{
file,
uploading,
progress,
uploaded,
message,
error,
total,
success,
openFileSelector,
}"
>
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload File')
}}
</Button>
</template>
</FileUploader>
<div v-else>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ submissionFile.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(submissionFile.file_size) }}
</span>
</div>
<X
@click="removeSubmission()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<div v-else-if="assignment.data.type == 'URL'">
<div class="text-sm mb-4">
{{ __('Enter a URL') }}
</div>
<FormControl v-model="answer" />
</div>
<div v-else>
<div class="text-sm mb-4">
{{ __('Write your answer here') }}
</div>
<TextEditor
:content="answer"
@change="(val) => (answer = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
</div>
<div class="overflow-hidden h-[calc(100vh-3.2rem)]">
<Assignment :assignmentID="assignmentID" :submissionName="submissionName" />
</div>
</template>
<script setup>
import {
Breadcrumbs,
createResource,
FileUploader,
Button,
FormControl,
TextEditor,
} from 'frappe-ui'
import { FileText, X } from 'lucide-vue-next'
import { computed, inject, onMounted, ref } from 'vue'
import { showToast, getFileSize } from '../utils'
import { useRouter } from 'vue-router'
import { Breadcrumbs, createResource } from 'frappe-ui'
import { computed, inject, onMounted } from 'vue'
import Assignment from '@/components/Assignment.vue'
const user = inject('$user')
const submissionFile = ref(null)
const answer = ref(null)
const router = useRouter()
const props = defineProps({
assignmentName: {
assignmentID: {
type: String,
required: true,
},
@@ -143,186 +26,40 @@ const props = defineProps({
},
})
const assignment = createResource({
url: 'frappe.client.get',
const title = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Assignment',
name: props.assignmentName,
fieldname: 'title',
filters: {
name: props.assignmentID,
},
},
auto: true,
})
const showUploader = () => {
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
}
const updateSubmission = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
let fieldname = {}
if (showUploader()) {
fieldname.assignment_attachment = submissionFile.value.file_url
} else {
fieldname.answer = answer.value
}
return {
doctype: 'LMS Assignment Submission',
name: props.submissionName,
fieldname: fieldname,
}
},
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
submissionFile.value = data
},
})
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentName,
member: user.data?.name,
}
if (showUploader()) {
doc.assignment_attachment = submissionFile.value.file_url
} else {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
const submissionResource = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Assignment Submission',
fieldname: showUploader() ? 'assignment_attachment' : 'answer',
filters: {
name: props.submissionName,
},
},
onSuccess(data) {
if (data.assignment_attachment)
imageResource.reload({ image: data.assignment_attachment })
if (data.answer) answer.value = data.answer
},
})
onMounted(() => {
if (!user.data) {
window.location.href = '/login'
}
if (props.submissionName != 'new') {
submissionResource.reload()
}
})
const submitAssignment = () => {
if (props.submissionName != 'new') {
updateSubmission.submit(
{},
{
onSuccess(data) {
showToast('Success', 'Submission updated successfully.', 'check')
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
} else {
addNewSubmission()
}
}
const addNewSubmission = () => {
newSubmission.submit(
{},
{
onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check')
router.push({
name: 'AssignmentSubmission',
params: {
assignmentName: props.assignmentName,
submissionName: data.name,
},
})
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Assignment',
label: 'Submissions',
route: { name: 'AssignmentSubmissionList' },
},
{
label: assignment.data?.title,
label: title.data?.title,
route: {
name: 'AssignmentSubmission',
params: {
assignmentName: assignment.data?.name,
assignmentID: props.assignmentID,
},
},
},
]
return crumbs
})
const saveSubmission = (file) => {
submissionFile.value = file
}
const getType = () => {
const type = assignment.data?.type
if (type == 'Image') {
return ['image/*']
} else if (type == 'Document') {
return [
'.doc',
'.docx',
'.xml',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
} else if (type == 'PDF') {
return ['.pdf']
}
}
const validateFile = (file) => {
let type = assignment.data?.type
let extension = file.name.split('.').pop().toLowerCase()
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
} else if (
type == 'Document' &&
!['doc', 'docx', 'xml'].includes(extension)
) {
return 'Only document file is allowed.'
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
return 'Only PDF file is allowed.'
}
}
const removeSubmission = () => {
submissionFile.value = null
}
</script>

View File

@@ -0,0 +1,217 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="grid grid-cols-3 gap-5 mb-5">
<Link
doctype="LMS Assignment"
v-model="assignmentID"
:placeholder="__('Assignment')"
/>
<Link doctype="User" v-model="member" :placeholder="__('Member')" />
<FormControl
v-model="status"
type="select"
:options="statusOptions"
:placeholder="__('Status')"
/>
</div>
<ListView
v-if="submissions.loading || submissions.data?.length"
:columns="submissionColumns"
:rows="submissions.data"
rowKey="name"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in submissionColumns" />
</ListHeader>
<ListRows>
<router-link
v-for="row in submissions.data"
:to="{
name: 'AssignmentSubmission',
params: {
assignmentID: row.assignment,
submissionName: row.name,
},
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'status'">
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-else
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<Pencil class="size-8 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No submissions') }}
</div>
<div class="leading-5">
{{ __('There are no submissions for this assignment.') }}
</div>
</div>
</div>
</template>
<script setup>
import {
Badge,
Breadcrumbs,
createListResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Pencil } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
const router = useRouter()
const assignmentID = ref('')
const member = ref('')
const status = ref('')
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator) {
router.push({ name: 'Courses' })
}
assignmentID.value = router.currentRoute.value.query.assignmentID
member.value = router.currentRoute.value.query.member
status.value = router.currentRoute.value.query.status
reloadSubmissions()
})
const getAssignmentFilters = () => {
let filters = {}
if (assignmentID.value) {
filters.assignment = assignmentID.value
}
if (member.value) {
filters.member = member.value
}
if (status.value) {
filters.status = status.value
}
return filters
}
const submissions = createListResource({
doctype: 'LMS Assignment Submission',
fields: [
'name',
'assignment',
'assignment_title',
'member_name',
'creation',
'status',
],
orderBy: 'creation desc',
transform(data) {
return data.map((row) => {
return {
...row,
creation: dayjs(row.creation).fromNow(),
}
})
},
})
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
watch([assignmentID, member, status], () => {
router.push({
query: {
assignmentID: assignmentID.value,
member: member.value,
status: status.value,
},
})
reloadSubmissions()
})
const reloadSubmissions = () => {
submissions.update({
filters: getAssignmentFilters(),
})
submissions.reload()
}
const submissionColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: 1,
},
{
label: 'Assignment',
key: 'assignment_title',
width: 2,
},
{
label: 'Submitted',
key: 'creation',
width: 1,
align: 'left',
},
{
label: 'Status',
key: 'status',
width: 1,
align: 'center',
},
]
})
const statusOptions = computed(() => {
return [
{ label: '', value: '' },
{ label: 'Pass', value: 'Pass' },
{ label: 'Fail', value: 'Fail' },
{ label: 'Not Graded', value: 'Not Graded' },
]
})
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status === 'Not Graded') {
return 'blue'
} else {
return 'red'
}
}
const breadcrumbs = computed(() => {
return [
{
label: 'Assignment Submissions',
},
]
})
</script>

View File

@@ -0,0 +1,187 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
:to="{
name: 'AssignmentForm',
params: {
assignmentID: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New') }}
</Button>
</router-link>
</header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="grid grid-cols-3 gap-5 mb-5">
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" />
<FormControl
v-model="typeFilter"
type="select"
:options="assignmentTypes"
:placeholder="__('Type')"
/>
</div>
<ListView
v-if="assignments.data?.length"
:columns="assignmentColumns"
:rows="assignments.data"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
getRowRoute: (row) => ({
name: 'AssignmentForm',
params: {
assignmentID: row.name,
},
}),
}"
>
</ListView>
<div
v-else
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<Pencil class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No assignments found') }}
</div>
<div class="leading-5">
{{
__(
'You have not created any assignments yet. To create a new assignment, click on the "New" button above.'
)
}}
</div>
</div>
<div
v-if="assignments.data && assignments.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="assignments.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
createListResource,
FormControl,
ListView,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus, Pencil } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
const user = inject('$user')
const dayjs = inject('$dayjs')
const titleFilter = ref('')
const typeFilter = ref('')
const router = useRouter()
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
titleFilter.value = router.currentRoute.value.query.title
typeFilter.value = router.currentRoute.value.query.type
})
watch([titleFilter, typeFilter], () => {
router.push({
query: {
title: titleFilter.value,
type: typeFilter.value,
},
})
reloadAssignments()
})
const reloadAssignments = () => {
assignments.update({
filters: assignmentFilter.value,
})
assignments.reload()
}
const assignmentFilter = computed(() => {
let filters = {}
if (titleFilter.value) {
filters.title = ['like', `%${titleFilter.value}%`]
}
if (typeFilter.value) {
filters.type = typeFilter.value
}
if (!user.data?.is_moderator) {
filters.owner = user.data?.email
}
return filters
})
const assignments = createListResource({
doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation'],
orderBy: 'modified desc',
cache: ['assignments'],
transform(data) {
return data.map((row) => {
return {
...row,
creation: dayjs(row.creation).fromNow(),
}
})
},
})
const assignmentColumns = computed(() => {
return [
{
label: __('Title'),
key: 'title',
width: 2,
},
{
label: __('Type'),
key: 'type',
width: 1,
align: 'left',
},
{
label: __('Created'),
key: 'creation',
width: 1,
align: 'center',
},
]
})
const assignmentTypes = computed(() => {
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
return types.map((type) => {
return {
label: __(type),
value: type,
}
})
})
const breadcrumbs = computed(() => [
{
label: 'Assignments',
route: { name: 'Assignments' },
},
])
</script>

View File

@@ -1,32 +1,33 @@
<template>
<div v-if="badge.doc">
<div v-if="badge.data">
<div class="p-5 flex flex-col items-center mt-40">
<div class="text-3xl font-semibold">
{{ badge.doc.title }}
{{ badge.data.badge }}
</div>
<img :src="badge.doc.image" :alt="badge.doc.title" class="h-60 mt-2" />
<div class="text-lg">
<img
:src="badge.data.badge_image"
:alt="badge.data.badge"
class="h-60 mt-2"
/>
<div class="">
{{
__('This badge has been awarded to {0} on {1}.').format(
userName,
dayjs(issuedOn.data?.issued_on).format('DD MMM YYYY')
badge.data.member_name,
dayjs(badge.data.issued_on).format('DD MMM YYYY')
)
}}
</div>
<div class="text-lg mt-2">
{{ badge.doc.description }}
<div class="mt-2">
{{ badge.data.badge_description }}
</div>
</div>
</div>
</template>
<script setup>
import { createDocumentResource, createResource, Breadcrumbs } from 'frappe-ui'
import { createDocumentResource, createResource } from 'frappe-ui'
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
const allUsers = inject('$allUsers')
const dayjs = inject('$dayjs')
const router = useRouter()
const props = defineProps({
badgeName: {
@@ -39,33 +40,15 @@ const props = defineProps({
},
})
const badge = createDocumentResource({
doctype: 'LMS Badge',
name: props.badgeName,
})
const userName = computed(() => {
const user = Object.values(allUsers.data).find(
(user) => user.name === props.email
)
return user ? user.full_name : props.email
})
const issuedOn = createResource({
url: 'frappe.client.get_value',
const badge = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Badge Assignment',
filters: {
member: props.email,
badge: props.badgeName,
member: props.email,
},
fieldname: 'issued_on',
}
},
onSuccess(data) {
if (!data.issued_on) {
router.push({ name: 'Courses' })
}
},
auto: true,
@@ -77,11 +60,11 @@ const breadcrumbs = computed(() => {
label: 'Badges',
},
{
label: badge.doc.title,
label: badge.data.badge,
route: {
name: 'Badge',
params: {
badge: badge.doc.name,
badge: badge.data.badge,
},
},
},

View File

@@ -21,8 +21,8 @@
</Button>
</div>
</header>
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
<div class="border-r-2">
<div v-if="batch.data" class="grid grid-cols-[75%,25%] h-screen">
<div class="border-r">
<Tabs
v-model="tabIndex"
:tabs="tabs"
@@ -59,14 +59,14 @@
<div v-if="tab.label == 'Courses'">
<BatchCourses :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Dashboard'">
<div v-else-if="tab.label == 'Dashboard' && isStudent">
<BatchDashboard :batch="batch" :isStudent="isStudent" />
</div>
<div v-else-if="tab.label == 'Live Class'">
<LiveClass :batch="batch.data.name" />
<div v-else-if="tab.label == 'Dashboard'">
<BatchStudents :batch="batch.data" />
</div>
<div v-else-if="tab.label == 'Students'">
<BatchStudents :batch="batch.data.name" />
<div v-else-if="tab.label == 'Classes'">
<LiveClass :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Assessments'">
<Assessments :batch="batch.data.name" />
@@ -81,20 +81,23 @@
:title="__('Discussions')"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="true"
:scrollToBottom="false"
/>
</div>
<div v-else-if="tab.label == 'Feedback'">
<BatchFeedback :batch="batch.data.name" />
</div>
</div>
</template>
</Tabs>
</div>
<div class="p-5">
<div class="text-2xl font-semibold mb-2">
{{ batch.data.title }}
<div class="text-gray-700 font-semibold mb-4">
{{ __('About this batch') }}:
</div>
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
<div class="flex avatar-group overlap mb-5">
<div class="flex items-center avatar-group overlap mb-5">
<div
class="h-6 mr-1"
:class="{
@@ -190,11 +193,11 @@ import {
BookOpen,
Laptop,
BookOpenCheck,
Contact2,
Mail,
SendIcon,
MessageCircle,
Globe,
ClipboardPen,
} from 'lucide-vue-next'
import { formatTime, updateDocumentTitle } from '@/utils'
import BatchDashboard from '@/components/BatchDashboard.vue'
@@ -207,6 +210,7 @@ import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue'
const user = inject('$user')
const showAnnouncementModal = ref(false)
@@ -229,7 +233,7 @@ const batch = createResource({
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: 'All Batches', route: { name: 'Batches' } }]
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
if (!isStudent.value) {
crumbs.push({
label: 'Details',
@@ -259,38 +263,42 @@ const isStudent = computed(() => {
const tabIndex = ref(0)
const tabs = computed(() => {
let batchTabs = []
if (isStudent.value) {
batchTabs.push({
label: 'Dashboard',
icon: LayoutDashboard,
})
}
batchTabs.push({
label: 'Dashboard',
icon: LayoutDashboard,
})
batchTabs.push({
label: 'Courses',
icon: BookOpen,
})
batchTabs.push({
label: 'Classes',
icon: Laptop,
})
if (user.data?.is_moderator) {
batchTabs.push({
label: 'Students',
icon: Contact2,
})
batchTabs.push({
label: 'Assessments',
icon: BookOpenCheck,
})
}
batchTabs.push({
label: 'Live Class',
icon: Laptop,
})
batchTabs.push({
label: 'Courses',
icon: BookOpen,
})
batchTabs.push({
label: 'Announcements',
icon: Mail,
})
batchTabs.push({
label: 'Discussions',
icon: MessageCircle,
})
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs
})

View File

@@ -137,7 +137,7 @@ const courses = createResource({
})
const breadcrumbs = computed(() => {
let items = [{ label: 'All Batches', route: { name: 'Batches' } }]
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
items.push({
label: batch?.data?.title,
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },

View File

@@ -252,7 +252,7 @@ import {
} from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { useRouter } from 'vue-router'
import { showToast } from '../utils'
import { showToast } from '@/utils'
import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -345,6 +345,10 @@ const batchDetail = createResource({
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (['start_time', 'end_time'].includes(key)) {
let [hours, minutes, seconds] = data[key].split(':')
hours = hours.length == 1 ? '0' + hours : hours
batch[key] = `${hours}:${minutes}`
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
})
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']

View File

@@ -1,254 +1,296 @@
<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
<header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
/>
<div class="flex space-x-2">
<div class="w-44">
<Select
v-if="categories.data?.length"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</router-link>
</header>
<div class="p-5 pb-10">
<div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
>
<div class="text-lg font-semibold">
{{ __('All Batches') }}
</div>
<div
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-2"
>
<TabButtons
v-if="user.data"
:buttons="batchTabs"
v-model="currentTab"
/>
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="title"
:placeholder="__('Search by Title')"
type="text"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateBatches()"
/>
</div>
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</router-link>
</div>
</header>
<div v-if="batches.data" class="pb-5">
<div
v-if="batches.data.length == 0 && batches.list.loading"
class="p-5 text-base text-gray-700"
>
{{ __('Loading Batches...') }}
</div>
<Tabs
v-if="hasBatches"
v-model="tabIndex"
:tabs="makeTabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
>
<template #tab="{ tab, selected }">
<div>
<button
class="group -mb-px flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
:class="{ 'text-gray-900': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge
:class="
selected
? 'text-gray-800 border border-gray-800'
: 'border border-gray-500'
"
variant="subtle"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
<div class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40">
<Select
v-if="categories.length"
v-model="currentCategory"
:options="categories"
:placeholder="__('Category')"
@change="updateBatches()"
/>
</div>
</template>
<template #default="{ tab }">
<div
v-if="tab.batches && tab.batches.value.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5"
>
<router-link
v-for="batch in tab.batches.value"
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
>
<BatchCard :batch="batch" />
</router-link>
</div>
<div v-else class="p-5 italic text-gray-500">
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div
v-else-if="
!batches.loading &&
!hasBatches &&
(user.data?.is_instructor || user.data?.is_moderator)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'BatchForm',
params: {
batchName: 'new',
},
}"
>
<div class="bg-gray-50 py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
/>
<div class="font-medium">
{{ __('Create a Batch') }}
</div>
<span class="text-gray-700 text-sm leading-4">
{{ __('You can link courses and assessments to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!batches.loading && !hasBatches"
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No batches found') }}
</div>
<div>
{{
__(
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div>
<div
v-if="batches.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5"
>
<router-link
v-for="batch in batches.data"
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
>
<BatchCard :batch="batch" />
</router-link>
</div>
<div
v-else-if="!batches.list.loading"
class="flex flex-col items-center justify-center text-sm text-gray-600 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-lg font-medium mb-1">
{{ __('No batches found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no batches matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<div
v-if="!batches.list.loading && batches.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="batches.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</template>
<script setup>
import {
createResource,
Breadcrumbs,
Button,
Tabs,
Badge,
createListResource,
FormControl,
Select,
TabButtons,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
import BatchCard from '@/components/BatchCard.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
const start = ref(0)
const pageLength = ref(20)
const categories = ref([])
const currentCategory = ref(null)
const hasBatches = ref(false)
const title = ref('')
const filters = ref({})
const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming')
const orderBy = ref('start_date')
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
const batches = createResource({
doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches',
cache: ['batches', user.data?.email],
auto: true,
})
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Batch',
filters: {
published: 1,
},
}
},
cache: ['batchCategories'],
auto: true,
transform(data) {
data.unshift({
setFiltersFromQuery()
updateBatches()
categories.value = [
{
label: '',
value: null,
})
},
]
})
const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search)
title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null
}
const batches = createListResource({
doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches',
cache: ['batches', user.data?.name],
pageLength: pageLength.value,
start: start.value,
onSuccess(data) {
let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
},
})
const tabIndex = ref(0)
let tabs
const updateBatches = () => {
updateFilters()
batches.update({
filters: filters.value,
orderBy: orderBy.value,
})
batches.reload()
}
const makeTabs = computed(() => {
tabs = []
addToTabs('Upcoming')
const updateFilters = () => {
updateCategoryFilter()
updateTitleFilter()
updateTabFilter()
updateStudentFilter()
setQueryParams()
}
const updateCategoryFilter = () => {
if (currentCategory.value) {
filters.value['category'] = currentCategory.value
} else {
delete filters.value['category']
}
}
const updateTitleFilter = () => {
if (title.value) {
filters.value['title'] = ['like', `%${title.value}%`]
} else {
delete filters.value['title']
}
}
const updateTabFilter = () => {
orderBy.value = 'start_date'
if (!user.data) {
return
}
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
filters.value['enrolled'] = 1
delete filters.value['start_date']
delete filters.value['published']
orderBy.value = 'start_date desc'
} else if (user.data?.is_student) {
delete filters.value['enrolled']
} else {
delete filters.value['start_date']
delete filters.value['published']
orderBy.value = 'start_date desc'
if (currentTab.value == 'Upcoming') {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
filters.value['published'] = 1
orderBy.value = 'start_date'
} else if (currentTab.value == 'Archived') {
filters.value['start_date'] = ['<=', dayjs().format('YYYY-MM-DD')]
} else if (currentTab.value == 'Unpublished') {
filters.value['published'] = 0
}
}
}
const updateStudentFilter = () => {
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
filters.value['published'] = 1
}
}
const setQueryParams = () => {
let queries = new URLSearchParams(location.search)
let filterKeys = {
title: title.value,
category: currentCategory.value,
}
Object.keys(filterKeys).forEach((key) => {
if (filterKeys[key]) {
queries.set(key, filterKeys[key])
} else {
queries.delete(key)
}
})
history.replaceState({}, '', `${location.pathname}?${queries.toString()}`)
}
const updateCategories = (data) => {
data.forEach((batch) => {
if (
batch.category &&
!categories.value.find((category) => category.value === batch.category)
)
categories.value.push({
label: batch.category,
value: batch.category,
})
})
}
watch(currentTab, () => {
updateBatches()
})
const batchType = computed(() => {
let types = [
{ label: __(''), value: null },
{ label: __('Upcoming'), value: 'Upcoming' },
{ label: __('Archived'), value: 'Archived' },
]
if (user.data?.is_moderator) {
addToTabs('Archived')
addToTabs('Private')
types.push({ label: __('Unpublished'), value: 'Unpublished' })
}
return types
})
if (user.data) {
addToTabs('Enrolled')
const batchTabs = computed(() => {
let tabs = [
{
label: __('All'),
},
]
if (user.data?.is_student) {
tabs.push({ label: __('Enrolled') })
} else {
tabs.push({ label: __('Upcoming') })
tabs.push({ label: __('Archived') })
tabs.push({ label: __('Unpublished') })
}
return tabs
})
const getBatches = (type) => {
if (currentCategory.value && currentCategory.value != '') {
return batches.data[type].filter(
(batch) => batch.category == currentCategory.value
)
}
return batches.data[type]
}
const addToTabs = (label) => {
let batches = getBatches(label.toLowerCase().split(' ').join('_'))
tabs.push({
label,
batches: computed(() => batches),
count: computed(() => batches.length),
})
}
watch(batches, () => {
Object.keys(batches.data).forEach((key) => {
if (batches.data[key].length) {
hasBatches.value = true
}
})
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const breadcrumbs = computed(() => [
{
label: __('Batches'),
route: { name: 'Batches' },
},
])
const pageMeta = computed(() => {
return {
title: 'Batches',
description: 'All batches divided by categories',
description: 'All upcoming batches.',
}
})

View File

@@ -1,93 +1,175 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
class="sticky flex items-center justify-between top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div>
<FormControl
type="text"
placeholder="Search"
v-model="searchQuery"
@input="participants.reload()"
class="w-40"
>
<template #prefix>
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
</div>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
<div class="p-5 lg:w-3/4 mx-auto">
<div
v-if="participants.data?.length"
v-for="participant in participantsList"
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5"
>
<router-link
:to="{
name: 'Profile',
params: { username: participant.username },
}"
>
<div class="flex shadow rounded-md h-full p-2">
<UserAvatar :user="participant" size="3xl" class="mr-2" />
<div>
<router-link
:to="{
name: 'Profile',
params: { username: participant.username },
}"
>
<div class="text-lg font-semibold mb-2">
<div class="text-lg font-semibold">
{{ __('All Certified Participants') }}
</div>
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="nameFilter"
:placeholder="__('Search by Name')"
type="text"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateParticipants()"
/>
<div
v-if="categories.data?.length"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
>
<Select
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
@change="updateParticipants()"
/>
</div>
</div>
</div>
<div v-if="participants.data?.length">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<router-link
v-for="participant in participants.data"
:to="{
name: 'ProfileCertificates',
params: { username: participant.username },
}"
>
<div
class="flex items-center space-x-2 border rounded-md hover:bg-gray-50 p-2"
>
<Avatar
:image="participant.user_image"
:label="participant.full_name"
size="2xl"
/>
<div class="flex flex-col space-y-2">
<div class="font-medium">
{{ participant.full_name }}
</div>
</router-link>
<div class="leading-5" v-for="course in participant.courses">
{{ course }}
<div
v-if="participant.headline"
class="headline text-sm text-gray-700"
>
{{ participant.headline }}
</div>
</div>
</div>
</div>
</router-link>
</router-link>
</div>
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="participants.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
<div
v-else-if="!participants.list.loading"
class="flex flex-col items-center justify-center text-sm text-gray-600 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-lg font-medium mb-1">
{{ __('No participants found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{ __('There are no participants matching this criteria.') }}
</div>
</div>
</div>
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
import { ref, computed } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { Search } from 'lucide-vue-next'
import {
Avatar,
Breadcrumbs,
Button,
createListResource,
FormControl,
Select,
} from 'frappe-ui'
import { computed, onMounted, ref } from 'vue'
import { updateDocumentTitle } from '@/utils'
import { BookOpen } from 'lucide-vue-next'
const searchQuery = ref('')
const currentCategory = ref('')
const filters = ref({})
const nameFilter = ref('')
const participants = createResource({
onMounted(() => {
updateParticipants()
})
const participants = createListResource({
doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certified_participants',
method: 'GET',
cache: 'certified-participants',
auto: true,
cache: ['certified_participants'],
start: 0,
pageLength: 30,
})
const breadcrumbs = computed(() => {
return [{ label: 'Certified Participants', to: '/certified-participants' }]
const categories = createListResource({
doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories',
cache: ['certification_categories'],
auto: true,
transform(data) {
data.unshift({ label: __(''), value: '' })
return data
},
})
const updateParticipants = () => {
updateFilters()
participants.update({
filters: filters.value,
})
participants.reload()
}
const updateFilters = () => {
if (currentCategory.value) {
filters.value.category = currentCategory.value
} else {
delete filters.value.category
}
if (nameFilter.value) {
filters.value.member_name = ['like', `%${nameFilter.value}%`]
} else {
delete filters.value.member_name
}
}
const breadcrumbs = computed(() => [
{
label: __('Certified Participants'),
route: { name: 'CertifiedParticipants' },
},
])
const pageMeta = computed(() => {
return {
title: 'Certified Participants',
description: 'All participants that have been certified.',
}
})
const participantsList = computed(() => {
if (searchQuery.value) {
return participants.data.filter((participant) => {
return participant.full_name
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
})
}
return participants.data
})
updateDocumentTitle(pageMeta)
</script>
<style>
.headline {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
}
</style>

View File

@@ -16,7 +16,7 @@
</div>
<div class="flex items-center">
<Tooltip
v-if="course.data.rating"
v-if="parseInt(course.data.rating) > 0"
:text="__('Average Rating')"
class="flex items-center"
>
@@ -25,7 +25,9 @@
{{ course.data.rating }}
</span>
</Tooltip>
<span v-if="course.data.rating" class="mx-3">&middot;</span>
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
>&middot;</span
>
<Tooltip
v-if="course.data.enrollment_count"
:text="__('Enrolled Students')"
@@ -117,7 +119,7 @@ const course = createResource({
})
const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
label: course?.data?.title,
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },

View File

@@ -133,8 +133,8 @@
</div>
<FormControl
v-model="newTag"
:placeholder="__('Keywords for the course')"
class="w-52"
:placeholder="__('Add a keyword and then press enter')"
class="w-72"
@keyup.enter="updateTags()"
id="tags"
/>
@@ -288,6 +288,7 @@ const course = reactive({
video_link: '',
course_image: null,
tags: '',
category: '',
published: false,
published_on: '',
featured: false,

View File

@@ -71,7 +71,7 @@
<template #default="{ tab }">
<div
v-if="tab.courses && tab.courses.value.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 my-5 mx-5"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-7 my-5 mx-5"
>
<router-link
v-for="course in tab.courses.value"

View File

@@ -7,44 +7,63 @@
class="h-7"
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
/>
<div class="flex space-x-2">
<div class="w-40 md:w-44">
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
:placeholder="__('Type')"
/>
</div>
<div class="w-28 md:w-36">
<FormControl type="text" placeholder="Search" v-model="searchQuery">
<template #prefix>
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
</div>
<router-link
v-if="user.data?.name"
:to="{
name: 'JobCreation',
params: {
jobName: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('New Job') }}
</Button>
</router-link>
</div>
<router-link
v-if="user.data?.name"
:to="{
name: 'JobCreation',
params: {
jobName: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('New Job') }}
</Button>
</router-link>
</header>
<div v-if="jobsList?.length">
<div class="divide-y lg:w-3/4 mx-auto p-5">
<div v-for="job in jobsList">
<div>
<div class="lg:w-3/4 mx-auto p-5">
<div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
>
<div class="text-xl font-semibold">
{{ __('Find the perfect job for you') }}
</div>
<div class="grid grid-cols-2 gap-2">
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-gray-600"
name="search"
/>
</template>
</FormControl>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div>
<div
v-if="jobs.data?.length"
class="grid grid-cols-1 lg:grid-cols-2 gap-5"
>
<router-link
v-for="job in jobs.data"
:to="{
name: 'JobDetail',
params: { job: job.name },
@@ -54,15 +73,15 @@
<JobCard :job="job" />
</router-link>
</div>
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
{{ __('No jobs posted') }}
</div>
</div>
</div>
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
{{ __('No jobs posted') }}
</div>
</div>
</template>
<script setup>
import { Button, Breadcrumbs, createResource, FormControl } from 'frappe-ui'
import { Button, Breadcrumbs, createListResource, FormControl } from 'frappe-ui'
import { Plus, Search } from 'lucide-vue-next'
import { inject, computed, ref, onMounted } from 'vue'
import JobCard from '@/components/JobCard.vue'
@@ -71,43 +90,59 @@ import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const jobType = ref(null)
const searchQuery = ref('')
const filters = ref({})
const orFilters = ref({})
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('type')) {
jobType.value = queries.get('type')
}
updateJobs()
})
const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities',
cache: ['jobs'],
auto: true,
const jobs = createListResource({
doctype: 'Job Opportunity',
fields: [
'name',
'job_title',
'company_name',
'company_logo',
'location',
'type',
'creation',
],
start: 0,
pageLength: 20,
cache: ['jobOpportunities'],
})
const pageMeta = computed(() => {
return {
title: 'Jobs',
description: 'An open job board for the community',
const updateJobs = () => {
updateFilters()
jobs.update({
filters: filters.value,
orFilters: orFilters.value,
})
jobs.reload()
}
const updateFilters = () => {
if (jobType.value) {
filters.value.type = jobType.value
} else {
delete filters.value.type
}
})
const jobsList = computed(() => {
let jobData = jobs.data
if (jobType.value && jobType.value != '') {
jobData = jobData.filter((job) => job.type == jobType.value)
}
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
jobData = jobData.filter(
(job) =>
job.job_title.toLowerCase().includes(query) ||
job.company_name.toLowerCase().includes(query) ||
job.location.toLowerCase().includes(query)
)
orFilters.value = {
job_title: ['like', `%${searchQuery.value}%`],
company_name: ['like', `%${searchQuery.value}%`],
location: ['like', `%${searchQuery.value}%`],
}
} else {
orFilters.value = {}
}
return jobData
})
}
const jobTypes = computed(() => {
return [
@@ -118,6 +153,12 @@ const jobTypes = computed(() => {
{ label: __('Freelance'), value: 'Freelance' },
]
})
const pageMeta = computed(() => {
return {
title: 'Jobs',
description: 'An open job board for the community',
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -305,7 +305,7 @@ const progress = createResource({
})
const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
label: lesson?.data?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
@@ -475,7 +475,8 @@ updateDocumentTitle(pageMeta)
font-weight: 500;
}
.embed-tool__caption {
.embed-tool__caption,
.cdx-simple-image__caption {
display: none;
}
@@ -585,4 +586,8 @@ iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
.tc-table {
border-left: 1px solid #e8e8eb;
}
</style>

View File

@@ -132,6 +132,7 @@ const renderEditor = (holder) => {
holder: holder,
tools: getEditorTools(true),
autofocus: true,
defaultBlock: 'markdown',
})
}
@@ -618,4 +619,8 @@ iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
.tc-table {
border-left: 1px solid #e8e8eb;
}
</style>

View File

@@ -25,7 +25,7 @@
class="flex items-center py-2 justify-between"
>
<div class="flex items-center">
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
<Avatar :image="log.user_image" :label="log.full_name" class="mr-2" />
<div class="notification" v-html="log.subject"></div>
</div>
<div class="flex items-center space-x-2">
@@ -57,6 +57,7 @@
</template>
<script setup>
import {
Avatar,
createListResource,
createResource,
Breadcrumbs,
@@ -66,14 +67,12 @@ import {
Tooltip,
} from 'frappe-ui'
import { computed, inject, ref, onMounted } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const socket = inject('$socket')
const allUsers = inject('$allUsers')
const activeTab = ref('Unread')
const router = useRouter()
@@ -93,24 +92,22 @@ const notifications = computed(() => {
const unReadNotifications = createListResource({
doctype: 'Notification Log',
fields: ['subject', 'from_user', 'link', 'read', 'name'],
url: 'lms.lms.api.get_notifications',
filters: {
for_user: user.data?.name,
read: 0,
},
orderBy: 'creation desc',
auto: true,
cache: 'Unread Notifications',
})
const readNotifications = createListResource({
doctype: 'Notification Log',
fields: ['subject', 'from_user', 'link', 'read', 'name'],
url: 'lms.lms.api.get_notifications',
filters: {
for_user: user.data?.name,
read: 1,
},
orderBy: 'creation desc',
auto: true,
cache: 'Read Notifications',
})

View File

@@ -7,14 +7,14 @@
<div
v-for="certificate in certificates.data"
:key="certificate.name"
class="bg-white shadow rounded-lg p-3 cursor-pointer"
class="flex flex-col bg-white shadow rounded-lg p-3 cursor-pointer hover:bg-gray-50"
@click="openCertificate(certificate)"
>
<div class="font-medium leading-5">
{{ certificate.course_title }}
<div class="font-medium leading-5 mb-2">
{{ certificate.course_title || certificate.batch_title }}
</div>
<div class="mt-2">
<span class="text-xs text-gray-700"> {{ __('issued on') }}: </span>
<div class="text-sm text-gray-700 font-medium mt-auto">
<span> {{ __('Issued on') }}: </span>
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
</div>
</div>
@@ -22,8 +22,8 @@
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { inject } from 'vue'
import { createListResource } from 'frappe-ui'
import { inject, onMounted } from 'vue'
const dayjs = inject('$dayjs')
const props = defineProps({
@@ -33,12 +33,19 @@ const props = defineProps({
},
})
const certificates = createResource({
url: 'lms.lms.api.get_certificates',
params: {
member: props.profile.data.name,
onMounted(() => {
if (props.profile.data?.name) {
certificates.reload()
}
})
const certificates = createListResource({
doctype: 'LMS Certificate',
filters: {
member: props.profile.data?.name,
},
auto: true,
fields: ['name', 'course_title', 'batch_title', 'issue_date'],
cache: ['certificates', props.profile.data?.name],
})
const openCertificate = (certificate) => {

View File

@@ -28,9 +28,7 @@
size="lg"
>
{{ program.members }}
{{
program.members == 1 ? __(singularize('members')) : __('members')
}}
{{ program.members == 1 ? __('member') : __('members') }}
</Badge>
<Badge
v-if="program.progress"
@@ -133,7 +131,7 @@ import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue'
import { useRouter } from 'vue-router'
import { showToast, singularize } from '@/utils'
import { showToast } from '@/utils'
import { useSettings } from '@/stores/settings'
const user = inject('$user')

View File

@@ -256,11 +256,7 @@ onMounted(() => {
})
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
submitQuiz()
e.preventDefault()
}

View File

@@ -5,6 +5,9 @@
<Breadcrumbs :items="breadcrumbs" />
</header>
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="text-xl font-semibold mb-5">
{{ submissions.data[0].quiz_title }}
</div>
<ListView
:columns="quizColumns"
:rows="submissions.data"
@@ -31,12 +34,18 @@
</router-link>
</ListRows>
</ListView>
<div class="flex justify-center my-5">
<Button v-if="submissions.hasNextPage" @click="submissions.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</template>
<script setup>
import {
createListResource,
Breadcrumbs,
Button,
ListView,
ListRow,
ListRows,
@@ -76,12 +85,7 @@ const quizColumns = computed(() => {
{
label: __('Member'),
key: 'member_name',
width: 2,
},
{
label: __('Quiz'),
key: 'quiz_title',
width: 2,
width: 1,
},
{
label: __('Score'),

View File

@@ -46,6 +46,11 @@
</router-link>
</ListRows>
</ListView>
<div class="flex justify-center my-5">
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
<div
v-else
@@ -67,13 +72,13 @@
<script setup>
import {
Breadcrumbs,
Button,
createListResource,
ListView,
ListRows,
ListRow,
ListHeader,
ListHeaderItem,
Button,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, inject, onMounted } from 'vue'
@@ -103,9 +108,6 @@ const quizzes = createListResource({
auto: true,
cache: ['quizzes', user.data?.name],
orderBy: 'modified desc',
onSuccess(data) {
data.forEach((row) => {})
},
})
const quizColumns = computed(() => {

View File

@@ -131,12 +131,6 @@ const routes = [
component: () => import('@/pages/JobCreation.vue'),
props: true,
},
{
path: '/assignment-submission/:assignmentName/:submissionName',
name: 'AssignmentSubmission',
component: () => import('@/pages/AssignmentSubmission.vue'),
props: true,
},
{
path: '/certified-participants',
name: 'CertifiedParticipants',
@@ -193,6 +187,28 @@ const routes = [
name: 'Programs',
component: () => import('@/pages/Programs.vue'),
},
{
path: '/assignments',
name: 'Assignments',
component: () => import('@/pages/Assignments.vue'),
},
{
path: '/assignments/:assignmentID',
name: 'AssignmentForm',
component: () => import('@/pages/AssignmentForm.vue'),
props: true,
},
{
path: '/assignment-submission/:assignmentID/:submissionName',
name: 'AssignmentSubmission',
component: () => import('@/pages/AssignmentSubmission.vue'),
props: true,
},
{
path: '/assignment-submissions',
name: 'AssignmentSubmissionList',
component: () => import('@/pages/AssignmentSubmissionList.vue'),
},
]
let router = createRouter({
@@ -201,22 +217,13 @@ let router = createRouter({
})
router.beforeEach(async (to, from, next) => {
const { userResource, allUsers } = usersStore()
const { userResource } = usersStore()
let { isLoggedIn } = sessionStore()
try {
if (isLoggedIn) {
await userResource.promise
}
if (
isLoggedIn &&
(to.name == 'Lesson' ||
to.name == 'Batch' ||
to.name == 'Notifications' ||
to.name == 'Badge')
) {
await allUsers.promise
}
} catch (error) {
isLoggedIn = false
}

View File

@@ -5,7 +5,7 @@ import router from '@/router'
import { ref, computed } from 'vue'
export const sessionStore = defineStore('lms-session', () => {
let { userResource, allUsers } = usersStore()
let { userResource } = usersStore()
function sessionUser() {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
@@ -17,9 +17,6 @@ export const sessionStore = defineStore('lms-session', () => {
}
let user = ref(sessionUser())
if (user.value) {
allUsers.reload()
}
const isLoggedIn = computed(() => !!user.value)
const login = createResource({

View File

@@ -1,8 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { createResource } from 'frappe-ui'
import { sessionStore } from './session'
export const useSettings = defineStore('settings', () => {
const { isLoggedIn } = sessionStore()
const isSettingsOpen = ref(false)
const activeTab = ref(null)
const learningPaths = createResource({
@@ -13,13 +15,13 @@ export const useSettings = defineStore('settings', () => {
field: 'enable_learning_paths',
}
},
auto: true,
auto: isLoggedIn ? true : false,
cache: ['learningPaths'],
})
const onboardingDetails = createResource({
url: 'lms.lms.utils.is_onboarding_complete',
auto: true,
auto: isLoggedIn ? true : false,
cache: ['onboardingDetails'],
})

View File

@@ -0,0 +1,84 @@
import { Pencil } from 'lucide-vue-next'
import { createApp, h } from 'vue'
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
import AssignmentBlock from '@/components/AssignmentBlock.vue'
import translationPlugin from '../translation'
import { usersStore } from '@/stores/user'
import router from '../router'
export class Assignment {
constructor({ data, api, readOnly }) {
this.data = data
this.readOnly = readOnly
}
static get toolbox() {
const app = createApp({
render: () =>
h(Pencil, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: __('Assignment'),
icon: div.innerHTML,
}
}
static get isReadOnlySupported() {
return true
}
render() {
this.wrapper = document.createElement('div')
if (Object.keys(this.data).length) {
this.renderAssignment(this.data.assignment)
} else {
this.renderAssignmentModal()
}
return this.wrapper
}
renderAssignment(assignment) {
if (this.readOnly) {
const app = createApp(AssignmentBlock, {
assignmentID: assignment,
})
app.use(translationPlugin)
app.use(router)
const { userResource } = usersStore()
app.provide('$user', userResource)
app.mount(this.wrapper)
return
}
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
<span class="font-medium">
Assignment: ${assignment}
</span>
</div>`
return
}
renderAssignmentModal() {
if (this.readOnly) {
return
}
const app = createApp(AssessmentPlugin, {
type: 'assignment',
onAddition: (assignment) => {
this.data.assignment = assignment
this.renderAssignment(assignment)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
save(blockContent) {
return {
assignment: this.data.assignment,
}
}
}

View File

@@ -1,7 +1,9 @@
import { toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz'
import { Assignment } from '@/utils/assignment'
import { Upload } from '@/utils/upload'
import { Markdown } from '@/utils/markdownParser'
import Header from '@editorjs/header'
import Paragraph from '@editorjs/paragraph'
import { CodeBox } from '@/utils/code'
@@ -147,11 +149,21 @@ export function htmlToText(html) {
export function getEditorTools() {
return {
header: Header,
header: {
class: Header,
config: {
placeholder: 'Header',
},
},
quiz: Quiz,
assignment: Assignment,
upload: Upload,
markdown: Markdown,
image: SimpleImage,
table: Table,
table: {
class: Table,
inlineToolbar: true,
},
paragraph: {
class: Paragraph,
inlineToolbar: true,
@@ -170,6 +182,7 @@ export function getEditorTools() {
},
list: {
class: NestedList,
inlineToolbar: true,
config: {
defaultStyle: 'ordered',
},
@@ -520,3 +533,21 @@ export const validateFile = (file) => {
return __('Only image file is allowed.')
}
}
export const escapeHTML = (text) => {
if (!text) return ''
let escape_html_mapping = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'`': '&#x60;',
'=': '&#x3D;',
}
return String(text).replace(
/[&<>"'`=]/g,
(char) => escape_html_mapping[char] || char
)
}

View File

@@ -0,0 +1,156 @@
export class Markdown {
constructor({ data, api, readOnly, config }) {
this.api = api
this.data = data || {}
this.config = config || {}
this.text = data.text || ''
this.readOnly = readOnly
}
static get isReadOnlySupported() {
return true
}
static get conversionConfig() {
return {
export: 'text',
import: 'text',
}
}
onPaste(event) {
const data = {
text: event.detail.data.innerHTML,
}
this.data = data
window.requestAnimationFrame(() => {
if (!this.wrapper) {
return
}
this.wrapper.innerHTML = this.data.text || ''
})
}
static get pasteConfig() {
return {
tags: ['P'],
}
}
render() {
this.wrapper = document.createElement('div')
this.wrapper.classList.add('cdx-block')
this.wrapper.classList.add('ce-paragraph')
this.wrapper.innerHTML = this.text
if (!this.readOnly) {
this.wrapper.contentEditable = true
this.wrapper.innerHTML = this.text
this.wrapper.addEventListener('keydown', (event) => {
const value = event.target.textContent
if (event.keyCode === 32 && value.startsWith('#')) {
this.convertToHeader(event, value)
} else if (event.keyCode === 13) {
this.parseContent(event)
}
})
}
return this.wrapper
}
convertToHeader(event, value) {
event.preventDefault()
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {
let level = value.length
event.target.textContent = ''
this.convertBlock('header', {
level: level,
})
}
}
parseContent(event) {
event.preventDefault()
const previousLine = this.wrapper.textContent
if (previousLine && this.hasImage(previousLine)) {
this.wrapper.textContent = ''
this.convertBlock('image')
} else if (previousLine && this.hasLink(previousLine)) {
const { text, url } = this.extractLink(previousLine)
const anchorTag = `<a href="${url}" target="_blank">${text}</a>`
this.convertBlock('paragraph', {
text: previousLine.replace(/\[.+?\]\(.+?\)/, anchorTag),
})
} else if (previousLine && previousLine.startsWith('- ')) {
this.convertBlock('list', {
style: 'unordered',
items: [
{
content: previousLine.replace('- ', ''),
},
],
})
} else if (previousLine && previousLine.startsWith('1. ')) {
this.convertBlock('list', {
style: 'ordered',
items: [
{
content: previousLine.replace('1. ', ''),
},
],
})
} else if (previousLine && this.canBeEmbed(previousLine)) {
this.wrapper.textContent = ''
this.convertBlock('embed', {
source: previousLine,
})
}
}
async convertBlock(type, data, index = null) {
const currentIndex = this.api.blocks.getCurrentBlockIndex()
const currentBlock = this.api.blocks.getBlockByIndex(currentIndex)
await this.api.blocks.convert(currentBlock.id, type, data)
this.api.caret.focus(true)
}
save(blockContent) {
return {
text: blockContent.innerHTML,
}
}
hasImage(line) {
return /!\[.+?\]\(.+?\)/.test(line)
}
extractImage(line) {
const match = line.match(/!\[(.+?)\]\((.+?)\)/)
if (match) {
return { alt: match[1], url: match[2] }
}
return { alt: '', url: '' }
}
hasLink(line) {
return /\[.+?\]\(.+?\)/.test(line)
}
extractLink(line) {
const match = line.match(/\[(.+?)\]\((.+?)\)/)
if (match) {
return { text: match[1], url: match[2] }
}
return { text: '', url: '' }
}
canBeEmbed(line) {
return /^https?:\/\/.+/.test(line)
}
}
export default Markdown

View File

@@ -1,5 +1,5 @@
import QuizBlock from '@/components/QuizBlock.vue'
import QuizPlugin from '@/components/QuizPlugin.vue'
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
import { createApp, h } from 'vue'
import { usersStore } from '../stores/user'
import translationPlugin from '../translation'
@@ -63,8 +63,9 @@ export class Quiz {
if (this.readOnly) {
return
}
const app = createApp(QuizPlugin, {
onQuizAddition: (quiz) => {
const app = createApp(AssessmentPlugin, {
type: 'quiz',
onAddition: (quiz) => {
this.data.quiz = quiz
this.renderQuiz(quiz)
},

View File

@@ -0,0 +1,5 @@
import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from 'tailwind.config.js'
export const config = resolveConfig(tailwindConfig)
export const theme = config.theme

View File

@@ -11,6 +11,10 @@ module.exports = {
strokeWidth: {
1.5: '1.5',
},
screens: {
'2xl': '1536px',
'3xl': '1920px',
},
},
},
plugins: [],

View File

@@ -17,6 +17,7 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'tailwind.config.js': path.resolve(__dirname, 'tailwind.config.js'),
},
},
build: {
@@ -36,6 +37,11 @@ export default defineConfig({
},
},
optimizeDeps: {
include: ['frappe-ui > feather-icons', 'showdown', 'engine.io-client'],
include: [
'feather-icons',
'showdown',
'engine.io-client',
'tailwind.config.js',
],
},
})

View File

@@ -471,6 +471,33 @@
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
"@svgdotjs/svg.draggable.js@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz#505430e86b5e73b5b5abba12ac6002633897324e"
integrity sha512-vWi/Col5Szo74HJVBgMHz23kLVljt3jvngmh0DzST45iO2ubIZ487uUAHIxSZH2tVRyiaaTL+Phaasgp4gUD2g==
"@svgdotjs/svg.filter.js@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.8.tgz#998cb2481a871fa70d7dbaa891c886b335c562d7"
integrity sha512-YshF2YDaeRA2StyzAs5nUPrev7npQ38oWD0eTRwnsciSL2KrRPMoUw8BzjIXItb3+dccKGTX3IQOd2NFzmHkog==
dependencies:
"@svgdotjs/svg.js" "^3.1.1"
"@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.4":
version "3.2.4"
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz#4716be92a64c66b29921b63f7235fcfb953fb13a"
integrity sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==
"@svgdotjs/svg.resize.js@^2.0.2":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz#732e4cae15d09ad3021adeac63bc9fad0dc7255a"
integrity sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==
"@svgdotjs/svg.select.js@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.select.js/-/svg.select.js-4.0.2.tgz#80a10409e6c73206218690eac5c9f94f8c8909b5"
integrity sha512-5gWdrvoQX3keo03SCmgaBbD+kFftq0F/f2bzCbNnpkkvW6tk4rl4MakORzFuNjvXPWwB4az9GwuvVxQVnjaK2g==
"@swc/helpers@^0.5.0":
version "0.5.15"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7"
@@ -887,6 +914,11 @@
dependencies:
vue-demi ">=0.14.8"
"@yr/monotone-cubic-spline@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==
ace-builds@^1.36.2:
version "1.36.5"
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.5.tgz#ae9cc7a32eccc2f484926131c00545cd6b78a6a6"
@@ -927,6 +959,18 @@ anymatch@~3.1.2:
normalize-path "^3.0.0"
picomatch "^2.0.4"
apexcharts@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-4.3.0.tgz#eccf28e830ce1b5e018cfc0e99d1c6af0076c9c7"
integrity sha512-PfvZQpv91T68hzry9l5zP3Gip7sQvF0nFK91uCBrswIKX7rbIdbVNS4fOks9m9yP3Ppgs6LHgU2M/mjoG4NM0A==
dependencies:
"@svgdotjs/svg.draggable.js" "^3.0.4"
"@svgdotjs/svg.filter.js" "^3.0.8"
"@svgdotjs/svg.js" "^3.2.4"
"@svgdotjs/svg.resize.js" "^2.0.2"
"@svgdotjs/svg.select.js" "^4.0.1"
"@yr/monotone-cubic-spline" "^1.0.3"
arg@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
@@ -2164,6 +2208,11 @@ vue-router@^4.0.12:
dependencies:
"@vue/devtools-api" "^6.6.4"
vue3-apexcharts@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz#1984648d966aa91bc4dc3e87fa847f5289f7f1cf"
integrity sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==
vue@^3.4.23:
version "3.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"

View File

@@ -1 +1 @@
__version__ = "2.16.0"
__version__ = "2.21.0"

View File

@@ -4,6 +4,9 @@
frappe.ui.form.on("Job Opportunity", {
refresh: (frm) => {
if (frm.doc.name)
frm.add_web_link(`/job-openings/${frm.doc.name}`, "See on Website");
frm.add_web_link(
`/lms/job-openings/${frm.doc.name}`,
"See on Website"
);
},
});

View File

@@ -2,7 +2,6 @@
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format: JOB-{#####}",
"creation": "2022-02-07 12:01:41.074418",
"doctype": "DocType",
"editable_grid": 1,
@@ -117,11 +116,10 @@
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2024-02-07 23:02:06.102120",
"modified": "2025-01-17 12:38:57.134919",
"modified_by": "Administrator",
"module": "Job",
"name": "Job Opportunity",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -6,8 +6,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import get_link_to_form, add_months, getdate
from frappe.utils.user import get_system_managers
from lms.lms.utils import validate_image
from lms.lms.utils import validate_image, generate_slug
class JobOpportunity(Document):
@@ -18,6 +17,10 @@ class JobOpportunity(Document):
def validate_urls(self):
frappe.utils.validate_url(self.company_website, True)
def autoname(self):
if not self.name:
self.name = generate_slug(f"{self.job_title}-${self.company_name}", "LMS Course")
def update_job_openings():
old_jobs = frappe.get_all(

View File

@@ -13,10 +13,21 @@ from frappe.translate import get_all_translations
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
from frappe.utils import time_diff, now_datetime, get_datetime, flt
from frappe.utils import (
time_diff,
now_datetime,
get_datetime,
cint,
flt,
now,
add_days,
format_date,
date_diff,
)
from typing import Optional
from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress
@frappe.whitelist()
@@ -168,6 +179,7 @@ def get_user_info():
user.is_instructor = "Course Creator" in user.roles
user.is_moderator = "Moderator" in user.roles
user.is_evaluator = "Batch Evaluator" in user.roles
user.is_student = "LMS Student" in user.roles
return user
@@ -349,34 +361,59 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True)
def get_certified_participants():
LMSCertificate = DocType("LMS Certificate")
participants = (
frappe.qb.from_(LMSCertificate)
.select(LMSCertificate.member)
.distinct()
.where(LMSCertificate.published == 1)
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
.run(as_dict=1)
def get_certified_participants(filters=None, start=0, page_length=30, search=None):
or_filters = {}
if not filters:
filters = {}
filters.update({"published": 1})
category = filters.get("category")
if category:
del filters["category"]
or_filters["course_title"] = ["like", f"%{category}%"]
or_filters["batch_title"] = ["like", f"%{category}%"]
participants = frappe.get_all(
"LMS Certificate",
filters=filters,
or_filters=or_filters,
fields=["member"],
group_by="member",
order_by="creation desc",
start=start,
page_length=page_length,
)
participant_details = []
for participant in participants:
details = frappe.db.get_value(
"User",
participant.member,
["name", "full_name", "username", "user_image"],
as_dict=True,
["full_name", "user_image", "username", "country", "headline"],
as_dict=1,
)
course_names = frappe.get_all(
"LMS Certificate", {"member": participant.member}, pluck="course"
)
courses = []
for course in course_names:
courses.append(frappe.db.get_value("LMS Course", course, "title"))
details["courses"] = courses
participant_details.append(details)
return participant_details
participant.update(details)
return participants
@frappe.whitelist()
def get_certification_categories():
categories = []
docs = frappe.get_all(
"LMS Certificate",
filters={
"published": 1,
},
fields=["course_title", "batch_title"],
)
for doc in docs:
category = doc.course_title if doc.course_title else doc.batch_title
if category not in categories:
categories.append(category)
return categories
@frappe.whitelist()
@@ -395,19 +432,9 @@ def get_assigned_badges(member):
return assigned_badges
@frappe.whitelist()
def get_certificates(member):
"""Get certificates for a member."""
return frappe.get_all(
"LMS Certificate",
filters={"member": member},
fields=["name", "course", "course_title", "issue_date", "template"],
order_by="creation desc",
)
@frappe.whitelist()
def get_all_users():
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
users = frappe.get_all(
"User",
{
@@ -591,9 +618,13 @@ def get_categories(doctype, filters):
@frappe.whitelist()
def get_members(start=0, search=""):
"""Get members for the given search term and start index.
Args: start (int): Start index for the query.
Args: start (int): Start index for the query.
<<<<<<< HEAD
search (str): Search term to filter the results.
Returns: List of members.
=======
search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members.
"""
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
@@ -841,8 +872,6 @@ def delete_course(course):
frappe.delete_doc("Lesson Reference", lesson)
for lesson in lessons:
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
topics = frappe.get_all(
"Discussion Topic",
{"reference_doctype": "Course Lesson", "reference_docname": lesson},
@@ -862,6 +891,9 @@ def delete_course(course):
for chapter in chapters:
frappe.delete_doc("Course Chapter", chapter)
frappe.db.delete("LMS Course Progress", {"course": course})
frappe.db.delete("LMS Quiz", {"course": course})
frappe.db.delete("LMS Quiz Submission", {"course": course})
frappe.db.delete("LMS Enrollment", {"course": course})
frappe.delete_doc("LMS Course", course)
@@ -1029,3 +1061,150 @@ def delete_scorm_package(scorm_package_path):
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
if os.path.exists(scorm_package_path):
shutil.rmtree(scorm_package_path)
@frappe.whitelist()
def mark_lesson_progress(course, chapter_number, lesson_number):
chapter_name = frappe.get_value(
"Chapter Reference", {"parent": course, "idx": chapter_number}, "chapter"
)
lesson_name = frappe.get_value(
"Lesson Reference", {"parent": chapter_name, "idx": lesson_number}, "lesson"
)
save_progress(lesson_name, course)
@frappe.whitelist()
def get_heatmap_data(member=None, base_days=200):
if not member:
member = frappe.session.user
base_date, start_date, number_of_days, days = calculate_date_ranges(base_days)
date_count = initialize_date_count(days)
lesson_completions, quiz_submissions, assignment_submissions = fetch_activity_data(
member, start_date
)
count_dates(lesson_completions, date_count)
count_dates(quiz_submissions, date_count)
count_dates(assignment_submissions, date_count)
heatmap_data, labels, total_activities, weeks = prepare_heatmap_data(
start_date, number_of_days, date_count
)
return {
"heatmap_data": heatmap_data,
"labels": labels,
"total_activities": total_activities,
"weeks": weeks,
}
def calculate_date_ranges(base_days):
today = format_date(now(), "YYYY-MM-dd")
day_today = get_datetime(today).strftime("%w")
padding_end = 6 - cint(day_today)
base_date = add_days(today, -base_days)
day_of_base_date = cint(get_datetime(base_date).strftime("%w"))
start_date = add_days(base_date, -day_of_base_date)
number_of_days = base_days + day_of_base_date + padding_end
days = [add_days(start_date, i) for i in range(number_of_days + 1)]
return base_date, start_date, number_of_days, days
def initialize_date_count(days):
return {format_date(day, "YYYY-MM-dd"): 0 for day in days}
def fetch_activity_data(member, start_date):
lesson_completions = frappe.get_all(
"LMS Course Progress",
fields=["creation"],
filters={"member": member, "creation": [">=", start_date]},
)
quiz_submissions = frappe.get_all(
"LMS Quiz Submission",
fields=["creation"],
filters={"member": member, "creation": [">=", start_date]},
)
assignment_submissions = frappe.get_all(
"LMS Assignment Submission",
fields=["creation"],
filters={"member": member, "creation": [">=", start_date]},
)
return lesson_completions, quiz_submissions, assignment_submissions
def count_dates(data, date_count):
for entry in data:
date = format_date(entry.creation, "YYYY-MM-dd")
if date in date_count:
date_count[date] += 1
def prepare_heatmap_data(start_date, number_of_days, date_count):
days_of_week = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
heatmap_data = {day: [] for day in days_of_week}
week_count = -(number_of_days // -7)
labels = [None] * week_count
last_seen_month = None
sorted_dates = sorted(date_count.keys())
for date in sorted_dates:
activity_count = date_count[date]
day_of_week = get_datetime(date).strftime("%a")
current_month = get_datetime(date).strftime("%b")
column_index = get_week_difference(start_date, date)
if 0 <= column_index < week_count:
heatmap_data[day_of_week].append(
{
"date": date,
"count": activity_count,
"label": f"{activity_count} activities on {format_date(date, 'dd MMM')}",
}
)
if last_seen_month != current_month:
labels[column_index] = current_month
last_seen_month = current_month
for (index, label) in enumerate(labels):
if not label:
labels[index] = ""
formatted_heatmap_data = [
{"name": day, "data": heatmap_data[day]} for day in days_of_week
]
total_activities = sum(date_count.values())
return formatted_heatmap_data, labels, total_activities, week_count
def get_week_difference(start_date, current_date):
diff_in_days = date_diff(current_date, start_date)
return diff_in_days // 7
@frappe.whitelist()
def get_notifications(filters):
notifications = frappe.get_all(
"Notification Log",
filters,
["subject", "from_user", "link", "read", "name"],
order_by="creation desc",
)
for notification in notifications:
from_user_details = frappe.db.get_value(
"User", notification.from_user, ["full_name", "user_image"], as_dict=1
)
notification.update(from_user_details)
return notifications

View File

@@ -72,16 +72,6 @@ class CourseLesson(Document):
exercises = [value for name, value in macros if name == "Exercise"]
return [frappe.get_doc("LMS 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):
@@ -89,27 +79,25 @@ def save_progress(lesson, course):
"LMS Enrollment", {"course": course, "member": frappe.session.user}
)
if not membership:
return
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
if frappe.db.exists(
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
):
return
quiz_completed = get_quiz_progress(lesson)
if not quiz_completed:
return 0
frappe.get_doc(
{
"doctype": "LMS Course Progress",
"lesson": lesson,
"status": "Complete",
"member": frappe.session.user,
}
).save(ignore_permissions=True)
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
already_completed = frappe.db.exists(
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
)
quiz_completed = get_quiz_progress(lesson)
assignment_completed = get_assignment_progress(lesson)
if not already_completed and quiz_completed and assignment_completed:
frappe.get_doc(
{
"doctype": "LMS Course Progress",
"lesson": lesson,
"status": "Complete",
"member": frappe.session.user,
}
).save(ignore_permissions=True)
progress = get_course_progress(course)
capture_progress_for_analytics(progress, course)
@@ -159,6 +147,32 @@ def get_quiz_progress(lesson):
return True
def get_assignment_progress(lesson):
lesson_details = frappe.db.get_value(
"Course Lesson", lesson, ["body", "content"], as_dict=1
)
assignments = []
if lesson_details.content:
content = json.loads(lesson_details.content)
for block in content.get("blocks"):
if block.get("type") == "assignment":
assignments.append(block.get("data").get("assignment"))
elif lesson_details.body:
macros = find_macros(lesson_details.body)
assignments = [value for name, value in macros if name == "Assignment"]
for assignment in assignments:
if not frappe.db.exists(
"LMS Assignment Submission",
{"assignment": assignment, "member": frappe.session.user},
):
return False
return True
@frappe.whitelist()
def get_lesson_info(chapter):
return frappe.db.get_value("Course Chapter", chapter, "course")

View File

@@ -9,10 +9,11 @@
"engine": "InnoDB",
"field_order": [
"title",
"grade_assignment",
"question",
"column_break_hmwv",
"type",
"grade_assignment",
"section_break_sjti",
"show_answer",
"answer"
],
@@ -20,7 +21,8 @@
{
"fieldname": "question",
"fieldtype": "Text Editor",
"label": "Question"
"label": "Question",
"reqd": 1
},
{
"fieldname": "type",
@@ -28,14 +30,16 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Type",
"options": "Document\nPDF\nURL\nImage\nText"
"options": "Document\nPDF\nURL\nImage\nText",
"reqd": 1
},
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Title"
"label": "Title",
"reqd": 1
},
{
"fieldname": "column_break_hmwv",
@@ -60,11 +64,15 @@
"fieldname": "grade_assignment",
"fieldtype": "Check",
"label": "Grade Assignment"
},
{
"fieldname": "section_break_sjti",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-05 12:01:36.601160",
"modified": "2024-12-24 09:36:31.464508",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Assignment",

View File

@@ -14,19 +14,17 @@
"member",
"member_name",
"section_break_dlzh",
"question",
"column_break_zvis",
"assignment_attachment",
"answer",
"section_break_rqal",
"status",
"column_break_oqqy",
"evaluator",
"column_break_esgd",
"status",
"comments",
"section_break_cwaw",
"lesson",
"section_break_rqal",
"question",
"column_break_esgd",
"course",
"column_break_ygdu"
"lesson"
],
"fields": [
{
@@ -89,8 +87,7 @@
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",
"options": "User",
"read_only": 1
"options": "User"
},
{
"depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);",
@@ -128,14 +125,6 @@
"fieldname": "column_break_esgd",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_cwaw",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_ygdu",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);",
"fieldname": "answer",
@@ -148,14 +137,14 @@
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_zvis",
"fieldname": "column_break_oqqy",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2024-04-05 15:57:22.758563",
"modified": "2024-12-24 21:22:35.212732",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Assignment Submission",

View File

@@ -6,12 +6,14 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import validate_url, validate_email_address
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
class LMSAssignmentSubmission(Document):
def validate(self):
self.validate_duplicates()
self.validate_url()
self.validate_status()
def after_insert(self):
if not frappe.flags.in_test:
@@ -69,6 +71,31 @@ class LMSAssignmentSubmission(Document):
header=[subject, "green"],
)
def validate_status(self):
if not self.is_new():
doc_before_save = self.get_doc_before_save()
if (
doc_before_save.status != self.status or doc_before_save.comments != self.comments
):
self.trigger_update_notification()
def trigger_update_notification(self):
notification = frappe._dict(
{
"subject": _(
"There has been an update on your submission for assignment {0}"
).format(self.assignment_title),
"email_content": self.comments,
"document_type": self.doctype,
"document_name": self.name,
"for_user": self.owner,
"from_user": self.evaluator,
"type": "Alert",
"link": f"/assignment-submission/{self.assignment}/{self.name}",
}
)
make_notification_logs(notification, [self.member])
@frappe.whitelist()
def upload_assignment(

View File

@@ -10,5 +10,11 @@ frappe.ui.form.on("LMS Badge Assignment", {
},
};
});
if (frm.doc.name)
frm.add_web_link(
`/badges/${frm.doc.badge}/${frm.doc.member}`,
"See on Website"
);
},
});

View File

@@ -6,6 +6,7 @@
"engine": "InnoDB",
"field_order": [
"member",
"member_name",
"issued_on",
"column_break_ugix",
"badge",
@@ -57,11 +58,18 @@
"label": "Badge Description",
"read_only": 1,
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-05-13 20:16:00.191517",
"modified": "2025-01-06 12:32:28.450028",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Badge Assignment",

View File

@@ -48,7 +48,10 @@ frappe.ui.form.on("LMS Batch", {
},
refresh: (frm) => {
frm.add_web_link(`/batches/details/${frm.doc.name}`, "See on website");
frm.add_web_link(
`/lms/batches/details/${frm.doc.name}`,
"See on website"
);
},
});

View File

@@ -2,7 +2,6 @@
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format: CLS-{#####}",
"creation": "2022-11-09 16:14:05.876933",
"default_view": "List",
"doctype": "DocType",
@@ -330,11 +329,10 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-11-18 16:28:41.336928",
"modified": "2025-01-17 10:23:10.580311",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -16,6 +16,7 @@ from lms.lms.utils import (
get_quiz_details,
get_assignment_details,
update_payment_record,
generate_slug,
)
from frappe.email.doctype.email_template.email_template import get_email_template
@@ -36,6 +37,10 @@ class LMSBatch(Document):
self.validate_evaluation_end_date()
self.add_students_to_live_class()
def autoname(self):
if not self.name:
self.name = generate_slug(self.title, "LMS Batch")
def validate_batch_end_date(self):
if self.end_date < self.start_date:
frappe.throw(_("Batch end date cannot be before the batch start date"))

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Batch Feedback", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,112 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-01-07 18:53:22.279844",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"member",
"member_name",
"member_image",
"batch",
"column_break_swst",
"content",
"instructors",
"value",
"feedback"
],
"fields": [
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fieldname": "batch",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Batch",
"options": "LMS Batch",
"reqd": 1
},
{
"fieldname": "feedback",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Feedback",
"reqd": 1
},
{
"fieldname": "column_break_swst",
"fieldtype": "Column Break"
},
{
"fieldname": "content",
"fieldtype": "Rating",
"label": "Content"
},
{
"fieldname": "instructors",
"fieldtype": "Rating",
"label": "Instructors"
},
{
"fieldname": "value",
"fieldtype": "Rating",
"label": "Value"
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name",
"read_only": 1
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-13 19:02:58.259908",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch Feedback",
"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,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSBatchFeedback(Document):
pass

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestLMSBatchFeedback(UnitTestCase):
"""
Unit tests for LMSBatchFeedback.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSBatchFeedback(IntegrationTestCase):
"""
Integration tests for LMSBatchFeedback.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -6,20 +6,21 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"course_title",
"member",
"member_name",
"column_break_vwbn",
"issue_date",
"template",
"published",
"section_break_scyf",
"evaluator",
"evaluator_name",
"column_break_slaw",
"column_break_vwbn",
"issue_date",
"expiry_date",
"batch_name"
"template",
"published",
"section_break_unwn",
"course",
"course_title",
"column_break_ywee",
"batch_name",
"batch_title"
],
"fields": [
{
@@ -32,11 +33,9 @@
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"reqd": 1
"options": "LMS Course"
},
{
"fieldname": "expiry_date",
@@ -46,7 +45,6 @@
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member",
"options": "User",
@@ -56,6 +54,8 @@
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member Name",
"read_only": 1
},
@@ -90,14 +90,6 @@
"fieldname": "column_break_vwbn",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_scyf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_slaw",
"fieldtype": "Column Break"
},
{
"fieldname": "evaluator",
"fieldtype": "Link",
@@ -108,13 +100,29 @@
"fetch_from": "evaluator.full_name",
"fieldname": "evaluator_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Evaluator Name",
"read_only": 1
},
{
"fieldname": "section_break_unwn",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_ywee",
"fieldtype": "Column Break"
},
{
"fetch_from": "batch_name.title",
"fieldname": "batch_title",
"fieldtype": "Data",
"label": "Batch Title",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-11 11:37:20.419956",
"modified": "2025-01-17 11:57:02.859109",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate",

View File

@@ -7,12 +7,16 @@ from frappe.model.document import Document
from frappe.utils import add_years, nowdate
from lms.lms.utils import is_certified
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.model.naming import make_autoname
class LMSCertificate(Document):
def validate(self):
self.validate_duplicate_certificate()
def autoname(self):
self.name = make_autoname("hash", self.doctype)
def after_insert(self):
if not frappe.flags.in_test:
outgoing_email_account = frappe.get_cached_value(
@@ -48,16 +52,46 @@ class LMSCertificate(Document):
)
def validate_duplicate_certificate(self):
certificates = frappe.get_all(
"LMS Certificate",
{"member": self.member, "course": self.course, "name": ["!=", self.name]},
)
if len(certificates):
full_name = frappe.db.get_value("User", self.member, "full_name")
course_name = frappe.db.get_value("LMS Course", self.course, "title")
frappe.throw(
_("{0} is already certified for the course {1}").format(full_name, course_name)
self.validate_course_duplicates()
self.validate_batch_duplicates()
def validate_course_duplicates(self):
if self.course:
course_duplicates = frappe.get_all(
"LMS Certificate",
filters={
"member": self.member,
"name": ["!=", self.name],
"course": self.course,
},
fields=["name", "course", "course_title"],
)
if len(course_duplicates):
full_name = frappe.db.get_value("User", self.member, "full_name")
frappe.throw(
_("{0} is already certified for the course {1}").format(
full_name, course_duplicates[0].course_title
)
)
def validate_batch_duplicates(self):
if self.batch_name:
batch_duplicates = frappe.get_all(
"LMS Certificate",
filters={
"member": self.member,
"name": ["!=", self.name],
"batch_name": self.batch_name,
},
fields=["name", "batch_name", "batch_title"],
)
if len(batch_duplicates):
full_name = frappe.db.get_value("User", self.member, "full_name")
frappe.throw(
_("{0} is already certified for the batch {1}").format(
full_name, batch_duplicates[0].batch_title
)
)
def on_update(self):
frappe.share.add_docshare(

View File

@@ -93,10 +93,7 @@ class LMSCourse(Document):
def autoname(self):
if not self.name:
title = self.title
if self.title == "New Course":
title = self.title + str(random.randint(0, 99))
self.name = generate_slug(title, "LMS Course")
self.name = generate_slug(self.title, "LMS Course")
def __repr__(self):
return f"<Course#{self.name}>"

View File

@@ -22,7 +22,8 @@
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"fetch_from": "lesson.chapter",
@@ -30,14 +31,16 @@
"fieldtype": "Link",
"label": "Chapter",
"options": "Course Chapter",
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"fieldname": "lesson",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Lesson",
"options": "Course Lesson"
"options": "Course Lesson",
"search_index": 1
},
{
"fieldname": "status",
@@ -45,7 +48,8 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "Complete\nPartially Complete\nIncomplete"
"options": "Complete\nPartially Complete\nIncomplete",
"search_index": 1
},
{
"fieldname": "column_break_3",
@@ -55,7 +59,8 @@
"fieldname": "member",
"fieldtype": "Link",
"label": "Member",
"options": "User"
"options": "User",
"search_index": 1
},
{
"fetch_from": "member.full_name",
@@ -67,7 +72,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-02-27 11:43:08.326886",
"modified": "2025-01-17 15:54:34.040621",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course Progress",

View File

@@ -132,13 +132,13 @@
},
{
"fieldname": "duration",
"fieldtype": "Duration",
"label": "Duration"
"fieldtype": "Data",
"label": "Duration (in minutes)"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-10-11 22:39:40.381183",
"modified": "2025-01-06 11:02:09.749207",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz",

View File

@@ -17,6 +17,7 @@ class LMSQuizSubmission(Document):
self.notify_member()
def validate_marks(self):
self.score = 0
for row in self.result:
if cint(row.marks) > cint(row.marks_out_of):
frappe.throw(

View File

@@ -450,24 +450,6 @@ def get_signup_optin_checks():
return (", ").join(links)
def get_popular_courses():
courses = frappe.get_all("LMS Course", {"published": 1, "upcoming": 0})
course_membership = []
for course in courses:
course_membership.append(
{
"course": course.name,
"members": cint(frappe.db.count("LMS Enrollment", {"course": course.name})),
}
)
course_membership = sorted(
course_membership, key=lambda x: x.get("members"), reverse=True
)
return course_membership[:3]
def format_amount(amount, currency):
amount_reduced = amount / 1000
if amount_reduced < 1:
@@ -874,26 +856,6 @@ def is_onboarding_complete():
}
def has_submitted_assessment(assessment, type, member=None):
if not member:
member = frappe.session.user
doctype = (
"LMS Assignment Submission" if type == "LMS Assignment" else "LMS Quiz Submission"
)
docfield = "assignment" if type == "LMS Assignment" else "quiz"
filters = {}
filters[docfield] = assessment
filters["member"] = member
return frappe.db.exists(doctype, filters)
def has_graded_assessment(submission):
status = frappe.db.get_value("LMS Assignment Submission", submission, "status")
return False if status == "Not Graded" else True
def get_evaluator(course, batch):
evaluator = None
evaluator = frappe.db.get_value(
@@ -955,7 +917,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
# Conversion logic starts here. Exchange rate is fetched and amount is converted.
exchange_rate = get_current_exchange_rate(currency, "USD")
amount = amount * exchange_rate
amount = flt(amount * exchange_rate, 2)
currency = "USD"
# Check if the amount should be rounded and then apply rounding
@@ -1050,6 +1012,7 @@ def get_course_details(course):
course_details.tags = course_details.tags.split(",") if course_details.tags else []
course_details.instructors = get_instructors(course_details.name)
# course_details.is_instructor = is_instructor(course_details.name)
if course_details.paid_course:
"""course_details.course_price, course_details.currency = check_multicurrency(
course_details.course_price, course_details.currency, None, course_details.amount_usd
@@ -1068,7 +1031,6 @@ def get_course_details(course):
["name", "course", "current_lesson", "progress", "member"],
as_dict=1,
)
course_details.is_instructor = is_instructor(course_details.name)
if course_details.membership and course_details.membership.current_lesson:
course_details.current_lesson = get_lesson_index(
@@ -1230,21 +1192,6 @@ def get_neighbour_lesson(course, chapter, lesson):
}
@frappe.whitelist(allow_guest=True)
def get_batches():
batches = []
filters = {}
if frappe.session.user == "Guest":
filters.update({"start_date": [">=", getdate()], "published": 1})
batch_list = frappe.get_all("LMS Batch", filters)
for batch in batch_list:
batches.append(get_batch_details(batch.name))
batches = categorize_batches(batches)
return batches
@frappe.whitelist(allow_guest=True)
def get_batch_details(batch):
batch_details = frappe.db.get_value(
@@ -1347,7 +1294,6 @@ def get_question_details(question):
for i in range(1, 5):
fields.append(f"option_{i}")
fields.append(f"explanation_{i}")
fields.append(f"is_correct_{i}")
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
return question_details
@@ -1441,7 +1387,7 @@ def get_quiz_details(assessment, member):
if len(existing_submission):
assessment.submission = existing_submission[0]
assessment.completed = True
assessment.status = assessment.submission.score
assessment.status = assessment.submission.percentage or assessment.submission.score
else:
assessment.status = "Not Attempted"
assessment.color = "red"
@@ -1459,13 +1405,11 @@ def get_quiz_details(assessment, member):
@frappe.whitelist()
def get_batch_students(batch):
students = []
students_list = frappe.get_all(
"Batch Student", filters={"parent": batch}, fields=["student", "name"]
)
batch_courses = frappe.get_all("Batch Course", {"parent": batch}, pluck="course")
batch_courses = frappe.get_all("Batch Course", {"parent": batch}, ["course", "title"])
assessments = frappe.get_all(
"LMS Assessment",
filters={"parent": batch},
@@ -1483,29 +1427,76 @@ def get_batch_students(batch):
)
detail.last_active = format_datetime(detail.last_active, "dd MMM YY")
detail.name = student.name
students.append(detail)
detail.courses = frappe._dict()
detail.assessments = frappe._dict()
""" Iterate through courses and track their progress """
for course in batch_courses:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": course, "member": student.student}, "progress"
"LMS Enrollment", {"course": course.course, "member": student.student}, "progress"
)
detail.courses[course.title] = progress
if progress == 100:
courses_completed += 1
detail.courses_completed = courses_completed
""" Iterate through assessments and track their progress """
for assessment in assessments:
if has_submitted_assessment(
title = frappe.db.get_value(
assessment.assessment_type, assessment.assessment_name, "title"
)
status = has_submitted_assessment(
assessment.assessment_name, assessment.assessment_type, student.student
):
)
detail.assessments[title] = status
if status not in ["Not Attempted", 0]:
assessments_completed += 1
detail.courses_completed = courses_completed
detail.assessments_completed = assessments_completed
if len(batch_courses) + len(assessments):
detail.progress = flt(
(
(courses_completed + assessments_completed)
/ (len(batch_courses) + len(assessments))
* 100
),
2,
)
else:
detail.progress = 0
students.append(detail)
students = sorted(students, key=lambda x: x.progress, reverse=True)
return students
def has_submitted_assessment(assessment, assessment_type, member=None):
if not member:
member = frappe.session.user
if assessment_type == "LMS Assignment":
doctype = "LMS Assignment Submission"
docfield = "assignment"
fields = ["status"]
not_attempted = "Not Attempted"
elif assessment_type == "LMS Quiz":
doctype = "LMS Quiz Submission"
docfield = "quiz"
fields = ["percentage"]
not_attempted = 0
filters = {}
filters[docfield] = assessment
filters["member"] = member
attempt = frappe.db.exists(doctype, filters)
if attempt:
attempt_details = frappe.db.get_value(doctype, filters, fields)
return attempt_details
else:
return not_attempted
@frappe.whitelist()
def get_discussion_topics(doctype, docname, single_thread):
if single_thread:
@@ -1726,31 +1717,31 @@ def enroll_in_batch(batch, payment_name=None):
if not frappe.db.exists(
"Batch Student", {"parent": batch, "student": frappe.session.user}
):
student = frappe.new_doc("Batch Student")
current_count = frappe.db.count("Batch Student", {"parent": batch})
batch_doc = frappe.get_doc("LMS Batch", batch)
if batch_doc.seat_count and len(batch_doc.students) >= batch_doc.seat_count:
frappe.throw(_("The batch is full. Please contact the Administrator."))
student.update(
{
"student": frappe.session.user,
"parent": batch,
"parenttype": "LMS Batch",
"parentfield": "students",
"idx": current_count + 1,
}
)
new_student = {
"student": frappe.session.user,
"parent": batch,
"parenttype": "LMS Batch",
"parentfield": "students",
"idx": len(batch_doc.students) + 1,
}
if payment_name:
payment = frappe.db.get_value(
"LMS Payment", payment_name, ["name", "source"], as_dict=True
)
student.update(
new_student.update(
{
"payment": payment.name,
"source": payment.source,
}
)
student.save(ignore_permissions=True)
batch_doc.append("students", new_student)
batch_doc.save(ignore_permissions=True)
@frappe.whitelist()
@@ -1839,3 +1830,96 @@ def enroll_in_program_course(program, course):
)
enrollment.save()
return enrollment
@frappe.whitelist(allow_guest=True)
def get_batches(filters=None, start=0, page_length=20, order_by="start_date"):
if not filters:
filters = {}
if filters.get("enrolled"):
enrolled_batches = frappe.get_all(
"Batch Student", {"student": frappe.session.user}, pluck="parent"
)
filters.update({"name": ["in", enrolled_batches]})
del filters["enrolled"]
batches = frappe.get_all(
"LMS Batch",
filters=filters,
fields=[
"name",
"title",
"description",
"seat_count",
"paid_batch",
"amount",
"amount_usd",
"currency",
"start_date",
"end_date",
"start_time",
"end_time",
"timezone",
"published",
"category",
],
order_by=order_by,
start=start,
page_length=page_length,
)
batches = filter_batches_based_on_start_time(batches, filters)
batches = get_batch_card_details(batches)
return batches
def filter_batches_based_on_start_time(batches, filters):
batchType = get_batch_type(filters)
if batchType == "upcoming":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate()
and get_time_str(batch.start_time) < nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
elif batchType == "archived":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate()
and get_time_str(batch.start_time) >= nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
return batches
def get_batch_type(filters):
start_date_filter = filters.get("start_date")
batchType = None
if start_date_filter:
sign = start_date_filter[0]
if ">" in sign:
batchType = "upcoming"
elif "<" in sign:
batchType = "archived"
return batchType
def get_batch_card_details(batches):
for batch in batches:
batch.instructors = get_instructors(batch.name)
students_count = frappe.db.count("Batch Student", {"parent": batch.name})
if batch.seat_count:
batch.seats_left = batch.seat_count - students_count
if batch.paid_batch and batch.start_date >= getdate():
batch.amount, batch.currency = check_multicurrency(
batch.amount, batch.currency, None, batch.amount_usd
)
batch.price = fmt_money(batch.amount, 0, batch.currency)
return batches

View File

@@ -1,31 +1,9 @@
{% set enrolled = get_enrolled_courses().in_progress + get_enrolled_courses().completed %}
{% if enrolled | length %}
<div class="cards-parent">
{% for course in enrolled %}
{{ widgets.CourseCard(course=course) }}
{% endfor %}
</div>
{% else %}
{% set site_name = frappe.db.get_single_value("System Settings", "app_name") %}
<div class="empty-state p-5">
<div style="text-align: left; flex: 1;">
<div class="text-center">
<div class="empty-state-heading">{{ _("You haven't enrolled for any courses") }}</div>
<div class="course-meta mb-6">{{ _("Here are a few courses we recommend for you to get started with {0}").format(site_name) }}</div>
</div>
{% set recommended_courses = get_popular_courses() %}
<div class="cards-parent">
{% for course in recommended_courses %}
{% if course %}
{% set course_details = frappe.get_doc("LMS Course", course.course) %}
{{ widgets.CourseCard(course=course_details) }}
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}

File diff suppressed because it is too large Load Diff

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