Compare commits

..

144 Commits

Author SHA1 Message Date
Frappe PR Bot
795d95b482 chore(release): Bumped to Version 2.26.0 2025-03-05 13:04:57 +00:00
Jannat Patel
5b5b95c85c Merge pull request #1363 from pateljannat/scorm-issue-js-files
fix: scorm files getting wrong path
2025-03-05 17:03:40 +05:30
Jannat Patel
8490b07c90 fix: scorm files getting wrong path 2025-03-05 16:31:14 +05:30
Jannat Patel
dee2c51c60 Merge pull request #1359 from pateljannat/evaluation-validation-issue
fix: allow scheduling evals if future eval has been cancelled
2025-03-04 17:47:42 +05:30
Jannat Patel
4149fa6ce4 fix: renamed evaluation and certification buttons 2025-03-04 17:38:43 +05:30
Jannat Patel
7a69611f09 Merge pull request #1358 from pateljannat/payment-reminder-issue
fix: don't send payment reminder if member has already paid later
2025-03-04 17:34:30 +05:30
Jannat Patel
6692252df9 fix: allow scheduling evals if furture eval has been calcelled 2025-03-04 17:33:39 +05:30
Jannat Patel
486ce1bdb0 Merge pull request #1357 from pateljannat/course-certification-filter
refactor: course list fetching and filters
2025-03-04 17:27:01 +05:30
Jannat Patel
cceff77bc2 fix: don't send payment reminder if member has already paid later 2025-03-04 17:24:03 +05:30
Jannat Patel
22a9169f87 fix: show progress bar for enrolled courses 2025-03-04 17:14:01 +05:30
Jannat Patel
47a30763a0 refactor: course list fetching and filters 2025-03-04 17:02:47 +05:30
Jannat Patel
73379a1bd8 Merge pull request #1354 from pateljannat/dont-override-user
fix: reverting user doctype override
2025-03-04 13:08:12 +05:30
Jannat Patel
7cc46629b4 test: increased login request timeout in ui tests 2025-03-04 12:53:33 +05:30
Jannat Patel
67304245ba test: increased login request timeout in ui tests 2025-03-04 12:39:22 +05:30
Jannat Patel
8edd3a1a34 chore: upgrading actions/cache to v4 for ui tests 2025-03-04 11:43:07 +05:30
Jannat Patel
e4bc7c8d78 Merge pull request #1356 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-03-04 10:22:56 +05:30
Jannat Patel
a8af78d400 chore: Esperanto translations 2025-03-04 02:59:49 +05:30
Jannat Patel
0afe3de818 chore: Bosnian translations 2025-03-04 02:59:48 +05:30
Jannat Patel
3c81aadec6 chore: Persian translations 2025-03-04 02:59:47 +05:30
Jannat Patel
1dfcb035da chore: Chinese Simplified translations 2025-03-04 02:59:45 +05:30
Jannat Patel
77b24882a9 chore: Turkish translations 2025-03-04 02:59:44 +05:30
Jannat Patel
1fd0673257 chore: Swedish translations 2025-03-04 02:59:42 +05:30
Jannat Patel
dbda76e0ce chore: Russian translations 2025-03-04 02:59:40 +05:30
Jannat Patel
a9d22521ce chore: Polish translations 2025-03-04 02:59:39 +05:30
Jannat Patel
6da1d9629f chore: Hungarian translations 2025-03-04 02:59:37 +05:30
Jannat Patel
37b61a7087 chore: German translations 2025-03-04 02:59:35 +05:30
Jannat Patel
9b484e6ee9 chore: Arabic translations 2025-03-04 02:59:34 +05:30
Jannat Patel
5ef67ef21c chore: Spanish translations 2025-03-04 02:59:32 +05:30
Jannat Patel
f902166643 chore: French translations 2025-03-04 02:59:31 +05:30
Md Hussain Nagaria
8f91466b3d Merge pull request #1355 from frappe/enhance-timezone
feat: autofill timezone based on user timezone
2025-03-03 22:43:24 +05:30
Hussain Nagaria
fa1621c3d1 feat: autofill client timezone based on user timezone 2025-03-03 22:42:43 +05:30
Jannat Patel
2acd45feae fix: reverting user doctype override 2025-03-03 19:54:41 +05:30
Jannat Patel
f19e974b9d Merge pull request #1353 from pateljannat/mark-eval-request-complete
feat: mark evaluation requests as complete
2025-03-03 17:14:42 +05:30
Jannat Patel
01598ac002 feat: mark evaluation requests as complete 2025-03-03 17:01:10 +05:30
Frappe PR Bot
9b3906359b chore(release): Bumped to Version 2.25.0 2025-03-03 10:20:45 +00:00
Jannat Patel
4224580d6f Merge pull request #1351 from pateljannat/billing-flow-changes
fix: redirect to FC dashboard when login to FC
2025-03-03 14:59:21 +05:30
Jannat Patel
07d30647d8 chore: upgrading actions/cache to v4 for ci tests 2025-03-03 13:58:39 +05:30
Jannat Patel
263096fc77 fix: changed frappe cloud login flow 2025-03-03 13:53:16 +05:30
Jannat Patel
b510cbce7f Merge pull request #1347 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-03-03 10:13:26 +05:30
Jannat Patel
0b84dc3266 Merge pull request #1346 from frappe/pot_develop_2025-02-28
chore: update POT file
2025-03-03 10:13:13 +05:30
Md Hussain Nagaria
7ee7b95eb5 Merge pull request #1350 from frappe/tz-autocomplete
feat: timezone autocomplete in live class & misc fixes
2025-03-03 06:35:07 +05:30
Hussain Nagaria
83b8bdde45 fix: transform tabIndex query param to number 2025-03-03 06:22:19 +05:30
Hussain Nagaria
1b5dd15b90 feat(LiveClass): timezone autocomplete field 2025-03-03 06:19:06 +05:30
Hussain Nagaria
47c224fcad chore: remove unused imports 2025-03-03 06:01:02 +05:30
Jannat Patel
1c866f40eb chore: Persian translations 2025-03-01 02:00:07 +05:30
frappe-pr-bot
1861aabaca chore: update POT file 2025-02-28 16:04:26 +00:00
Jannat Patel
cd8fb6eb38 Merge pull request #1342 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-28 10:42:18 +05:30
Jannat Patel
21d05d3731 chore: Bosnian translations 2025-02-28 01:36:22 +05:30
Jannat Patel
7c953925f9 chore: Bosnian translations 2025-02-27 01:40:12 +05:30
Jannat Patel
33a4bbbe47 chore: Persian translations 2025-02-27 01:40:10 +05:30
Frappe PR Bot
dfb82570ea chore(release): Bumped to Version 2.24.0 2025-02-26 04:50:44 +00:00
Jannat Patel
e712d6ae42 Merge pull request #1334 from pateljannat/paid-certificate-on-courses
feat: paid certifications on courses
2025-02-25 14:47:07 +05:30
Jannat Patel
6ffc953370 test: removed course expiry from test 2025-02-25 14:33:53 +05:30
Jannat Patel
63bf6a5574 fix: polished the course certification flow 2025-02-25 12:46:35 +05:30
Jannat Patel
1e73fc5751 Merge pull request #1338 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-25 10:50:30 +05:30
Jannat Patel
65604a0b88 chore: Esperanto translations 2025-02-25 01:05:38 +05:30
Jannat Patel
5a1a39f5f5 chore: Bosnian translations 2025-02-25 01:05:36 +05:30
Jannat Patel
d22576c85c chore: Persian translations 2025-02-25 01:05:35 +05:30
Jannat Patel
b7e5332c38 chore: Chinese Simplified translations 2025-02-25 01:05:33 +05:30
Jannat Patel
ed8570fb88 chore: Turkish translations 2025-02-25 01:05:32 +05:30
Jannat Patel
ce69e6634d chore: Swedish translations 2025-02-25 01:05:31 +05:30
Jannat Patel
274db20c60 chore: Russian translations 2025-02-25 01:05:29 +05:30
Jannat Patel
3d72072f1f chore: Polish translations 2025-02-25 01:05:28 +05:30
Jannat Patel
ed156c09d7 chore: Hungarian translations 2025-02-25 01:05:27 +05:30
Jannat Patel
fda3a1a468 chore: German translations 2025-02-25 01:05:25 +05:30
Jannat Patel
c261387635 chore: Arabic translations 2025-02-25 01:05:24 +05:30
Jannat Patel
7a2fa4dae8 chore: Spanish translations 2025-02-25 01:05:22 +05:30
Jannat Patel
b0c41958d9 chore: French translations 2025-02-25 01:05:21 +05:30
Jannat Patel
4f1dcbfb78 feat: eval and certification flow with purchased certificate 2025-02-24 19:15:26 +05:30
Jannat Patel
dc9ed099d0 Merge pull request #1335 from frappe/pot_develop_2025-02-21
chore: update POT file
2025-02-24 10:38:54 +05:30
Md Hussain Nagaria
95255d44a9 feat(batch): track active tab in URL/route (#1337)
* chore: remove defineModel imports

* it is a compiler macro now, so no longer needs to be imported

* feat(batch): track active tab in URL/route

Fixes #1336

* style: lint
2025-02-22 22:30:14 +05:30
Hussain Nagaria
5a94e8df75 style: lint 2025-02-22 22:23:40 +05:30
Hussain Nagaria
015e3f8490 feat(batch): track active tab in URL/route
Fixes #1336
2025-02-22 22:21:26 +05:30
Hussain Nagaria
558601f02b chore: remove defineModel imports
* it is a compiler macro now, so no longer needs to be imported
2025-02-22 21:58:50 +05:30
frappe-pr-bot
461d96a079 chore: update POT file 2025-02-21 16:04:10 +00:00
Jannat Patel
bacfaf4a71 feat: paid certifications on courses 2025-02-21 19:12:20 +05:30
Jannat Patel
0678def698 Merge pull request #1330 from pateljannat/markdown-links
fix: link issue in lesson
2025-02-20 16:37:33 +05:30
Jannat Patel
07b0a0af51 test: fixed lesson content test 2025-02-20 16:31:09 +05:30
Jannat Patel
f12f6cb720 fix: link issue in lesson 2025-02-20 15:08:14 +05:30
Jannat Patel
4e6c1478f9 Merge pull request #1328 from pateljannat/reschedule-evals
feat: cancel evaluations
2025-02-20 10:34:08 +05:30
Jannat Patel
f9fd36f77e feat: cancel evaluations 2025-02-19 22:29:24 +05:30
Jannat Patel
db4c7424b3 Merge pull request #1327 from pateljannat/issues-79
fix: misc batch issues
2025-02-19 16:51:42 +05:30
Jannat Patel
9311043190 fix: misc batch issues 2025-02-19 16:40:11 +05:30
Jannat Patel
03915ccfbd fix: only system managers should login to FC 2025-02-19 15:39:13 +05:30
Jannat Patel
c6d59216fd fix: redirect to FC dashboard when login to FC 2025-02-19 15:35:34 +05:30
Frappe PR Bot
a8690e41e6 chore(release): Bumped to Version 2.23.0 2025-02-19 05:29:30 +00:00
Jannat Patel
cda42b9ec5 Merge pull request #1325 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-19 08:49:19 +05:30
Jannat Patel
21a75fdd6d chore: Bosnian translations 2025-02-18 23:05:44 +05:30
Jannat Patel
a90a1e9855 chore: Persian translations 2025-02-18 23:05:43 +05:30
Jannat Patel
2a046e2e8b chore: German translations 2025-02-18 23:05:40 +05:30
Jannat Patel
bb41656d81 Merge branch 'develop' of https://github.com/frappe/lms into develop 2025-02-18 19:12:23 +05:30
Jannat Patel
a88a107718 fix: batch confirmation email template 2025-02-18 19:12:04 +05:30
Jannat Patel
2d21469f91 Merge pull request #1324 from pateljannat/issues-78
fix: redirect users to the batch page after login
2025-02-18 18:29:51 +05:30
Jannat Patel
960ebe4a79 fix: redirect users to the batch page after login 2025-02-18 18:10:33 +05:30
Jannat Patel
46dba0c394 Merge pull request #1323 from pateljannat/batch-reminders
feat: batch start and live class reminder
2025-02-18 17:34:44 +05:30
Jannat Patel
ba27e8ca95 fix: send live class reminder on the day of the class 2025-02-18 17:26:40 +05:30
Jannat Patel
30574ea0fd feat: batch start and live class reminder 2025-02-18 17:22:52 +05:30
Jannat Patel
c3c985c4a1 Merge pull request #1322 from pateljannat/certification-batches
feat: filter certification batches
2025-02-18 17:05:16 +05:30
Jannat Patel
7b3d2d8812 feat: filter certification batches 2025-02-18 15:57:55 +05:30
Jannat Patel
d573a9f008 Merge pull request #1320 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-18 12:41:05 +05:30
Jannat Patel
85a05f56b2 chore: Esperanto translations 2025-02-17 22:33:51 +05:30
Jannat Patel
904adfb905 chore: Bosnian translations 2025-02-17 22:33:50 +05:30
Jannat Patel
b2201c29fd chore: Persian translations 2025-02-17 22:33:48 +05:30
Jannat Patel
fe01f68623 chore: Chinese Simplified translations 2025-02-17 22:33:47 +05:30
Jannat Patel
531c8ebe94 chore: Turkish translations 2025-02-17 22:33:45 +05:30
Jannat Patel
52dfb5a360 chore: Swedish translations 2025-02-17 22:33:44 +05:30
Jannat Patel
7e04e7e461 chore: Russian translations 2025-02-17 22:33:42 +05:30
Jannat Patel
bce47f606d chore: Polish translations 2025-02-17 22:33:41 +05:30
Jannat Patel
4dc1fdfdd8 chore: Hungarian translations 2025-02-17 22:33:40 +05:30
Jannat Patel
9a852b52bc chore: German translations 2025-02-17 22:33:38 +05:30
Jannat Patel
71a57b1fc0 chore: Arabic translations 2025-02-17 22:33:37 +05:30
Jannat Patel
d634598db1 chore: Spanish translations 2025-02-17 22:33:35 +05:30
Jannat Patel
6377d682a4 chore: French translations 2025-02-17 22:33:33 +05:30
Jannat Patel
6e1acfdc24 Merge pull request #1316 from FahidLatheef/fix/quiz-maximum-attempts
fix: fixed bug in which user can submit quiz over the maximum limit allowed
2025-02-17 19:59:57 +05:30
Jannat Patel
30ec1dfd7c Merge pull request #1319 from pateljannat/assignment-grading-comment-field
feat: assignment comments is now text editor
2025-02-17 19:56:22 +05:30
Jannat Patel
3d209024dd fix: height of batch page 2025-02-17 19:45:45 +05:30
Jannat Patel
9ce64a037d fix: increased column width for grading 2025-02-17 19:41:24 +05:30
Jannat Patel
43117bc035 feat:assignment comments is now text editor 2025-02-17 19:28:50 +05:30
Jannat Patel
2af704043e Merge pull request #1318 from pateljannat/batch-email-template
feat: batch specific email templates
2025-02-17 18:36:05 +05:30
Jannat Patel
fa14ffdcba feat: batch specific email templates 2025-02-17 18:17:50 +05:30
Jannat Patel
492b715ea0 Merge pull request #1317 from pateljannat/trial-signup
feat: billing banner for FC trial sites
2025-02-17 16:00:46 +05:30
Jannat Patel
d452e20b8a feat: show trial banner only if fc site 2025-02-17 15:39:15 +05:30
Jannat Patel
6b634c15d9 feat: billing banner for FC trial sites 2025-02-17 15:07:31 +05:30
Jannat Patel
eeaec3369f Merge pull request #1313 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-17 11:10:39 +05:30
Jannat Patel
ce1eece90d Merge pull request #1312 from frappe/pot_develop_2025-02-14
chore: update POT file
2025-02-17 11:05:48 +05:30
Jannat Patel
030bff6592 chore: Bosnian translations 2025-02-16 22:32:22 +05:30
Jannat Patel
65de46a59e chore: Swedish translations 2025-02-16 22:32:18 +05:30
Fahid Latheef Alungal
974f67aefe fix: validate if submission exceeds the allowed limit in backend 2025-02-16 19:29:03 +05:30
Fahid Latheef Alungal
e374ae3229 fix: fixed spelling nextQuetion -> nextQuestion 2025-02-16 18:28:48 +05:30
Fahid Latheef Alungal
8b1058e577 fix: fixed issue in which submissions are not reflected gracefully until page reload
ListView throws error if initialized without emptyState which was causing the component to not reload when number of submissions was 0.
2025-02-16 18:27:38 +05:30
Fahid Latheef Alungal
aaa2eea5e6 fix: fixed incomplete router initialization in Quiz.vue which was allowing user to submit quiz multiple times 2025-02-16 18:19:14 +05:30
Fahid Latheef Alungal
54047e3c2c fix: fix spelling typo Maximun Attempts -> Maximum Attempts 2025-02-16 16:10:14 +05:30
Fahid Latheef Alungal
50fe94e47b fix: fix yarn dev not working due to const variable re-assignment
It was causing this error

  ✘ [ERROR] Cannot assign to "isLoggedIn" because it is a constant

    src/router.js:230:2:
      230 │     isLoggedIn = false
          ╵     ~~~~~~~~~~

  The symbol "isLoggedIn" was declared a constant here:

    src/router.js:222:7:
      222 │   const { isLoggedIn } = sessionStore()
          ╵         ^
2025-02-16 16:08:35 +05:30
Jannat Patel
6999f6641a chore: Bosnian translations 2025-02-15 22:29:52 +05:30
frappe-pr-bot
c2b12aa65f chore: update POT file 2025-02-14 16:04:13 +00:00
Jannat Patel
1a731b6908 Merge pull request #1311 from pateljannat/issues-77
fix: students should have access private batch if enrolled
2025-02-14 20:21:54 +05:30
Jannat Patel
837d050628 fix: students should be able to access private batch if they are enrolled 2025-02-14 20:10:32 +05:30
Jannat Patel
8b00bec49c fix: students should be able to access private batch if they are enrolled 2025-02-14 20:04:37 +05:30
Jannat Patel
9ade643af0 Merge pull request #1310 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-14 17:06:53 +05:30
Jannat Patel
a29b92a886 chore: Bosnian translations 2025-02-13 22:17:06 +05:30
Jannat Patel
e2c28e211f Merge pull request #1309 from pateljannat/issues-76
fix: misc batch issues
2025-02-13 21:26:17 +05:30
Jannat Patel
65f5b6a0a4 fix: delete unused custom fields from web form 2025-02-13 17:23:57 +05:30
Jannat Patel
75cea1ab78 fix: delete unused custom fields from web form 2025-02-13 17:21:14 +05:30
Jannat Patel
5ab9131629 fix: misc batch issues 2025-02-13 16:57:21 +05:30
109 changed files with 13212 additions and 10212 deletions

View File

@@ -39,7 +39,7 @@ jobs:
node-version: '18' node-version: '18'
check-latest: true check-latest: true
- name: setup cache for bench - name: setup cache for bench
uses: actions/cache@v2 uses: actions/cache@v4
with: with:
path: ~/bench-cache path: ~/bench-cache
key: ${{ runner.os }} key: ${{ runner.os }}

View File

@@ -58,7 +58,7 @@ jobs:
echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts
- name: Cache pip - name: Cache pip
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}

View File

@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://lms1:8000", baseUrl: "http://testui:8000",
}, },
}); });

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Create a course // Create a course
cy.get("header").children().last().children().last().click(); cy.get("button").contains("New").click();
cy.wait(1000); cy.wait(1000);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");
@@ -84,9 +84,8 @@ describe("Course Creation", () => {
cy.wait(1000); cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson"); cy.get("label").contains("Title").type("Test Lesson");
cy.get("#content .ce-block").type( cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. 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." "{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. 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."
); );
cy.button("Save").click(); cy.button("Save").click();

View File

@@ -37,6 +37,7 @@ Cypress.Commands.add("login", (email, password) => {
url: "/api/method/login", url: "/api/method/login",
method: "POST", method: "POST",
body: { usr: email, pwd: password }, body: { usr: email, pwd: password },
timeout: 60000,
}); });
}); });

View File

@@ -42,6 +42,7 @@
<script> <script>
window.csrf_token = '{{ csrf_token }}' window.csrf_token = '{{ csrf_token }}'
window.setup_complete = '{{ setup_complete }}'
document.getElementById('seo-content').style.display = 'none'; document.getElementById('seo-content').style.display = 'none';
</script> </script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>

View File

@@ -19,13 +19,14 @@
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0", "@editorjs/simple-image": "^1.6.0",
"@editorjs/table": "^2.4.2", "@editorjs/table": "^2.4.2",
"@vueuse/router": "^12.7.0",
"ace-builds": "^1.36.2", "ace-builds": "^1.36.2",
"apexcharts": "^4.3.0", "apexcharts": "^4.3.0",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.109", "frappe-ui": "^0.1.112",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",

View File

@@ -62,25 +62,33 @@
</div> </div>
</div> </div>
</div> </div>
<SidebarLink <div>
:link="{ <TrialBanner
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse', v-if="
}" userResource.data?.is_system_manager && userResource.data?.is_fc_site
:isCollapsed="sidebarStore.isSidebarCollapsed" "
@click="toggleSidebar()" :isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
class="m-2" />
> <SidebarLink
<template #icon> :link="{
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
<CollapseSidebar }"
class="h-4.5 w-4.5 text-ink-gray-7 duration-300 ease-in-out" :isCollapsed="sidebarStore.isSidebarCollapsed"
:class="{ @click="toggleSidebar()"
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed, class="m-2"
}" >
/> <template #icon>
</span> <span class="grid h-5 w-6 flex-shrink-0 place-items-center">
</template> <CollapseSidebar
</SidebarLink> class="h-4.5 w-4.5 text-ink-gray-7 duration-300 ease-in-out"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/>
</span>
</template>
</SidebarLink>
</div>
</div> </div>
<PageModal <PageModal
v-model="showPageModal" v-model="showPageModal"
@@ -101,7 +109,7 @@ import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { ChevronRight, Plus } from 'lucide-vue-next' import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui' import { Button, createResource, TrialBanner } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue' import PageModal from '@/components/Modals/PageModal.vue'
const { user, sidebarSettings } = sessionStore() const { user, sidebarSettings } = sessionStore()

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="assignment.data" v-if="assignment.data"
class="grid grid-cols-[68%,32%] h-full" class="grid grid-cols-[65%,35%] h-full"
:class="{ 'border rounded-lg': !showTitle }" :class="{ 'border rounded-lg': !showTitle }"
> >
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"> <div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
@@ -81,8 +81,8 @@
</template> </template>
</FileUploader> </FileUploader>
<div v-else> <div v-else>
<div class="flex items-center text-ink-gray-7"> <div class="flex text-ink-gray-7">
<div class="border rounded-md p-2 mr-2"> <div class="border self-start rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" /> <FileText class="h-5 w-5 stroke-1.5" />
</div> </div>
<a <a
@@ -90,7 +90,7 @@
target="_blank" target="_blank"
class="flex flex-col cursor-pointer !no-underline" class="flex flex-col cursor-pointer !no-underline"
> >
<span> <span class="text-sm leading-5">
{{ submissionFile.file_name }} {{ submissionFile.file_name }}
</span> </span>
<span class="text-sm text-ink-gray-5 mt-1"> <span class="text-sm text-ink-gray-5 mt-1">
@@ -155,12 +155,23 @@
type="select" type="select"
:options="submissionStatusOptions" :options="submissionStatusOptions"
/> />
<FormControl <div>
v-if="submissionResource.doc" <div class="text-sm text-ink-gray-5 mb-1">
v-model="submissionResource.doc.comments" {{ __('Comments') }}
:label="__('Comments')" </div>
type="textarea" <TextEditor
/> :content="comments"
@change="
(val) => {
comments = val
isDirty = true
}
"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -184,6 +195,7 @@ import { useRouter } from 'vue-router'
const submissionFile = ref(null) const submissionFile = ref(null)
const answer = ref(null) const answer = ref(null)
const comments = ref(null)
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission' const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
@@ -281,6 +293,9 @@ watch(submissionResource, () => {
if (submissionResource.doc.answer) { if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer answer.value = submissionResource.doc.answer
} }
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
if (submissionResource.isDirty) { if (submissionResource.isDirty) {
isDirty.value = true isDirty.value = true
} else if (showUploader() && !submissionFile.value) { } else if (showUploader() && !submissionFile.value) {
@@ -305,11 +320,14 @@ const submitAssignment = () => {
submissionResource.doc && submissionResource.doc.owner != user.data?.name submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name ? user.data?.name
: null : null
submissionResource.setValue.submit( submissionResource.setValue.submit(
{ {
...submissionResource.doc, ...submissionResource.doc,
assignment_attachment: submissionFile.value?.file_url, assignment_attachment: submissionFile.value?.file_url,
evaluator: evaluator, evaluator: evaluator,
comments: comments.value,
answer: answer.value,
}, },
{ {
onSuccess(data) { onSuccess(data) {

View File

@@ -78,7 +78,7 @@
<template #prefix> <template #prefix>
<div v-if="column.key == 'member_name'"> <div v-if="column.key == 'member_name'">
<Avatar <Avatar
class="flex items-center" class="flex"
:image="row['member_image']" :image="row['member_image']"
:label="item" :label="item"
size="sm" size="sm"
@@ -240,6 +240,6 @@ const feedbackColumns = computed(() => {
<style> <style>
.feedback-list > button > div { .feedback-list > button > div {
align-items: start; align-items: start;
padding: 0.25rem 0; padding: 0.15rem 0;
} }
</style> </style>

View File

@@ -2,7 +2,7 @@
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72"> <div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div <div
v-if="batch.data.seat_count && seats_left > 0" v-if="batch.data.seat_count && seats_left > 0"
class="text-xs bg-green-200 text-green-800 float-right px-2 py-0.5 rounded-md" class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md"
> >
{{ seats_left }} {{ seats_left }}
<span v-if="seats_left > 1"> <span v-if="seats_left > 1">
@@ -14,7 +14,7 @@
</div> </div>
<div <div
v-else-if="batch.data.seat_count && seats_left <= 0" v-else-if="batch.data.seat_count && seats_left <= 0"
class="text-xs bg-red-200 text-red-900 float-right px-2 py-0.5 rounded-md" class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
> >
{{ __('Sold Out') }} {{ __('Sold Out') }}
</div> </div>
@@ -69,7 +69,11 @@
name: batch.data.name, name: batch.data.name,
}, },
}" }"
v-else-if="batch.data.paid_batch && batch.data.seats_left" v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
> >
<Button v-if="!isStudent" class="w-full mt-4" variant="solid"> <Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<span> <span>
@@ -80,7 +84,11 @@
<Button <Button
variant="solid" variant="solid"
class="w-full mt-2" class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left" v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()" @click="enrollInBatch()"
> >
{{ __('Enroll Now') }} {{ __('Enroll Now') }}
@@ -112,6 +120,7 @@ import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
batch: { batch: {

View File

@@ -78,7 +78,7 @@
:options="chartOptions" :options="chartOptions"
:series="chartData" :series="chartData"
type="bar" type="bar"
height="200" :height="chartData[0].data.length * 30 + 100"
/> />
<div <div
class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4" class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4"
@@ -357,7 +357,7 @@ const getChartData = () => {
}) })
Object.keys(student.assessments).forEach((assessment) => { Object.keys(student.assessments).forEach((assessment) => {
if (student.assessments[assessment].result === 'Passed') { if (student.assessments[assessment].result === 'Pass') {
categories[assessment].value += 1 categories[assessment].value += 1
} }
}) })

View File

@@ -0,0 +1,67 @@
<template>
<div
v-if="
certification.data &&
certification.data.membership &&
certification.data.paid_certificate &&
user.data?.is_student
"
>
<router-link
v-if="!certification.data.membership.purchased_certificate"
:to="{
name: 'Billing',
params: {
type: 'certificate',
name: courseName,
},
}"
>
<Button class="w-full">
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
<router-link
v-else-if="!certification.data.membership.certficate"
:to="{
name: 'CourseCertification',
params: {
courseName: courseName,
},
}"
>
<Button class="w-full">
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
</div>
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { inject } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
const user = inject('$user')
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const certification = createResource({
url: 'lms.lms.api.get_certification_details',
params: {
course: props.courseName,
},
auto: true,
cache: ['certificationData', user.data?.name],
})
</script>

View File

@@ -130,7 +130,7 @@ import {
ComboboxOptions, ComboboxOptions,
ComboboxOption, ComboboxOption,
} from '@headlessui/vue' } from '@headlessui/vue'
import { Popover, Button } from 'frappe-ui' import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next' import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue' import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'

View File

@@ -16,7 +16,8 @@
{{ __('Featured') }} {{ __('Featured') }}
</Badge> </Badge>
<div <div
v-for="tag in course.tags" v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md" class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md"
> >
{{ tag }} {{ tag }}
@@ -100,9 +101,15 @@
<CourseInstructors :instructors="course.instructors" /> <CourseInstructors :instructors="course.instructors" />
</div> </div>
<div class="font-semibold"> <div v-if="course.paid_course" class="font-semibold">
{{ course.price }} {{ course.price }}
</div> </div>
<div
v-if="course.paid_certificate || course.enable_certification"
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
>
{{ __('Certification') }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,30 +6,32 @@
class="rounded-t-md min-h-56 w-full" class="rounded-t-md min-h-56 w-full"
/> />
<div class="p-5"> <div class="p-5">
<div v-if="course.data.price" class="text-2xl font-semibold mb-3"> <div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }} {{ course.data.price }}
</div> </div>
<router-link <div v-if="course.data.membership" class="space-y-2">
v-if="course.data.membership" <router-link
:to="{ :to="{
name: 'Lesson', name: 'Lesson',
params: { params: {
courseName: course.name, courseName: course.name,
chapterNumber: course.data.current_lesson chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0] ? course.data.current_lesson.split('-')[0]
: 1, : 1,
lessonNumber: course.data.current_lesson lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1] ? course.data.current_lesson.split('-')[1]
: 1, : 1,
}, },
}" }"
> >
<Button variant="solid" size="md" class="w-full"> <Button variant="solid" size="md" class="w-full">
<span> <span>
{{ __('Continue Learning') }} {{ __('Continue Learning') }}
</span> </span>
</Button> </Button>
</router-link> </router-link>
<CertificationLinks :courseName="course.data.name" />
</div>
<router-link <router-link
v-else-if="course.data.paid_course" v-else-if="course.data.paid_course"
:to="{ :to="{
@@ -113,17 +115,36 @@
{{ course.data.rating }} {{ __('Rating') }} {{ course.data.rating }} {{ __('Rating') }}
</span> </span>
</div> </div>
<div
v-if="course.data.enable_certification"
class="flex items-center font-semibold text-ink-gray-9"
>
<GraduationCap class="h-4 w-4 stroke-2" />
<span class="ml-2">
{{ __('Certificate of Completion') }}
</span>
</div>
<div
v-if="course.data.paid_certificate"
class="flex items-center font-semibold text-ink-gray-9"
>
<GraduationCap class="h-4 w-4 stroke-2" />
<span class="ml-2">
{{ __('Paid Certificate after Evaluation') }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next' import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui' import { Button, createResource, Tooltip } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/' import { showToast, formatAmount } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')

View File

@@ -38,7 +38,7 @@
<div class="flex mt-2"> <div class="flex mt-2">
<Star <Star
v-for="index in 5" v-for="index in 5"
class="h-5 w-5 text-ink-gray-2 rounded-sm mr-2" class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
:class=" :class="
index <= Math.ceil(review.rating) index <= Math.ceil(review.rating)
? 'fill-orange-500' ? 'fill-orange-500'

View File

@@ -0,0 +1,23 @@
<template>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.75"
y="0.75"
width="30.5"
height="30.5"
rx="6.25"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M24.5011 14.1124C23.3954 12.4873 21.532 11.5477 19.594 11.6747C18.7616 10.1384 17.2211 9.12267 15.4198 9.0084C14.1651 8.93222 12.8979 9.3766 11.9165 10.24C11.2456 10.8367 10.7611 11.5223 10.463 12.2968C10.289 12.7539 9.89151 13.0459 9.46912 13.0459H6.5V15.5852H9.46912C10.9226 15.5852 12.2271 14.6584 12.7737 13.2237C12.9227 12.8301 13.1712 12.4873 13.5439 12.1571C14.0284 11.7255 14.662 11.4969 15.2583 11.535C16.1528 11.5985 16.7863 12.0175 17.1839 12.538C17.6063 13.0205 17.8423 13.7696 17.979 14.5187C18.774 14.2902 19.6437 14.0997 20.476 14.2394C21.1593 14.3536 21.7929 14.7218 22.2525 15.2678C22.327 15.3567 22.4016 15.4456 22.4637 15.5471C23.06 16.4232 23.1718 17.5024 22.7743 18.5689C22.414 19.5592 21.0847 20.4607 19.9791 20.4607H11.3326C10.1524 20.4607 9.18339 19.5592 9.03432 18.4038H6.54969C6.71119 20.9686 8.78585 23 11.3326 23H19.9915C22.1283 23 24.3769 21.451 25.1098 19.4704C25.7931 17.6167 25.5695 15.6614 24.5135 14.0997L24.5011 14.1124Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex space-x-4 border rounded-md p-2"> <div class="flex space-x-4 border rounded-md p-2">
<Avatar :image="job.company_logo" :label="job.job_title" size="2xl" /> <img :src="job.company_logo" class="size-10 rounded-full object-contain" />
<div class="flex flex-col space-y-2 flex-1"> <div class="flex flex-col space-y-2 flex-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-semibold text-ink-gray-9"> <span class="font-semibold text-ink-gray-9">

View File

@@ -36,7 +36,7 @@
<FormControl <FormControl
v-model="member.first_name" v-model="member.first_name"
:placeholder="__('First Name')" :placeholder="__('First Name')"
type="test" type="text"
class="w-full" class="w-full"
/> />
<Button @click="addMember()" variant="subtle"> <Button @click="addMember()" variant="subtle">

View File

@@ -67,13 +67,16 @@ const announcement = reactive({
}) })
const announcementResource = createResource({ const announcementResource = createResource({
url: 'lms.lms.api.make_announcement', url: 'frappe.core.doctype.communication.email.make',
makeParams(values) { makeParams(values) {
return { return {
students: props.students, recipients: props.students.join(', '),
cc: announcement.replyTo, cc: announcement.replyTo,
subject: announcement.subject, subject: announcement.subject,
content: announcement.announcement, content: announcement.announcement,
doctype: 'LMS Batch',
name: props.batch,
send_email: 1,
} }
}, },
}) })

View File

@@ -31,7 +31,7 @@
</template> </template>
<script setup> <script setup>
import { Dialog, createResource } from 'frappe-ui' import { Dialog, createResource } from 'frappe-ui'
import { ref, defineModel } from 'vue' import { ref } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils' import { showToast } from '@/utils'

View File

@@ -32,25 +32,44 @@
{{ __('Assessment') }} {{ __('Assessment') }}
</span> </span>
<span> <span>
{{ __('Progress') }} {{ __('Percentage/Status') }}
</span> </span>
</div> </div>
<div <router-link
v-for="assessment in Object.keys(student.assessments)" v-for="assessment in Object.keys(student.assessments)"
class="flex items-center text-ink-gray-7 font-medium" class="flex items-center text-ink-gray-7 font-medium"
:to="{
name:
student.assessments[assessment].type == 'LMS Assignment'
? 'AssignmentSubmission'
: '',
params:
student.assessments[assessment].type == 'LMS Assignment'
? {
assignmentID:
student.assessments[assessment].assessment,
submissionName:
student.assessments[assessment].submission,
}
: {},
}"
> >
<span class="flex-1"> <span class="flex-1">
{{ assessment }} {{ assessment }}
</span> </span>
<span v-if="isAssignment(student.assessments[assessment])"> <span v-if="isAssignment(student.assessments[assessment].status)">
<Badge :theme="getStatusTheme(student.assessments[assessment])"> <Badge
{{ student.assessments[assessment] }} :theme="
getStatusTheme(student.assessments[assessment].status)
"
>
{{ student.assessments[assessment].status }}
</Badge> </Badge>
</span> </span>
<span v-else> <span v-else>
{{ student.assessments[assessment] }} {{ student.assessments[assessment].status }}
</span> </span>
</div> </router-link>
</div> </div>
<!-- Courses --> <!-- Courses -->

View File

@@ -77,7 +77,7 @@ import {
FormControl, FormControl,
Switch, Switch,
} from 'frappe-ui' } from 'frappe-ui'
import { defineModel, reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { showToast, getFileSize } from '@/utils/' import { showToast, getFileSize } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'

View File

@@ -35,7 +35,7 @@
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui' import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel } from 'vue' import { reactive } from 'vue'
import { showToast, singularize } from '@/utils' import { showToast, singularize } from '@/utils'
const topics = defineModel('reloadTopics') const topics = defineModel('reloadTopics')

View File

@@ -94,7 +94,7 @@ import {
createResource, createResource,
TextEditor, TextEditor,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, watch, defineModel } from 'vue' import { reactive, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast, escapeHTML } from '@/utils' import { getFileSize, showToast, escapeHTML } from '@/utils'

View File

@@ -25,7 +25,15 @@
<div class="mb-1.5 text-sm text-ink-gray-5"> <div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Date') }} {{ __('Date') }}
</div> </div>
<FormControl type="date" v-model="evaluation.date" /> <FormControl
type="date"
v-model="evaluation.date"
:min="
dayjs()
.add(dayjs.duration({ days: 1 }))
.format('YYYY-MM-DD')
"
/>
</div> </div>
<div v-if="slots.data?.length"> <div v-if="slots.data?.length">
<div class="mb-1.5 text-sm text-ink-gray-5"> <div class="mb-1.5 text-sm text-ink-gray-5">
@@ -58,7 +66,7 @@
</template> </template>
<script setup> <script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui' import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue' import { reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/' import { createToast, formatTime } from '@/utils/'
const user = inject('$user') const user = inject('$user')
@@ -161,6 +169,11 @@ const getCourses = () => {
}) })
} }
} }
if (courses.length == 1) {
evaluation.course = courses[0].value
}
return courses return courses
} }

View File

@@ -66,7 +66,7 @@
<script setup> <script setup>
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui' import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
import { FileText } from 'lucide-vue-next' import { FileText } from 'lucide-vue-next'
import { ref, inject, defineModel } from 'vue' import { ref, inject } from 'vue'
import { createToast, getFileSize } from '@/utils/' import { createToast, getFileSize } from '@/utils/'
const resume = ref(null) const resume = ref(null)

View File

@@ -39,13 +39,19 @@
:required="true" :required="true"
/> />
</Tooltip> </Tooltip>
<FormControl
v-model="liveClass.timezone" <div class="space-y-1.5">
type="select" <label class="block text-ink-gray-5 text-xs" for="batchTimezone">
:options="getTimezoneOptions()" {{ __('Timezone') }}
:label="__('Timezone')" <span class="text-ink-red-3">*</span>
:required="true" </label>
/> <Autocomplete
@update:modelValue="(opt) => (liveClass.timezone = opt.value)"
:modelValue="liveClass.timezone"
:options="getTimezoneOptions()"
:required="true"
/>
</div>
</div> </div>
<div> <div>
<FormControl <FormControl
@@ -83,18 +89,14 @@
</template> </template>
<script setup> <script setup>
import { import {
Input,
DatePicker,
Select,
Textarea,
Dialog, Dialog,
createResource, createResource,
Tooltip, Tooltip,
FormControl, FormControl,
Autocomplete,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, inject } from 'vue' import { reactive, inject, onMounted } from 'vue'
import { getTimezones, createToast } from '@/utils/' import { getTimezones, createToast, getUserTimezone } from '@/utils/'
import { Info } from 'lucide-vue-next'
const liveClasses = defineModel('reloadLiveClasses') const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel() const show = defineModel()
@@ -120,6 +122,10 @@ let liveClass = reactive({
host: user.data.name, host: user.data.name,
}) })
onMounted(() => {
liveClass.timezone = getUserTimezone()
})
const getTimezoneOptions = () => { const getTimezoneOptions = () => {
return getTimezones().map((timezone) => { return getTimezones().map((timezone) => {
return { return {

View File

@@ -33,7 +33,7 @@
</template> </template>
<script setup> <script setup>
import { Dialog, Textarea, createResource } from 'frappe-ui' import { Dialog, Textarea, createResource } from 'frappe-ui'
import { defineModel, reactive } from 'vue' import { reactive } from 'vue'
import Rating from '@/components/Controls/Rating.vue' import Rating from '@/components/Controls/Rating.vue'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'

View File

@@ -207,7 +207,7 @@
</Button> </Button>
<Button <Button
v-else-if="activeQuestion != questions.length" v-else-if="activeQuestion != questions.length"
@click="nextQuetion()" @click="nextQuestion()"
> >
<span> <span>
{{ __('Next') }} {{ __('Next') }}
@@ -258,14 +258,22 @@
</Button> </Button>
</div> </div>
<div <div
v-if="quiz.data.show_submission_history && attempts?.data" v-if="
quiz.data.show_submission_history &&
attempts?.data &&
attempts.data.length > 0
"
class="mt-10" class="mt-10"
> >
<ListView <ListView
:columns="getSubmissionColumns()" :columns="getSubmissionColumns()"
:rows="attempts?.data" :rows="attempts?.data"
row-key="name" row-key="name"
:options="{ selectable: false, showTooltip: false }" :options="{
selectable: false,
showTooltip: false,
emptyState: { title: __('No Quiz submissions found') },
}"
> >
</ListView> </ListView>
</div> </div>
@@ -282,7 +290,7 @@ import {
FormControl, FormControl,
} from 'frappe-ui' } from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue' import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast } from '@/utils/' import { createToast, showToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next' import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -536,7 +544,7 @@ const addToLocalStorage = () => {
localStorage.setItem(quiz.data.title, JSON.stringify(quizData)) localStorage.setItem(quiz.data.title, JSON.stringify(quizData))
} }
const nextQuetion = () => { const nextQuestion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') { if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer() checkAnswer()
} else { } else {
@@ -574,6 +582,16 @@ const createSubmission = () => {
if (quiz.data && quiz.data.max_attempts) attempts.reload() if (quiz.data && quiz.data.max_attempts) attempts.reload()
if (quiz.data.duration) clearInterval(timerInterval) if (quiz.data.duration) clearInterval(timerInterval)
}, },
onError(err) {
const errorTitle = err?.message || ''
if (errorTitle.includes('MaximumAttemptsExceededError')) {
const errorMessage = err.messages?.[0] || err
showToast(__('Error'), __(errorMessage), 'x')
setTimeout(() => {
window.location.reload()
}, 3000)
}
},
} }
) )
} }

View File

@@ -4,12 +4,18 @@
<div class="text-lg font-semibold"> <div class="text-lg font-semibold">
{{ __('Upcoming Evaluations') }} {{ __('Upcoming Evaluations') }}
</div> </div>
<Button @click="openEvalModal"> <Button
v-if="
!upcoming_evals.data?.length ||
upcoming_evals.length == courses.length
"
@click="openEvalModal"
>
{{ __('Schedule Evaluation') }} {{ __('Schedule Evaluation') }}
</Button> </Button>
</div> </div>
<div v-if="upcoming_evals.data?.length"> <div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-3 gap-4">
<div v-for="evl in upcoming_evals.data"> <div v-for="evl in upcoming_evals.data">
<div class="border rounded-md p-3"> <div class="border rounded-md p-3">
<div class="font-semibold mb-3"> <div class="font-semibold mb-3">
@@ -28,17 +34,39 @@
</span> </span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<UserCog2 class="w-4 h-4 stroke-1.5" /> <GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2 font-medium"> <span class="ml-2">
{{ evl.evaluator_name }} {{ evl.evaluator_name }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between space-x-2 mt-4">
<Button
v-if="evl.google_meet_link"
@click="openEvalCall(evl)"
class="w-full"
>
<template #prefix>
<HeadsetIcon class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Join Call') }}
</Button>
<Button
v-if="evl.date > dayjs().format()"
@click="cancelEvaluation(evl)"
class="w-full"
>
<template #prefix>
<Ban class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Cancel') }}
</Button>
</div>
</div> </div>
</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">
{{ __('No upcoming evaluations.') }} {{ __('Please schedule an evaluation to get certified.') }}
</div> </div>
</div> </div>
<EvaluationModal <EvaluationModal
@@ -50,15 +78,23 @@
/> />
</template> </template>
<script setup> <script setup>
import { Calendar, Clock, UserCog2 } from 'lucide-vue-next' import {
import { inject, ref } from 'vue' Ban,
Calendar,
Clock,
GraduationCap,
HeadsetIcon,
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance } from 'vue'
import { formatTime } from '../utils' import { formatTime } from '../utils'
import { Button, createResource } from 'frappe-ui' import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue' import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const user = inject('$user') const user = inject('$user')
const showEvalModal = ref(false) const showEvalModal = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -77,10 +113,10 @@ const props = defineProps({
const upcoming_evals = createResource({ const upcoming_evals = createResource({
url: 'lms.lms.utils.get_upcoming_evals', url: 'lms.lms.utils.get_upcoming_evals',
cache: ['upcoming_evals', user.data.name],
params: { params: {
student: user.data.name, student: user.data.name,
courses: props.courses.map((course) => course.course), courses: props.courses.map((course) => course.course),
batch: props.batch,
}, },
auto: true, auto: true,
}) })
@@ -88,4 +124,32 @@ const upcoming_evals = createResource({
function openEvalModal() { function openEvalModal() {
showEvalModal.value = true showEvalModal.value = true
} }
const openEvalCall = (evl) => {
window.open(evl.google_meet_link, '_blank')
}
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),
message: __(
'Are you sure you want to cancel this evaluation? This action cannot be undone.'
),
actions: [
{
label: __('Cancel'),
theme: 'red',
variant: 'solid',
onClick(close) {
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
() => {
upcoming_evals.reload()
}
)
close()
},
},
],
})
}
</script> </script>

View File

@@ -66,6 +66,14 @@ import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui' import { Dropdown } from 'frappe-ui'
import Apps from '@/components/Apps.vue' import Apps from '@/components/Apps.vue'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
import { createDialog } from '@/utils/dialogs'
import SettingsModal from '@/components/Modals/Settings.vue'
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
import { import {
ChevronDown, ChevronDown,
LogIn, LogIn,
@@ -75,12 +83,6 @@ import {
Settings, Settings,
Sun, Sun,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter() const router = useRouter()
const { logout, branding } = sessionStore() const { logout, branding } = sessionStore()
@@ -89,6 +91,8 @@ const settingsStore = useSettings()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false) const showSettingsModal = ref(false)
const theme = ref('light') const theme = ref('light')
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
const $dialog = createDialog
const props = defineProps({ const props = defineProps({
isCollapsed: { isCollapsed: {
@@ -130,6 +134,13 @@ const userDropdownOptions = computed(() => {
return isLoggedIn return isLoggedIn
}, },
}, },
{
icon: theme.value === 'light' ? Moon : Sun,
label: 'Toggle Theme',
onClick: () => {
toggleTheme()
},
},
{ {
component: markRaw(Apps), component: markRaw(Apps),
condition: () => { condition: () => {
@@ -139,13 +150,6 @@ const userDropdownOptions = computed(() => {
else return false else return false
}, },
}, },
{
icon: theme.value === 'light' ? Moon : Sun,
label: 'Toggle Theme',
onClick: () => {
toggleTheme()
},
},
{ {
icon: Settings, icon: Settings,
label: 'Settings', label: 'Settings',
@@ -156,6 +160,33 @@ const userDropdownOptions = computed(() => {
return userResource.data?.is_moderator return userResource.data?.is_moderator
}, },
}, },
{
icon: FrappeCloudIcon,
label: 'Login to Frappe Cloud',
onClick: () => {
$dialog({
title: __('Login to Frappe Cloud?'),
message: __(
'Are you sure you want to login to your Frappe Cloud dashboard?'
),
actions: [
{
label: __('Confirm'),
variant: 'solid',
onClick(close) {
loginToFrappeCloud()
close()
},
},
],
})
},
condition: () => {
return (
userResource.data?.is_system_manager && userResource.data?.is_fc_site
)
},
},
{ {
icon: LogOut, icon: LogOut,
label: 'Log out', label: 'Log out',
@@ -180,4 +211,12 @@ const userDropdownOptions = computed(() => {
}, },
] ]
}) })
const loginToFrappeCloud = () => {
let redirect_to = '/dashboard/welcome'
if (userResource.data?.site_info.is_payment_method_added) {
redirect_to = '/dashboard/sites/' + userResource.data.sitename
}
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
}
</script> </script>

View File

@@ -6,7 +6,7 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" /> <Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Button <Button
v-if="user.data?.is_moderator" v-if="user.data?.is_moderator && batch.data?.certification"
@click="openCertificateDialog = true" @click="openCertificateDialog = true"
> >
{{ __('Generate Certificates') }} {{ __('Generate Certificates') }}
@@ -21,7 +21,10 @@
</Button> </Button>
</div> </div>
</header> </header>
<div v-if="batch.data" class="grid grid-cols-[75%,25%]"> <div
v-if="batch.data"
class="grid grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
>
<div class="border-r"> <div class="border-r">
<Tabs <Tabs
v-model="tabIndex" v-model="tabIndex"
@@ -190,8 +193,9 @@
<BulkCertificates v-model="openCertificateDialog" :batch="batch.data" /> <BulkCertificates v-model="openCertificateDialog" :batch="batch.data" />
</template> </template>
<script setup> <script setup>
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
import { computed, inject, ref } from 'vue' import { computed, inject, ref } from 'vue'
import { useRouteQuery } from '@vueuse/router'
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { import {
@@ -267,7 +271,7 @@ const isStudent = computed(() => {
) )
}) })
const tabIndex = ref(0) const tabIndex = useRouteQuery('tab', 0, { transform: Number })
const tabs = computed(() => { const tabs = computed(() => {
let batchTabs = [] let batchTabs = []
batchTabs.push({ batchTabs.push({
@@ -310,7 +314,7 @@ const tabs = computed(() => {
}) })
const redirectToLogin = () => { const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/batches` window.location.href = `/login?redirect-to=/lms/batches/${props.batchName}`
} }
const openAnnouncementModal = () => { const openAnnouncementModal = () => {

View File

@@ -127,6 +127,11 @@ const batch = createResource({
batch: props.batchName, batch: props.batchName,
}, },
auto: true, auto: true,
onSuccess: (data) => {
if (!data) {
router.push({ name: 'Batches' })
}
},
}) })
const courses = createResource({ const courses = createResource({

View File

@@ -13,15 +13,14 @@
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2"> <div class="space-y-4 mb-4">
<div> <FormControl
<FormControl v-model="batch.title"
v-model="batch.title" :label="__('Title')"
:label="__('Title')" :required="true"
:required="true" class="w-full"
/> />
</div> <div class="flex items-center space-x-5">
<div class="flex flex-col space-y-2">
<FormControl <FormControl
v-model="batch.published" v-model="batch.published"
type="checkbox" type="checkbox"
@@ -32,6 +31,11 @@
type="checkbox" type="checkbox"
:label="__('Allow self enrollment')" :label="__('Allow self enrollment')"
/> />
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -90,30 +94,8 @@
:required="true" :required="true"
:filters="{ ignore_user_type: 1 }" :filters="{ ignore_user_type: 1 }"
/> />
<div class="mb-4">
<FormControl <div class="my-10">
v-model="batch.description"
:label="__('Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div>
<div class="mb-4">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Date and Time') }} {{ __('Date and Time') }}
</div> </div>
@@ -133,6 +115,14 @@
class="mb-4" class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div> </div>
<div> <div>
<FormControl <FormControl
@@ -149,18 +139,11 @@
class="mb-4" class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4">
<div class="mb-10">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
@@ -179,6 +162,11 @@
type="date" type="date"
class="mb-4" class="mb-4"
/> />
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
/>
</div> </div>
<div> <div>
<FormControl <FormControl
@@ -230,6 +218,33 @@
/> />
</div> </div>
</div> </div>
<div class="my-10">
<div class="text-lg font-semibold mb-4">
{{ __('Description') }}
</div>
<FormControl
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -278,10 +293,12 @@ const batch = reactive({
end_time: '', end_time: '',
timezone: '', timezone: '',
evaluation_end_date: '', evaluation_end_date: '',
confirmation_email_template: '',
seat_count: '', seat_count: '',
medium: '', medium: '',
category: '', category: '',
allow_self_enrollment: false, allow_self_enrollment: false,
certification: false,
image: null, image: null,
paid_batch: false, paid_batch: false,
currency: '', currency: '',
@@ -351,7 +368,12 @@ const batchDetail = createResource({
batch[key] = `${hours}:${minutes}` batch[key] = `${hours}:${minutes}`
} else if (Object.hasOwn(batch, key)) batch[key] = data[key] } else if (Object.hasOwn(batch, key)) batch[key] = data[key]
}) })
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment'] let checkboxes = [
'published',
'paid_batch',
'allow_self_enrollment',
'certification',
]
for (let idx in checkboxes) { for (let idx in checkboxes) {
let key = checkboxes[idx] let key = checkboxes[idx]
batch[key] = batch[key] ? true : false batch[key] = batch[key] ? true : false

View File

@@ -26,13 +26,19 @@
{{ __('All Batches') }} {{ __('All Batches') }}
</div> </div>
<div <div
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-2" class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
> >
<TabButtons <TabButtons
v-if="user.data" v-if="user.data"
:buttons="batchTabs" :buttons="batchTabs"
v-model="currentTab" v-model="currentTab"
/> />
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateBatches()"
/>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<FormControl <FormControl
v-model="title" v-model="title"
@@ -111,6 +117,7 @@ const pageLength = ref(20)
const categories = ref([]) const categories = ref([])
const currentCategory = ref(null) const currentCategory = ref(null)
const title = ref('') const title = ref('')
const certification = ref(false)
const filters = ref({}) const filters = ref({})
const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming') const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming')
const orderBy = ref('start_date') const orderBy = ref('start_date')
@@ -130,6 +137,7 @@ const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
title.value = queries.get('title') || '' title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false
} }
const batches = createListResource({ const batches = createListResource({
@@ -161,6 +169,7 @@ const updateBatches = () => {
const updateFilters = () => { const updateFilters = () => {
updateCategoryFilter() updateCategoryFilter()
updateTitleFilter() updateTitleFilter()
updateCertificationFilter()
updateTabFilter() updateTabFilter()
updateStudentFilter() updateStudentFilter()
setQueryParams() setQueryParams()
@@ -182,6 +191,14 @@ const updateTitleFilter = () => {
} }
} }
const updateCertificationFilter = () => {
if (certification.value) {
filters.value['certification'] = 1
} else {
delete filters.value['certification']
}
}
const updateTabFilter = () => { const updateTabFilter = () => {
orderBy.value = 'start_date' orderBy.value = 'start_date'
if (!user.data) { if (!user.data) {
@@ -222,6 +239,7 @@ const setQueryParams = () => {
let filterKeys = { let filterKeys = {
title: title.value, title: title.value,
category: currentCategory.value, category: currentCategory.value,
certification: certification.value,
} }
Object.keys(filterKeys).forEach((key) => { Object.keys(filterKeys).forEach((key) => {

View File

@@ -12,20 +12,15 @@
v-if="access.data?.access && orderSummary.data" v-if="access.data?.access && orderSummary.data"
class="pt-5 pb-10 mx-5" class="pt-5 pb-10 mx-5"
> >
<!-- <div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
</div> -->
<div class="flex flex-col lg:flex-row justify-between"> <div class="flex flex-col lg:flex-row justify-between">
<div <div
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4" class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 font-medium lg:w-1/3"
> >
<div class="flex items-center justify-between space-x-2"> <div class="flex items-baseline justify-between space-y-2">
<div class="text-ink-gray-5"> <div class="text-ink-gray-5">
{{ __('Ordered Item') }} {{ __('Payment for ') }} {{ type }}:
</div> </div>
<div class=""> <div class="leading-5">
{{ orderSummary.data.title }} {{ orderSummary.data.title }}
</div> </div>
</div> </div>
@@ -126,7 +121,7 @@
<p class="text-ink-gray-5"> <p class="text-ink-gray-5">
{{ {{
__( __(
'Make sure to enter the right billing name as the same will be used in your invoice.' 'Make sure to enter the correct billing name as the same will be used in your invoice.'
) )
}} }}
</p> </p>
@@ -140,10 +135,10 @@
<div v-else-if="access.data?.message"> <div v-else-if="access.data?.message">
<NotPermitted <NotPermitted
:text="access.data.message" :text="access.data.message"
:buttonLabel=" :buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
type == 'course' ? 'Checkout Courses' : 'Checkout Batches' :buttonLink="
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
" "
:buttonLink="type == 'course' ? '/lms/courses' : '/lms/batches'"
/> />
</div> </div>
<div v-else-if="!user.data?.name"> <div v-else-if="!user.data?.name">
@@ -163,7 +158,7 @@ import {
Breadcrumbs, Breadcrumbs,
Tooltip, Tooltip,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, inject, onMounted, ref } from 'vue' import { reactive, inject, onMounted, computed } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue' import NotPermitted from '@/components/NotPermitted.vue'
import { showToast } from '@/utils/' import { showToast } from '@/utils/'
@@ -193,7 +188,7 @@ const props = defineProps({
const access = createResource({ const access = createResource({
url: 'lms.lms.api.validate_billing_access', url: 'lms.lms.api.validate_billing_access',
params: { params: {
type: props.type, billing_type: props.type,
name: props.name, name: props.name,
}, },
onSuccess(data) { onSuccess(data) {
@@ -206,7 +201,7 @@ const orderSummary = createResource({
url: 'lms.lms.utils.get_order_summary', url: 'lms.lms.utils.get_order_summary',
makeParams(values) { makeParams(values) {
return { return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch', doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name, docname: props.name,
country: billingDetails.country, country: billingDetails.country,
} }
@@ -236,22 +231,26 @@ const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link', url: 'lms.lms.payments.get_payment_link',
makeParams(values) { makeParams(values) {
return { return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch', doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name, docname: props.name,
title: orderSummary.data.title, title: orderSummary.data.title,
amount: orderSummary.data.original_amount, amount: orderSummary.data.original_amount,
total_amount: orderSummary.data.amount, total_amount: orderSummary.data.amount,
currency: orderSummary.data.currency, currency: orderSummary.data.currency,
address: billingDetails, address: billingDetails,
redirect_to: redirectTo.value,
payment_for_certificate: props.type == 'certificate',
} }
}, },
}) })
const generatePaymentLink = () => { const generatePaymentLink = () => {
console.log('called')
paymentLink.submit( paymentLink.submit(
{}, {},
{ {
validate() { validate() {
console.log('validation start')
if (!billingDetails.source) { if (!billingDetails.source) {
return __('Please let us know where you heard about us from.') return __('Please let us know where you heard about us from.')
} }
@@ -330,6 +329,8 @@ const validateAddress = () => {
!states.includes(billingDetails.state) !states.includes(billingDetails.state)
) )
return 'Please enter a valid state with correct spelling and the first letter capitalized.' return 'Please enter a valid state with correct spelling and the first letter capitalized.'
console.log('validation address')
} }
const showError = (err) => { const showError = (err) => {
@@ -347,4 +348,14 @@ const changeCurrency = (country) => {
billingDetails.country = country billingDetails.country = country
orderSummary.reload() orderSummary.reload()
} }
const redirectTo = computed(() => {
if (props.type == 'course') {
return `/lms/courses/${props.name}`
} else if (props.type == 'batch') {
return `/lms/batches/${props.name}`
} else if (props.type == 'certificate') {
return `/lms/courses/${props.name}/certification`
}
})
</script> </script>

View File

@@ -0,0 +1,117 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div class="p-5">
<div v-if="certificate.data && Object.keys(certificate.data).length">
<div class="text-lg text-ink-gray-9 font-semibold mb-1">
{{ __('Certification') }}
</div>
<div class="text-ink-gray-9 text-sm">
{{
__(
'You are already certified for this course. Click on the card below to open your certificate.'
)
}}
</div>
<div
class="border p-3 w-fit min-w-60 rounded-md space-y-2 hover:bg-surface-gray-1 cursor-pointer mt-5"
@click="openCertificate(certificate.data)"
>
<div class="text-ink-gray-9 font-semibold">
{{ courseTitle }}
</div>
<div class="text-sm text-ink-gray-7 font-medium">
{{ __('Issued On') }}:
{{ dayjs(certificate.data.issue_date).format('DD MMM YYYY') }}
</div>
</div>
</div>
<div v-else>
<UpcomingEvaluations v-if="courses.length" :courses="courses" />
</div>
</div>
</template>
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
import { Breadcrumbs, call, createResource } from 'frappe-ui'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const courseTitle = ref(null)
const evaluator = ref(null)
const courses = ref([])
const user = inject('$user')
const dayjs = inject('$dayjs')
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
onMounted(() => {
fetchCourseDetails()
})
const certificate = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Certificate',
filters: {
member: user.data?.name,
course: props.courseName,
},
fieldname: ['name', 'template', 'issue_date'],
},
auto: true,
cache: [user.data?.name, props.courseName],
})
const fetchCourseDetails = () => {
call('frappe.client.get_value', {
doctype: 'LMS Course',
filters: { name: props.courseName },
fieldname: ['title', 'evaluator'],
}).then((data) => {
courseTitle.value = data.title
evaluator.value = data.evaluator
populateCourses()
})
}
const populateCourses = () => {
courses.value = [
{
course: props.courseName,
title: courseTitle.value,
evaluator: evaluator.value,
},
]
}
const openCertificate = (certificate) => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certificate.name
}&format=${encodeURIComponent(certificate.template)}`,
'_blank'
)
}
const breadcrumbs = computed(() => [
{
label: __('Courses'),
route: { name: 'Courses' },
},
{
label: courseTitle.value,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
},
{
label: __('Certification'),
},
])
</script>

View File

@@ -160,7 +160,7 @@
<div class="text-lg font-semibold mt-5 mb-4"> <div class="text-lg font-semibold mt-5 mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
<div class="grid grid-cols-3 gap-10 mb-4"> <div class="grid grid-cols-2 gap-10 mb-4">
<div <div
v-if="user.data?.is_moderator" v-if="user.data?.is_moderator"
class="flex flex-col space-y-4" class="flex flex-col space-y-4"
@@ -188,43 +188,48 @@
v-model="course.featured" v-model="course.featured"
:label="__('Featured')" :label="__('Featured')"
/> />
</div>
<div class="flex flex-col space-y-3">
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.disable_self_learning" v-model="course.disable_self_learning"
:label="__('Disable Self Enrollment')" :label="__('Disable Self Enrollment')"
/> />
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
</div> </div>
</div> </div>
</div> </div>
<div class="container border-t"> <div class="container border-t space-y-4">
<div class="text-lg font-semibold mt-5 mb-4"> <div class="text-lg font-semibold mt-5">
{{ __('Pricing') }} {{ __('Pricing and Certification') }}
</div> </div>
<div class="mb-4"> <div class="grid grid-cols-3">
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.paid_course" v-model="course.paid_course"
:label="__('Paid Course')" :label="__('Paid Course')"
/> />
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
<FormControl
type="checkbox"
v-model="course.paid_certificate"
:label="__('Paid Certificate')"
/>
</div> </div>
<FormControl <FormControl v-model="course.course_price" :label="__('Amount')" />
v-model="course.course_price"
:label="__('Course Price')"
class="mb-4"
/>
<Link <Link
doctype="Currency" doctype="Currency"
v-model="course.currency" v-model="course.currency"
:filters="{ enabled: 1 }" :filters="{ enabled: 1 }"
:label="__('Currency')" :label="__('Currency')"
/> />
<Link
v-if="course.paid_certificate"
doctype="Course Evaluator"
v-model="course.evaluator"
:label="__('Evaluator')"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -296,8 +301,10 @@ const course = reactive({
disable_self_learning: false, disable_self_learning: false,
enable_certification: false, enable_certification: false,
paid_course: false, paid_course: false,
paid_certificate: false,
course_price: '', course_price: '',
currency: '', currency: '',
evaluator: '',
}) })
onMounted(() => { onMounted(() => {
@@ -391,6 +398,7 @@ const courseResource = createResource({
'paid_course', 'paid_course',
'featured', 'featured',
'enable_certification', 'enable_certification',
'paid_certifiate',
] ]
for (let idx in checkboxes) { for (let idx in checkboxes) {
let key = checkboxes[idx] let key = checkboxes[idx]

View File

@@ -1,316 +1,325 @@
<template> <template>
<div v-if="courses.data"> <header
<header class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" >
<Breadcrumbs :items="breadcrumbs" />
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'CourseForm',
params: { courseName: 'new' },
}"
> >
<Breadcrumbs <Button variant="solid">
class="h-7" <template #prefix>
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]" <Plus class="h-4 w-4 stroke-1.5" />
/> </template>
<div class="flex space-x-2 justify-end"> {{ __('New') }}
<div class="w-40 md:w-44"> </Button>
<FormControl </router-link>
v-if="categories.data?.length" </header>
type="select" <div class="p-5 pb-10">
v-model="currentCategory" <div
:options="categories.data" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
:placeholder="__('Category')" >
/> <div class="text-lg font-semibold">
</div> {{ __('All Courses') }}
<div class="w-28 md:w-36"> </div>
<div
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
>
<TabButtons
v-if="user.data"
:buttons="courseTabs"
v-model="currentTab"
/>
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateCourses()"
/>
<div class="grid grid-cols-2 gap-2">
<FormControl <FormControl
v-model="title"
:placeholder="__('Search by Title')"
type="text" type="text"
placeholder="Search" class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
v-model="searchQuery" @input="updateCourses()"
@input="courses.reload()" />
> <div class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40">
<template #prefix> <Select
<Search v-if="categories.length"
class="w-4 h-4 stroke-1.5 text-ink-gray-5" v-model="currentCategory"
name="search" :options="categories"
/> :placeholder="__('Category')"
</template> @change="updateCourses()"
</FormControl> />
</div>
<router-link
v-if="user.data?.is_moderator || user.data?.is_instructor"
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('New') }}
</Button>
</router-link>
</div>
</header>
<div class="">
<Tabs
v-if="hasCourses"
as="div"
v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs"
>
<template #tab="{ tab, selected }">
<div>
<button
class="group -mb-px flex items-center gap-2 overflow-hidden border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
:class="{ 'text-ink-gray-9': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge theme="gray">
{{ tab.count }}
</Badge>
</button>
</div> </div>
</template>
<template #tab-panel="{ tab }">
<div
v-if="tab.courses && tab.courses.value.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-7 my-5 mx-5"
>
<router-link
v-for="course in tab.courses.value"
:to="
course.membership && course.current_lesson
? {
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.current_lesson.split('-')[0],
lessonNumber: course.current_lesson.split('-')[1],
},
}
: course.membership
? {
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: 1,
lessonNumber: 1,
},
}
: {
name: 'CourseDetail',
params: { courseName: course.name },
}
"
>
<CourseCard :course="course" />
</router-link>
</div>
<div v-else class="p-5 italic text-ink-gray-4">
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div
v-else-if="
!courses.loading &&
(user.data?.is_moderator || user.data?.is_instructor)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
}"
>
<div class="bg-surface-menu-bar py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-ink-gray-8 p-1 rounded-full border bg-surface-white"
/>
<div class="font-medium">
{{ __('Create a Course') }}
</div>
<span class="text-ink-gray-7 text-sm leading-4">
{{ __('You can add chapters and lessons to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!courses.loading && !hasCourses"
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No courses found') }}
</div>
<div class="leading-5">
{{
__(
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div> </div>
</div> </div>
</div> </div>
<div
v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
>
<router-link
v-for="course in courses.data"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
>
<CourseCard :course="course" />
</router-link>
</div>
<div
v-else-if="!courses.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No courses found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no courses matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<div
v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="courses.next()">
{{ __('Load More') }}
</Button>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
Badge,
Breadcrumbs, Breadcrumbs,
Button, Button,
call, createListResource,
createResource,
FormControl, FormControl,
Tabs, Select,
TabButtons,
} from 'frappe-ui' } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus, Search } from 'lucide-vue-next' import { BookOpen, Plus } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router' import CourseCard from '@/components/CourseCard.vue'
import { useSettings } from '@/stores/settings'
const user = inject('$user') const user = inject('$user')
const searchQuery = ref('') const dayjs = inject('$dayjs')
const start = ref(0)
const pageLength = ref(30)
const categories = ref([])
const currentCategory = ref(null) const currentCategory = ref(null)
const hasCourses = ref(false) const title = ref('')
const router = useRouter() const certification = ref(false)
const settings = useSettings() const filters = ref({})
const currentTab = ref('Live')
onMounted(() => { onMounted(() => {
checkLearningPath() setFiltersFromQuery()
let queries = new URLSearchParams(location.search) updateCourses()
if (queries.has('category')) { categories.value = [
currentCategory.value = queries.get('category') {
} label: '',
value: null,
},
]
}) })
const checkLearningPath = () => { const setFiltersFromQuery = () => {
if ( let queries = new URLSearchParams(location.search)
settings.learningPaths.data && title.value = queries.get('title') || ''
(!user.data?.is_moderator || !user.data?.is_instructor) currentCategory.value = queries.get('category') || null
) { certification.value = queries.get('certification') || false
router.push({ name: 'Programs' }) }
const courses = createListResource({
doctype: 'LMS Course',
url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.name],
pageLength: pageLength.value,
start: start.value,
onSuccess(data) {
let allCategories = data.map((course) => course.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
},
})
const updateCourses = () => {
updateFilters()
courses.update({
filters: filters.value,
})
courses.reload()
}
const updateFilters = () => {
updateCategoryFilter()
updateTitleFilter()
updateCertificationFilter()
updateTabFilter()
updateStudentFilter()
setQueryParams()
}
const updateCategoryFilter = () => {
if (currentCategory.value) {
filters.value['category'] = currentCategory.value
} else {
delete filters.value['category']
} }
} }
const courses = createResource({ const updateTitleFilter = () => {
url: 'lms.lms.utils.get_courses', if (title.value) {
cache: ['courses', user.data?.email], filters.value['title'] = ['like', `%${title.value}%`]
auto: true, } else {
delete filters.value['title']
}
}
const updateCertificationFilter = () => {
if (certification.value) {
filters.value['certification'] = 1
} else {
delete filters.value['certification']
}
}
const updateTabFilter = () => {
if (!user.data) {
return
}
delete filters.value['live']
delete filters.value['created']
delete filters.value['published_on']
delete filters.value['upcoming']
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
filters.value['enrolled'] = 1
delete filters.value['published']
} else {
delete filters.value['published']
delete filters.value['enrolled']
if (currentTab.value == 'Live') {
filters.value['published'] = 1
filters.value['upcoming'] = 0
filters.value['live'] = 1
} else if (currentTab.value == 'Upcoming') {
filters.value['upcoming'] = 1
filters.value['published'] = 1
} else if (currentTab.value == 'New') {
filters.value['published'] = 1
filters.value['published_on'] = [
'>=',
dayjs().add(-3, 'month').format('YYYY-MM-DD'),
]
} else if (currentTab.value == 'Created') {
filters.value['created'] = 1
}
}
}
const updateStudentFilter = () => {
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
filters.value['published'] = 1
}
}
const setQueryParams = () => {
let queries = new URLSearchParams(location.search)
let filterKeys = {
title: title.value,
category: currentCategory.value,
certification: certification.value,
}
Object.keys(filterKeys).forEach((key) => {
if (filterKeys[key]) {
queries.set(key, filterKeys[key])
} else {
queries.delete(key)
}
})
history.replaceState({}, '', `${location.pathname}?${queries.toString()}`)
}
const updateCategories = (data) => {
data.forEach((course) => {
if (
course.category &&
!categories.value.find((category) => category.value === course.category)
)
categories.value.push({
label: course.category,
value: course.category,
})
})
}
watch(currentTab, () => {
updateCourses()
}) })
const tabIndex = ref(0) const courseType = computed(() => {
let tabs let types = [
{ label: __(''), value: null },
{ label: __('New'), value: 'New' },
{ label: __('Upcoming'), value: 'Upcoming' },
]
if (user.data?.is_student) {
types.push({ label: __('Enrolled'), value: 'Enrolled' })
} else {
types.push({ label: __('Created'), value: 'Created' })
}
return types
})
const makeTabs = computed(() => { const courseTabs = computed(() => {
tabs = [] let tabs = [
addToTabs('Live') {
addToTabs('New') label: __('Live'),
addToTabs('Upcoming') },
{
if (user.data) { label: __('New'),
addToTabs('Enrolled') },
{
if ( label: __('Upcoming'),
user.data.is_moderator || },
user.data.is_instructor || ]
courses.data?.created?.length if (user.data?.is_student) {
) { tabs.push({ label: __('Enrolled') })
addToTabs('Created') } else {
} tabs.push({ label: __('Created') })
if (user.data.is_moderator) {
addToTabs('Under Review')
}
} }
return tabs return tabs
}) })
const addToTabs = (label) => { const breadcrumbs = computed(() => [
let courses = getCourses(label.toLowerCase().split(' ').join('_')) {
tabs.push({ label: __('Courses'),
label, route: { name: 'Courses' },
courses: computed(() => courses),
count: computed(() => courses.length),
})
}
const getCourses = (type) => {
let courseList = courses.data[type]
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
courseList = courseList.filter(
(course) =>
course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
)
}
if (currentCategory.value && currentCategory.value != '') {
courseList = courseList.filter(
(course) => course.category == currentCategory.value
)
}
return courseList
}
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Course',
filters: {
published: 1,
},
}
}, },
cache: ['courseCategories'], ])
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
watch(courses, () => {
if (courses.data) {
Object.keys(courses.data).forEach((section) => {
if (courses.data[section].length) {
hasCourses.value = true
}
})
}
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const pageMeta = computed(() => { const pageMeta = computed(() => {
return { return {
title: 'Courses', title: 'Courses',
description: 'All Courses divided by categories', description: 'All published courses.',
} }
}) })

View File

@@ -4,6 +4,7 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs class="h-7" :items="breadcrumbs" /> <Breadcrumbs class="h-7" :items="breadcrumbs" />
<CertificationLinks :courseName="courseName" />
</header> </header>
<div class="grid md:grid-cols-[70%,30%] h-screen"> <div class="grid md:grid-cols-[70%,30%] h-screen">
<div <div
@@ -197,13 +198,14 @@ import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next' import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue' import Discussions from '@/components/Discussions.vue'
import { getEditorTools, updateDocumentTitle } from '../utils' import { getEditorTools, updateDocumentTitle } from '../utils'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue' import LessonContent from '@/components/LessonContent.vue'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()

View File

@@ -139,7 +139,7 @@ const renderEditor = (holder) => {
const lesson = reactive({ const lesson = reactive({
title: '', title: '',
include_in_preview: false, include_in_preview: false,
body: 'Test', body: '',
instructor_notes: '', instructor_notes: '',
content: '', content: '',
}) })
@@ -294,7 +294,7 @@ const convertToJSON = (lessonData) => {
type: 'upload', type: 'upload',
data: { data: {
file_url: video, file_url: video,
file_type: 'video', file_type: video.split('.').pop(),
}, },
}) })
} else if (block.includes('{{ Audio')) { } else if (block.includes('{{ Audio')) {
@@ -303,7 +303,7 @@ const convertToJSON = (lessonData) => {
type: 'upload', type: 'upload',
data: { data: {
file_url: audio, file_url: audio,
file_type: 'audio', file_type: audio.split('.').pop(),
}, },
}) })
} else if (block.includes('{{ PDF')) { } else if (block.includes('{{ PDF')) {

View File

@@ -72,7 +72,7 @@ const roles = createResource({
}) })
const updateRole = createResource({ const updateRole = createResource({
url: 'lms.overrides.user.save_role', url: 'lms.lms.api.save_role',
makeParams(values) { makeParams(values) {
return { return {
user: props.profile.data?.name, user: props.profile.data?.name,

View File

@@ -55,7 +55,7 @@
<FormControl <FormControl
type="number" type="number"
v-model="quiz.max_attempts" v-model="quiz.max_attempts"
:label="__('Maximun Attempts')" :label="__('Maximum Attempts')"
/> />
<FormControl <FormControl
type="number" type="number"

View File

@@ -28,6 +28,12 @@ const routes = [
component: () => import('@/pages/Lesson.vue'), component: () => import('@/pages/Lesson.vue'),
props: true, props: true,
}, },
{
path: '/courses/:courseName/certification',
name: 'CourseCertification',
component: () => import('@/pages/CourseCertification.vue'),
props: true,
},
{ {
path: '/courses/:courseName/learn/:chapterName', path: '/courses/:courseName/learn/:chapterName',
name: 'SCORMChapter', name: 'SCORMChapter',
@@ -219,7 +225,7 @@ let router = createRouter({
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const { userResource } = usersStore() const { userResource } = usersStore()
const { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const { allowGuestAccess } = useSettings() const { allowGuestAccess } = useSettings()
try { try {

View File

@@ -441,6 +441,22 @@ export function getTimezones() {
] ]
} }
export function getUserTimezone() {
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const supportedTimezones = getTimezones()
if (supportedTimezones.includes(timezone)) {
return timezone // e.g., 'Asia/Calcutta', 'America/New_York', etc.
} else {
throw Error('unsupported timezone')
}
} catch (error) {
console.error('Error getting timezone:', error)
return null
}
}
export function getSidebarLinks() { export function getSidebarLinks() {
return [ return [
{ {

View File

@@ -50,10 +50,18 @@ export class Markdown {
this.wrapper.innerHTML = this.text this.wrapper.innerHTML = this.text
this.wrapper.addEventListener('keydown', (event) => { this.wrapper.addEventListener('keydown', (event) => {
const value = event.target.textContent let value = event.target.textContent
if (event.keyCode === 32 && value.startsWith('#')) { if (event.keyCode === 32 && value.startsWith('#')) {
this.convertToHeader(event, value) this.convertToHeader(event, value)
} else if (event.keyCode === 13) { } else if (event.keyCode == 189) {
this.convertBlock('list', {
style: 'unordered',
})
} else if (/^[a-zA-Z]/.test(event.key)) {
this.convertBlock('paragraph', {
text: value,
})
} else if (event.keyCode === 13 || event.keyCode === 190) {
this.parseContent(event) this.parseContent(event)
} }
}) })
@@ -75,7 +83,11 @@ export class Markdown {
parseContent(event) { parseContent(event) {
event.preventDefault() event.preventDefault()
const previousLine = this.wrapper.textContent let previousLine = this.wrapper.textContent
if (event.keyCode === 190) {
previousLine = previousLine + '.'
}
if (previousLine && this.hasImage(previousLine)) { if (previousLine && this.hasImage(previousLine)) {
this.wrapper.textContent = '' this.wrapper.textContent = ''
this.convertBlock('image') this.convertBlock('image')
@@ -94,12 +106,12 @@ export class Markdown {
}, },
], ],
}) })
} else if (previousLine && previousLine.startsWith('1. ')) { } else if (previousLine && previousLine.startsWith('1.')) {
this.convertBlock('list', { this.convertBlock('list', {
style: 'ordered', style: 'ordered',
items: [ items: [
{ {
content: previousLine.replace('1. ', ''), content: previousLine.replace('1.', ''),
}, },
], ],
}) })
@@ -108,6 +120,10 @@ export class Markdown {
this.convertBlock('embed', { this.convertBlock('embed', {
source: previousLine, source: previousLine,
}) })
} else {
this.convertBlock('paragraph', {
text: previousLine,
})
} }
} }

View File

@@ -4,6 +4,7 @@ import { createApp, h } from 'vue'
import { usersStore } from '../stores/user' import { usersStore } from '../stores/user'
import translationPlugin from '../translation' import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next' import { CircleHelp } from 'lucide-vue-next'
import router from '@/router'
export class Quiz { export class Quiz {
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly }) {
@@ -46,6 +47,7 @@ export class Quiz {
quiz: quiz, quiz: quiz,
}) })
app.use(translationPlugin) app.use(translationPlugin)
app.use(router)
const { userResource } = usersStore() const { userResource } = usersStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.mount(this.wrapper) app.mount(this.wrapper)

View File

@@ -12,7 +12,7 @@ module.exports = {
1.5: '1.5', 1.5: '1.5',
}, },
screens: { screens: {
'2xl': '1536px', '2xl': '1600px',
'3xl': '1920px', '3xl': '1920px',
}, },
}, },

View File

@@ -15,7 +15,7 @@ export default defineConfig({
}), }),
], ],
server: { server: {
allowedHosts: ['fs'], allowedHosts: ['fs', 'bs'],
}, },
resolve: { resolve: {
alias: { alias: {

2441
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.22.0" __version__ = "2.26.0"

View File

@@ -951,62 +951,6 @@
"unique": 0, "unique": 0,
"width": null "width": null
}, },
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "payments_tab",
"fieldtype": "Tab Break",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "custom_css",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Payments",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.329032",
"module": null,
"name": "Web Form-payments_tab",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{ {
"allow_in_quick_entry": 0, "allow_in_quick_entry": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
@@ -1119,230 +1063,6 @@
"unique": 0, "unique": 0,
"width": null "width": null
}, },
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": "accept_payment",
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "payment_gateway",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "accept_payment",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Payment Gateway",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.408659",
"module": null,
"name": "Web Form-payment_gateway",
"no_copy": 0,
"non_negative": 0,
"options": "Payment Gateway",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": "Buy Now",
"depends_on": "accept_payment",
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "payment_button_label",
"fieldtype": "Data",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "payment_gateway",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Button Label",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.439246",
"module": null,
"name": "Web Form-payment_button_label",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "payments_cb",
"fieldtype": "Column Break",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "payment_button_help",
"is_system_generated": 1,
"is_virtual": 0,
"label": null,
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.491696",
"module": null,
"name": "Web Form-payments_cb",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": "eval:doc.accept_payment && !doc.amount_based_on_field",
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "amount",
"fieldtype": "Currency",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "amount_field",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Amount",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.569591",
"module": null,
"name": "Web Form-amount",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{ {
"allow_in_quick_entry": 0, "allow_in_quick_entry": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
@@ -1399,174 +1119,6 @@
"unique": 0, "unique": 0,
"width": null "width": null
}, },
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": "accept_payment",
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "payment_button_help",
"fieldtype": "Text",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "payment_button_label",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Button Help",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.466744",
"module": null,
"name": "Web Form-payment_button_help",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": "0",
"depends_on": "accept_payment",
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "amount_based_on_field",
"fieldtype": "Check",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "payments_cb",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Amount Based On Field",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.517344",
"module": null,
"name": "Web Form-amount_based_on_field",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": "eval:doc.accept_payment && doc.amount_based_on_field",
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "amount_field",
"fieldtype": "Select",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "amount_based_on_field",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Amount Field",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.543136",
"module": null,
"name": "Web Form-amount_field",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{ {
"allow_in_quick_entry": 0, "allow_in_quick_entry": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
@@ -1679,62 +1231,6 @@
"unique": 0, "unique": 0,
"width": null "width": null
}, },
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": "accept_payment",
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Web Form",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "amount",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Currency",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2024-09-19 08:35:17.595419",
"module": null,
"name": "Web Form-currency",
"no_copy": 0,
"non_negative": 0,
"options": "Currency",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{ {
"allow_in_quick_entry": 0, "allow_in_quick_entry": 0,
"allow_on_submit": 0, "allow_on_submit": 0,

View File

@@ -88,7 +88,6 @@ setup_wizard_requires = "assets/lms/js/setup_wizard.js"
# Override standard doctype classes # Override standard doctype classes
override_doctype_class = { override_doctype_class = {
"User": "lms.overrides.user.CustomUser",
"Web Template": "lms.overrides.web_template.CustomWebTemplate", "Web Template": "lms.overrides.web_template.CustomWebTemplate",
} }
@@ -104,6 +103,10 @@ doc_events = {
}, },
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"}, "Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"}, "Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
"User": {
"validate": "lms.lms.user.validate_username_duplicates",
"after_insert": "lms.lms.user.after_insert",
},
} }
# Scheduled Tasks # Scheduled Tasks
@@ -112,10 +115,13 @@ scheduler_events = {
"hourly": [ "hourly": [
"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",
], ],
"daily": [ "daily": [
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings", "lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
"lms.lms.doctype.lms_payment.lms_payment.send_payment_reminder", "lms.lms.doctype.lms_payment.lms_payment.send_payment_reminder",
"lms.lms.doctype.lms_batch.lms_batch.send_batch_start_reminder",
"lms.lms.doctype.lms_live_class.lms_live_class.send_live_class_reminder",
], ],
} }
@@ -188,8 +194,8 @@ jinja = {
"lms.lms.utils.get_lesson_index", "lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url", "lms.lms.utils.get_lesson_url",
"lms.page_renderers.get_profile_url", "lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette",
"lms.lms.utils.is_instructor", "lms.lms.utils.is_instructor",
"lms.lms.utils.get_palette",
], ],
"filters": [], "filters": [],
} }
@@ -236,8 +242,6 @@ profile_url_prefix = "/users/"
signup_form_template = "lms.plugins.show_custom_signup" signup_form_template = "lms.plugins.show_custom_signup"
on_session_creation = "lms.overrides.user.on_session_creation"
add_to_apps_screen = [ add_to_apps_screen = [
{ {
"name": "lms", "name": "lms",

View File

@@ -7,15 +7,10 @@ import zipfile
import os import os
import re import re
import shutil import shutil
import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from frappe.translate import get_all_translations from frappe.translate import get_all_translations
from frappe import _ from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
from frappe.utils import ( from frappe.utils import (
time_diff,
now_datetime,
get_datetime, get_datetime,
cint, cint,
flt, flt,
@@ -24,10 +19,13 @@ from frappe.utils import (
format_date, format_date,
date_diff, date_diff,
) )
from typing import Optional
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
from frappe.integrations.frappe_providers.frappecloud_billing import (
is_fc_site,
current_site_info,
)
@frappe.whitelist() @frappe.whitelist()
@@ -180,6 +178,11 @@ def get_user_info():
user.is_moderator = "Moderator" in user.roles user.is_moderator = "Moderator" in user.roles
user.is_evaluator = "Batch Evaluator" in user.roles user.is_evaluator = "Batch Evaluator" in user.roles
user.is_student = "LMS Student" in user.roles user.is_student = "LMS Student" in user.roles
user.is_fc_site = is_fc_site()
user.is_system_manager = "System Manager" in user.roles
if user.is_fc_site and user.is_system_manager:
user.site_info = current_site_info()
user.sitename = frappe.local.site
return user return user
@@ -193,24 +196,24 @@ def get_translations():
@frappe.whitelist() @frappe.whitelist()
def validate_billing_access(type, name): def validate_billing_access(billing_type, name):
access = True access = True
message = "" message = ""
doctype = "LMS Course" if type == "course" else "LMS Batch" doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
if frappe.session.user == "Guest": if frappe.session.user == "Guest":
access = False access = False
message = _("Please login to continue with payment.") message = _("Please login to continue with payment.")
if type not in ["course", "batch"]: if access and billing_type not in ["course", "batch", "certificate"]:
access = False access = False
message = _("Module is incorrect.") message = _("Module is incorrect.")
if not frappe.db.exists(doctype, name): if access and not frappe.db.exists(doctype, name):
access = False access = False
message = _("Module Name is incorrect or does not exist.") message = _("Module Name is incorrect or does not exist.")
if type == "course": if access and billing_type == "course":
membership = frappe.db.exists( membership = frappe.db.exists(
"LMS Enrollment", {"member": frappe.session.user, "course": name} "LMS Enrollment", {"member": frappe.session.user, "course": name}
) )
@@ -218,7 +221,7 @@ def validate_billing_access(type, name):
access = False access = False
message = _("You are already enrolled for this course.") message = _("You are already enrolled for this course.")
else: elif access and billing_type == "batch":
membership = frappe.db.exists( membership = frappe.db.exists(
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": name} "LMS Batch Enrollment", {"member": frappe.session.user, "batch": name}
) )
@@ -226,6 +229,19 @@ def validate_billing_access(type, name):
access = False access = False
message = _("You are already enrolled for this batch.") message = _("You are already enrolled for this batch.")
elif access and billing_type == "certificate":
purchased_certificate = frappe.db.exists(
"LMS Enrollment",
{
"course": name,
"member": frappe.session.user,
"purchased_certificate": 1,
},
)
if purchased_certificate:
access = False
message = _("You have already purchased the certificate for this course.")
address = frappe.db.get_value( address = frappe.db.get_value(
"Address", "Address",
{"email_id": frappe.session.user}, {"email_id": frappe.session.user},
@@ -373,7 +389,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, search=None): def get_certified_participants(filters=None, start=0, page_length=30):
or_filters = {} or_filters = {}
if not filters: if not filters:
filters = {} filters = {}
@@ -845,7 +861,7 @@ def update_course_statistics():
@frappe.whitelist() @frappe.whitelist()
def get_announcements(batch): def get_announcements(batch):
return frappe.get_all( communications = frappe.get_all(
"Communication", "Communication",
filters={ filters={
"reference_doctype": "LMS Batch", "reference_doctype": "LMS Batch",
@@ -863,6 +879,13 @@ def get_announcements(batch):
order_by="communication_date desc", order_by="communication_date desc",
) )
for communication in communications:
communication.image = frappe.get_cached_value(
"User", communication.sender, "user_image"
)
return communications
@frappe.whitelist() @frappe.whitelist()
def delete_course(course): def delete_course(course):
@@ -1228,13 +1251,73 @@ def is_guest_allowed():
@frappe.whitelist() @frappe.whitelist()
def make_announcement(students, cc, subject, content): def cancel_evaluation(evaluation):
for student in students: evaluation = frappe._dict(evaluation)
frappe.sendmail(
recipients=student, if evaluation.member != frappe.session.user:
cc=cc, return
subject=subject,
message=content, frappe.db.set_value("LMS Certificate Request", evaluation.name, "status", "Cancelled")
header=[subject, "green"], events = frappe.get_all(
retry=3, "Event Participants",
{
"email": evaluation.member,
},
["parent", "name"],
)
for event in events:
info = frappe.db.get_value("Event", event.parent, ["starts_on", "subject"], as_dict=1)
date = str(info.starts_on).split(" ")[0]
if (
date == str(evaluation.date.format("YYYY-MM-DD"))
and evaluation.member_name in info.subject
):
communication = frappe.db.get_value(
"Communication",
{"reference_doctype": "Event", "reference_name": event.parent},
"name",
)
if communication:
frappe.delete_doc("Communication", communication, ignore_permissions=True)
frappe.delete_doc("Event Participants", event.name, ignore_permissions=True)
frappe.delete_doc("Event", event.parent, ignore_permissions=True)
@frappe.whitelist()
def get_certification_details(course):
membership = None
filters = {"course": course, "member": frappe.session.user}
if frappe.db.exists("LMS Enrollment", filters):
membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "certificate", "purchased_certificate"],
as_dict=1,
) )
paid_certificate = frappe.db.get_value("LMS Course", course, "paid_certificate")
return {"membership": membership, "paid_certificate": paid_certificate}
@frappe.whitelist()
def save_role(user, role, value):
frappe.only_for("Moderator")
if cint(value):
doc = frappe.get_doc(
{
"doctype": "Has Role",
"parent": user,
"role": role,
"parenttype": "User",
"parentfield": "roles",
}
)
doc.save(ignore_permissions=True)
else:
frappe.db.delete("Has Role", {"parent": user, "role": role})
return True

View File

@@ -50,7 +50,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-04-15 18:45:08.614466", "modified": "2025-02-24 12:17:08.436659",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Evaluator", "name": "Course Evaluator",

View File

@@ -13,11 +13,12 @@
"column_break_3", "column_break_3",
"member", "member",
"member_name", "member_name",
"evaluator",
"section_break_dlzh", "section_break_dlzh",
"assignment_attachment", "assignment_attachment",
"answer", "answer",
"section_break_ydgh",
"column_break_oqqy", "column_break_oqqy",
"evaluator",
"status", "status",
"comments", "comments",
"section_break_rqal", "section_break_rqal",
@@ -80,7 +81,7 @@
}, },
{ {
"fieldname": "comments", "fieldname": "comments",
"fieldtype": "Small Text", "fieldtype": "Text Editor",
"label": "Comments" "label": "Comments"
}, },
{ {
@@ -139,12 +140,16 @@
{ {
"fieldname": "column_break_oqqy", "fieldname": "column_break_oqqy",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "section_break_ydgh",
"fieldtype": "Section Break"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2024-12-24 21:22:35.212732", "modified": "2025-02-17 18:40:53.374932",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Assignment Submission", "name": "LMS Assignment Submission",

View File

@@ -8,25 +8,31 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"published", "section_break_earo",
"title", "title",
"start_date", "start_date",
"end_date", "end_date",
"column_break_4", "column_break_4",
"allow_self_enrollment",
"start_time", "start_time",
"end_time", "end_time",
"timezone", "timezone",
"section_break_rgfj", "section_break_cssv",
"medium", "published",
"category", "column_break_wfkz",
"column_break_flwy", "allow_self_enrollment",
"seat_count", "column_break_vnrp",
"evaluation_end_date", "certification",
"section_break_6", "section_break_6",
"description", "description",
"column_break_hlqw", "column_break_hlqw",
"instructors", "instructors",
"section_break_rgfj",
"medium",
"category",
"confirmation_email_template",
"column_break_flwy",
"seat_count",
"evaluation_end_date",
"meta_image", "meta_image",
"section_break_khcn", "section_break_khcn",
"batch_details", "batch_details",
@@ -206,6 +212,7 @@
"default": "0", "default": "0",
"fieldname": "published", "fieldname": "published",
"fieldtype": "Check", "fieldtype": "Check",
"in_standard_filter": 1,
"label": "Published" "label": "Published"
}, },
{ {
@@ -318,6 +325,35 @@
{ {
"fieldname": "section_break_khcn", "fieldname": "section_break_khcn",
"fieldtype": "Section Break" "fieldtype": "Section Break"
},
{
"fieldname": "confirmation_email_template",
"fieldtype": "Link",
"label": "Confirmation Email Template",
"options": "Email Template"
},
{
"fieldname": "column_break_wfkz",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_vnrp",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "certification",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Certification"
},
{
"fieldname": "section_break_earo",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_cssv",
"fieldtype": "Section Break"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
@@ -335,7 +371,7 @@
"link_fieldname": "batch_name" "link_fieldname": "batch_name"
} }
], ],
"modified": "2025-02-12 11:59:35.312487", "modified": "2025-02-18 15:43:18.512504",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",

View File

@@ -8,9 +8,8 @@ import json
from frappe import _ from frappe import _
from datetime import timedelta from datetime import timedelta
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint, format_datetime, get_time from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
from lms.lms.utils import ( from lms.lms.utils import (
get_lessons,
get_lesson_index, get_lesson_index,
get_lesson_url, get_lesson_url,
get_quiz_details, get_quiz_details,
@@ -176,6 +175,10 @@ def create_live_class(
class_details = frappe.get_doc(payload) class_details = frappe.get_doc(payload)
class_details.save() class_details.save()
return class_details return class_details
else:
frappe.throw(
_("Error creating live class. Please try again. {0}").format(response.text)
)
def authenticate(): def authenticate():
@@ -254,17 +257,6 @@ def create_batch(
return doc return doc
@frappe.whitelist()
def fetch_lessons(courses):
lessons = []
courses = json.loads(courses)
for course in courses:
lessons.extend(get_lessons(course.get("course")))
return lessons
@frappe.whitelist() @frappe.whitelist()
def add_course(course, parent, name=None, evaluator=None): def add_course(course, parent, name=None, evaluator=None):
frappe.only_for("Moderator") frappe.only_for("Moderator")
@@ -405,3 +397,40 @@ def is_milestone_complete(idx, batch):
return False return False
return True return True
def send_batch_start_reminder():
batches = frappe.get_all(
"LMS Batch",
{"start_date": add_days(nowdate(), 1), "published": 1},
["name", "title", "start_date", "start_time", "medium"],
)
for batch in batches:
students = frappe.get_all(
"LMS Batch Enrollment", {"batch": batch}, ["member", "member_name"]
)
for student in students:
send_mail(batch, student)
def send_mail(batch, student):
subject = _("Batch Start Reminder")
template = "batch_start_reminder"
args = {
"student_name": student.member_name,
"title": batch.title,
"start_date": batch.start_date,
"start_time": batch.start_time,
"medium": batch.medium,
"name": batch.name,
}
frappe.sendmail(
recipients=student.member,
subject=subject,
template=template,
args=args,
header=[_(f"Batch Start Reminder: {batch.title}"), "orange"],
)

View File

@@ -76,17 +76,26 @@ def send_confirmation_email(doc):
def send_mail(doc): def send_mail(doc):
subject = _("Enrollment Confirmation for the Next Training Batch")
template = "batch_confirmation"
custom_template = frappe.db.get_single_value(
"LMS Settings", "batch_confirmation_template"
)
batch = frappe.db.get_value( batch = frappe.db.get_value(
"LMS Batch", "LMS Batch",
doc.batch, doc.batch,
["name", "title", "start_date", "start_time", "medium"], [
"name",
"title",
"start_date",
"start_time",
"medium",
"confirmation_email_template",
],
as_dict=1, as_dict=1,
) )
subject = _("Enrollment Confirmation for {0}").format(batch.title)
template = "batch_confirmation"
custom_template = batch.confirmation_email_template or frappe.db.get_single_value(
"LMS Settings", "batch_confirmation_template"
)
args = { args = {
"title": batch.title, "title": batch.title,
"student_name": doc.member_name, "student_name": doc.member_name,
@@ -107,6 +116,6 @@ def send_mail(doc):
template=template if not custom_template else None, template=template if not custom_template else None,
content=content if custom_template else None, content=content if custom_template else None,
args=args, args=args,
header=[subject, "green"], header=[_(batch.title), "green"],
retry=3, retry=3,
) )

View File

@@ -122,7 +122,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-01-17 11:57:02.859109", "modified": "2025-02-19 16:26:05.902473",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Certificate", "name": "LMS Certificate",
@@ -156,6 +156,7 @@
"create": 1, "create": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,

View File

@@ -122,11 +122,6 @@ def create_certificate(course):
) )
else: else:
expires_after_yrs = int(frappe.db.get_value("LMS Course", course, "expiry"))
expiry_date = None
if expires_after_yrs:
expiry_date = add_years(nowdate(), expires_after_yrs)
default_certificate_template = frappe.db.get_value( default_certificate_template = frappe.db.get_value(
"Property Setter", "Property Setter",
{ {
@@ -148,7 +143,6 @@ def create_certificate(course):
"member": frappe.session.user, "member": frappe.session.user,
"course": course, "course": course,
"issue_date": nowdate(), "issue_date": nowdate(),
"expiry_date": expiry_date,
"template": default_certificate_template, "template": default_certificate_template,
} }
) )

View File

@@ -16,7 +16,6 @@ class TestLMSCertificate(unittest.TestCase):
"Test Certificate", "Test Certificate",
{ {
"enable_certification": 1, "enable_certification": 1,
"expiry": 2,
}, },
) )
certificate = create_certificate(course.name) certificate = create_certificate(course.name)
@@ -24,7 +23,6 @@ class TestLMSCertificate(unittest.TestCase):
self.assertEqual(certificate.member, "Administrator") self.assertEqual(certificate.member, "Administrator")
self.assertEqual(certificate.course, course.name) self.assertEqual(certificate.course, course.name)
self.assertEqual(certificate.issue_date, nowdate()) self.assertEqual(certificate.issue_date, nowdate())
self.assertEqual(certificate.expiry_date, add_years(nowdate(), cint(course.expiry)))
frappe.db.delete("LMS Certificate", certificate.name) frappe.db.delete("LMS Certificate", certificate.name)
frappe.db.delete("LMS Course", course.name) frappe.db.delete("LMS Course", course.name)

View File

@@ -4,7 +4,7 @@
frappe.ui.form.on("LMS Certificate Evaluation", { frappe.ui.form.on("LMS Certificate Evaluation", {
refresh: function (frm) { refresh: function (frm) {
if (!frm.is_new() && frm.doc.status == "Pass") { if (!frm.is_new() && frm.doc.status == "Pass") {
frm.add_custom_button(__("Create LMS Certificate"), () => { frm.add_custom_button(__("Create Certificate"), () => {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({
method: "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.create_lms_certificate", method: "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.create_lms_certificate",
frm: frm, frm: frm,

View File

@@ -3,18 +3,15 @@
frappe.ui.form.on("LMS Certificate Request", { frappe.ui.form.on("LMS Certificate Request", {
refresh: function (frm) { refresh: function (frm) {
if (!frm.is_new()) { if (!frm.is_new() && frm.doc.status == "Upcoming") {
frm.add_custom_button( frm.add_custom_button(__("Conduct Evaluation"), () => {
__("Create LMS Certificate Evaluation"), frappe.model.open_mapped_doc({
() => { method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.create_lms_certificate_evaluation",
frappe.model.open_mapped_doc({ frm: frm,
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.create_lms_certificate_evaluation", });
frm: frm, });
});
}
);
} }
if (!frm.doc.google_meet_link) { if (!frm.doc.google_meet_link && frm.doc.status == "Upcoming") {
frm.add_custom_button(__("Generate Google Meet Link"), () => { frm.add_custom_button(__("Generate Google Meet Link"), () => {
frappe.call({ frappe.call({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.setup_calendar_event", method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.setup_calendar_event",

View File

@@ -24,7 +24,8 @@
"google_meet_link", "google_meet_link",
"column_break_ddyh", "column_break_ddyh",
"start_time", "start_time",
"end_time" "end_time",
"status"
], ],
"fields": [ "fields": [
{ {
@@ -144,11 +145,19 @@
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1, "hidden": 1,
"label": "Batch Title" "label": "Batch Title"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"options": "Upcoming\nCompleted\nCancelled",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-09-11 11:19:44.669132", "modified": "2025-02-19 17:20:02.526294",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Certificate Request", "name": "LMS Certificate Request",
@@ -204,6 +213,19 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [
{
"color": "Blue",
"title": "Upcoming"
},
{
"color": "Green",
"title": "Completed"
},
{
"color": "Red",
"title": "Cancelled"
}
],
"title_field": "member_name" "title_field": "member_name"
} }

View File

@@ -77,6 +77,7 @@ class LMSCertificateRequest(Document):
"member": self.member, "member": self.member,
"course": self.course, "course": self.course,
"name": ["!=", self.name], "name": ["!=", self.name],
"status": "Upcoming",
}, },
["date", "start_time", "course"], ["date", "start_time", "course"],
) )
@@ -150,7 +151,11 @@ def schedule_evals():
timelapse = add_to_date(get_datetime(), hours=-5) timelapse = add_to_date(get_datetime(), hours=-5)
evals = frappe.get_all( evals = frappe.get_all(
"LMS Certificate Request", "LMS Certificate Request",
{"creation": [">=", timelapse], "google_meet_link": ["is", "not set"]}, {
"creation": [">=", timelapse],
"google_meet_link": ["is", "not set"],
"status": "Upcoming",
},
["name", "member", "member_name", "evaluator", "date", "start_time", "end_time"], ["name", "member", "member_name", "evaluator", "date", "start_time", "end_time"],
) )
for eval in evals: for eval in evals:
@@ -254,3 +259,20 @@ def create_lms_certificate_evaluation(source_name, target_doc=None):
target_doc, target_doc,
) )
return doc return doc
def mark_eval_as_completed():
requests = frappe.get_all(
"LMS Certificate Request",
{
"status": "Upcoming",
"date": ["<=", getdate()],
},
["name", "end_time", "date"],
)
for req in requests:
if req.date < getdate():
frappe.db.set_value("LMS Certificate Request", req.name, "status", "Completed")
elif req.date == getdate() and get_time(req.end_time) < get_time(nowtime()):
frappe.db.set_value("LMS Certificate Request", req.name, "status", "Completed")

View File

@@ -40,15 +40,13 @@
"pricing_tab", "pricing_tab",
"pricing_section", "pricing_section",
"paid_course", "paid_course",
"enable_certification",
"paid_certificate",
"evaluator",
"column_break_acoj", "column_break_acoj",
"course_price", "course_price",
"currency", "currency",
"amount_usd", "amount_usd",
"certification_tab",
"certification_section",
"enable_certification",
"column_break_rxww",
"expiry",
"tab_4_tab", "tab_4_tab",
"statistics_section", "statistics_section",
"enrollments", "enrollments",
@@ -134,22 +132,11 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Course Settings" "label": "Course Settings"
}, },
{
"fieldname": "certification_section",
"fieldtype": "Section Break"
},
{ {
"default": "0", "default": "0",
"fieldname": "enable_certification", "fieldname": "enable_certification",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Certification" "label": "Completion Certificate"
},
{
"default": "0",
"depends_on": "enable_certification",
"fieldname": "expiry",
"fieldtype": "Int",
"label": "Certification Expires After (Years)"
}, },
{ {
"fieldname": "related_courses", "fieldname": "related_courses",
@@ -181,7 +168,6 @@
"fieldtype": "Section Break" "fieldtype": "Section Break"
}, },
{ {
"depends_on": "paid_course",
"fieldname": "currency", "fieldname": "currency",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Currency", "label": "Currency",
@@ -195,22 +181,16 @@
"label": "Paid Course" "label": "Paid Course"
}, },
{ {
"depends_on": "paid_course",
"fieldname": "course_price", "fieldname": "course_price",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Course Price", "label": "Amount",
"mandatory_depends_on": "paid_course" "mandatory_depends_on": "paid_course"
}, },
{
"fieldname": "column_break_rxww",
"fieldtype": "Column Break"
},
{ {
"fieldname": "column_break_acoj", "fieldname": "column_break_acoj",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"depends_on": "paid_course",
"description": "If you set an amount here, then the USD equivalent setting will not get applied.", "description": "If you set an amount here, then the USD equivalent setting will not get applied.",
"fieldname": "amount_usd", "fieldname": "amount_usd",
"fieldtype": "Currency", "fieldtype": "Currency",
@@ -238,12 +218,7 @@
{ {
"fieldname": "pricing_tab", "fieldname": "pricing_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Pricing" "label": "Pricing and Certification"
},
{
"fieldname": "certification_tab",
"fieldtype": "Tab Break",
"label": "Certification"
}, },
{ {
"fieldname": "column_break_htgn", "fieldname": "column_break_htgn",
@@ -267,14 +242,14 @@
{ {
"default": "0", "default": "0",
"fieldname": "enrollments", "fieldname": "enrollments",
"fieldtype": "Data", "fieldtype": "Int",
"label": "Enrollments", "label": "Enrollments",
"read_only": 1 "read_only": 1
}, },
{ {
"default": "0", "default": "0",
"fieldname": "lessons", "fieldname": "lessons",
"fieldtype": "Data", "fieldtype": "Int",
"label": "Lessons", "label": "Lessons",
"read_only": 1 "read_only": 1
}, },
@@ -284,6 +259,19 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Rating", "label": "Rating",
"read_only": 1 "read_only": 1
},
{
"default": "0",
"fieldname": "paid_certificate",
"fieldtype": "Check",
"label": "Paid Certificate"
},
{
"depends_on": "paid_certificate",
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",
"options": "Course Evaluator"
} }
], ],
"is_published_field": "published", "is_published_field": "published",
@@ -310,7 +298,7 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2024-10-30 23:08:31.842860", "modified": "2025-03-04 15:43:25.151554",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Course", "name": "LMS Course",

View File

@@ -5,9 +5,8 @@ import json
import random import random
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint, today from frappe.utils import today, cint
from frappe.utils.telemetry import capture from lms.lms.utils import get_chapters
from lms.lms.utils import get_chapters, can_create_courses
from ...utils import generate_slug, validate_image, update_payment_record from ...utils import generate_slug, validate_image, update_payment_record
from frappe import _ from frappe import _
@@ -19,6 +18,7 @@ class LMSCourse(Document):
self.validate_video_link() self.validate_video_link()
self.validate_status() self.validate_status()
self.validate_payments_app() self.validate_payments_app()
self.validate_certification()
self.validate_amount_and_currency() self.validate_amount_and_currency()
self.image = validate_image(self.image) self.image = validate_image(self.image)
@@ -52,10 +52,22 @@ class LMSCourse(Document):
if "payments" not in installed_apps: if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses.")) frappe.throw(_("Please install the Payments app to create a paid courses."))
def validate_certification(self):
if self.enable_certification and self.paid_certificate:
frappe.throw(
_("A course cannot have both paid certificate and certificate of completion.")
)
if self.paid_certificate and not self.evaluator:
frappe.throw(_("Evaluator is required for paid certificates."))
def validate_amount_and_currency(self): def validate_amount_and_currency(self):
if self.paid_course and (not self.course_price and not self.currency): if self.paid_course and (cint(self.course_price) < 0 or not self.currency):
frappe.throw(_("Amount and currency are required for paid courses.")) frappe.throw(_("Amount and currency are required for paid courses."))
if self.paid_certificate and (cint(self.course_price) <= 0 or not self.currency):
frappe.throw(_("Amount and currency are required for paid certificates."))
def on_update(self): def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"): if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users() self.send_email_to_interested_users()

View File

@@ -14,6 +14,9 @@
"member", "member",
"member_name", "member_name",
"member_username", "member_username",
"certification_section",
"purchased_certificate",
"certificate",
"section_break_8", "section_break_8",
"cohort", "cohort",
"subgroup", "subgroup",
@@ -123,11 +126,28 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Payment", "label": "Payment",
"options": "LMS Payment" "options": "LMS Payment"
},
{
"fieldname": "certification_section",
"fieldtype": "Section Break",
"label": "Certification"
},
{
"default": "0",
"fieldname": "purchased_certificate",
"fieldtype": "Check",
"label": "Purchased Certificate"
},
{
"fieldname": "certificate",
"fieldtype": "Link",
"label": "Certificate",
"options": "LMS Certificate"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-10-30 12:44:16.103598", "modified": "2025-02-21 17:11:37.986157",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Enrollment", "name": "LMS Enrollment",

View File

@@ -2,9 +2,10 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
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 from frappe.utils import cint, get_datetime, format_date, nowdate, format_time
class LMSLiveClass(Document): class LMSLiveClass(Document):
@@ -56,8 +57,48 @@ class LMSLiveClass(Document):
{ {
"sync_with_google_calendar": 1, "sync_with_google_calendar": 1,
"google_calendar": calendar, "google_calendar": calendar,
"description": f"A Live Class has been scheduled on {frappe.utils.format_date(self.date, 'medium')} at { frappe.utils.format_time(self.time, 'hh:mm a')}. Click on this link to join. {self.join_url}. {self.description}", "description": f"A Live Class has been scheduled on {format_date(self.date, 'medium')} at {format_time(self.time, 'hh:mm a')}. Click on this link to join. {self.join_url}. {self.description}",
} }
) )
event.save() event.save()
def send_live_class_reminder():
classes = frappe.get_all(
"LMS Live Class",
{
"date": nowdate(),
},
["name", "batch_name", "title", "date", "time"],
)
for live_class in classes:
students = frappe.get_all(
"LMS Batch Enrollment",
{"batch": live_class.batch_name},
["member", "member_name"],
)
for student in students:
send_mail(live_class, student)
def send_mail(live_class, student):
subject = f"Your class on {live_class.title} is tomorrow"
template = "live_class_reminder"
args = {
"student_name": student.member_name,
"title": live_class.title,
"date": live_class.date,
"time": live_class.time,
"batch_name": live_class.batch_name,
}
frappe.sendmail(
recipients=student.member,
subject=subject,
template=template,
args=args,
header=[_(f"Class Reminder: {live_class.title}"), "orange"],
)

View File

@@ -14,6 +14,7 @@
"payment_for_document_type", "payment_for_document_type",
"payment_for_document", "payment_for_document",
"payment_received", "payment_received",
"payment_for_certificate",
"payment_details_section", "payment_details_section",
"currency", "currency",
"amount", "amount",
@@ -136,11 +137,26 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Source", "label": "Source",
"options": "LMS Source" "options": "LMS Source"
},
{
"default": "0",
"fieldname": "payment_for_certificate",
"fieldtype": "Check",
"label": "Payment for Certificate"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2025-02-11 14:48:27.801895", {
"link_doctype": "LMS Batch Enrollment",
"link_fieldname": "payment"
},
{
"link_doctype": "LMS Enrollment",
"link_fieldname": "payment"
}
],
"modified": "2025-02-21 18:29:55.436611",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Payment", "name": "LMS Payment",

View File

@@ -33,9 +33,24 @@ def send_payment_reminder():
) )
for payment in incomplete_payments: for payment in incomplete_payments:
if has_paid_later(payment):
continue
send_mail(payment) send_mail(payment)
def has_paid_later(payment):
return frappe.db.exists(
"LMS Payment",
{
"member": payment.member,
"payment_received": 1,
"payment_for_document": payment.payment_for_document,
"payment_for_document_type": payment.payment_for_document_type,
},
)
def send_mail(payment): def send_mail(payment):
subject = _("Complete Your Enrollment - Don't miss out!") subject = _("Complete Your Enrollment - Don't miss out!")
template = "payment_reminder" template = "payment_reminder"

View File

@@ -10,12 +10,29 @@ from frappe.desk.doctype.notification_log.notification_log import make_notificat
class LMSQuizSubmission(Document): class LMSQuizSubmission(Document):
def validate(self): def validate(self):
self.validate_if_max_attempts_exceeded()
self.validate_marks() self.validate_marks()
self.set_percentage() self.set_percentage()
def on_update(self): def on_update(self):
self.notify_member() self.notify_member()
def validate_if_max_attempts_exceeded(self):
max_attempts = frappe.db.get_value("LMS Quiz", self.quiz, ["max_attempts"])
if max_attempts == 0:
return
current_user_submission_count = frappe.db.count(
self.doctype, filters={"quiz": self.quiz, "member": frappe.session.user}
)
if current_user_submission_count >= max_attempts:
frappe.throw(
_("You have exceeded the maximum number of attempts ({0}) for this quiz").format(
max_attempts
),
MaximumAttemptsExceededError,
)
def validate_marks(self): def validate_marks(self):
self.score = 0 self.score = 0
for row in self.result: for row in self.result:
@@ -52,3 +69,7 @@ class LMSQuizSubmission(Document):
) )
make_notification_logs(notification, [self.member]) make_notification_logs(notification, [self.member])
class MaximumAttemptsExceededError(frappe.DuplicateEntryError):
pass

View File

@@ -18,19 +18,26 @@ def validate_currency(payment_gateway, currency):
@frappe.whitelist() @frappe.whitelist()
def get_payment_link(doctype, docname, title, amount, total_amount, currency, address): def get_payment_link(
doctype,
docname,
title,
amount,
total_amount,
currency,
address,
redirect_to,
payment_for_certificate,
):
payment_gateway = get_payment_gateway() payment_gateway = get_payment_gateway()
address = frappe._dict(address) address = frappe._dict(address)
amount_with_gst = total_amount if total_amount != amount else 0 amount_with_gst = total_amount if total_amount != amount else 0
payment = record_payment(address, doctype, docname, amount, currency, amount_with_gst) payment = record_payment(
address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
)
controller = get_controller(payment_gateway) controller = get_controller(payment_gateway)
if doctype == "LMS Course":
redirect_to = f"/lms/courses/{docname}/learn/1-1"
elif doctype == "LMS Batch":
redirect_to = f"/lms/batches/{docname}"
payment_details = { payment_details = {
"amount": total_amount, "amount": total_amount,
"title": f"Payment for {doctype} {title} {docname}", "title": f"Payment for {doctype} {title} {docname}",
@@ -53,7 +60,15 @@ def get_payment_link(doctype, docname, title, amount, total_amount, currency, ad
return url return url
def record_payment(address, doctype, docname, amount, currency, amount_with_gst=0): def record_payment(
address,
doctype,
docname,
amount,
currency,
amount_with_gst=0,
payment_for_certificate=0,
):
address = frappe._dict(address) address = frappe._dict(address)
address_name = save_address(address) address_name = save_address(address)
@@ -71,6 +86,7 @@ def record_payment(address, doctype, docname, amount, currency, amount_with_gst=
"source": address.source, "source": address.source,
"payment_for_document_type": doctype, "payment_for_document_type": doctype,
"payment_for_document": docname, "payment_for_document": docname,
"payment_for_certificate": payment_for_certificate,
} }
) )
payment_doc.save(ignore_permissions=True) payment_doc.save(ignore_permissions=True)

85
lms/lms/user.py Normal file
View File

@@ -0,0 +1,85 @@
import frappe
from frappe import _
from frappe.model.naming import append_number_if_name_exists
from frappe.website.utils import cleanup_page_name
from frappe.website.utils import is_signup_disabled
from frappe.utils import random_string, escape_html
from lms.lms.utils import get_country_code
def validate_username_duplicates(doc, method):
while not doc.username or doc.username_exists():
doc.username = append_number_if_name_exists(
doc.doctype, cleanup_page_name(doc.full_name), fieldname="username"
)
if " " in doc.username:
doc.username = doc.username.replace(" ", "")
if len(doc.username) < 4:
doc.username = doc.email.replace("@", "").replace(".", "")
def after_insert(doc, method):
doc.add_roles("LMS Student")
@frappe.whitelist(allow_guest=True)
def sign_up(email, full_name, verify_terms, user_category):
if is_signup_disabled():
frappe.throw(_("Sign Up is disabled"), _("Not Allowed"))
user = frappe.db.get("User", {"email": email})
if user:
if user.enabled:
return 0, _("Already Registered")
else:
return 0, _("Registered but disabled")
else:
if frappe.db.get_creation_count("User", 60) > 300:
frappe.respond_as_web_page(
_("Temporarily Disabled"),
_(
"Too many users signed up recently, so the registration is disabled. Please try back in an hour"
),
http_status_code=429,
)
user = frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": escape_html(full_name),
"verify_terms": verify_terms,
"user_category": user_category,
"country": "",
"enabled": 1,
"new_password": random_string(10),
"user_type": "Website User",
}
)
user.flags.ignore_permissions = True
user.flags.ignore_password_policy = True
user.insert()
# set default signup role as per Portal Settings
default_role = frappe.db.get_single_value("Portal Settings", "default_role")
if default_role:
user.add_roles(default_role)
user.add_roles("LMS Student")
set_country_from_ip(None, user.name)
if user.flags.email_sent:
return 1, _("Please check your email for verification")
else:
return 2, _("Please ask your administrator to verify your sign-up")
def set_country_from_ip(login_manager=None, user=None):
if not user and login_manager:
user = login_manager.user
user_country = frappe.db.get_value("User", user, "country")
# if user_country:
# return
frappe.db.set_value("User", user, "country", get_country_code())
return

View File

@@ -1,6 +1,7 @@
import re import re
import string import string
import frappe import frappe
import hashlib
import json import json
import razorpay import razorpay
import requests import requests
@@ -68,27 +69,26 @@ def generate_slug(title, doctype):
return slugify(title, used_slugs=slugs) return slugify(title, used_slugs=slugs)
def get_membership(course, member=None, batch=None): def get_membership(course, member=None):
if not member: if not member:
member = frappe.session.user member = frappe.session.user
filters = {"member": member, "course": course} filters = {"member": member, "course": course}
if batch:
filters["batch_old"] = batch
is_member = frappe.db.exists("LMS Enrollment", filters) if frappe.db.exists("LMS Enrollment", filters):
if is_member:
membership = frappe.db.get_value( membership = frappe.db.get_value(
"LMS Enrollment", "LMS Enrollment",
filters, filters,
["name", "batch_old", "current_lesson", "member_type", "progress", "member"], [
"name",
"current_lesson",
"progress",
"member",
"purchased_certificate",
"certificate",
],
as_dict=True, as_dict=True,
) )
if membership and membership.batch_old:
membership.batch_title = frappe.db.get_value(
"LMS Batch Old", membership.batch_old, "title"
)
return membership return membership
return False return False
@@ -856,26 +856,44 @@ def is_onboarding_complete():
} }
def get_evaluator(course, batch): def get_evaluator(course, batch=None):
evaluator = None evaluator = None
evaluator = frappe.db.get_value( if batch:
"Batch Course", evaluator = frappe.db.get_value(
{"parent": batch, "course": course}, "Batch Course",
"evaluator", {"parent": batch, "course": course},
) "evaluator",
)
else:
evaluator = frappe.db.get_value("LMS Course", course, "evaluator")
return evaluator return evaluator
@frappe.whitelist() @frappe.whitelist()
def get_upcoming_evals(student, courses): def get_upcoming_evals(student, courses, batch=None):
filters = {
"member": student,
"course": ["in", courses],
"date": [">=", frappe.utils.nowdate()],
"status": "Upcoming",
}
if batch:
filters["batch_name"] = batch
upcoming_evals = frappe.get_all( upcoming_evals = frappe.get_all(
"LMS Certificate Request", "LMS Certificate Request",
{ filters,
"member": student, [
"course": ["in", courses], "name",
"date": [">=", frappe.utils.nowdate()], "date",
}, "start_time",
["date", "start_time", "course", "evaluator", "google_meet_link"], "course",
"evaluator",
"google_meet_link",
"member",
"member_name",
],
order_by="date", order_by="date",
) )
@@ -967,17 +985,145 @@ def change_currency(amount, currency, country=None):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_courses(): def get_courses(filters=None, start=0, page_length=20):
"""Returns the list of courses.""" """Returns the list of courses."""
courses = []
course_list = frappe.get_all("LMS Course", pluck="name")
for course in course_list:
courses.append(get_course_details(course))
courses = get_categorized_courses(courses) if not filters:
filters = {}
filters, or_filters, show_featured = update_course_filters(filters)
fields = get_course_fields()
courses = frappe.get_all(
"LMS Course",
filters=filters,
fields=fields,
or_filters=or_filters,
order_by="enrollments desc",
start=start,
page_length=page_length,
)
if show_featured:
courses = get_featured_courses(filters, or_filters, fields) + courses
courses = get_enrollment_details(courses)
courses = get_course_card_details(courses)
return courses return courses
def get_course_card_details(courses):
for course in courses:
course.instructors = get_instructors(course.name)
if course.paid_course and course.published == 1:
course.amount, course.currency = check_multicurrency(
course.course_price, course.currency, None, course.amount_usd
)
course.price = fmt_money(course.amount, 0, course.currency)
return courses
def get_course_or_filters(filters):
or_filters = {}
or_filters.update({"title": filters.get("title")})
or_filters.update({"short_introduction": filters.get("title")})
or_filters.update({"description": filters.get("title")})
or_filters.update({"tags": filters.get("title")})
return or_filters
def update_course_filters(filters):
or_filters = {}
show_featured = False
if filters.get("title"):
or_filters = get_course_or_filters(filters)
del filters["title"]
if filters.get("enrolled"):
enrolled_courses = frappe.get_all(
"LMS Enrollment", {"member": frappe.session.user}, pluck="course"
)
filters.update({"name": ["in", enrolled_courses]})
del filters["enrolled"]
if filters.get("created"):
created_courses = frappe.get_all(
"Course Instructor", {"instructor": frappe.session.user}, pluck="parent"
)
filters.update({"name": ["in", created_courses]})
del filters["created"]
if filters.get("live"):
filters.update({"featured": 0})
show_featured = True
del filters["live"]
if filters.get("certification"):
or_filters.update({"enable_certification": 1})
or_filters.update({"paid_certificate": 1})
del filters["certification"]
return filters, or_filters, show_featured
def get_enrollment_details(courses):
for course in courses:
filters = {
"course": course.name,
"member": frappe.session.user,
}
if frappe.db.exists("LMS Enrollment", filters):
course.membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "course", "current_lesson", "progress", "member"],
as_dict=1,
)
return courses
def get_featured_courses(filters, or_filters, fields):
filters.update({"featured": 1})
featured_courses = frappe.get_all(
"LMS Course",
filters=filters,
fields=fields,
or_filters=or_filters,
order_by="enrollments desc",
)
return featured_courses
def get_course_fields():
return [
"name",
"title",
"tags",
"image",
"short_introduction",
"published",
"upcoming",
"featured",
"disable_self_learning",
"published_on",
"category",
"status",
"paid_course",
"paid_certificate",
"course_price",
"currency",
"amount_usd",
"enable_certification",
"lessons",
"enrollments",
"rating",
]
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_course_details(course): def get_course_details(course):
course_details = frappe.db.get_value( course_details = frappe.db.get_value(
@@ -999,6 +1145,7 @@ def get_course_details(course):
"category", "category",
"status", "status",
"paid_course", "paid_course",
"paid_certificate",
"course_price", "course_price",
"currency", "currency",
"amount_usd", "amount_usd",
@@ -1013,7 +1160,7 @@ def get_course_details(course):
course_details.instructors = get_instructors(course_details.name) course_details.instructors = get_instructors(course_details.name)
# course_details.is_instructor = is_instructor(course_details.name) # course_details.is_instructor = is_instructor(course_details.name)
if course_details.paid_course: if course_details.paid_course or course_details.paid_certificate:
"""course_details.course_price, course_details.currency = check_multicurrency( """course_details.course_price, course_details.currency = check_multicurrency(
course_details.course_price, course_details.currency, None, course_details.amount_usd course_details.course_price, course_details.currency, None, course_details.amount_usd
)""" )"""
@@ -1126,14 +1273,21 @@ def get_lesson(course, chapter, lesson):
return {} return {}
membership = get_membership(course) membership = get_membership(course)
course_title = frappe.db.get_value("LMS Course", course, "title") course_info = frappe.db.get_value(
"LMS Course", course, ["title", "paid_certificate"], as_dict=1
)
if ( if (
not lesson_details.include_in_preview not lesson_details.include_in_preview
and not membership and not membership
and not has_course_moderator_role() and not has_course_moderator_role()
and not is_instructor(course) and not is_instructor(course)
): ):
return {"no_preview": 1, "title": lesson_details.title, "course_title": course_title} return {
"no_preview": 1,
"title": lesson_details.title,
"course_title": course_info.title,
}
lesson_details = frappe.db.get_value( lesson_details = frappe.db.get_value(
"Course Lesson", "Course Lesson",
@@ -1168,7 +1322,8 @@ def get_lesson(course, chapter, lesson):
lesson_details.prev = neighbours["prev"] lesson_details.prev = neighbours["prev"]
lesson_details.membership = membership lesson_details.membership = membership
lesson_details.instructors = get_instructors(course) lesson_details.instructors = get_instructors(course)
lesson_details.course_title = course_title lesson_details.course_title = course_info.title
lesson_details.paid_certificate = course_info.paid_certificate
return lesson_details return lesson_details
@@ -1194,6 +1349,16 @@ def get_neighbour_lesson(course, chapter, lesson):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_batch_details(batch): def get_batch_details(batch):
batch_students = frappe.get_all(
"LMS Batch Enrollment", {"batch": batch}, pluck="member"
)
if (
not frappe.db.get_value("LMS Batch", batch, "published")
and has_student_role()
and frappe.session.user not in batch_students
):
return
batch_details = frappe.db.get_value( batch_details = frappe.db.get_value(
"LMS Batch", "LMS Batch",
batch, batch,
@@ -1215,6 +1380,7 @@ def get_batch_details(batch):
"paid_batch", "paid_batch",
"evaluation_end_date", "evaluation_end_date",
"allow_self_enrollment", "allow_self_enrollment",
"certification",
"timezone", "timezone",
"category", "category",
], ],
@@ -1222,13 +1388,12 @@ def get_batch_details(batch):
) )
batch_details.instructors = get_instructors(batch) batch_details.instructors = get_instructors(batch)
batch_details.accept_enrollments = batch_details.start_date > getdate()
batch_details.courses = frappe.get_all( batch_details.courses = frappe.get_all(
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"] "Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
) )
batch_details.students = frappe.get_all( batch_details.students = batch_students
"LMS Batch Enrollment", {"batch": batch}, pluck="member"
)
if batch_details.paid_batch and batch_details.start_date >= getdate(): if batch_details.paid_batch and batch_details.start_date >= getdate():
batch_details.amount, batch_details.currency = check_multicurrency( batch_details.amount, batch_details.currency = check_multicurrency(
@@ -1450,7 +1615,7 @@ def get_batch_students(batch):
) )
detail.assessments[title] = assessment_info detail.assessments[title] = assessment_info
if assessment_info.result == "Passed": if assessment_info.result == "Pass":
assessments_completed += 1 assessments_completed += 1
detail.courses_completed = courses_completed detail.courses_completed = courses_completed
@@ -1493,20 +1658,26 @@ def has_submitted_assessment(assessment, assessment_type, member=None):
attempt = frappe.db.exists(doctype, filters) attempt = frappe.db.exists(doctype, filters)
if attempt: if attempt:
attempt_details = frappe.db.get_value(doctype, filters, fields) fields.append("name")
attempt_details = frappe.db.get_value(doctype, filters, fields, as_dict=1)
if assessment_type == "LMS Quiz": if assessment_type == "LMS Quiz":
result = "Failed" result = "Failed"
passing_percentage = frappe.db.get_value( passing_percentage = frappe.db.get_value(
"LMS Quiz", assessment, "passing_percentage" "LMS Quiz", assessment, "passing_percentage"
) )
if attempt_details >= passing_percentage: if attempt_details.percentage >= passing_percentage:
result = "Passed" result = "Pass"
else: else:
result = attempt_details result = attempt_details.status
return frappe._dict( return frappe._dict(
{ {
"status": attempt_details, "status": attempt_details.percentage
if assessment_type == "LMS Quiz"
else attempt_details.status,
"result": result, "result": result,
"assessment": assessment,
"type": assessment_type,
"submission": attempt_details.name,
} }
) )
else: else:
@@ -1586,11 +1757,19 @@ def get_order_summary(doctype, docname, country=None):
details = frappe.db.get_value( details = frappe.db.get_value(
"LMS Course", "LMS Course",
docname, docname,
["title", "name", "paid_course", "course_price as amount", "currency", "amount_usd"], [
"title",
"name",
"paid_course",
"paid_certificate",
"course_price as amount",
"currency",
"amount_usd",
],
as_dict=True, as_dict=True,
) )
if not details.paid_course: if not details.paid_course and not details.paid_certificate:
raise frappe.throw(_("This course is free.")) raise frappe.throw(_("This course is free."))
else: else:
@@ -1704,9 +1883,14 @@ def update_payment_record(doctype, docname):
"order_id": data.get("order_id"), "order_id": data.get("order_id"),
}, },
) )
payment_for_certificate = frappe.db.get_value(
"LMS Payment", data.payment, "payment_for_certificate"
)
try: try:
if doctype == "LMS Course": if payment_for_certificate:
update_certificate_purchase(docname)
elif doctype == "LMS Course":
enroll_in_course(data.payment, docname) enroll_in_course(data.payment, docname)
else: else:
enroll_in_batch(docname, data.payment) enroll_in_batch(docname, data.payment)
@@ -1766,6 +1950,15 @@ def enroll_in_batch(batch, payment_name=None):
new_student.save() new_student.save()
def update_certificate_purchase(course):
frappe.db.set_value(
"LMS Enrollment",
{"member": frappe.session.user, "course": course},
"purchased_certificate",
1,
)
@frappe.whitelist() @frappe.whitelist()
def get_programs(): def get_programs():
if ( if (
@@ -1945,3 +2138,25 @@ def get_batch_card_details(batches):
batch.price = fmt_money(batch.amount, 0, batch.currency) batch.price = fmt_money(batch.amount, 0, batch.currency)
return batches return batches
def get_palette(full_name):
"""
Returns a color unique to each member for Avatar"""
palette = [
["--orange-avatar-bg", "--orange-avatar-color"],
["--pink-avatar-bg", "--pink-avatar-color"],
["--blue-avatar-bg", "--blue-avatar-color"],
["--green-avatar-bg", "--green-avatar-color"],
["--dark-green-avatar-bg", "--dark-green-avatar-color"],
["--red-avatar-bg", "--red-avatar-color"],
["--yellow-avatar-bg", "--yellow-avatar-color"],
["--purple-avatar-bg", "--purple-avatar-color"],
["--gray-avatar-bg", "--gray-avatar-color0"],
]
encoded_name = str(full_name).encode("utf-8")
hash_name = hashlib.md5(encoded_name).hexdigest()
idx = cint((int(hash_name[4:6], 16) + 1) / 5.33)
return palette[idx % 8]

View File

@@ -1,11 +0,0 @@
{% set member = frappe.get_doc("User", frappe.session.user) %}
<div class="mt-10">
{% if member.get_mentored_courses() | length %}
<div class="course-home-headings"> {{ _("Courses Mentored") }} </div>
<div class="cards-parent">
{% for course in member.get_mentored_courses() %}
{{ widgets.CourseCard(course=course) }}
{% endfor %}
</div>
{% endif %}
</div>

View File

@@ -1,16 +0,0 @@
{
"__unsaved": 1,
"creation": "2021-10-21 11:32:57.411626",
"docstatus": 0,
"doctype": "Web Template",
"fields": [],
"idx": 0,
"modified": "2021-10-21 12:01:56.270656",
"modified_by": "Administrator",
"module": "LMS",
"name": "Courses Mentored",
"owner": "Administrator",
"standard": 1,
"template": "",
"type": "Section"
}

View File

@@ -1,110 +0,0 @@
{% set chapters = get_chapters(course.name) %}
{% set is_instructor = is_instructor(course.name) %}
{% if chapters | length %}
<div class="course-home-outline">
{% if not lesson_page %}
<div class="page-title mb-8" id="outline-heading" data-course="{{ course.name }}">
{{ _("Course Content") }}
</div>
<!-- <div class="mb-2">
<span>
{{ chapters | length }} chapters
</span>
<span>
. {{ get_lessons(course.name, None, False) }} lessons
</span>
</div> -->
{% endif %}
{% if chapters | length %}
<div>
{% for chapter in chapters %}
{% set lessons = get_lessons(course.name, chapter) %}
<div class="chapter-parent" data-chapter="{{ chapter.name }}">
<div class="chapter-title" data-toggle="collapse" aria-expanded="false"
data-target="#{{ get_slugified_chapter_title(chapter.title) }}">
<img class="chapter-icon" src="/assets/lms/icons/chevron-right.svg">
<div class="chapter-title-main">
{{ chapter.title }}
</div>
<!-- <div class="small ml-auto">
{{ lessons | length }} lessons
</div> -->
</div>
<div class="chapter-content collapse navbar-collapse" id="{{ get_slugified_chapter_title(chapter.title) }}">
{% if chapter.description %}
<div class="chapter-description">
{{ chapter.description }}
</div>
{% endif %}
<div class="lessons">
{% if lessons | length %}
{% for lesson in lessons %}
{% set active = membership.current_lesson == lesson.name %}
<div data-lesson="{{ lesson.name }}" class="lesson-info {% if active %} active-lesson {% endif %}">
{% if membership or lesson.include_in_preview or is_instructor or has_course_moderator_role() %}
<a class="lesson-links"
href="{{ get_lesson_url(course.name, lesson.number) }}{% if classname %}?class={{ classname }}{% endif %}{{course.query_parameter}}"
{% if is_instructor and not lesson.include_in_preview %}
title="{{ _('This lesson is not available for preview. As you are the Instructor of the course only you can see it.') }}"
{% endif %}>
<svg class="icon icon-sm mr-2">
<use class="" href="#{{ lesson.icon }}">
</svg>
<span>{{ lesson.title }}</span>
{% if membership %}
<svg class="icon icon-md lesson-progress-tick ml-auto {{ get_progress(course.name, lesson.name) != 'Complete' and 'hide' }}">
<use class="" href="#icon-success">
</svg>
{% endif %}
</a>
{% else %}
<div class="no-preview" title="This lesson is not available for preview">
<div class="lesson-links">
<svg class="icon icon-sm mr-2">
<use class="" href="#icon-lock-gray">
</svg>
<div>{{ lesson.title }}</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% if chapters | length %}
<!-- No Preview Modal -->
{{ widgets.NoPreviewModal(course=course, membership=membership) }}
{% endif %}

View File

@@ -1,31 +0,0 @@
{% set color = get_palette(member.full_name) %}
<div class="common-card-style member-card">
<div class="d-flex">
{{ widgets.Avatar(member=member, avatar_class=avatar_class) }}
<div class="ml-3 my-auto">
<div class="member-card-title">
{{ member.full_name }}
</div>
{% if member.headline %}
<div> {{ member.headline }} </div>
{% endif %}
{% if member.looking_for_job %}
<div class="indicator-pill green"> {{ _("Open Network") }} </div>
{% endif %}
{% set course_count = get_authored_courses(member.name, True) | length %}
{% set suffix = "Courses" if course_count > 1 else "Course" %}
{% if show_course_count and course_count > 0 %}
<div class="">
Created {{ course_count }} {{ suffix }}
</div>
{% endif %}
</div>
<a class="stretched-link" href="{{ get_profile_url(member.username) }}"></a>
</div>
</div>

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

@@ -1,34 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
import unittest
import frappe
from lms.lms.doctype.lms_course.test_lms_course import new_user
class TestCustomUser(unittest.TestCase):
def test_with_basic_username(self):
user = new_user("Username", "test_with_basic_username@example.com")
self.assertEqual(user.username, "username")
def test_without_username(self):
"""The user in this test has the same first name as the user of the test test_with_basic_username.
In such cases frappe makes the username of the second user empty.
The condition in lms app should override this and save a username."""
user = new_user("Username", "test-without-username@example.com")
self.assertTrue(user.username)
def test_with_short_first_name(self):
user = new_user("USN", "test_with_short_first_name@example.com")
self.assertGreaterEqual(len(user.username), 4)
@classmethod
def tearDownClass(cls) -> None:
users = [
"test_with_basic_username@example.com",
"test-without-username@example.com",
"test_with_short_first_name@example.com",
]
frappe.db.delete("User", {"name": ["in", users]})

View File

@@ -1,363 +0,0 @@
import hashlib
import frappe
import requests
from frappe import _
from frappe.core.doctype.user.user import User
from frappe.utils import cint, escape_html, random_string
from frappe.website.utils import is_signup_disabled
from lms.lms.utils import get_average_rating, get_country_code
from frappe.website.utils import cleanup_page_name
from frappe.model.naming import append_number_if_name_exists
from lms.widgets import Widgets
class CustomUser(User):
def validate(self):
super().validate()
self.validate_username_duplicates()
def after_insert(self):
super().after_insert()
self.add_roles("LMS Student")
def validate_username_duplicates(self):
while not self.username or self.username_exists():
self.username = append_number_if_name_exists(
self.doctype, cleanup_page_name(self.full_name), fieldname="username"
)
if " " in self.username:
self.username = self.username.replace(" ", "")
if len(self.username) < 4:
self.username = self.email.replace("@", "").replace(".", "")
def validate_skills(self):
unique_skills = []
for skill in self.skill:
if not skill.skill_name:
return
if not skill.skill_name in unique_skills:
unique_skills.append(skill.skill_name)
else:
frappe.throw(_("Skills must be unique"))
def get_batch_count(self) -> int:
"""Returns the number of batches authored by this user."""
return frappe.db.count(
"LMS Enrollment", {"member": self.name, "member_type": "Mentor"}
)
def get_user_reviews(self):
"""Returns the reviews created by user"""
return frappe.get_all("LMS Course Review", {"owner": self.name})
def get_mentored_courses(self):
"""Returns all courses mentored by this user"""
mentored_courses = []
mapping = frappe.get_all(
"LMS Course Mentor Mapping",
{
"mentor": self.name,
},
["name", "course"],
)
for map in mapping:
if frappe.db.get_value("LMS Course", map.course, "published"):
course = frappe.db.get_value(
"LMS Course",
map.course,
["name", "upcoming", "title", "image", "enable_certification"],
as_dict=True,
)
mentored_courses.append(course)
return mentored_courses
def get_enrolled_courses():
in_progress = []
completed = []
memberships = get_course_membership(None, member_type="Student")
for membership in memberships:
course = frappe.db.get_value(
"LMS Course",
membership.course,
[
"name",
"upcoming",
"title",
"short_introduction",
"image",
"enable_certification",
"paid_course",
"course_price",
"currency",
"published",
"creation",
],
as_dict=True,
)
if not course.published:
continue
course.enrollment_count = frappe.db.count(
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
)
course.avg_rating = get_average_rating(course.name) or 0
progress = cint(membership.progress)
if progress < 100:
in_progress.append(course)
else:
completed.append(course)
in_progress.sort(key=lambda x: x.enrollment_count, reverse=True)
completed.sort(key=lambda x: x.enrollment_count, reverse=True)
return {"in_progress": in_progress, "completed": completed}
def get_course_membership(member=None, member_type=None):
"""Returns all memberships of the user."""
filters = {"member": member or frappe.session.user}
if member_type:
filters["member_type"] = member_type
return frappe.get_all("LMS Enrollment", filters, ["name", "course", "progress"])
def get_authored_courses(member=None, only_published=True):
"""Returns the number of courses authored by this user."""
course_details = []
courses = frappe.get_all(
"Course Instructor", {"instructor": member or frappe.session.user}, ["parent"]
)
for course in courses:
detail = frappe.db.get_value(
"LMS Course",
course.parent,
[
"name",
"upcoming",
"title",
"short_introduction",
"image",
"paid_course",
"course_price",
"currency",
"status",
"published",
"creation",
],
as_dict=True,
)
if only_published and detail and not detail.published:
continue
detail.enrollment_count = frappe.db.count(
"LMS Enrollment", {"course": detail.name, "member_type": "Student"}
)
detail.avg_rating = get_average_rating(detail.name) or 0
course_details.append(detail)
course_details.sort(key=lambda x: x.enrollment_count, reverse=True)
return course_details
def get_palette(full_name):
"""
Returns a color unique to each member for Avatar"""
palette = [
["--orange-avatar-bg", "--orange-avatar-color"],
["--pink-avatar-bg", "--pink-avatar-color"],
["--blue-avatar-bg", "--blue-avatar-color"],
["--green-avatar-bg", "--green-avatar-color"],
["--dark-green-avatar-bg", "--dark-green-avatar-color"],
["--red-avatar-bg", "--red-avatar-color"],
["--yellow-avatar-bg", "--yellow-avatar-color"],
["--purple-avatar-bg", "--purple-avatar-color"],
["--gray-avatar-bg", "--gray-avatar-color0"],
]
encoded_name = str(full_name).encode("utf-8")
hash_name = hashlib.md5(encoded_name).hexdigest()
idx = cint((int(hash_name[4:6], 16) + 1) / 5.33)
return palette[idx % 8]
@frappe.whitelist(allow_guest=True)
def sign_up(email, full_name, verify_terms, user_category):
if is_signup_disabled():
frappe.throw(_("Sign Up is disabled"), _("Not Allowed"))
user = frappe.db.get("User", {"email": email})
if user:
if user.enabled:
return 0, _("Already Registered")
else:
return 0, _("Registered but disabled")
else:
if frappe.db.get_creation_count("User", 60) > 300:
frappe.respond_as_web_page(
_("Temporarily Disabled"),
_(
"Too many users signed up recently, so the registration is disabled. Please try back in an hour"
),
http_status_code=429,
)
user = frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": escape_html(full_name),
"verify_terms": verify_terms,
"user_category": user_category,
"country": "",
"enabled": 1,
"new_password": random_string(10),
"user_type": "Website User",
}
)
user.flags.ignore_permissions = True
user.flags.ignore_password_policy = True
user.insert()
# set default signup role as per Portal Settings
default_role = frappe.db.get_value("Portal Settings", None, "default_role")
if default_role:
user.add_roles(default_role)
user.add_roles("LMS Student")
set_country_from_ip(None, user.name)
if user.flags.email_sent:
return 1, _("Please check your email for verification")
else:
return 2, _("Please ask your administrator to verify your sign-up")
def set_country_from_ip(login_manager=None, user=None):
if not user and login_manager:
user = login_manager.user
user_country = frappe.db.get_value("User", user, "country")
# if user_country:
# return
frappe.db.set_value("User", user, "country", get_country_code())
return
def on_session_creation(login_manager):
if frappe.db.get_single_value(
"System Settings", "setup_complete"
) and frappe.db.get_single_value("LMS Settings", "default_home"):
frappe.local.response["home_page"] = "/lms"
@frappe.whitelist()
def search_users(start: int = 0, text: str = ""):
start = cint(start)
search_text = frappe.db.escape(f"%{text}%")
or_filters = get_or_filters(search_text)
count = len(get_users(or_filters, 0, 900000000))
users = get_users(or_filters, start, 24)
user_details = get_user_details(users)
return {"user_details": user_details, "start": start + 24, "count": count}
def get_or_filters(text):
user_fields = [
"first_name",
"last_name",
"full_name",
"email",
"preferred_location",
"dream_companies",
]
education_fields = ["institution_name", "location", "degree_type", "major"]
work_fields = ["title", "company"]
certification_fields = ["certification_name", "organization"]
or_filters = []
if text:
for field in user_fields:
or_filters.append(f"u.{field} like {text}")
for field in education_fields:
or_filters.append(f"ed.{field} like {text}")
for field in work_fields:
or_filters.append(f"we.{field} like {text}")
for field in certification_fields:
or_filters.append(f"c.{field} like {text}")
or_filters.append(f"s.skill_name like {text}")
or_filters.append(f"pf.function like {text}")
or_filters.append(f"pi.industry like {text}")
return "AND ({})".format(" OR ".join(or_filters)) if or_filters else ""
def get_user_details(users):
user_details = []
for user in users:
details = frappe.db.get_value(
"User",
user,
["name", "username", "full_name", "user_image", "headline", "looking_for_job"],
as_dict=True,
)
user_details.append(Widgets().MemberCard(member=details, avatar_class="avatar-large"))
return user_details
def get_users(or_filters, start, page_length):
users = frappe.db.sql(
"""
SELECT DISTINCT u.name
FROM `tabUser` u
LEFT JOIN `tabEducation Detail` ed
ON u.name = ed.parent
LEFT JOIN `tabWork Experience` we
ON u.name = we.parent
LEFT JOIN `tabCertification` c
ON u.name = c.parent
LEFT JOIN `tabSkills` s
ON u.name = s.parent
LEFT JOIN `tabPreferred Function` pf
ON u.name = pf.parent
LEFT JOIN `tabPreferred Industry` pi
ON u.name = pi.parent
WHERE u.enabled = True {or_filters}
ORDER BY u.creation desc
LIMIT {start}, {page_length}
""".format(
or_filters=or_filters, start=start, page_length=page_length
),
as_dict=1,
)
return users
@frappe.whitelist()
def save_role(user, role, value):
frappe.only_for("Moderator")
if cint(value):
doc = frappe.get_doc(
{
"doctype": "Has Role",
"parent": user,
"role": role,
"parenttype": "User",
"parentfield": "roles",
}
)
doc.save(ignore_permissions=True)
else:
frappe.db.delete("Has Role", {"parent": user, "role": role})
return True

View File

@@ -6,6 +6,7 @@ import re
import os import os
import mimetypes import mimetypes
import frappe import frappe
from frappe.utils import get_files_path
from frappe.website.page_renderers.base_renderer import BaseRenderer from frappe.website.page_renderers.base_renderer import BaseRenderer
from frappe.website.page_renderers.document_page import DocumentPage from frappe.website.page_renderers.document_page import DocumentPage
from frappe.website.page_renderers.list_page import ListPage from frappe.website.page_renderers.list_page import ListPage
@@ -173,3 +174,23 @@ class SCORMRenderer(BaseRenderer):
) )
response.mimetype = mimetypes.guess_type(index_path)[0] response.mimetype = mimetypes.guess_type(index_path)[0]
return response return response
elif not os.path.exists(path):
chapter_folder = "/".join(self.path.split("/")[:3])
chapter_folder_path = os.path.realpath(
frappe.get_site_path("public", chapter_folder)
)
file = path.split("/")[-1]
correct_file_path = None
for root, dirs, files in os.walk(chapter_folder_path):
if file in files:
correct_file_path = os.path.join(root, file)
break
if correct_file_path:
f = open(correct_file_path, "rb")
response = Response(
wrap_file(frappe.local.request.environ, f), direct_passthrough=True
)
response.mimetype = mimetypes.guess_type(correct_file_path)[0]
return response

View File

@@ -99,4 +99,6 @@ lms.patches.v2_0.update_quiz_submission_data
lms.patches.v2_0.convert_quiz_duration_to_minutes lms.patches.v2_0.convert_quiz_duration_to_minutes
lms.patches.v2_0.allow_guest_access #05-02-2025 lms.patches.v2_0.allow_guest_access #05-02-2025
lms.patches.v2_0.migrate_batch_student_data #10-02-2025 lms.patches.v2_0.migrate_batch_student_data #10-02-2025
lms.patches.v2_0.delete_old_enrollment_doctypes lms.patches.v2_0.delete_old_enrollment_doctypes
lms.patches.v2_0.delete_unused_custom_fields
lms.patches.v2_0.update_certificate_request_status

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