Compare commits

..

165 Commits

Author SHA1 Message Date
Frappe PR Bot
7fcf6a253d chore(release): Bumped to Version 2.30.0 2025-06-10 10:24:40 +00:00
Jannat Patel
be8d985d15 fix: removed duplicate import 2025-06-10 15:34:19 +05:30
Jannat Patel
974c90dddc Merge branch 'main' into develop 2025-06-10 15:29:39 +05:30
Jannat Patel
4811d395d2 Merge pull request #1568 from pateljannat/issues-114
fix: misc evaluation issues
2025-06-10 11:06:45 +05:30
Jannat Patel
132423d577 Merge pull request #1567 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-10 10:58:22 +05:30
Jannat Patel
10829e2f00 fix: misc evaluation issues 2025-06-10 10:58:03 +05:30
Jannat Patel
47b908c964 chore: Esperanto translations 2025-06-10 03:35:55 +05:30
Jannat Patel
0f8e471d5d chore: Chinese Simplified translations 2025-06-10 03:35:54 +05:30
Jannat Patel
2537119250 chore: Serbian (Latin) translations 2025-06-10 03:35:52 +05:30
Jannat Patel
977066d114 chore: Bosnian translations 2025-06-10 03:35:51 +05:30
Jannat Patel
46e956dc74 chore: Croatian translations 2025-06-10 03:35:50 +05:30
Jannat Patel
7afdd8d44f chore: Thai translations 2025-06-10 03:35:48 +05:30
Jannat Patel
6daf204b4f chore: Persian translations 2025-06-10 03:35:47 +05:30
Jannat Patel
2f4a550a4a chore: Portuguese, Brazilian translations 2025-06-10 03:35:46 +05:30
Jannat Patel
fe214f6b41 chore: Turkish translations 2025-06-10 03:35:44 +05:30
Jannat Patel
ca7de81888 chore: Swedish translations 2025-06-10 03:35:43 +05:30
Jannat Patel
17ce20355a chore: Russian translations 2025-06-10 03:35:42 +05:30
Jannat Patel
34981b4765 chore: Portuguese translations 2025-06-10 03:35:41 +05:30
Jannat Patel
21151a2e09 chore: Polish translations 2025-06-10 03:35:39 +05:30
Jannat Patel
1abb7f5b8c chore: Hungarian translations 2025-06-10 03:35:38 +05:30
Jannat Patel
05998549a4 chore: German translations 2025-06-10 03:35:37 +05:30
Jannat Patel
96283a3629 chore: Arabic translations 2025-06-10 03:35:35 +05:30
Jannat Patel
2bfc7abe9c chore: Spanish translations 2025-06-10 03:35:34 +05:30
Jannat Patel
4f389eca8d chore: French translations 2025-06-10 03:35:33 +05:30
Jannat Patel
1789479955 Merge pull request #1564 from frappe/pot_develop_2025-06-06
chore: update POT file
2025-06-09 19:44:09 +05:30
frappe-pr-bot
59316dbaf9 chore: update POT file 2025-06-06 16:04:34 +00:00
Jannat Patel
b726073a5b Merge pull request #1562 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-06 10:52:56 +05:30
Jannat Patel
adf897c812 chore: French translations 2025-06-06 03:03:37 +05:30
Jannat Patel
1fc4c2442c Merge pull request #1561 from pateljannat/evaluator-link-in-batch
fix batch instructor should be linked to evaluator
2025-06-05 15:06:46 +05:30
Jannat Patel
414643ee90 test: link evaluator as batch instructor 2025-06-05 14:46:09 +05:30
Jannat Patel
1a1cbd6ea1 fix: batch instructor should be linked to evaluator 2025-06-05 12:58:12 +05:30
Jannat Patel
9ae809a62f Merge pull request #1559 from pateljannat/issues-113
fix: dont allow enrollment is self learning is disabled from api
2025-06-05 12:53:42 +05:30
Jannat Patel
eb9b1c905d Merge pull request #1558 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-05 10:46:03 +05:30
Jannat Patel
fe9a8f49c1 fix: dont allow enrollment is self learning is disabled from api 2025-06-05 10:43:25 +05:30
Jannat Patel
f912c8fce3 chore: French translations 2025-06-05 02:22:54 +05:30
Jannat Patel
1d1ca43c35 chore: Serbian (Latin) translations 2025-06-04 01:58:16 +05:30
Jannat Patel
bce45f44e4 Merge pull request #1557 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-03 13:23:54 +05:30
Jannat Patel
07583fb563 fix: show error in toast when scheduling evaluations 2025-06-03 12:47:57 +05:30
Jannat Patel
775aa23992 chore: Esperanto translations 2025-06-03 02:00:23 +05:30
Jannat Patel
05ed6b7e73 chore: Chinese Simplified translations 2025-06-03 02:00:22 +05:30
Jannat Patel
d602694ea7 chore: Serbian (Latin) translations 2025-06-03 02:00:20 +05:30
Jannat Patel
18d71bc0d4 chore: Bosnian translations 2025-06-03 02:00:19 +05:30
Jannat Patel
3fa68643ba chore: Croatian translations 2025-06-03 02:00:18 +05:30
Jannat Patel
8904525c36 chore: Thai translations 2025-06-03 02:00:16 +05:30
Jannat Patel
3ce09a98f3 chore: Persian translations 2025-06-03 02:00:15 +05:30
Jannat Patel
b833768e71 chore: Portuguese, Brazilian translations 2025-06-03 02:00:14 +05:30
Jannat Patel
b9a6afd993 chore: Turkish translations 2025-06-03 02:00:12 +05:30
Jannat Patel
b5a81ea927 chore: Swedish translations 2025-06-03 02:00:11 +05:30
Jannat Patel
750e92cdde chore: Russian translations 2025-06-03 02:00:09 +05:30
Jannat Patel
da45f4c011 chore: Portuguese translations 2025-06-03 02:00:08 +05:30
Jannat Patel
544bb5c11c chore: Polish translations 2025-06-03 02:00:07 +05:30
Jannat Patel
1fc6f62f70 chore: Hungarian translations 2025-06-03 02:00:05 +05:30
Jannat Patel
8751ad27ec chore: German translations 2025-06-03 02:00:04 +05:30
Jannat Patel
159d3d5b87 chore: Arabic translations 2025-06-03 02:00:03 +05:30
Jannat Patel
34d6d99d8c chore: Spanish translations 2025-06-03 02:00:01 +05:30
Jannat Patel
6c46931b1a chore: French translations 2025-06-03 02:00:00 +05:30
Jannat Patel
2c3e2d9d08 Merge pull request #1554 from pateljannat/quiz-in-video
feat: show quiz in between videos
2025-06-02 19:35:55 +05:30
Jannat Patel
7be1562fa4 fix: simplified timestamp label 2025-06-02 19:18:27 +05:30
Jannat Patel
294389e7c7 Merge branch 'develop' of https://github.com/frappe/lms into quiz-in-video 2025-06-02 19:16:27 +05:30
Jannat Patel
2c8ce133f7 fix: quiz and time validation before linking to video 2025-06-02 19:12:13 +05:30
Ankush Menat
4f1d4d90d0 fix: remove invasive configs (#1555) 2025-06-02 19:04:55 +05:30
Jannat Patel
7b7484332b feat: quiz in videos 2025-06-02 18:18:13 +05:30
Jannat Patel
50e94b85aa chore: resolved conflicts 2025-06-02 12:24:16 +05:30
Jannat Patel
9b820594ef Merge pull request #1553 from pateljannat/batch-test
test: batch creation flow
2025-06-02 12:22:58 +05:30
Jannat Patel
ddcd45d56d test: don't add course to batch 2025-06-02 12:15:34 +05:30
Jannat Patel
c4a4c16516 test: batch creation flow 2025-06-02 10:48:54 +05:30
Jannat Patel
5ae9ad0762 Merge pull request #1552 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-02 10:38:49 +05:30
Jannat Patel
405f7d498e Merge pull request #1548 from frappe/pot_develop_2025-05-30
chore: update POT file
2025-06-02 10:38:39 +05:30
Jannat Patel
bcd6a5b1e7 chore: Persian translations 2025-06-02 01:08:03 +05:30
Jannat Patel
e5e5ac994c Merge pull request #1550 from pateljannat/issues-112
fix: misc issues
2025-05-31 12:49:16 +05:30
Jannat Patel
e1f8d6ec49 fix: count of jobs and certified members 2025-05-31 12:41:58 +05:30
Jannat Patel
6f50242f5a fix: misc issues 2025-05-31 11:52:25 +05:30
frappe-pr-bot
036f7ece05 chore: update POT file 2025-05-30 16:04:24 +00:00
Jannat Patel
622a2ff072 feat: display quiz when time is reached 2025-05-30 18:55:26 +05:30
Jannat Patel
60334ca04a feat: show quiz in between videos 2025-05-30 13:00:00 +05:30
Jannat Patel
ade47b4e83 Merge pull request #1547 from pateljannat/seo-in-forms
feat: seo tags and keywords for courses and batches
2025-05-30 10:19:29 +05:30
Jannat Patel
d7e550dfea feat: seo tags and keywords for courses and batches 2025-05-29 20:13:35 +05:30
Jannat Patel
c3cc0b9bf7 Merge pull request #1546 from pateljannat/issues-111
fix: misc issues
2025-05-29 16:29:57 +05:30
Jannat Patel
5ad89189c1 fix: changed certified members count based on filters 2025-05-29 16:09:51 +05:30
Jannat Patel
f1bbd4eb13 fix: settings ui cleanup 2025-05-29 16:09:14 +05:30
Jannat Patel
fba89dfacb feat: show unpushlished courses to admins on frontend 2025-05-29 12:50:25 +05:30
Jannat Patel
b93ed41215 fix: course and chapter permissions to moderators 2025-05-29 12:49:30 +05:30
Jannat Patel
13ff6a7304 chore: record sessions while creating courses and lessons 2025-05-29 12:48:58 +05:30
Jannat Patel
ad97405e55 Merge pull request #1544 from Rl0007/fix/edit-profile-escape-html
fix: Edit profile escape html
2025-05-28 20:20:53 +05:30
Rahul Agrawal
376e231d7b chore: remove unwanted line profile.bio = profile.bio 2025-05-28 16:00:14 +05:30
Rahul Agrawal
e16d76f6dd fix: remove escapeHtml from edit profile bio on save 2025-05-28 15:44:54 +05:30
Jannat Patel
ffd0fd92fc Merge pull request #1542 from pateljannat/zoom-refactor
feat: multiple zoom accounts and zoom attendance
2025-05-28 12:06:21 +05:30
Jannat Patel
933613d730 fix: jobs list header issue 2025-05-28 11:22:03 +05:30
Jannat Patel
9b0673bf92 feat: zoom attendance 2025-05-27 23:01:04 +05:30
Jannat Patel
7cba22aa28 Merge pull request #1539 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-27 12:02:06 +05:30
Jannat Patel
af05b614a9 feat: delete zoom accounts from settings 2025-05-27 11:40:22 +05:30
Jannat Patel
c0fa219a8b chore: Esperanto translations 2025-05-26 23:41:15 +05:30
Jannat Patel
4e3a47b0f4 chore: Chinese Simplified translations 2025-05-26 23:41:14 +05:30
Jannat Patel
161276b58a chore: Serbian (Latin) translations 2025-05-26 23:41:13 +05:30
Jannat Patel
47713019a5 chore: Bosnian translations 2025-05-26 23:41:11 +05:30
Jannat Patel
010632a21d chore: Croatian translations 2025-05-26 23:41:10 +05:30
Jannat Patel
e77fe550af chore: Thai translations 2025-05-26 23:41:09 +05:30
Jannat Patel
0a4233da14 chore: Persian translations 2025-05-26 23:41:08 +05:30
Jannat Patel
56fb70ab1e chore: Portuguese, Brazilian translations 2025-05-26 23:41:06 +05:30
Jannat Patel
4a1f2bc01d chore: Turkish translations 2025-05-26 23:41:05 +05:30
Jannat Patel
20292fbf16 chore: Swedish translations 2025-05-26 23:41:04 +05:30
Jannat Patel
1290cf8991 chore: Russian translations 2025-05-26 23:41:02 +05:30
Jannat Patel
b8b8af7cf1 chore: Portuguese translations 2025-05-26 23:41:01 +05:30
Jannat Patel
75f4f452d3 chore: Polish translations 2025-05-26 23:41:00 +05:30
Jannat Patel
9de492384f chore: Hungarian translations 2025-05-26 23:40:58 +05:30
Jannat Patel
14c4e161f2 chore: German translations 2025-05-26 23:40:57 +05:30
Jannat Patel
c55efbc0ba chore: Arabic translations 2025-05-26 23:40:56 +05:30
Jannat Patel
f0610222d9 chore: Spanish translations 2025-05-26 23:40:55 +05:30
Jannat Patel
302ee4a50f chore: French translations 2025-05-26 23:40:53 +05:30
Jannat Patel
2170819159 chore: telemetry fixes 2025-05-26 22:02:46 +05:30
Jannat Patel
0d1fac321a feat: zoom settings on frontend 2025-05-26 21:35:13 +05:30
Jannat Patel
dbbc1756dd Merge branch 'develop' of https://github.com/frappe/lms into zoom-refactor 2025-05-26 21:27:18 +05:30
Jannat Patel
d5b882d3f8 feat: multiple zoom accounts 2025-05-26 18:08:17 +05:30
Jannat Patel
5dba4d1384 Merge pull request #1537 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-26 15:33:03 +05:30
Jannat Patel
de240e40a5 Merge pull request #1507 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-16 12:01:07 +05:30
Jannat Patel
3bbdc828d9 Merge pull request #1506 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-14 17:48:42 +05:30
Jannat Patel
b2b92aea31 chore: merged upstream 2025-05-07 22:04:53 +05:30
Jannat Patel
e0680d9612 chore: merged upstream 2025-05-07 22:03:57 +05:30
Jannat Patel
d286df649e Merge pull request #1490 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-07 12:56:56 +05:30
Jannat Patel
e0cbc247b2 Merge pull request #1485 from pateljannat/release-conflicts
chore: merge to 'main'
2025-05-06 13:10:08 +05:30
Jannat Patel
a2c8a82559 chore: merged conflicts 2025-05-06 12:59:22 +05:30
Jannat Patel
8b91323705 Merge pull request #1457 from pateljannat/vimeo
fix: allow fullscreen on vimeo
2025-04-21 17:32:12 +05:30
Jannat Patel
89fdbf5660 test: find the course image label and attach course image to its sibling input 2025-04-21 17:10:33 +05:30
Jannat Patel
7ed5dfdb8f fix: allow fullscreen on video and adjust video height on mobile devices 2025-04-21 16:34:34 +05:30
Jannat Patel
824c65eb38 Merge pull request #1440 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-16 18:55:21 +05:30
Jannat Patel
e43eeeba4a Merge pull request #1423 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-10 16:01:54 +05:30
Jannat Patel
9e2c7cc145 Merge pull request #1417 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-09 15:17:25 +05:30
Jannat Patel
989598b9cd Merge pull request #1398 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-27 09:44:00 +05:30
Jannat Patel
6a41942de6 Merge pull request #1384 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-20 13:04:16 +05:30
Jannat Patel
d263072aca Merge pull request #1373 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-12 11:10:48 +05:30
Jannat Patel
78c8467bf6 Merge pull request #1361 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-05 18:33:33 +05:30
Jannat Patel
084908bd04 Merge pull request #1352 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-03 15:38:08 +05:30
Jannat Patel
039a775ce4 Merge pull request #1340 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-26 10:18:26 +05:30
Jannat Patel
dd9e80f067 Merge pull request #1326 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-19 11:06:43 +05:30
Jannat Patel
a3a2af948e Merge pull request #1303 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-14 18:07:10 +05:30
Jannat Patel
0bedf3ea59 Merge pull request #1264 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-22 12:59:34 +05:30
Jannat Patel
1775ac4803 Merge pull request #1247 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-15 11:24:49 +05:30
Jannat Patel
ae1a615863 Merge pull request #1237 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-09 11:01:37 +05:30
Jannat Patel
a6ef1b8902 Merge pull request #1220 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-02 20:24:58 +05:30
Jannat Patel
94d17b81d4 Merge pull request #1197 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-18 20:30:52 +05:30
Jannat Patel
44a63d9cec Merge pull request #1180 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-13 12:27:07 +05:30
Jannat Patel
e2b4b5a57e Merge pull request #1164 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-06 11:05:49 +05:30
Jannat Patel
ec30aa323e Merge pull request #1155 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-29 17:05:30 +05:30
Jannat Patel
95e9087c6e Merge pull request #1151 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-25 15:06:00 +05:30
Jannat Patel
db38099557 Merge pull request #1143 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-20 13:30:06 +05:30
Jannat Patel
164d5cdec9 Merge pull request #1130 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-13 11:53:57 +05:30
Jannat Patel
c6b1076092 Merge pull request #1099 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-07 09:51:07 +05:30
Jannat Patel
6aebe856da Merge pull request #1087 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-31 12:15:12 +05:30
Jannat Patel
4737551918 Merge pull request #1075 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-23 13:00:37 +05:30
Jannat Patel
c2cb79f700 Merge pull request #1067 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-17 12:33:35 +05:30
Jannat Patel
d7c05984be Merge pull request #1048 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-09 22:11:23 +05:30
Jannat Patel
55429e2f03 Merge pull request #1036 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-02 12:30:46 +05:30
Jannat Patel
25ffe8b0e4 Merge pull request #1029 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-25 11:47:14 +05:30
Jannat Patel
303a9d1110 Merge pull request #1020 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-18 10:21:11 +05:30
Jannat Patel
de8c907c51 Merge pull request #1013 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-11 11:09:55 +05:30
Jannat Patel
0fd1cabd60 Merge pull request #1003 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-04 10:36:05 +05:30
Jannat Patel
8dd480735c Merge pull request #996 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-28 11:24:59 +05:30
Jannat Patel
676f1a1f0e Merge pull request #984 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-21 10:48:23 +05:30
Jannat Patel
ce75422126 Merge pull request #966 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-14 11:24:10 +05:30
Jannat Patel
3a097d6b15 Merge pull request #956 from frappe/develop
chore: Merge develop into main
2024-08-06 11:27:00 +05:30
Jannat Patel
9de1bf1020 Merge pull request #954 from frappe/develop
chore: Merge develop into main
2024-08-05 14:47:45 +05:30
Jannat Patel
93e5cf1c25 Merge pull request #952 from frappe/develop
chore: Merge develop to main
2024-08-05 12:22:05 +05:30
Jannat Patel
6e2376570b Merge pull request #949 from frappe/develop
chore: Merge develop to main
2024-08-01 17:16:22 +05:30
Jannat Patel
b20c4bf197 Merge pull request #948 from frappe/develop
chore: Merge develop to main
2024-07-31 16:33:43 +05:30
Jannat Patel
6ae1d92033 Merge pull request #925 from frappe/develop
chore: merge `develop` into `main`
2024-07-11 09:11:50 +05:30
102 changed files with 13776 additions and 5726 deletions

View File

@@ -0,0 +1,180 @@
describe("Batch Creation", () => {
it("creates a new batch", () => {
cy.login();
cy.wait(500);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add a new member
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Members$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const dateNow = Date.now();
const randomEmail = `testuser_${dateNow}@example.com`;
const randomName = `Test User ${dateNow}`;
cy.get("input[placeholder='Email']").type(randomEmail);
cy.get("input[placeholder='First Name']").type(randomName);
cy.get("button").contains("Add").click();
// Add evaluator
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Evaluators$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='Email']").type(randomEvaluator);
cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Create a batch
cy.get("button").contains("New").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("evaluator");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
// View Batch
cy.wait(1000);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/batches");
cy.get('[id^="headlessui-radiogroup-v-"]')
.find("span")
.contains("Upcoming")
.should("be.visible")
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span")
.contains("10:00 AM - 11:00 AM")
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
cy.get("p")
.contains(
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
)
.should("be.visible");
cy.get("button").contains("Manage Batch").click();
/* Add student to batch */
cy.get("button").contains("Add").click();
cy.get('div[id^="headlessui-dialog-panel-v-"]')
.first()
.find("button")
.eq(1)
.click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("div")
.contains("9")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
});

View File

@@ -72,8 +72,15 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
Cypress.Commands.add("closeOnboardingModal", () => { Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500); cy.wait(500);
cy.get('[class*="z-50"]') cy.get("body").then(($body) => {
.find('button:has(svg[class*="feather-x"])') // Check if any element with class including 'z-50' exists
.realClick(); if ($body.find('[class*="z-50"]').length > 0) {
cy.wait(1000); cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
} else {
cy.log("Onboarding modal not found, skipping close.");
}
});
}); });

1
frappe-ui Submodule

Submodule frappe-ui added at fd5252663b

View File

@@ -27,9 +27,9 @@ declare module 'vue' {
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default'] BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default'] BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default'] BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/BrandSettings.vue')['default'] BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default'] BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Categories.vue')['default'] Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default'] CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default'] ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
@@ -48,10 +48,10 @@ declare module 'vue' {
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default'] EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default'] EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default'] EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplates: typeof import('./src/components/EmailTemplates.vue')['default'] EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default'] EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default'] EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Evaluators.vue')['default'] Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default'] Event: typeof import('./src/components/Modals/Event.vue')['default']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default'] ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default'] FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
@@ -65,28 +65,30 @@ declare module 'vue' {
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default'] LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default'] Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default'] LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default'] LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default'] LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
Members: typeof import('./src/components/Members.vue')['default'] Members: typeof import('./src/components/Settings/Members.vue')['default']
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default'] MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default'] MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default'] NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default'] NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default'] NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default'] PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default'] PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default'] Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default'] ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default'] Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default'] Quiz: typeof import('./src/components/Quiz.vue')['default']
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default'] QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default'] Rating: typeof import('./src/components/Controls/Rating.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default'] ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SettingDetails: typeof import('./src/components/SettingDetails.vue')['default'] SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/SettingFields.vue')['default'] SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Modals/Settings.vue')['default'] Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default'] SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default'] StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default'] StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
@@ -97,5 +99,7 @@ declare module 'vue' {
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default'] UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default'] UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default'] VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
} }
} }

View File

@@ -9,16 +9,19 @@
<script setup> <script setup>
import { FrappeUIProvider } from 'frappe-ui' import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
import DesktopLayout from './components/DesktopLayout.vue' import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue' import MobileLayout from './components/MobileLayout.vue'
import NoSidebarLayout from './components/NoSidebarLayout.vue' import NoSidebarLayout from './components/NoSidebarLayout.vue'
import { usersStore } from '@/stores/user'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { posthogSettings } from '@/telemetry'
const screenSize = useScreenSize() const screenSize = useScreenSize()
const router = useRouter() const router = useRouter()
const noSidebar = ref(false) const noSidebar = ref(false)
const { userResource } = usersStore()
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.query.fromLesson || to.path === '/persona') { if (to.query.fromLesson || to.path === '/persona') {
@@ -42,6 +45,11 @@ const Layout = computed(() => {
onUnmounted(() => { onUnmounted(() => {
noSidebar.value = false noSidebar.value = false
stopSession() })
watch(userResource, () => {
if (userResource.data) {
posthogSettings.reload()
}
}) })
</script> </script>

View File

@@ -181,7 +181,16 @@
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue' import {
ref,
onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
@@ -626,4 +635,8 @@ watch(userResource, () => {
const redirectToWebsite = () => { const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank') window.open('https://frappe.io/learning', '_blank')
} }
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full" class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9"> <div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">

View File

@@ -106,7 +106,6 @@ const courses = createResource({
params: { params: {
batch: props.batch, batch: props.batch,
}, },
cache: ['batchCourses', props.batchName],
auto: true, auto: true,
}) })

View File

@@ -55,9 +55,10 @@
</div> </div>
</li> </li>
</ComboboxOption> </ComboboxOption>
<div class="h-10"></div>
<div <div
v-if="attrs.onCreate" v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[98%] pt-2 bg-white border-t" class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
> >
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -146,7 +146,7 @@
<script setup> <script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next' import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Badge, Button, createResource, toast } from 'frappe-ui' import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/' import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -175,15 +175,11 @@ function enrollStudent() {
toast.success(__('You need to login first to enroll for this course')) toast.success(__('You need to login first to enroll for this course'))
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 1000) }, 500)
} else { } else {
const enrollStudentResource = createResource({ call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', course: props.course.data.name,
}) })
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => { .then(() => {
capture('enrolled_in_course', { capture('enrolled_in_course', {
course: props.course.data.name, course: props.course.data.name,
@@ -198,7 +194,11 @@ function enrollStudent() {
lessonNumber: 1, lessonNumber: 1,
}, },
}) })
}, 2000) }, 1000)
})
.catch((err) => {
toast.warning(__(err.messages?.[0] || err))
console.error(err)
}) })
} }
} }

View File

@@ -97,7 +97,7 @@ import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
} }
) )
} }
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script> </script>

View File

@@ -70,7 +70,7 @@
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils' import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue' import { ref, onMounted, inject, onUnmounted } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue' import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue' import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next' import { MessageSquareText } from 'lucide-vue-next'
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
const openTopicModal = () => { const openTopicModal = () => {
showTopicModal.value = true showTopicModal.value = true
} }
onUnmounted(() => {
socket.off('new_discussion_topic')
})
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4" class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
> >
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<div class="flex flex-col space-y-2 flex-1"> <div class="flex flex-col space-y-2 flex-1">

View File

@@ -1,5 +1,15 @@
<template> <template>
<div class="flex items-center justify-between mb-5"> <div
v-if="hasPermission() && !props.zoomAccount"
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
>
<AlertCircle class="size-4 stroke-1.5" />
<span>
{{ __('Please add a zoom account to the batch to create live classes.') }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }} {{ __('Live Class') }}
</div> </div>
@@ -12,10 +22,18 @@
</span> </span>
</Button> </Button>
</div> </div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5"> <div v-if="liveClasses.data?.length" class="grid grid-cols-3 gap-5 mt-5">
<div <div
v-for="cls in liveClasses.data" v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 p-3" class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': hasPermission() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
> >
<div class="font-semibold text-ink-gray-9 text-lg mb-1"> <div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }} {{ cls.title }}
@@ -23,7 +41,7 @@
<div class="short-introduction"> <div class="short-introduction">
{{ cls.description }} {{ cls.description }}
</div> </div>
<div class="space-y-3"> <div class="mt-auto space-y-3">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" /> <Calendar class="w-4 h-4 stroke-1.5" />
<span> <span>
@@ -33,18 +51,20 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" /> <Clock class="w-4 h-4 stroke-1.5" />
<span> <span>
{{ formatTime(cls.time) }} {{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
</span> </span>
</div> </div>
<div <div
v-if="cls.date >= dayjs().format('YYYY-MM-DD')" v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto" class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
> >
<a <a
v-if="user.data?.is_moderator || user.data?.is_evaluator" v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url" :href="cls.start_url"
target="_blank" target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded" class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
> >
<Monitor class="h-4 w-4 stroke-1.5" /> <Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }} {{ __('Start') }}
@@ -58,42 +78,63 @@
{{ __('Join') }} {{ __('Join') }}
</a> </a>
</div> </div>
<div v-else class="flex items-center space-x-2 text-yellow-700"> <Tooltip
<Info class="w-4 h-4 stroke-1.5" /> v-else-if="hasClassEnded(cls)"
<span> :text="__('This class has ended')"
{{ __('This class has ended') }} placement="right"
</span> >
</div> <div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="text-sm italic text-ink-gray-5"> <div v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }} {{ __('No live classes scheduled') }}
</div> </div>
<LiveClassModal <LiveClassModal
:batch="props.batch" :batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal" v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses" v-model:reloadLiveClasses="liveClasses"
/> />
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
</template> </template>
<script setup> <script setup>
import { createListResource, Button } from 'frappe-ui' import { createListResource, Button, Tooltip } from 'frappe-ui'
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next' import {
import { inject } from 'vue' Plus,
import LiveClassModal from '@/components/Modals/LiveClassModal.vue' Clock,
import { ref } from 'vue' Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/' import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user') const user = inject('$user')
const showLiveClassModal = ref(false) const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: String, type: String,
required: true, required: true,
}, },
zoomAccount: String,
}) })
const liveClasses = createListResource({ const liveClasses = createListResource({
@@ -106,6 +147,8 @@ const liveClasses = createListResource({
'description', 'description',
'time', 'time',
'date', 'date',
'duration',
'attendees',
'start_url', 'start_url',
'join_url', 'join_url',
'owner', 'owner',
@@ -120,8 +163,38 @@ const openLiveClassModal = () => {
const canCreateClass = () => { const canCreateClass = () => {
if (readOnlyMode) return false if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassEnd = (cls) => {
const classStart = new Date(`${cls.date}T${cls.time}`)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!hasPermission()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script> </script>
<style> <style>
.short-introduction { .short-introduction {

View File

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

View File

@@ -139,16 +139,7 @@ function submitEvaluation(close) {
close() close()
}, },
onError(err) { onError(err) {
const message = err.messages?.[0] || err toast.warning(__(err.messages?.[0] || err))
let unavailabilityMessage
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
toast.warning(__(unavailabilityMessage || 'Evaluator is unavailable'))
}, },
}) })
} }

View File

@@ -76,8 +76,8 @@
</Button> </Button>
</div> </div>
</div> </div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2"> <Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }"> <template #tab-panel="{ tab }">
<div <div
v-if="tab.label == 'Evaluation'" v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5" class="flex flex-col space-y-4 p-5"

View File

@@ -0,0 +1,91 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Attendance for Class - {0}').format(live_class?.title),
size: 'xl',
}"
>
<template #body-content>
<div class="space-y-5">
<div
v-for="participant in participants.data"
@click="redirectToProfile(participant.member_username)"
class="cursor-pointer text-base w-fit"
>
<Tooltip placement="right">
<div class="flex items-center space-x-2">
<Avatar
:image="participant.member_image"
:label="participant.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ participant.member_name }}
</div>
<div>
{{ participant.member }}
</div>
</div>
</div>
<template #body>
<div
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl"
>
{{ dayjs(participant.joined_at).format('HH:mm a') }} -
{{ dayjs(participant.left_at).format('HH:mm a') }}
<br />
{{ __('attended for') }} {{ participant.duration }}
{{ __('minutes') }}
</div>
</template>
</Tooltip>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Avatar, createListResource, Dialog, Tooltip } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { inject } from 'vue'
const show = defineModel()
const router = useRouter()
const dayjs = inject('$dayjs')
interface LiveClass {
name: String
title: String
}
const props = defineProps<{
live_class: LiveClass | null
}>()
const participants = createListResource({
doctype: 'LMS Live Class Participant',
filter: {
live_class: props.live_class?.name,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'joined_at',
'left_at',
'duration',
],
auto: true,
})
const redirectToProfile = (username: string) => {
router.push({
name: 'Profile',
params: { username },
})
}
</script>

View File

@@ -8,7 +8,7 @@
{ {
label: 'Submit', label: 'Submit',
variant: 'solid', variant: 'solid',
onClick: (close) => submitLiveClass(close), onClick: ({ close }) => submitLiveClass(close),
}, },
], ],
}" }"
@@ -16,14 +16,29 @@
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div class="space-y-4">
<FormControl <FormControl
type="text" type="text"
v-model="liveClass.title" v-model="liveClass.title"
:label="__('Title')" :label="__('Title')"
class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="liveClass.date"
type="date"
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
:required="true"
/>
</Tooltip>
</div>
<div class="space-y-4">
<Tooltip <Tooltip
:text=" :text="
__( __(
@@ -35,7 +50,6 @@
v-model="liveClass.time" v-model="liveClass.time"
type="time" type="time"
:label="__('Time')" :label="__('Time')"
class="mb-4"
:required="true" :required="true"
/> />
</Tooltip> </Tooltip>
@@ -52,24 +66,6 @@
:required="true" :required="true"
/> />
</div> </div>
</div>
<div>
<FormControl
v-model="liveClass.date"
type="date"
class="mb-4"
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
class="mb-4"
:required="true"
/>
</Tooltip>
<FormControl <FormControl
v-model="liveClass.auto_recording" v-model="liveClass.auto_recording"
type="select" type="select"
@@ -107,7 +103,11 @@ const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: String, type: String,
default: null, required: true,
},
zoomAccount: {
type: String,
required: true,
}, },
}) })
@@ -159,6 +159,7 @@ const createLiveClass = createResource({
return { return {
doctype: 'LMS Live Class', doctype: 'LMS Live Class',
batch_name: values.batch, batch_name: values.batch,
zoom_account: props.zoomAccount,
...values, ...values,
} }
}, },
@@ -167,39 +168,11 @@ const createLiveClass = createResource({
const submitLiveClass = (close) => { const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, { return createLiveClass.submit(liveClass, {
validate() { validate() {
if (!liveClass.title) { validateFormFields()
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
}, },
onSuccess() { onSuccess() {
liveClasses.value.reload() liveClasses.value.reload()
refreshForm()
close() close()
}, },
onError(err) { onError(err) {
@@ -208,6 +181,39 @@ const submitLiveClass = (close) => {
}) })
} }
const validateFormFields = () => {
if (!liveClass.title) {
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
}
const valideTime = () => { const valideTime = () => {
let time = liveClass.time.split(':') let time = liveClass.time.split(':')
if (time.length != 2) { if (time.length != 2) {
@@ -221,4 +227,14 @@ const valideTime = () => {
} }
return true return true
} }
const refreshForm = () => {
liveClass.title = ''
liveClass.description = ''
liveClass.date = ''
liveClass.time = ''
liveClass.duration = ''
liveClass.timezone = getUserTimezone()
liveClass.auto_recording = 'No Recording'
}
</script> </script>

View File

@@ -0,0 +1,225 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add quiz to this video'),
size: '2xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="flex items-end gap-4">
<FormControl
:label="__('Time in Video')"
v-model="quiz.time"
type="text"
placeholder="2:15"
class="flex-1"
/>
<Link
v-model="quiz.quiz"
:label="__('Quiz')"
doctype="LMS Quiz"
class="flex-1"
/>
<Button @click="addQuiz()" variant="solid">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button>
</div>
<div class="mt-10 mb-5">
<div class="font-medium mb-4">
{{ __('Quizzes in this video') }}
</div>
<ListView
v-if="allQuizzes.length"
:columns="columns"
:rows="allQuizzes"
row-key="quiz"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<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 allQuizzes">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key as keyof Quiz]"
:align="column.align"
>
<div v-if="column.key == 'time'" class="leading-5 text-sm">
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key as keyof Quiz] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeQuiz(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<div v-else class="text-ink-gray-5 italic text-xs">
{{ __('No quizzes added yet.') }}
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
Button,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { formatTimestamp } from '@/utils'
import Link from '@/components/Controls/Link.vue'
type Quiz = {
time: string
quiz: string
}
const show = defineModel()
const allQuizzes = ref<Quiz[]>([])
const quiz = reactive<Quiz>({
time: '',
quiz: '',
})
const props = defineProps({
quizzes: {
type: Array as () => Quiz[],
default: () => [],
},
saveQuizzes: {
type: Function,
required: true,
},
duration: {
type: Number,
default: 0,
},
})
const addQuiz = () => {
quiz.time = `${getTimeInSeconds()}`
if (!isTimeValid() || !isFormComplete()) return
allQuizzes.value.push({
time: quiz.time,
quiz: quiz.quiz,
})
props.saveQuizzes(allQuizzes.value)
quiz.time = ''
quiz.quiz = ''
}
const getTimeInSeconds = () => {
if (quiz.time && !quiz.time.includes(':')) {
quiz.time = `${quiz.time}:00`
}
const timeParts = quiz.time.split(':')
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
return timeInSeconds
}
const isTimeValid = () => {
if (parseInt(quiz.time) > props.duration) {
toast.error(__('Time in video exceeds the total duration of the video.'))
return false
}
return true
}
const isFormComplete = () => {
if (!quiz.time) {
toast.error(__('Please enter a valid timestamp'))
return false
}
if (!quiz.quiz) {
toast.error(__('Please select a quiz'))
return false
}
return true
}
const removeQuiz = (selections: string, unselectAll: () => void) => {
Array.from(selections).forEach((selection) => {
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
if (index !== -1) {
allQuizzes.value.splice(index, 1)
}
unselectAll()
})
props.saveQuizzes(allQuizzes.value)
}
watch(
() => props.quizzes,
(newQuizzes) => {
allQuizzes.value = newQuizzes
},
{ immediate: true }
)
const columns = computed(() => {
return [
{
key: 'quiz',
label: __('Quiz'),
},
{
key: 'time',
label: __('Time in Video (minutes)'),
align: 'center',
},
]
})
</script>

View File

@@ -0,0 +1,206 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new' ? __('New Zoom Account') : __('Edit Zoom Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<FormControl
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<FormControl
v-model="account.client_id"
:label="__('Client ID')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value, close) => openSettings('Members', close)"
:required="true"
/>
<FormControl
v-model="account.client_secret"
:label="__('Client Secret')"
type="password"
:required="true"
/>
<FormControl
v-model="account.account_id"
:label="__('Account ID')"
type="text"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
interface ZoomAccount {
name: string
account_name: string
enabled: boolean
member: string
account_id: string
client_id: string
client_secret: string
}
interface ZoomAccounts {
data: ZoomAccount[]
reload: () => void
insert: {
submit: (
data: ZoomAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
account_id: '',
client_id: '',
client_secret: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val != 'new') {
zoomAccounts.value?.data.forEach((acc) => {
if (acc.name === val) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.account_id = acc.account_id
account.client_id = acc.client_id
account.client_secret = acc.client_secret
}
})
}
}
)
watch(show, (val) => {
if (!val) {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.account_id = ''
account.client_id = ''
account.client_secret = ''
}
})
const saveAccount = (close) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close) => {
zoomAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account created successfully'))
},
onError(err) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error creating Zoom Account')
)
},
}
)
}
const updateAccount = async (close) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Zoom Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close) => {
zoomAccounts.value?.setValue.submit(
{
...account,
name: account.name,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')
)
},
}
)
}
</script>

View File

@@ -1,8 +1,11 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div <div
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3" class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
> >
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</div>
<div class="leading-5"> <div class="leading-5">
{{ {{
__('This quiz consists of {0} questions.').format(questions.length) __('This quiz consists of {0} questions.').format(questions.length)
@@ -55,19 +58,30 @@
<div class="font-semibold text-lg text-ink-gray-9"> <div class="font-semibold text-lg text-ink-gray-9">
{{ quiz.data.title }} {{ quiz.data.title }}
</div> </div>
<Button <div class="flex items-center justify-center space-x-2 mt-4">
<Button
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
"
variant="solid"
@click="startQuiz"
>
<span>
{{ inVideo ? __('Start the Quiz') : __('Start') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
<div
v-if=" v-if="
!quiz.data.max_attempts || quiz.data.max_attempts &&
attempts.data?.length < quiz.data.max_attempts attempts.data?.length >= quiz.data.max_attempts
" "
@click="startQuiz" class="leading-5 text-ink-gray-7"
class="mt-2"
> >
<span>
{{ __('Start') }}
</span>
</Button>
<div v-else class="leading-5 text-ink-gray-7">
{{ {{
__( __(
'You have already exceeded the maximum number of attempts allowed for this quiz.' 'You have already exceeded the maximum number of attempts allowed for this quiz.'
@@ -247,18 +261,23 @@
) )
}} }}
</div> </div>
<Button <div class="space-x-2">
@click="resetQuiz()" <Button
class="mt-2" @click="resetQuiz()"
v-if=" class="mt-2"
!quiz.data.max_attempts || v-if="
attempts?.data.length < quiz.data.max_attempts !quiz.data.max_attempts ||
" attempts?.data.length < quiz.data.max_attempts
> "
<span> >
{{ __('Try Again') }} <span>
</span> {{ __('Try Again') }}
</Button> </span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
</div> </div>
<div <div
v-if=" v-if="
@@ -308,13 +327,20 @@ let questions = reactive([])
const possibleAnswer = ref(null) const possibleAnswer = ref(null)
const timer = ref(0) const timer = ref(0)
let timerInterval = null let timerInterval = null
const router = useRouter()
const props = defineProps({ const props = defineProps({
quizName: { quizName: {
type: String, type: String,
required: true, required: true,
}, },
inVideo: {
type: Boolean,
default: false,
},
backToVideo: {
type: Function,
default: () => {},
},
}) })
const quiz = createResource({ const quiz = createResource({
@@ -611,11 +637,15 @@ const getInstructions = (question) => {
} }
const markLessonProgress = () => { const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') { let pathname = window.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')
if (lessonIndex.length == 2) {
call('lms.lms.api.mark_lesson_progress', { call('lms.lms.api.mark_lesson_progress', {
course: router.currentRoute.value.params.courseName, course: pathname[3],
chapter_number: router.currentRoute.value.params.chapterNumber, chapter_number: lessonIndex[0],
lesson_number: router.currentRoute.value.params.lessonNumber, lesson_number: lessonIndex[1],
}) })
} }
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col justify-between min-h-0"> <div class="flex flex-col justify-between h-full">
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9"> <div class="font-semibold mb-1 text-ink-gray-9">
@@ -28,7 +28,7 @@
</template> </template>
<script setup> <script setup>
import { createResource, Button, Badge } from 'frappe-ui' import { createResource, Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/Settings/SettingFields.vue'
import { watch, ref } from 'vue' import { watch, ref } from 'vue'
const isDirty = ref(false) const isDirty = ref(false)

View File

@@ -5,9 +5,9 @@
<div class="text-xl font-semibold text-ink-gray-9"> <div class="text-xl font-semibold text-ink-gray-9">
{{ label }} {{ label }}
</div> </div>
<div class="text-xs text-ink-gray-5"> <!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }} {{ __(description) }}
</div> </div> -->
</div> </div>
<div class="flex items-center space-x-5"> <div class="flex items-center space-x-5">
<Button @click="openTemplateForm('new')"> <Button @click="openTemplateForm('new')">

View File

@@ -33,6 +33,7 @@
:placeholder="__('Email')" :placeholder="__('Email')"
type="email" type="email"
class="w-full" class="w-full"
@keydown.enter="addEvaluator"
/> />
<Button @click="addEvaluator()" variant="subtle"> <Button @click="addEvaluator()" variant="subtle">
{{ __('Add') }} {{ __('Add') }}

View File

@@ -118,23 +118,7 @@ import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue' import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next' import { RefreshCw, Plus, X } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
}
const router = useRouter() const router = useRouter()
const show = defineModel('show') const show = defineModel('show')

View File

@@ -30,9 +30,9 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/Settings/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui' import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue' import { watch } from 'vue'
const props = defineProps({ const props = defineProps({
label: { label: {

View File

@@ -28,7 +28,7 @@
<script setup> <script setup>
import { Button, Badge, toast } from 'frappe-ui' import { Button, Badge, toast } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/Settings/SettingFields.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {

View File

@@ -6,7 +6,7 @@
<div v-for="(column, index) in columns" :key="index"> <div v-for="(column, index) in columns" :key="index">
<div <div
class="flex flex-col space-y-5" class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-[21rem]' : 'w-1/2'" :class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
> >
<div v-for="field in column"> <div v-for="field in column">
<Link <Link
@@ -55,11 +55,13 @@
<div v-else> <div v-else>
<div class="flex items-center text-sm space-x-2"> <div class="flex items-center text-sm space-x-2">
<div <div
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2 px-20 py-5" class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
> >
<img <img
:src="data[field.name]?.file_url || data[field.name]" :src="data[field.name]?.file_url || data[field.name]"
class="size-6 rounded" class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
/> />
</div> </div>
<div class="flex flex-col flex-wrap"> <div class="flex flex-col flex-wrap">
@@ -101,6 +103,7 @@
:rows="field.rows" :rows="field.rows"
:options="field.options" :options="field.options"
:description="field.description" :description="field.description"
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
/> />
</div> </div>
</div> </div>

View File

@@ -56,6 +56,11 @@
:label="activeTab.label" :label="activeTab.label"
:description="activeTab.description" :description="activeTab.description"
/> />
<ZoomSettings
v-else-if="activeTab.label === 'Zoom Accounts'"
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings <PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'" v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label" :label="activeTab.label"
@@ -86,14 +91,15 @@
import { Dialog, createDocumentResource, createResource } from 'frappe-ui' import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue' import SettingDetails from '@/components/Settings/SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue' import Members from '@/components/Settings/Members.vue'
import Evaluators from '@/components/Evaluators.vue' import Evaluators from '@/components/Settings/Evaluators.vue'
import Categories from '@/components/Categories.vue' import Categories from '@/components/Settings/Categories.vue'
import EmailTemplates from '@/components/EmailTemplates.vue' import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
import BrandSettings from '@/components/BrandSettings.vue' import BrandSettings from '@/components/Settings/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue' import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
const show = defineModel() const show = defineModel()
const doctype = ref('LMS Settings') const doctype = ref('LMS Settings')
@@ -149,13 +155,13 @@ const tabsStructure = computed(() => {
type: 'Column Break', type: 'Column Break',
}, },
{ {
label: 'Batch Confirmation Template', label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template', name: 'batch_confirmation_template',
doctype: 'Email Template', doctype: 'Email Template',
type: 'Link', type: 'Link',
}, },
{ {
label: 'Certification Template', label: 'Certification Email Template',
name: 'certification_template', name: 'certification_template',
doctype: 'Email Template', doctype: 'Email Template',
type: 'Link', type: 'Link',
@@ -239,6 +245,11 @@ const tabsStructure = computed(() => {
description: 'Manage the email templates for your learning system', description: 'Manage the email templates for your learning system',
icon: 'MailPlus', icon: 'MailPlus',
}, },
{
label: 'Zoom Accounts',
description: 'Manage the Zoom accounts for your learning system',
icon: 'Video',
},
], ],
}, },
{ {
@@ -282,8 +293,8 @@ const tabsStructure = computed(() => {
type: 'checkbox', type: 'checkbox',
}, },
{ {
label: 'Certified Participants', label: 'Certified Members',
name: 'certified_participants', name: 'certified_members',
type: 'checkbox', type: 'checkbox',
}, },
{ {
@@ -324,6 +335,9 @@ const tabsStructure = computed(() => {
description: description:
'New users will have to be manually registered by Admins.', 'New users will have to be manually registered by Admins.',
}, },
{
type: 'Column Break',
},
{ {
label: 'Signup Consent HTML', label: 'Signup Consent HTML',
name: 'custom_signup_content', name: 'custom_signup_content',
@@ -351,12 +365,16 @@ const tabsStructure = computed(() => {
type: 'textarea', type: 'textarea',
rows: 4, rows: 4,
description: description:
'Keywords for search engines to find your website. Separated by commas.', 'Comma separated keywords for search engines to find your website.',
},
{
type: 'Column Break',
}, },
{ {
label: 'Meta Image', label: 'Meta Image',
name: 'meta_image', name: 'meta_image',
type: 'Upload', type: 'Upload',
size: 'lg',
}, },
], ],
}, },

View File

@@ -0,0 +1,188 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div> -->
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="zoomAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<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 zoomAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="blue">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<ZoomAccountModal
v-model="showForm"
v-model:zoomAccounts="zoomAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Button,
Badge,
call,
createListResource,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const zoomAccounts = createListResource({
doctype: 'LMS Zoom Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'account_id',
'client_id',
'client_secret',
],
cache: ['zoomAccounts'],
})
onMounted(() => {
fetchZoomAccounts()
})
const fetchZoomAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
zoomAccounts.update({
filters: {
member: user.data.name,
},
})
}
zoomAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Zoom Settings',
documents: Array.from(selections),
})
.then(() => {
zoomAccounts.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const columns = computed(() => {
return [
{
label: __('Account'),
key: 'name',
},
{
label: __('Member'),
key: 'member_name',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
},
]
})
</script>

View File

@@ -0,0 +1,16 @@
export interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
}

View File

@@ -61,7 +61,7 @@
</button> </button>
</template> </template>
<script setup> <script setup>
import { Tooltip, Button } from 'frappe-ui' import { Tooltip } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as icons from 'lucide-vue-next' import * as icons from 'lucide-vue-next'

View File

@@ -5,10 +5,7 @@
{{ __('Upcoming Evaluations') }} {{ __('Upcoming Evaluations') }}
</div> </div>
<Button <Button
v-if=" v-if="upcoming_evals.data?.length != evaluationCourses.length"
!upcoming_evals.data?.length ||
upcoming_evals.length == courses.length
"
@click="openEvalModal" @click="openEvalModal"
> >
{{ __('Schedule Evaluation') }} {{ __('Schedule Evaluation') }}
@@ -118,7 +115,7 @@ import {
HeadsetIcon, HeadsetIcon,
EllipsisVertical, EllipsisVertical,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { inject, ref, getCurrentInstance } from 'vue' import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '../utils' import { formatTime } from '../utils'
import { Button, createResource, call } from 'frappe-ui' import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue' import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
@@ -163,6 +160,12 @@ const openEvalCall = (evl) => {
window.open(evl.google_meet_link, '_blank') window.open(evl.google_meet_link, '_blank')
} }
const evaluationCourses = computed(() => {
return props.courses.filter((course) => {
return course.evaluator != ''
})
})
const cancelEvaluation = (evl) => { const cancelEvaluation = (evl) => {
$dialog({ $dialog({
title: __('Cancel this evaluation?'), title: __('Cancel this evaluation?'),

View File

@@ -72,7 +72,7 @@ import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue' import { markRaw, watch, ref, onMounted, computed } from 'vue'
import { createDialog } from '@/utils/dialogs' import { createDialog } from '@/utils/dialogs'
import SettingsModal from '@/components/Modals/Settings.vue' import SettingsModal from '@/components/Settings/Settings.vue'
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue' import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
import { import {
ChevronDown, ChevronDown,

View File

@@ -1,80 +1,129 @@
<template> <template>
<div ref="videoContainer" class="video-block relative group"> <div>
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div <div
v-if="!playing" v-if="quizzes.length && !showQuiz && readOnly"
class="absolute inset-0 flex items-center justify-center cursor-pointer" class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
@click="playVideo"
> >
<div {{
class="rounded-full p-4 pl-4.5" __('This video contains {0} {1}:').format(
style=" quizzes.length,
background: radial-gradient( quizzes.length == 1 ? 'quiz' : 'quizzes'
circle, )
rgba(0, 0, 0, 0.3) 0%, }}
rgba(0, 0, 0, 0.4) 50%
); <div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
" <span> {{ index + 1 }}. {{ quiz.quiz }} </span>
> {{ __('at {0}').format(formatTimestamp(quiz.time)) }}
<Play />
</div> </div>
</div> </div>
<div <div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md" v-if="!showQuiz"
:class="{ ref="videoContainer"
'invisible group-hover:visible': playing, class="video-block relative group"
}"
> >
<Button variant="ghost"> <video
<template #icon> @timeupdate="updateTime"
<Play @ended="videoEnded"
v-if="!playing" @click="togglePlay"
@click="playVideo" oncontextmenu="return false"
class="size-4 text-ink-gray-9" class="rounded-md border border-gray-100 cursor-pointer"
/> ref="videoRef"
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" /> >
</template> <source :src="fileURL" :type="type" />
</Button> </video>
<Button variant="ghost" @click="toggleMute"> <div
<template #icon> v-if="!playing"
<Volume2 v-if="!muted" class="size-5 text-ink-white" /> class="absolute inset-0 flex items-center justify-center cursor-pointer"
<VolumeX v-else class="size-5 text-ink-white" /> @click="playVideo"
</template> >
</Button> <div
<input class="rounded-full p-4 pl-4.5"
type="range" style="
min="0" background: radial-gradient(
:max="duration" circle,
step="0.1" rgba(0, 0, 0, 0.3) 0%,
v-model="currentTime" rgba(0, 0, 0, 0.4) 50%
@input="changeCurrentTime" );
class="duration-slider w-full h-1" "
/> >
<span class="text-sm font-semibold"> <Play />
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} </div>
</span> </div>
<Button variant="ghost" @click="toggleFullscreen"> <div
<template #icon> class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
<Maximize class="size-5 text-ink-white" /> :class="{
</template> 'invisible group-hover:visible': playing,
}"
>
<Button variant="ghost" class="hover:bg-transparent">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="size-4 text-ink-gray-9"
/>
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-sm font-medium">
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
</span>
<Button
variant="ghost"
@click="toggleMute"
class="hover:bg-transparent"
>
<template #icon>
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="size-5 text-ink-white" />
</template>
</Button>
<Button
variant="ghost"
@click="toggleFullscreen"
class="hover:bg-transparent"
>
<template #icon>
<Maximize class="size-5 text-ink-white" />
</template>
</Button>
</div>
</div>
<Quiz
v-if="showQuiz"
:quizName="currentQuiz"
:inVideo="true"
:backToVideo="resumeVideo"
/>
<div v-if="!readOnly" @click="showQuizModal = true">
<Button>
{{ __('Add Quiz to Video') }}
</Button> </Button>
</div> </div>
</div> </div>
<QuizInVideo
v-model="showQuizModal"
:quizzes="quizzes"
:saveQuizzes="saveQuizzes"
:duration="duration"
/>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next' import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui' import { Button } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import Play from '@/components/Icons/Play.vue' import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
const videoRef = ref(null) const videoRef = ref(null)
const videoContainer = ref(null) const videoContainer = ref(null)
@@ -82,6 +131,10 @@ let playing = ref(false)
let currentTime = ref(0) let currentTime = ref(0)
let duration = ref(0) let duration = ref(0)
let muted = ref(false) let muted = ref(false)
const showQuizModal = ref(false)
const showQuiz = ref(false)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const props = defineProps({ const props = defineProps({
file: { file: {
@@ -92,34 +145,81 @@ const props = defineProps({
type: String, type: String,
default: 'video/mp4', default: 'video/mp4',
}, },
readOnly: {
type: String,
default: true,
},
quizzes: {
type: Array,
default: () => [],
},
saveQuizzes: {
type: Function,
},
}) })
onMounted(() => { onMounted(() => {
updateCurrentTime()
updateNextQuiz()
})
const updateCurrentTime = () => {
setTimeout(() => { setTimeout(() => {
videoRef.value.onloadedmetadata = () => { videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration duration.value = videoRef.value.duration
} }
videoRef.value.ontimeupdate = () => { videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value.currentTime currentTime.value = videoRef.value?.currentTime || currentTime.value
if (currentTime.value >= nextQuiz.value.time) {
videoRef.value.pause()
playing.value = false
videoRef.value.onTimeupdate = null
currentQuiz.value = nextQuiz.value.quiz
showQuiz.value = true
}
} }
}, 0) }, 0)
}) }
const resumeVideo = (restart = false) => {
showQuiz.value = false
currentQuiz.value = null
updateCurrentTime()
setTimeout(() => {
videoRef.value.currentTime = restart ? 0 : currentTime.value
videoRef.value.play()
playing.value = true
updateNextQuiz()
}, 0)
}
const updateNextQuiz = () => {
if (!props.quizzes.length) return
props.quizzes.forEach((quiz) => {
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
let time = quiz.time.split(':')
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
quiz.time = timeInSeconds
}
})
props.quizzes.sort((a, b) => a.time - b.time)
const nextQuizIndex = props.quizzes.findIndex(
(quiz) => quiz.time > currentTime.value
)
if (nextQuizIndex !== -1) {
nextQuiz.value = props.quizzes[nextQuizIndex]
} else {
nextQuiz.value = {}
}
}
const fileURL = computed(() => { const fileURL = computed(() => {
if (isYoutube) {
let url = props.file
if (url.includes('watch?v=')) {
url = url.replace('watch?v=', 'embed/')
}
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
}
return props.file return props.file
}) })
const isYoutube = computed(() => {
return props.type == 'video/youtube'
})
const playVideo = () => { const playVideo = () => {
videoRef.value.play() videoRef.value.play()
playing.value = true playing.value = true
@@ -149,12 +249,7 @@ const toggleMute = () => {
const changeCurrentTime = () => { const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value videoRef.value.currentTime = currentTime.value
} updateNextQuiz()
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
} }
const toggleFullscreen = () => { const toggleFullscreen = () => {

View File

@@ -70,7 +70,10 @@
<BatchStudents :batch="batch" /> <BatchStudents :batch="batch" />
</div> </div>
<div v-else-if="tab.label == 'Classes'"> <div v-else-if="tab.label == 'Classes'">
<LiveClass :batch="batch.data.name" /> <LiveClass
:batch="batch.data.name"
:zoomAccount="batch.data.zoom_account"
/>
</div> </div>
<div v-else-if="tab.label == 'Assessments'"> <div v-else-if="tab.label == 'Assessments'">
<Assessments :batch="batch.data.name" /> <Assessments :batch="batch.data.name" />
@@ -121,7 +124,7 @@
:endDate="batch.data.end_date" :endDate="batch.data.end_date"
class="mb-3" class="mb-3"
/> />
<div class="flex items-center mb-4 text-ink-gray-7"> <div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" /> <Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span> <span>
{{ formatTime(batch.data.start_time) }} - {{ formatTime(batch.data.start_time) }} -
@@ -130,7 +133,7 @@
</div> </div>
<div <div
v-if="batch.data.timezone" v-if="batch.data.timezone"
class="flex items-center mb-4 text-ink-gray-7" class="flex items-center mb-3 text-ink-gray-7"
> >
<Globe class="h-4 w-4 stroke-1.5 mr-2" /> <Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span> <span>

View File

@@ -23,10 +23,10 @@
/> />
<MultiSelect <MultiSelect
v-model="instructors" v-model="instructors"
doctype="User" doctype="Course Evaluator"
:label="__('Instructors')" :label="__('Instructors')"
:required="true" :required="true"
:onCreate="(close) => openSettings('Members', close)" :onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }" :filters="{ ignore_user_type: 1 }"
/> />
</div> </div>
@@ -159,6 +159,16 @@
} }
" "
/> />
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batch.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<FormControl <FormControl
@@ -263,6 +273,27 @@
/> />
</div> </div>
</div> </div>
<div class="px-20 pb-5 space-y-5 border-b">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Meta Tags') }}
</div>
<div class="space-y-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="7"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -292,12 +323,13 @@ import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { openSettings } from '@/utils' import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const { brand } = sessionStore() const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const props = defineProps({ const props = defineProps({
batchName: { batchName: {
@@ -327,20 +359,29 @@ const batch = reactive({
paid_batch: false, paid_batch: false,
currency: '', currency: '',
amount: 0, amount: 0,
zoom_account: '',
}) })
const instructors = ref([]) const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => { onMounted(() => {
if (!user.data) window.location.href = '/login' if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') { if (props.batchName != 'new') {
batchDetail.reload() fetchBatchInfo()
} else { } else {
capture('batch_form_opened') capture('batch_form_opened')
} }
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
}) })
const fetchBatchInfo = () => {
batchDetail.reload()
getMetaInfo('batches', props.batchName, meta)
}
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
if ( if (
e.key === 's' && e.key === 's' &&
@@ -454,7 +495,7 @@ const createNewBatch = () => {
localStorage.setItem('firstBatch', data.name) localStorage.setItem('firstBatch', data.name)
}) })
} }
updateMetaInfo('batches', data.name, meta)
capture('batch_created') capture('batch_created')
router.push({ router.push({
name: 'BatchDetail', name: 'BatchDetail',
@@ -475,6 +516,7 @@ const editBatchDetails = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
router.push({ router.push({
name: 'BatchDetail', name: 'BatchDetail',
params: { params: {

View File

@@ -12,10 +12,7 @@
</Button> </Button>
</router-link> </router-link>
</header> </header>
<div <div class="mx-auto w-full max-w-4xl pt-6 pb-10">
v-if="participants.data?.length"
class="mx-auto w-full max-w-4xl pt-6 pb-10"
>
<div class="flex flex-col md:flex-row justify-between mb-4 px-3"> <div class="flex flex-col md:flex-row justify-between mb-4 px-3">
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"> <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }} {{ memberCount }} {{ __('certified members') }}
@@ -41,7 +38,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="divide-y"> <div v-if="participants.data?.length" class="divide-y">
<template v-for="participant in participants.data"> <template v-for="participant in participants.data">
<router-link <router-link
:to="{ :to="{
@@ -92,6 +89,7 @@
</router-link> </router-link>
</template> </template>
</div> </div>
<EmptyState v-else type="Certified Members" />
<div <div
v-if="!participants.list.loading && participants.hasNextPage" v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5" class="flex justify-center mt-5"
@@ -101,7 +99,6 @@
</Button> </Button>
</div> </div>
</div> </div>
<EmptyState v-else type="Certified Members" />
</template> </template>
<script setup> <script setup>
import { import {
@@ -127,22 +124,24 @@ const memberCount = ref(0)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
onMounted(() => { onMounted(() => {
getMemberCount()
updateParticipants() updateParticipants()
}) })
const participants = createListResource({ const participants = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certified_participants', url: 'lms.lms.api.get_certified_participants',
cache: ['certified_participants'],
start: 0, start: 0,
pageLength: 30, pageLength: 100,
}) })
const count = call('lms.lms.api.get_count_of_certified_members').then( const getMemberCount = () => {
(data) => { call('lms.lms.api.get_count_of_certified_members', {
filters: filters.value,
}).then((data) => {
memberCount.value = data memberCount.value = data
} })
) }
const categories = createListResource({ const categories = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
@@ -157,6 +156,7 @@ const categories = createListResource({
const updateParticipants = () => { const updateParticipants = () => {
updateFilters() updateFilters()
getMemberCount()
participants.update({ participants.update({
filters: filters.value, filters: filters.value,
}) })

View File

@@ -76,7 +76,7 @@
<FormControl <FormControl
v-model="course.short_introduction" v-model="course.short_introduction"
type="textarea" type="textarea"
:rows="4" :rows="5"
:label="__('Short Introduction')" :label="__('Short Introduction')"
:placeholder=" :placeholder="
__( __(
@@ -201,7 +201,7 @@
/> />
</div> </div>
<div class="px-10 pb-5 space-y-5"> <div class="px-10 pb-5 space-y-5 border-b">
<div class="text-lg font-semibold mt-5"> <div class="text-lg font-semibold mt-5">
{{ __('Pricing and Certification') }} {{ __('Pricing and Certification') }}
</div> </div>
@@ -248,6 +248,27 @@
/> />
</div> </div>
</div> </div>
<div class="px-10 pb-5 space-y-5">
<div class="text-lg font-semibold mt-5">
{{ __('Meta Tags') }}
</div>
<div class="space-y-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="7"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
/>
</div>
</div>
</div> </div>
</div> </div>
<div class="border-l"> <div class="border-l">
@@ -264,6 +285,7 @@
<script setup> <script setup>
import { import {
Breadcrumbs, Breadcrumbs,
call,
TextEditor, TextEditor,
Button, Button,
createResource, createResource,
@@ -284,10 +306,10 @@ import {
} from 'vue' } from 'vue'
import { Image, Trash2, X } from 'lucide-vue-next' import { Image, Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { capture } from '@/telemetry' import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { openSettings } from '@/utils' import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -328,19 +350,30 @@ const course = reactive({
evaluator: '', evaluator: '',
}) })
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => { onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
} }
if (props.courseName !== 'new') { if (props.courseName !== 'new') {
courseResource.reload() fetchCourseInfo()
} else { } else {
capture('course_form_opened') capture('course_form_opened')
startRecording()
} }
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
}) })
const fetchCourseInfo = () => {
courseResource.reload()
getMetaInfo('courses', props.courseName, meta)
}
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
if ( if (
e.key === 's' && e.key === 's' &&
@@ -354,6 +387,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut) window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
}) })
const courseCreationResource = createResource({ const courseCreationResource = createResource({
@@ -442,40 +476,50 @@ const imageResource = createResource({
const submitCourse = () => { const submitCourse = () => {
if (courseResource.data) { if (courseResource.data) {
courseEditResource.submit( editCourse()
{
course: courseResource.data.name,
},
{
onSuccess() {
toast.success(__('Course updated successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
} else { } else {
courseCreationResource.submit(course, { createCourse()
onSuccess(data) { }
if (user.data?.is_system_manager) { }
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
capture('course_created') const createCourse = () => {
toast.success(__('Course created successfully')) courseCreationResource.submit(course, {
router.push({ onSuccess(data) {
name: 'CourseForm', updateMetaInfo('courses', data.name, meta)
params: { courseName: data.name }, if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
}) })
}
capture('course_created')
toast.success(__('Course created successfully'))
router.push({
name: 'CourseForm',
params: { courseName: data.name },
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
})
}
const editCourse = () => {
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
updateMetaInfo('courses', props.courseName, meta)
toast.success(__('Course updated successfully'))
}, },
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
}, },
}) }
} )
} }
const deleteCourse = createResource({ const deleteCourse = createResource({
@@ -515,7 +559,7 @@ watch(
() => props.courseName !== 'new', () => props.courseName !== 'new',
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
courseResource.reload() fetchCourseInfo()
} }
} }
) )

View File

@@ -240,7 +240,6 @@ const updateTabFilter = () => {
filters.value['live'] = 1 filters.value['live'] = 1
} else if (currentTab.value == 'Upcoming') { } else if (currentTab.value == 'Upcoming') {
filters.value['upcoming'] = 1 filters.value['upcoming'] = 1
filters.value['published'] = 1
} else if (currentTab.value == 'New') { } else if (currentTab.value == 'New') {
filters.value['published'] = 1 filters.value['published'] = 1
filters.value['published_on'] = [ filters.value['published_on'] = [
@@ -249,6 +248,8 @@ const updateTabFilter = () => {
] ]
} else if (currentTab.value == 'Created') { } else if (currentTab.value == 'Created') {
filters.value['created'] = 1 filters.value['created'] = 1
} else if (currentTab.value == 'Unpublished') {
filters.value['published'] = 0
} }
} }
} }
@@ -318,6 +319,7 @@ const courseTabs = computed(() => {
user.data?.is_evaluator user.data?.is_evaluator
) { ) {
tabs.push({ label: __('Created') }) tabs.push({ label: __('Created') })
tabs.push({ label: __('Unpublished') })
} else if (user.data) { } else if (user.data) {
tabs.push({ label: __('Enrolled') }) tabs.push({ label: __('Enrolled') })
} }

View File

@@ -26,7 +26,6 @@
</header> </header>
<div> <div>
<div <div
v-if="jobCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
> >
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"> <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
@@ -34,8 +33,8 @@
</div> </div>
<div <div
v-if="jobs.data?.length || jobCount > 0" class="grid grid-cols-1 gap-2"
class="grid grid-cols-1 md:grid-cols-3 gap-2" :class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
> >
<FormControl <FormControl
type="text" type="text"
@@ -52,6 +51,7 @@
</template> </template>
</FormControl> </FormControl>
<Link <Link
v-if="user.data"
doctype="Country" doctype="Country"
v-model="country" v-model="country"
:placeholder="__('Country')" :placeholder="__('Country')"
@@ -117,12 +117,14 @@ onMounted(() => {
jobType.value = queries.get('type') jobType.value = queries.get('type')
} }
updateJobs() updateJobs()
getJobCount()
}) })
const jobs = createResource({ const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities', url: 'lms.lms.api.get_job_opportunities',
cache: ['jobs'], cache: ['jobs'],
onSuccess(data) {
jobCount.value = data.length
},
}) })
const updateJobs = () => { const updateJobs = () => {
@@ -163,18 +165,6 @@ const updateFilters = () => {
} }
} }
const getJobCount = () => {
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: {
status: 'Open',
disabled: 0,
},
}).then((data) => {
jobCount.value = data
})
}
watch(country, (val) => { watch(country, (val) => {
updateJobs() updateJobs()
}) })

View File

@@ -303,6 +303,7 @@ import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
const user = inject('$user') const user = inject('$user')
const socket = inject('$socket')
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const allowDiscussions = ref(false) const allowDiscussions = ref(false)
@@ -335,6 +336,11 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
startTimer() startTimer()
document.addEventListener('fullscreenchange', attachFullscreenEvent) document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
if (data.course === props.courseName) {
lessonProgress.value = data.progress
}
})
}) })
const attachFullscreenEvent = () => { const attachFullscreenEvent = () => {

View File

@@ -99,7 +99,7 @@ import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue' import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { getEditorTools, enablePlyr } from '@/utils' import { getEditorTools, enablePlyr } from '@/utils'
import { capture } from '@/telemetry' import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
const { brand } = sessionStore() const { brand } = sessionStore()
@@ -131,6 +131,7 @@ onMounted(() => {
window.location.href = '/login' window.location.href = '/login'
} }
capture('lesson_form_opened') capture('lesson_form_opened')
startRecording()
editor.value = renderEditor('content') editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes') instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
@@ -209,7 +210,7 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => { const enableAutoSave = () => {
autoSaveInterval = setInterval(() => { autoSaveInterval = setInterval(() => {
saveLesson({ showSuccessMessage: false }) saveLesson({ showSuccessMessage: false })
}, 10000) }, 5000)
} }
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
@@ -226,6 +227,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(autoSaveInterval) clearInterval(autoSaveInterval)
window.removeEventListener('keydown', keyboardShortcut) window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
}) })
const newLessonResource = createResource({ const newLessonResource = createResource({

View File

@@ -22,6 +22,7 @@
<div <div
v-if="notifications?.length" v-if="notifications?.length"
v-for="log in notifications" v-for="log in notifications"
:key="log.name"
class="flex items-center py-2 justify-between" class="flex items-center py-2 justify-between"
> >
<div class="flex items-center"> <div class="flex items-center">
@@ -32,22 +33,20 @@
<Link <Link
v-if="log.link" v-if="log.link"
:to="log.link" :to="log.link"
@click="markAsRead.submit({ name: log.name })" @click="(e) => handleMarkAsRead(e, log.name)"
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7" class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
> >
{{ __('View') }} {{ __('View') }}
</Link> </Link>
<Tooltip :text="__('Mark as read')"> <Button
<Button variant="ghost"
variant="ghost" v-if="!log.read"
v-if="!log.read" @click.stop="(e) => handleMarkAsRead(e, log.name)"
@click="markAsRead.submit({ name: log.name })" >
> <template #icon>
<template #icon> <X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" /> </template>
</template> </Button>
</Button>
</Tooltip>
</div> </div>
</div> </div>
<div v-else class="text-ink-gray-5"> <div v-else class="text-ink-gray-5">
@@ -64,11 +63,10 @@ import {
Link, Link,
TabButtons, TabButtons,
Button, Button,
Tooltip,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { computed, inject, ref, onMounted } from 'vue' import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
@@ -135,6 +133,14 @@ const markAllAsRead = createResource({
}, },
}) })
const handleMarkAsRead = (e, logName) => {
markAsRead.submit({ name: logName })
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
let crumbs = [ let crumbs = [
{ {

View File

@@ -58,6 +58,7 @@ const evaluations = createListResource({
doctype: 'LMS Certificate Request', doctype: 'LMS Certificate Request',
filters: { filters: {
evaluator: user.data?.name, evaluator: user.data?.name,
status: ['!=', 'Cancelled'],
}, },
fields: [ fields: [
'name', 'name',

View File

@@ -6,12 +6,14 @@ declare global {
posthog: any posthog: any
} }
} }
type PosthogSettings = { type PosthogSettings = {
posthog_project_id: string posthog_project_id: string
posthog_host: string posthog_host: string
enable_telemetry: boolean enable_telemetry: boolean
telemetry_site_age: number telemetry_site_age: number
} }
interface CaptureOptions { interface CaptureOptions {
data: { data: {
user: string user: string
@@ -67,17 +69,9 @@ function capture(
} }
function startRecording() { function startRecording() {
if (!isTelemetryEnabled()) return
if (window.posthog?.__loaded) {
window.posthog.startSessionRecording()
}
} }
function stopRecording() { function stopRecording() {
if (!isTelemetryEnabled()) return
if (window.posthog?.__loaded && window.posthog.sessionRecordingStarted()) {
window.posthog.stopSessionRecording()
}
} }
// Posthog Plugin // Posthog Plugin

View File

@@ -1,3 +1,5 @@
import { watch } from 'vue'
import { call, toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz' import { Quiz } from '@/utils/quiz'
import { Assignment } from '@/utils/assignment' import { Assignment } from '@/utils/assignment'
@@ -10,7 +12,6 @@ import Paragraph from '@editorjs/paragraph'
import { CodeBox } from '@/utils/code' import { CodeBox } from '@/utils/code'
import NestedList from '@editorjs/nested-list' import NestedList from '@editorjs/nested-list'
import InlineCode from '@editorjs/inline-code' import InlineCode from '@editorjs/inline-code'
import { watch } from 'vue'
import dayjs from '@/utils/dayjs' import dayjs from '@/utils/dayjs'
import Embed from '@editorjs/embed' import Embed from '@editorjs/embed'
import SimpleImage from '@editorjs/simple-image' import SimpleImage from '@editorjs/simple-image'
@@ -27,20 +28,21 @@ export function timeAgo(date) {
export function formatTime(timeString) { export function formatTime(timeString) {
if (!timeString) return '' if (!timeString) return ''
const [hour, minute] = timeString.split(':').map(Number) const [hour, minute] = timeString.split(':').map(Number)
// Create a Date object with dummy values for day, month, and year
const dummyDate = new Date(0, 0, 0, hour, minute) const dummyDate = new Date(0, 0, 0, hour, minute)
// Use Intl.DateTimeFormat to format the time in 12-hour format
const formattedTime = new Intl.DateTimeFormat('en-US', { const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
hour12: true, hour12: true,
}).format(dummyDate) }).format(dummyDate)
return formattedTime return formattedTime
} }
export const formatSeconds = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
export function formatNumber(number) { export function formatNumber(number) {
return number.toLocaleString('en-IN', { return number.toLocaleString('en-IN', {
maximumFractionDigits: 0, maximumFractionDigits: 0,
@@ -582,3 +584,41 @@ export const cleanError = (message) => {
.replace(/&#x3B;/g, ';') .replace(/&#x3B;/g, ';')
.replace(/&#x3A;/g, ':') .replace(/&#x3A;/g, ':')
} }
export const getMetaInfo = (type, route, meta) => {
call('lms.lms.api.get_meta_info', {
type: type,
route: route,
}).then((data) => {
if (data.length) {
data.forEach((row) => {
if (row.key == 'description') {
meta.description = row.value
} else if (row.key == 'keywords') {
meta.keywords = row.value
}
})
}
})
}
export const updateMetaInfo = (type, route, meta) => {
call('lms.lms.api.update_meta_info', {
type: type,
route: route,
meta_tags: [
{ key: 'description', value: meta.description },
{ key: 'keywords', value: meta.keywords },
],
}).catch((error) => {
toast.error(__('Failed to update meta tags {0}').format(error))
console.error(error)
})
}
export const formatTimestamp = (seconds) => {
const date = new Date(seconds * 1000)
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const secs = String(date.getUTCSeconds()).padStart(2, '0')
return `${minutes}:${secs}`
}

View File

@@ -46,7 +46,14 @@ export class Upload {
if (this.isVideo(file.file_type)) { if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, { const app = createApp(VideoBlock, {
file: file.file_url, file: file.file_url,
readOnly: this.readOnly,
quizzes: file.quizzes || [],
saveQuizzes: (quizzes) => {
if (this.readOnly) return
this.data.quizzes = quizzes
},
}) })
app.use(translationPlugin)
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} else if (this.isAudio(file.file_type)) { } else if (this.isAudio(file.file_type)) {
@@ -93,6 +100,7 @@ export class Upload {
return { return {
file_url: this.data.file_url, file_url: this.data.file_url,
file_type: this.data.file_type, file_type: this.data.file_type,
quizzes: this.data.quizzes || [],
} }
} }

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1 +1 @@
__version__ = "2.29.0" __version__ = "2.30.0"

View File

@@ -116,6 +116,7 @@ scheduler_events = {
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals", "lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
"lms.lms.api.update_course_statistics", "lms.lms.api.update_course_statistics",
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed", "lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed",
"lms.lms.doctype.lms_live_class.lms_live_class.update_attendance",
], ],
"daily": [ "daily": [
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings", "lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",

View File

@@ -20,7 +20,6 @@ from frappe.utils import (
date_diff, date_diff,
) )
from frappe.query_builder import DocType from frappe.query_builder import DocType
from pypika.functions import DistinctOptionFunction
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress from lms.lms.doctype.course_lesson.course_lesson import save_progress
@@ -413,7 +412,7 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_certified_participants(filters=None, start=0, page_length=30): def get_certified_participants(filters=None, start=0, page_length=100):
or_filters = {} or_filters = {}
if not filters: if not filters:
filters = {} filters = {}
@@ -451,23 +450,29 @@ def get_certified_participants(filters=None, start=0, page_length=30):
return participants return participants
class CountDistinct(DistinctOptionFunction):
def __init__(self, field):
super().__init__("COUNT", field, distinct=True)
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_count_of_certified_members(): def get_count_of_certified_members(filters=None):
Certificate = DocType("LMS Certificate") Certificate = DocType("LMS Certificate")
query = ( query = (
frappe.qb.from_(Certificate) frappe.qb.from_(Certificate)
.select(CountDistinct(Certificate.member).as_("total")) .select(Certificate.member)
.distinct()
.where(Certificate.published == 1) .where(Certificate.published == 1)
) )
if filters:
for field, value in filters.items():
if field == "category":
query = query.where(
Certificate.course_title.like(f"%{value}%")
| Certificate.batch_title.like(f"%{value}%")
)
elif field == "member_name":
query = query.where(Certificate.member_name.like(value[1]))
result = query.run(as_dict=True) result = query.run(as_dict=True)
return result[0]["total"] if result else 0 return len(result) or 0
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@@ -544,7 +549,7 @@ def get_sidebar_settings():
items = [ items = [
"courses", "courses",
"batches", "batches",
"certified_participants", "certified_members",
"jobs", "jobs",
"statistics", "statistics",
"notifications", "notifications",
@@ -691,13 +696,13 @@ def get_categories(doctype, filters):
@frappe.whitelist() @frappe.whitelist()
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index. """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 <<<<<<< HEAD
search (str): Search term to filter the results. search (str): Search term to filter the results.
======= =======
search (str): Search term to filter the results. search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577 >>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members. Returns: List of members.
""" """
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
@@ -838,6 +843,14 @@ def delete_documents(doctype, documents):
frappe.delete_doc(doctype, doc) frappe.delete_doc(doctype, doc)
@frappe.whitelist(allow_guest=True)
def get_count(doctype, filters):
return frappe.db.count(
doctype,
filters=filters,
)
@frappe.whitelist() @frappe.whitelist()
def get_payment_gateway_details(payment_gateway): def get_payment_gateway_details(payment_gateway):
fields = [] fields = []
@@ -1416,3 +1429,67 @@ def capture_user_persona(responses):
if response.get("message").get("name"): if response.get("message").get("name"):
frappe.db.set_single_value("LMS Settings", "persona_captured", True) frappe.db.set_single_value("LMS Settings", "persona_captured", True)
return response return response
@frappe.whitelist()
def get_meta_info(type, route):
if frappe.db.exists("Website Meta Tag", {"parent": f"{type}/{route}"}):
meta_tags = frappe.get_all(
"Website Meta Tag",
{
"parent": f"{type}/{route}",
},
["name", "key", "value"],
)
return meta_tags
return []
@frappe.whitelist()
def update_meta_info(type, route, meta_tags):
parent_name = f"{type}/{route}"
if not isinstance(meta_tags, list):
frappe.throw(_("Meta tags should be a list."))
for tag in meta_tags:
existing_tag = frappe.db.exists(
"Website Meta Tag",
{
"parent": parent_name,
"parenttype": "Website Route Meta",
"parentfield": "meta_tags",
"key": tag["key"],
},
)
if existing_tag:
if not tag.get("value"):
frappe.db.delete("Website Meta Tag", existing_tag)
continue
frappe.db.set_value("Website Meta Tag", existing_tag, "value", tag["value"])
elif tag.get("value"):
tag_properties = {
"parent": parent_name,
"parenttype": "Website Route Meta",
"parentfield": "meta_tags",
"key": tag["key"],
"value": tag["value"],
}
parent_exists = frappe.db.exists("Website Route Meta", parent_name)
if not parent_exists:
route_meta = frappe.new_doc("Website Route Meta")
route_meta.update(
{
"__newname": parent_name,
}
)
route_meta.append("meta_tags", tag_properties)
route_meta.insert()
else:
new_tag = frappe.new_doc("Website Meta Tag")
new_tag.update(tag_properties)
print(new_tag)
new_tag.insert()
print(new_tag.as_dict())

View File

@@ -103,6 +103,7 @@
"read_only": 1 "read_only": 1
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [ "links": [
{ {
@@ -111,7 +112,7 @@
"link_fieldname": "chapter" "link_fieldname": "chapter"
} }
], ],
"modified": "2025-02-03 15:23:17.125617", "modified": "2025-05-29 12:38:26.266673",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Chapter", "name": "Course Chapter",
@@ -151,8 +152,21 @@
"role": "Course Creator", "role": "Course Creator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
} }
], ],
"row_format": "Dynamic",
"search_fields": "title", "search_fields": "title",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",

View File

@@ -86,8 +86,8 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-03-26 14:02:46.588721", "modified": "2025-06-05 11:04:32.475711",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "Course Evaluator", "name": "Course Evaluator",
"naming_rule": "By fieldname", "naming_rule": "By fieldname",
@@ -133,5 +133,6 @@
"row_format": "Dynamic", "row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"title_field": "full_name"
} }

View File

@@ -2,12 +2,13 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
import json
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.telemetry import capture from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress from lms.lms.utils import get_course_progress
from ...md import find_macros from ...md import find_macros
import json from frappe.realtime import get_website_room
class CourseLesson(Document): class CourseLesson(Document):
@@ -76,6 +77,13 @@ def save_progress(lesson, course):
enrollment.save() enrollment.save()
enrollment.run_method("on_change") enrollment.run_method("on_change")
frappe.publish_realtime(
event="update_lesson_progress",
room=get_website_room(),
message={"course": course, "lesson": lesson, "progress": progress},
after_commit=True,
)
return progress return progress
@@ -96,6 +104,11 @@ def get_quiz_progress(lesson):
for block in content.get("blocks"): for block in content.get("blocks"):
if block.get("type") == "quiz": if block.get("type") == "quiz":
quizzes.append(block.get("data").get("quiz")) quizzes.append(block.get("data").get("quiz"))
if block.get("type") == "upload":
quizzes_in_video = block.get("data").get("quizzes")
if quizzes_in_video and len(quizzes_in_video) > 0:
for row in quizzes_in_video:
quizzes.append(row.get("quiz"))
elif lesson_details.body: elif lesson_details.body:
macros = find_macros(lesson_details.body) macros = find_macros(lesson_details.body)

View File

@@ -26,6 +26,7 @@
"description", "description",
"column_break_hlqw", "column_break_hlqw",
"instructors", "instructors",
"zoom_account",
"section_break_rgfj", "section_break_rgfj",
"medium", "medium",
"category", "category",
@@ -354,6 +355,12 @@
{ {
"fieldname": "section_break_cssv", "fieldname": "section_break_cssv",
"fieldtype": "Section Break" "fieldtype": "Section Break"
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -372,7 +379,7 @@
"link_fieldname": "batch_name" "link_fieldname": "batch_name"
} }
], ],
"modified": "2025-05-21 13:30:28.904260", "modified": "2025-05-26 15:30:55.083507",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",

View File

@@ -146,7 +146,15 @@ class LMSBatch(Document):
@frappe.whitelist() @frappe.whitelist()
def create_live_class( def create_live_class(
batch_name, title, duration, date, time, timezone, auto_recording, description=None batch_name,
zoom_account,
title,
duration,
date,
time,
timezone,
auto_recording,
description=None,
): ):
frappe.only_for("Moderator") frappe.only_for("Moderator")
payload = { payload = {
@@ -161,7 +169,7 @@ def create_live_class(
"timezone": timezone, "timezone": timezone,
} }
headers = { headers = {
"Authorization": "Bearer " + authenticate(), "Authorization": "Bearer " + authenticate(zoom_account),
"content-type": "application/json", "content-type": "application/json",
} }
response = requests.post( response = requests.post(
@@ -175,6 +183,8 @@ def create_live_class(
"doctype": "LMS Live Class", "doctype": "LMS Live Class",
"start_url": data.get("start_url"), "start_url": data.get("start_url"),
"join_url": data.get("join_url"), "join_url": data.get("join_url"),
"meeting_id": data.get("id"),
"uuid": data.get("uuid"),
"title": title, "title": title,
"host": frappe.session.user, "host": frappe.session.user,
"date": date, "date": date,
@@ -183,6 +193,7 @@ def create_live_class(
"password": data.get("password"), "password": data.get("password"),
"description": description, "description": description,
"auto_recording": auto_recording, "auto_recording": auto_recording,
"zoom_account": zoom_account,
} }
) )
class_details = frappe.get_doc(payload) class_details = frappe.get_doc(payload)
@@ -194,10 +205,10 @@ def create_live_class(
) )
def authenticate(): def authenticate(zoom_account):
zoom = frappe.get_single("Zoom Settings") zoom = frappe.get_doc("LMS Zoom Settings", zoom_account)
if not zoom.enable: if not zoom.enabled:
frappe.throw(_("Please enable Zoom Settings to use this feature.")) frappe.throw(_("Please enable the zoom account to use this feature."))
authenticate_url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}" authenticate_url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}"

View File

@@ -87,8 +87,7 @@ class LMSCertificateRequest(Document):
req.date == getdate(self.date) req.date == getdate(self.date)
or getdate() < getdate(req.date) or getdate() < getdate(req.date)
or ( or (
getdate() == getdate(req.date) getdate() == getdate(req.date) and get_time(nowtime()) < get_time(req.start_time)
and getdate(self.start_time) < getdate(req.start_time)
) )
): ):
course_title = frappe.db.get_value("LMS Course", req.course, "title") course_title = frappe.db.get_value("LMS Course", req.course, "title")

View File

@@ -290,7 +290,7 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-03-13 16:01:19.105212", "modified": "2025-05-29 12:38:01.002898",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Course", "name": "LMS Course",
@@ -319,8 +319,21 @@
"role": "Course Creator", "role": "Course Creator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",

View File

@@ -84,7 +84,11 @@ class LMSEnrollment(Document):
def create_membership( def create_membership(
course, batch=None, member=None, member_type="Student", role="Member" course, batch=None, member=None, member_type="Student", role="Member"
): ):
frappe.get_doc( if frappe.db.get_value("LMS Course", course, "disable_self_learning"):
return False
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.update(
{ {
"doctype": "LMS Enrollment", "doctype": "LMS Enrollment",
"batch_old": batch, "batch_old": batch,
@@ -93,8 +97,9 @@ def create_membership(
"member_type": member_type, "member_type": member_type,
"member": member or frappe.session.user, "member": member or frappe.session.user,
} }
).save(ignore_permissions=True) )
return "OK" enrollment.insert()
return enrollment
@frappe.whitelist() @frappe.whitelist()

View File

@@ -9,21 +9,27 @@
"field_order": [ "field_order": [
"title", "title",
"host", "host",
"zoom_account",
"batch_name", "batch_name",
"event",
"column_break_astv", "column_break_astv",
"description",
"section_break_glxh",
"date", "date",
"duration",
"column_break_spvt",
"time", "time",
"duration",
"timezone", "timezone",
"section_break_yrpq", "section_break_glxh",
"description",
"column_break_spvt",
"event",
"auto_recording",
"section_break_fhet",
"meeting_id",
"uuid",
"column_break_aony",
"attendees",
"password", "password",
"section_break_yrpq",
"start_url", "start_url",
"column_break_yokr", "column_break_yokr",
"auto_recording",
"join_url" "join_url"
], ],
"fields": [ "fields": [
@@ -73,8 +79,7 @@
}, },
{ {
"fieldname": "section_break_glxh", "fieldname": "section_break_glxh",
"fieldtype": "Section Break", "fieldtype": "Section Break"
"label": "Date and Time"
}, },
{ {
"fieldname": "column_break_spvt", "fieldname": "column_break_spvt",
@@ -130,13 +135,50 @@
"label": "Event", "label": "Event",
"options": "Event", "options": "Event",
"read_only": 1 "read_only": 1
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings",
"reqd": 1
},
{
"fieldname": "meeting_id",
"fieldtype": "Data",
"label": "Meeting ID"
},
{
"fieldname": "attendees",
"fieldtype": "Int",
"label": "Attendees",
"read_only": 1
},
{
"fieldname": "section_break_fhet",
"fieldtype": "Section Break"
},
{
"fieldname": "uuid",
"fieldtype": "Data",
"label": "UUID"
},
{
"fieldname": "column_break_aony",
"fieldtype": "Column Break"
} }
], ],
"grid_page_length": 50,
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2024-11-11 18:59:26.396111", {
"modified_by": "Administrator", "link_doctype": "LMS Live Class Participant",
"link_fieldname": "live_class"
}
],
"modified": "2025-05-27 14:44:35.679712",
"modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Live Class", "name": "LMS Live Class",
"owner": "Administrator", "owner": "Administrator",
@@ -175,6 +217,7 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",

View File

@@ -2,10 +2,13 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
import requests
import json
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from datetime import timedelta from datetime import timedelta
from frappe.utils import cint, get_datetime, format_date, nowdate, format_time from frappe.utils import cint, get_datetime, format_date, nowdate, format_time
from lms.lms.doctype.lms_batch.lms_batch import authenticate
class LMSLiveClass(Document): class LMSLiveClass(Document):
@@ -102,3 +105,56 @@ def send_mail(live_class, student):
args=args, args=args,
header=[_(f"Class Reminder: {live_class.title}"), "orange"], header=[_(f"Class Reminder: {live_class.title}"), "orange"],
) )
def update_attendance():
past_live_classes = frappe.get_all(
"LMS Live Class",
{
"uuid": ["is", "set"],
"attendees": ["is", "not set"],
},
["name", "uuid", "zoom_account"],
)
for live_class in past_live_classes:
attendance_data = get_attendance(live_class)
create_attendance(live_class, attendance_data)
update_attendees_count(live_class, attendance_data)
def get_attendance(live_class):
headers = {
"Authorization": "Bearer " + authenticate(live_class.zoom_account),
"content-type": "application/json",
}
encoded_uuid = requests.utils.quote(live_class.uuid, safe="")
response = requests.get(
f"https://api.zoom.us/v2/past_meetings/{encoded_uuid}/participants", headers=headers
)
if response.status_code != 200:
frappe.throw(
_("Failed to fetch attendance data from Zoom for class {0}: {1}").format(
live_class, response.text
)
)
data = response.json()
return data.get("participants", [])
def create_attendance(live_class, data):
for participant in data:
doc = frappe.new_doc("LMS Live Class Participant")
doc.live_class = live_class.name
doc.member = participant.get("user_email")
doc.joined_at = participant.get("join_time")
doc.left_at = participant.get("leave_time")
doc.duration = participant.get("duration")
doc.insert()
def update_attendees_count(live_class, data):
frappe.db.set_value("LMS Live Class", live_class.name, "attendees", len(data))

View File

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

View File

@@ -0,0 +1,116 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-05-27 12:09:57.712221",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"live_class",
"joined_at",
"column_break_dwbm",
"duration",
"left_at",
"section_break_xczy",
"member",
"member_name",
"column_break_bpjn",
"member_image",
"member_username"
],
"fields": [
{
"fieldname": "live_class",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Live Class",
"options": "LMS Live Class",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Member Name"
},
{
"fieldname": "column_break_dwbm",
"fieldtype": "Column Break"
},
{
"fieldname": "duration",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Duration",
"reqd": 1
},
{
"fieldname": "joined_at",
"fieldtype": "Datetime",
"label": "Joined At",
"reqd": 1
},
{
"fieldname": "left_at",
"fieldtype": "Datetime",
"label": "Left At",
"reqd": 1
},
{
"fieldname": "section_break_xczy",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_bpjn",
"fieldtype": "Column Break"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-27 22:32:24.196643",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Live Class Participant",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "member_name"
}

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 LMSLiveClassParticipant(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 UnitTestLMSLiveClassParticipant(UnitTestCase):
"""
Unit tests for LMSLiveClassParticipant.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSLiveClassParticipant(IntegrationTestCase):
"""
Integration tests for LMSLiveClassParticipant.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -35,6 +35,7 @@
"courses", "courses",
"batches", "batches",
"certified_participants", "certified_participants",
"certified_members",
"column_break_exdz", "column_break_exdz",
"jobs", "jobs",
"statistics", "statistics",
@@ -277,6 +278,7 @@
"default": "1", "default": "1",
"fieldname": "certified_participants", "fieldname": "certified_participants",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1,
"label": "Certified Participants" "label": "Certified Participants"
}, },
{ {
@@ -397,13 +399,19 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Persona Captured", "label": "Persona Captured",
"read_only": 1 "read_only": 1
},
{
"default": "0",
"fieldname": "certified_members",
"fieldtype": "Check",
"label": "Certified Members"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-05-14 12:43:22.749850", "modified": "2025-05-30 19:02:51.381668",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",

View File

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

View File

@@ -0,0 +1,128 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:account_name",
"creation": "2025-05-26 13:04:18.285735",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"enabled",
"section_break_xfow",
"account_name",
"member",
"member_name",
"column_break_fxxg",
"account_id",
"client_id",
"client_secret"
],
"fields": [
{
"default": "0",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "account_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Account ID",
"reqd": 1
},
{
"fieldname": "client_id",
"fieldtype": "Data",
"label": "Client ID",
"reqd": 1
},
{
"fieldname": "client_secret",
"fieldtype": "Password",
"label": "Client Secret",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name"
},
{
"fieldname": "section_break_xfow",
"fieldtype": "Section Break"
},
{
"fieldname": "account_name",
"fieldtype": "Data",
"label": "Account Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "column_break_fxxg",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-26 18:09:09.392368",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Zoom Settings",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"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 LMSZoomSettings(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 UnitTestLMSZoomSettings(UnitTestCase):
"""
Unit tests for LMSZoomSettings.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSZoomSettings(IntegrationTestCase):
"""
Integration tests for LMSZoomSettings.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -961,15 +961,6 @@ def apply_gst(amount, country=None):
return amount, gst_applied return amount, gst_applied
def create_membership(course, payment):
membership = frappe.new_doc("LMS Enrollment")
membership.update(
{"member": frappe.session.user, "course": course, "payment": payment.name}
)
membership.save(ignore_permissions=True)
return f"/lms/courses/{course}/learn/1-1"
def get_current_exchange_rate(source, target="USD"): def get_current_exchange_rate(source, target="USD"):
url = f"https://api.frankfurter.app/latest?from={source}&to={target}" url = f"https://api.frankfurter.app/latest?from={source}&to={target}"
@@ -1391,6 +1382,7 @@ def get_batch_details(batch):
"certification", "certification",
"timezone", "timezone",
"category", "category",
"zoom_account",
], ],
as_dict=True, as_dict=True,
) )

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -104,3 +104,8 @@ lms.patches.v2_0.delete_unused_custom_fields
lms.patches.v2_0.update_certificate_request_status lms.patches.v2_0.update_certificate_request_status
lms.patches.v2_0.update_job_city_and_country lms.patches.v2_0.update_job_city_and_country
lms.patches.v2_0.update_course_evaluator_data lms.patches.v2_0.update_course_evaluator_data
lms.patches.v2_0.move_zoom_settings #20-05-2025
lms.patches.v2_0.link_zoom_account_to_live_class
lms.patches.v2_0.link_zoom_account_to_batch
lms.patches.v2_0.sidebar_for_certified_members
lms.patches.v2_0.move_batch_instructors_to_evaluators

View File

@@ -0,0 +1,11 @@
import frappe
def execute():
live_classes = frappe.get_all("LMS Live Class", ["name", "batch_name"])
zoom_account = frappe.get_all("LMS Zoom Settings", pluck="name")
zoom_account = zoom_account[0] if zoom_account else None
if zoom_account:
for live_class in live_classes:
frappe.db.set_value("LMS Batch", live_class.batch_name, "zoom_account", zoom_account)

View File

@@ -0,0 +1,16 @@
import frappe
def execute():
live_classes = frappe.get_all("LMS Live Class", pluck="name")
zoom_account = frappe.get_all("LMS Zoom Settings", pluck="name")
zoom_account = zoom_account[0] if zoom_account else None
if zoom_account:
for live_class in live_classes:
frappe.db.set_value(
"LMS Live Class",
live_class,
"zoom_account",
zoom_account,
)

View File

@@ -0,0 +1,22 @@
import frappe
def execute():
batch_instructors = frappe.get_all(
"Course Instructor",
{
"parenttype": "LMS Batch",
},
["name", "instructor", "parent"],
)
for instructor in batch_instructors:
if not frappe.db.exists(
"Course Evaluator",
{
"evaluator": instructor.instructor,
},
):
doc = frappe.new_doc("Course Evaluator")
doc.evaluator = instructor.instructor
doc.insert()

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