Compare commits

..

159 Commits

Author SHA1 Message Date
Frappe PR Bot 2c2e8ca112 chore(release): Bumped to Version 2.45.2 2026-03-03 07:19:36 +00:00
Jannat Patel 4771ebbcfd fix: enrollment error during course progress 2026-03-03 12:48:51 +05:30
Frappe PR Bot 315ec3d655 chore(release): Bumped to Version 2.45.1 2026-02-25 12:36:10 +00:00
Jannat Patel 484c3d7402 Merge pull request #2128 from frappe/mergify/bp/main/pr-2126
fix: permission issue during quiz submission (backport #2126)
2026-02-25 18:05:27 +05:30
Jannat Patel e7ce850691 fix: removed trailing comma at the end of permission 2026-02-25 16:45:02 +05:30
Jannat Patel 593c70affb chore: resolved conflicts 2026-02-25 13:07:05 +05:30
Jannat Patel 3a1a7db386 chore: resolved conflicts 2026-02-25 13:06:37 +05:30
Jannat Patel a5e948bba8 fix: permission issue during quiz submission
(cherry picked from commit af5bce9e34)

# Conflicts:
#	lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json
2026-02-25 07:21:47 +00:00
Jannat Patel 2331ddfc67 Merge pull request #2123 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
2026-02-25 10:55:53 +05:30
Jannat Patel afe9674a6a Merge pull request #2124 from frappe/mergify/bp/main-hotfix/pr-2121
chore: sync translations from crowdin (backport #2121)
2026-02-25 10:47:30 +05:30
Jannat Patel 5b22ef46c0 chore: resolved conflicts 2026-02-25 10:40:06 +05:30
MochaMind 8f1604e237 chore: Persian translations
(cherry picked from commit 63321fe2c8)
2026-02-25 05:08:21 +00:00
MochaMind a9f4eb1291 chore: Spanish translations
(cherry picked from commit 68848fc642)

# Conflicts:
#	lms/locale/es.po
2026-02-25 05:08:20 +00:00
Jannat Patel fa7e59b4ad Merge pull request #2118 from frappe/mergify/bp/main-hotfix/pr-2117
chore: capture more events for analytics (backport #2117)
2026-02-23 16:59:02 +05:30
Jannat Patel 5fcd3ddabe revert: removed new batch modal changes 2026-02-23 16:45:37 +05:30
Jannat Patel a9dd43d0ea chore: resolved conflicts 2026-02-23 16:44:04 +05:30
Jannat Patel 22e005f19c chore: capture more events for analytics
(cherry picked from commit b3c8fbd833)

# Conflicts:
#	frontend/src/pages/Batches/components/NewBatchModal.vue
#	lms/lms/doctype/lms_course_review/lms_course_review.json
2026-02-23 11:11:44 +00:00
Jannat Patel 9b0a7f5fa5 Merge pull request #2112 from pateljannat/issues-187
fix: lesson progress issue
2026-02-23 12:03:30 +05:30
Jannat Patel aa93375e6c Merge pull request #2110 from frappe/mergify/bp/main-hotfix/pr-2109
fix: check permission of session user during batch enrollment (backport #2109)
2026-02-23 11:49:25 +05:30
Jannat Patel e8edf33be6 fix: lesson progress issue 2026-02-23 11:49:06 +05:30
Jannat Patel 619f02a74b fix: check permission of session user during batch enrollment
(cherry picked from commit c1260edb00)
2026-02-23 06:08:16 +00:00
Jannat Patel 61d13aeb12 Merge pull request #2094 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-02-18 12:11:10 +05:30
Jannat Patel 7b2a4fe24a Merge pull request #2092 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-18 11:10:40 +05:30
MochaMind c3b2907ebf chore: Portuguese, Brazilian translations 2026-02-17 21:06:06 +05:30
MochaMind 48c5b82c73 chore: Spanish translations 2026-02-17 21:06:04 +05:30
Jannat Patel 3b80ccd8db Merge pull request #2083 from raizasafeel/fix/dark-mode
fix: dark mode ui
2026-02-17 17:41:40 +05:30
Jannat Patel 6484d551d1 Merge pull request #2091 from frappe/mergify/bp/main-hotfix/pr-2087
chore: project URLs (backport #2087)
2026-02-17 17:41:05 +05:30
Ankush Menat 719d7b5e88 chore: project URLs (#2087)
(cherry picked from commit 3cd9d89f0b)
2026-02-17 11:38:09 +00:00
Ankush Menat 3cd9d89f0b chore: project URLs (#2087) 2026-02-17 15:54:23 +05:30
raizasafeel 6e44da1993 test: update batch creation test to match Badge component 2026-02-17 15:13:13 +05:30
Jannat Patel 0e382f77ef Merge pull request #2048 from raizasafeel/fix/video-embedding
fix(lesson): vimeo player rendered for private and unsanitized content
2026-02-17 14:57:00 +05:30
raizasafeel 7a1a247113 Merge upstream/develop into fix/dark-mode 2026-02-17 14:28:46 +05:30
raizasafeel 2c6ab3c331 Merge remote-tracking branch 'upstream/develop' into fix/video-embedding 2026-02-17 14:21:22 +05:30
Jannat Patel 79dba165c5 Merge pull request #2084 from pateljannat/issues-183
fix: misc issues
2026-02-17 13:57:09 +05:30
Jannat Patel d83f3464cd fix: permission issue when cancelling evaluation 2026-02-17 13:35:15 +05:30
Jannat Patel e2ef8f732d test: fixed category input selection in course creation test 2026-02-17 12:09:29 +05:30
Jannat Patel 919904a7f1 refactor: autocomplete component 2026-02-17 12:05:40 +05:30
raizasafeel 8453226f29 fix: add vimeo emded URL to extract hash properly 2026-02-17 11:38:26 +05:30
Jannat Patel 03759ca3c3 fix: spacing issue on course overview page 2026-02-16 19:53:33 +05:30
Jannat Patel c1608f8cc4 refactor: Link component 2026-02-16 19:48:58 +05:30
Jannat Patel 73b20653f0 chore: updated release process with main-hotfix 2026-02-16 18:33:45 +05:30
Jannat Patel 7e683f8b44 fix: permission checks for api 2026-02-16 18:20:02 +05:30
Jannat Patel eba1815390 fix: permission issues when adding new members 2026-02-16 18:05:53 +05:30
raizasafeel 7564f0418b fix: dark mode text and divider colors in course dashboard 2026-02-16 17:03:35 +05:30
raizasafeel 7e9cca2782 refactor: replace deprecated input with formcontrol in announcement modal 2026-02-16 17:00:18 +05:30
raizasafeel dbc7e7d6d4 fix: dark mode styling for controls and modals 2026-02-16 16:58:32 +05:30
raizasafeel ae25cfae6e fix: text editor border color in dark mode across forms and modals 2026-02-16 16:58:32 +05:30
raizasafeel 970635430b fix(settings): dark mode divider and text colors 2026-02-16 16:58:32 +05:30
raizasafeel fe869a5988 fix(batch): use frappe-ui badge for dark mode compatibility 2026-02-16 16:58:32 +05:30
raizasafeel 7ea8040790 fix(sidebar): configuration popover and dark mode fixes 2026-02-16 16:58:32 +05:30
Jannat Patel 9f6f717585 fix: discussions reply endpoint permission check 2026-02-16 12:18:41 +05:30
Jannat Patel ee73d8db86 Merge pull request #2077 from pateljannat/issues-182
refactor: MultiSelect field
2026-02-16 09:10:46 +05:30
Jannat Patel c7b5f9a04d Merge pull request #2078 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-16 09:10:28 +05:30
MochaMind fa4c3a8ad7 chore: Persian translations 2026-02-15 19:54:51 +05:30
MochaMind 71318bff04 chore: Portuguese translations 2026-02-15 19:54:42 +05:30
MochaMind 186ddc93c8 chore: Portuguese, Brazilian translations 2026-02-14 19:58:13 +05:30
Jannat Patel 2f1d9a8690 refactor: MultiSelect field 2026-02-13 13:04:02 +05:30
Jannat Patel 5fc7c52bfe Merge pull request #2073 from UmakanthKaspa/fix/categories-dark-mode-text
fix: add text color for category names in dark mode
2026-02-13 10:18:29 +05:30
Jannat Patel d0da6e7401 Merge pull request #2076 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-13 10:17:53 +05:30
UmakanthKaspa a437c197a5 fix: format code with pre-commit 2026-02-12 21:41:19 +05:30
MochaMind 80a9f2abe2 chore: Spanish translations 2026-02-12 19:28:48 +05:30
Jannat Patel c30b21e5ae Merge pull request #2075 from pateljannat/issues-181
fix: issues on home page
2026-02-12 11:07:12 +05:30
Jannat Patel 3e3afa63c2 fix: issues on home page 2026-02-12 10:44:01 +05:30
Jannat Patel c00cb100a9 Merge pull request #2072 from UmakanthKaspa/fix/job-details-missing-type-hint
fix: add missing type annotation to get_job_details
2026-02-11 21:41:28 +05:30
Jannat Patel f824ac3c28 Merge pull request #2070 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-11 21:40:28 +05:30
UmakanthKaspa 2dea096fa0 fix: use semantic bg class for MultiSelect "Create New" button 2026-02-11 21:39:30 +05:30
UmakanthKaspa f1853a3c97 fix: add text color for category names in dark mode 2026-02-11 21:13:48 +05:30
UmakanthKaspa 4995f8e3fd fix: add missing type annotation to get_job_details 2026-02-11 20:38:08 +05:30
MochaMind 560ac8d5c4 chore: Burmese translations 2026-02-11 18:52:08 +05:30
Jannat Patel d370ca796f Merge pull request #2067 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-10 19:53:48 +05:30
MochaMind a4035168be chore: Italian translations 2026-02-10 17:32:47 +05:30
Jannat Patel 70872857d1 fix: show console error if course creation fails 2026-02-10 10:55:59 +05:30
Jannat Patel 332334b556 Merge pull request #2066 from UmakanthKaspa/fix/course-creation-error-toast
fix: show error toast when course creation fails
2026-02-10 10:48:34 +05:30
UmakanthKaspa 1d91baa9c5 fix: show error toast when course creation fails 2026-02-09 13:41:12 +00:00
Jannat Patel 1e8040ef7b Merge pull request #2036 from Aradhya-Tripathi/setup-fix
fix(setup): Add frappe dependency and build utils
2026-02-09 17:21:05 +05:30
Jannat Patel ad6e0a3b80 Merge pull request #2064 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-09 15:32:59 +05:30
Jannat Patel 8f6810923d fix: only published courses should be added to batch 2026-02-09 14:42:32 +05:30
Jannat Patel 990db83ab3 chore: Italian translations 2026-02-09 13:02:11 +05:30
Jannat Patel 01f08ba449 chore: fixed typing for non mandatory fields of certificate 2026-02-09 12:44:54 +05:30
Jannat Patel 7a3701cc10 Merge pull request #2063 from pateljannat/issues-180
chore: added type hints to all whitelisted functions
2026-02-06 17:29:57 +05:30
Jannat Patel f021ddd84c chore: fixed type annotations for doc_event methods 2026-02-06 17:08:49 +05:30
Jannat Patel 0e3157c57e chore: fixed type check for batch flows 2026-02-06 16:53:25 +05:30
Raizaaa 22eb8b9f3f Merge branch 'frappe:develop' into fix/video-embedding 2026-02-06 16:51:52 +05:30
Jannat Patel 9609398643 chore: fixed type check for batch flows 2026-02-06 16:18:37 +05:30
Jannat Patel cd0d4c413d chore: added type hints to all whitelisted functions 2026-02-06 16:01:49 +05:30
Jannat Patel 1bbdff9aaf Merge pull request #2060 from raizasafeel/translations-fix
fix(profile): translations in tab are now rendered
2026-02-06 15:00:20 +05:30
Jannat Patel 8754d0498c Merge pull request #2062 from pateljannat/issues-179
fix: improved the default print format
2026-02-06 14:59:08 +05:30
raizasafeel 395ac52740 fix(assessments): translation render in list heading 2026-02-06 14:46:02 +05:30
Jannat Patel 29cdbe5b8b Merge pull request #2061 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-06 14:38:40 +05:30
Raizaaa 0677c21dc7 Merge branch 'frappe:develop' into translations-fix 2026-02-06 14:37:22 +05:30
Jannat Patel 1a58e2669f fix: improved the default print format 2026-02-06 14:33:24 +05:30
raizasafeel 3fa27024f9 fix(breadcrumbs): translations are now rendered 2026-02-06 12:53:17 +05:30
Jannat Patel 04c4069c75 chore: Serbian (Latin) translations 2026-02-06 12:08:55 +05:30
Jannat Patel dd77b01ff1 chore: Bosnian translations 2026-02-06 12:08:52 +05:30
Jannat Patel 085614bca6 chore: Croatian translations 2026-02-06 12:08:50 +05:30
Jannat Patel ef2606c41a chore: Swedish translations 2026-02-06 12:08:41 +05:30
Jannat Patel 1d95361587 chore: Serbian (Cyrillic) translations 2026-02-06 12:08:40 +05:30
raizasafeel 6ead16edf0 fix(profile): translations in tab are now rendered 2026-02-06 10:10:29 +05:30
Jannat Patel 31d21bf689 Merge pull request #2056 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-05 13:07:15 +05:30
Jannat Patel c5ee140551 Merge pull request #2054 from pateljannat/issues-178
feat: student progress in course dashboard
2026-02-05 12:40:26 +05:30
Jannat Patel 8e97b2f5bb chore: codecov config 2026-02-05 12:33:38 +05:30
Jannat Patel 19171a8019 chore: removed files that are no longer used 2026-02-05 12:24:56 +05:30
Jannat Patel 3f49cf0c9c chore: added type hints to course assessment progress functions 2026-02-05 12:24:36 +05:30
Jannat Patel 8f9cc536e2 chore: Esperanto translations 2026-02-05 12:13:55 +05:30
Jannat Patel 573bc74a41 chore: Serbian (Latin) translations 2026-02-05 12:13:53 +05:30
Jannat Patel bb2552b30c chore: Norwegian Bokmal translations 2026-02-05 12:13:52 +05:30
Jannat Patel 20ac312f57 chore: Bosnian translations 2026-02-05 12:13:50 +05:30
Jannat Patel f58842438b chore: Burmese translations 2026-02-05 12:13:49 +05:30
Jannat Patel a34f99ed49 chore: Croatian translations 2026-02-05 12:13:48 +05:30
Jannat Patel 44b7243f75 chore: Thai translations 2026-02-05 12:13:46 +05:30
Jannat Patel d2f7d80114 chore: Persian translations 2026-02-05 12:13:45 +05:30
Jannat Patel 192b246381 chore: Indonesian translations 2026-02-05 12:13:43 +05:30
Jannat Patel 17d9a3991e chore: Portuguese, Brazilian translations 2026-02-05 12:13:42 +05:30
Jannat Patel 3407a02046 chore: Vietnamese translations 2026-02-05 12:13:41 +05:30
Jannat Patel be3546e79c chore: Chinese Simplified translations 2026-02-05 12:13:39 +05:30
Jannat Patel 556067de7a chore: Turkish translations 2026-02-05 12:13:38 +05:30
Jannat Patel 4ce08af516 chore: Swedish translations 2026-02-05 12:13:36 +05:30
Jannat Patel 8a4477a01f chore: Serbian (Cyrillic) translations 2026-02-05 12:13:35 +05:30
Jannat Patel 21d868a355 chore: Slovenian translations 2026-02-05 12:13:33 +05:30
Jannat Patel 68a2cc1003 chore: Russian translations 2026-02-05 12:13:32 +05:30
Jannat Patel 5a288836e0 chore: Portuguese translations 2026-02-05 12:13:30 +05:30
Jannat Patel 4eab5e2867 chore: Polish translations 2026-02-05 12:13:29 +05:30
Jannat Patel 6082093fb6 chore: Dutch translations 2026-02-05 12:13:27 +05:30
Jannat Patel 66c26c2a2c chore: Italian translations 2026-02-05 12:13:26 +05:30
Jannat Patel f7eaf3faaa chore: Hungarian translations 2026-02-05 12:13:24 +05:30
Jannat Patel 82a43b4f24 chore: German translations 2026-02-05 12:13:23 +05:30
Jannat Patel 5bd33a1536 chore: Danish translations 2026-02-05 12:13:21 +05:30
Jannat Patel a063c0735c chore: Czech translations 2026-02-05 12:13:20 +05:30
Jannat Patel 732db8290d chore: Arabic translations 2026-02-05 12:13:19 +05:30
Jannat Patel 2b1d57f2bc chore: Spanish translations 2026-02-05 12:13:17 +05:30
Jannat Patel c8c051c1de chore: French translations 2026-02-05 12:13:16 +05:30
Jannat Patel 13139bc2de test: course assessment progress 2026-02-05 12:10:24 +05:30
Jannat Patel 9814abf55f fix: dark mode issues of course dashboard 2026-02-04 16:04:28 +05:30
Jannat Patel 249ecb8c4c Merge pull request #2053 from frappe/develop
chore: merge 'develop' into 'main'
2026-02-04 15:24:23 +05:30
Jannat Patel 582540e7f0 feat: student progress on course dashboard 2026-02-03 21:31:31 +05:30
raizasafeel 2f3fa7c295 fix: added regex anchors to embed urls 2026-02-03 16:22:50 +05:30
raizasafeel 3b49aac1b3 refactor: removed unused functions 2026-02-03 16:14:58 +05:30
raizasafeel dc25b408e6 fix(vimeo): video player is rendered for private videos and unsanitized vimeo links 2026-02-03 14:51:17 +05:30
raizasafeel c8d9b97ab7 refactor: reuse function 'escapehtml' from utils 2026-02-03 14:01:48 +05:30
Jannat Patel 754d3cf2ca fix: import permissions 2026-02-03 10:56:22 +05:30
Jannat Patel e4268d0437 fix: allow attaching payment information for batch enrollment 2026-02-03 10:49:48 +05:30
Jannat Patel 8febe21aa8 fix: dont allow contact us email sending to guest users 2026-02-03 10:48:59 +05:30
Jannat Patel 5384b26610 Merge pull request #2042 from raizasafeel/fix/chapter-deletion
test(chapter): added reindexing test on chapter deletion
2026-02-02 19:20:55 +05:30
Jannat Patel 2a4650e5ed Merge pull request #2044 from raizasafeel/fix/quiz-order
fix(batches): order assessments by their index
2026-02-02 19:12:50 +05:30
Jannat Patel 737993c543 Merge pull request #2043 from raizasafeel/fix/multilanguage-filters
fix: filter tabs not working in non-english languages
2026-02-02 18:34:50 +05:30
raizasafeel 58b49e3608 fix(batches): order assessments by their index 2026-02-02 18:28:06 +05:30
Raizaaa 27553464d6 Merge branch 'frappe:develop' into fix/chapter-deletion 2026-02-02 16:28:06 +05:30
raizasafeel be76268c70 fix(batches): use constant value for filter instead of translated label 2026-02-02 16:22:37 +05:30
raizasafeel df2f2e6603 fix(courses): use constant value for filter instead of translated label 2026-02-02 16:21:31 +05:30
Jannat Patel fb1e1ec2e4 Merge pull request #2040 from pateljannat/issues-177
fix: permissions cleanup
2026-02-02 15:55:22 +05:30
Jannat Patel ac81d1817b fix: batch dashboard chart visibility 2026-02-02 15:43:36 +05:30
Jannat Patel da33e1d3bd test: course and batch details 2026-02-02 15:11:16 +05:30
Jannat Patel 24a511f48e fix: do nor return details of unpublished courses and batches via api 2026-02-02 14:44:13 +05:30
Jannat Patel 14e669435f fix: permissions cleanup 2026-02-02 13:14:16 +05:30
Jannat Patel 0407f01016 Merge pull request #2038 from vishwajeet-13/fix/issue-date
fix: issue date not coming from backend
2026-02-02 11:06:53 +05:30
vishwajeet-13 2a63f781ac fix: issue date not coming from backend 2026-01-30 17:37:07 +05:30
raizasafeel a882432702 test: added test for chapter deletion and renumbering 2026-01-29 13:32:37 +05:30
raizasafeel f8b6dfc981 Merge branch 'develop' into fix/chapter-deletion 2026-01-29 13:27:29 +05:30
Jannat Patel e8768d5687 Merge pull request #2026 from frappe/develop
chore: merge 'develop' into 'main'
2026-01-28 11:32:28 +05:30
146 changed files with 6030 additions and 5426 deletions
+2
View File
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
- develop
- main-hotfix
pull_request: {}
jobs:
tests:
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
owner: frappe
repo: lms
title: |-
"chore: merge 'develop' into 'main'"
"chore: merge 'main-hotfix' into 'main'"
body: "Automated weekly release"
base: main
head: develop
head: main-hotfix
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+4 -1
View File
@@ -4,7 +4,10 @@ on:
pull_request:
workflow_dispatch:
push:
branches: [ main ]
branches:
- main
- develop
- main-hotfix
permissions:
# Do not change this as GITHUB_TOKEN is being used by roulette
+2 -1
View File
@@ -13,4 +13,5 @@ package-lock.json
lms/public/frontend
lms/www/lms.html
lms/www/_lms.html
frappe-ui
frappe-ui
frappe-semgrep-rules
+30
View File
@@ -0,0 +1,30 @@
pull_request_rules:
- name: backport to develop
conditions:
- label="backport develop"
actions:
backport:
branches:
- develop
assignees:
- "{{ author }}"
- name: backport to main-hotfix
conditions:
- label="backport main-hotfix"
actions:
backport:
branches:
- main-hotfix
assignees:
- "{{ author }}"
- name: backport to main
conditions:
- label="backport main"
actions:
backport:
branches:
- main
assignees:
- "{{ author }}"
+2
View File
@@ -0,0 +1,2 @@
ignore:
- "**/test_helper.py"
+9 -8
View File
@@ -128,12 +128,9 @@ describe("Batch Creation", () => {
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
cy.contains("div:visible", "10 Seats Left").should(
"be.visible"
);
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
});
@@ -162,8 +159,12 @@ describe("Batch Creation", () => {
/* Add student to batch */
cy.get("button").contains("Students").click();
cy.get("button").contains("Add").click();
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get('div[role="dialog"]')
.first()
.find("input[id^='headlessui-combobox-input-v-']")
.first()
.click();
cy.get("input[placeholder='Search']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
+1 -1
View File
@@ -65,7 +65,7 @@ describe("Course Creation", () => {
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
cy.get("input").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
@@ -35,7 +35,7 @@
<AxisChart
v-if="showProgressChart"
class="border"
class="border rounded-lg p-3 min-h-[300px]"
:config="{
data: filteredChartData,
title: __('Batch Summary'),
+3 -3
View File
@@ -208,12 +208,12 @@ const canAddAssessments = () => {
const getAssessmentColumns = () => {
let columns = [
{
label: 'Assessment',
label: __('Assessment'),
key: 'title',
width: '25rem',
},
{
label: 'Type',
label: __('Type'),
key: 'assessment_type',
width: '15rem',
},
@@ -221,7 +221,7 @@ const getAssessmentColumns = () => {
if (!user.data?.is_moderator) {
columns.push({
label: 'Status/Percentage',
label: __('Status/Percentage'),
key: 'status',
align: 'left',
width: '10rem',
+2 -2
View File
@@ -141,7 +141,7 @@
:uploadArgs="{
private: 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
@@ -190,7 +190,7 @@
:uploadArgs="{
private: 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
+19 -16
View File
@@ -6,24 +6,26 @@
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
{{ batch.title }}
</div>
<div
<Badge
v-if="batch.seat_count && batch.seats_left > 0"
class="text-xs bg-green-100 text-green-700 self-start px-2 py-0.5 rounded-md"
>
{{ batch.seats_left }}
<span v-if="batch.seats_left > 1">
{{ __('Seats Left') }}
</span>
<span v-else-if="batch.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
variant="subtle"
theme="green"
size="md"
class="self-start"
:label="
batch.seats_left +
' ' +
(batch.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
"
/>
<Badge
v-else-if="batch.seat_count && batch.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 self-start px-2 py-0.5 rounded-md"
>
{{ __('Sold Out') }}
</div>
variant="subtle"
theme="red"
size="md"
class="self-start"
:label="__('Sold Out')"
/>
<div class="short-introduction text-sm text-ink-gray-7">
{{ batch.description }}
</div>
@@ -70,6 +72,7 @@
</div>
</template>
<script setup>
import { Badge } from 'frappe-ui'
import { formatTime } from '@/utils'
import { Clock, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
+18 -17
View File
@@ -1,28 +1,29 @@
<template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div
<Badge
v-if="batch.data.seat_count && batch.data.seats_left > 0"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
variant="subtle"
theme="green"
size="md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
>
{{ batch.data.seats_left }}
<span v-if="batch.data.seats_left > 1">
{{ __('Seats Left') }}
</span>
<span v-else-if="batch.data.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
:label="
batch.data.seats_left +
' ' +
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
"
/>
<Badge
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
>
{{ __('Sold Out') }}
</div>
variant="subtle"
theme="red"
size="md"
class="float-right"
:label="__('Sold Out')"
/>
<div
v-if="batch.data.amount"
class="text-lg font-semibold mb-3 text-ink-gray-9"
@@ -136,7 +137,7 @@
</template>
<script setup>
import { inject, computed } from 'vue'
import { Button, createResource, toast } from 'frappe-ui'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
Clock,
+133 -192
View File
@@ -1,138 +1,95 @@
<template>
<div>
<!-- Label -->
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</div>
<Combobox
v-model="selectedValue"
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
:disabled="attrs.readonly"
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
<div class="relative w-full">
<ComboboxInput
class="form-input w-full"
:class="inputClasses"
type="text"
:value="selectedValue"
autocomplete="off"
@click="onFocus"
/>
<ComboboxButton ref="trigger" class="hidden" />
<!-- Dropdown -->
<ComboboxOptions
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals shadow-lg"
>
<input
ref="search"
v-model="query"
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
type="text"
placeholder="Search"
autocomplete="off"
/>
<!-- Options -->
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
<template v-for="group in groups" :key="group.key">
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
<div class="flex items-center w-[90%]">
<slot name="prefix" />
<span
class="block truncate text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen" class="">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
{{ group.group }}
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
<ComboboxOption
v-for="option in group.items"
:key="option.value"
:value="option.value"
v-slot="{ active }"
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
</div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
:class="[
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
{ 'bg-surface-gray-2': active },
]"
>
No results found
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value === option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
</ComboboxOption>
</template>
<div
v-if="groups.length === 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
{{ __('No results found') }}
</div>
</div>
</template>
</Popover>
<!-- Footer -->
<div
v-if="slots.footer"
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
>
<slot
name="footer"
v-bind="{
value: selectedValue,
close,
}"
/>
</div>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
@@ -143,15 +100,15 @@ import {
ComboboxInput,
ComboboxOptions,
ComboboxOption,
ComboboxButton,
} from '@headlessui/vue'
import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
modelValue: {
type: String,
default: '',
type: [String, Object],
default: null,
},
options: {
type: Array,
@@ -182,109 +139,95 @@ const props = defineProps({
default: true,
},
})
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
const query = ref('')
const showOptions = ref(false)
const trigger = ref(null)
const search = ref(null)
const attrs = useAttrs()
const slots = useSlots()
const selectedValue = ref(props.modelValue)
const query = ref('')
const valuePropPassed = computed(() => 'value' in attrs)
const selectedValue = computed({
get() {
return valuePropPassed.value ? attrs.value : props.modelValue
},
set(val) {
query.value = ''
if (val) {
showOptions.value = false
}
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
},
watch(selectedValue, (val) => {
query.value = ''
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
})
function close() {
showOptions.value = false
function clearValue() {
emit('update:modelValue', null)
}
const groups = computed(() => {
if (!props.options || props.options.length == 0) return []
if (!props.options?.length) return []
let groups = props.options[0]?.group
const normalized = props.options[0]?.group
? props.options
: [{ group: '', items: props.options }]
return groups
.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}
})
return normalized
.map((group, i) => ({
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}))
.filter((group) => group.items.length > 0)
})
function filterOptions(options) {
if (!query.value) {
return options
}
return options.filter((option) => {
let searchTexts = [option.label, option.value]
return searchTexts.some((text) =>
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
)
if (!query.value) return options
const q = query.value.toLowerCase()
return options.filter((option) =>
[option.label, option.value]
.filter(Boolean)
.some((text) => text.toString().toLowerCase().includes(q))
)
}
watchDebounced(
query,
(val) => {
emit('update:query', val)
},
{ debounce: 300 }
)
const onFocus = () => {
trigger.value?.$el.click()
nextTick(() => {
search.value?.focus()
})
}
function displayValue(option) {
if (typeof option === 'string') {
let allOptions = groups.value.flatMap((group) => group.items)
let selectedOption = allOptions.find((o) => o.value === option)
return selectedOption?.label || option
}
return option?.label
const close = () => {
selectedValue.value = null
trigger.value?.$el.click()
}
watch(query, (q) => {
emit('update:query', q)
})
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus()
})
}
})
const textColor = computed(() => {
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
})
const textColor = computed(() =>
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
)
const inputClasses = computed(() => {
let sizeClasses = {
const sizeClasses = {
sm: 'text-base rounded h-7',
md: 'text-base rounded h-8',
lg: 'text-lg rounded-md h-10',
xl: 'text-xl rounded-md h-10',
}[props.size]
let paddingClasses = {
const paddingClasses = {
sm: 'py-1.5 px-2',
md: 'py-1.5 px-2.5',
lg: 'py-1.5 px-3',
xl: 'py-1.5 px-3',
}[props.size]
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
const variant = props.disabled ? 'disabled' : props.variant
const variantClasses = {
subtle:
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
outline:
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
disabled: [
@@ -303,6 +246,4 @@ const inputClasses = computed(() => {
'transition-colors w-full',
]
})
defineExpose({ query })
</script>
@@ -3,10 +3,10 @@
<div class="text-xs text-ink-gray-5 mb-2">
{{ label }}
</div>
<div class="overflow-visible border rounded-md">
<div class="overflow-visible border border-outline-gray-modals rounded-md">
<div class="overflow-x-auto">
<div
class="grid items-center space-x-4 p-2 border-b"
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<div
@@ -28,7 +28,7 @@
<input
v-if="showKey(key)"
v-model="row[key]"
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
class="py-1.5 px-2 w-full border-none bg-transparent text-ink-gray-8 focus:ring-0 focus:border focus:border-outline-gray-3 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
/>
</template>
@@ -47,7 +47,7 @@
<div
v-if="menuOpenIndex === rowIndex"
ref="menuRef"
class="absolute right-0 w-32 z-50 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
:class="
rowIndex == (rows?.length ?? 0) - 1
? 'bottom-full mb-1'
+1 -3
View File
@@ -11,7 +11,6 @@
:size="attrs.size || 'sm'"
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly"
>
<template #target="{ open, togglePopover }">
@@ -96,8 +95,7 @@ const value = computed({
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
set: (val) => {
return (
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
)
},
})
+136 -176
View File
@@ -1,177 +1,145 @@
<template>
<div>
<label class="block mb-1" :class="labelClasses" v-if="label">
<label v-if="label" class="block mb-1" :class="labelClasses">
{{ label }}
<span class="text-ink-red-3" v-if="required">*</span>
<span v-if="required" class="text-ink-red-3">*</span>
</label>
<div class="w-full">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
@click="
(e) => {
showOptions = true
nextTick(() => {
setFocus()
})
}
"
@focus="
() => {
if (!filterOptions.data || filterOptions.data.length === 0) {
reload('')
}
}
"
autocomplete="off"
/>
</template>
<template #body="{ isOpen, close }">
<div v-show="isOpen">
<div
class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
<div class="relative w-full">
<ComboboxInput
ref="search"
class="form-input w-full focus-visible:!ring-0"
type="text"
@change="
(e) => {
query = e.target.value
}
"
autocomplete="off"
@focus="onFocus"
/>
<ComboboxButton ref="trigger" class="hidden" />
<ComboboxOptions
v-show="open"
static
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal border-2 border-outline-gray-modals max-h-[13rem] flex flex-col"
>
<div
class="flex-1 my-1 overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
>
<template v-if="options.length">
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<ComboboxOptions
class="flex-1 my-1 overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
static
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<ComboboxOption
v-if="options.length"
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value == option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
<div v-else class="text-ink-gray-7 px-4">
{{ __('No results found') }}
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value === option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</ComboboxOptions>
<div v-if="attrs.onCreate" class="px-1 pt-2 bg-white border-t">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</div>
</li>
</ComboboxOption>
</template>
<div v-else class="text-ink-gray-7 px-4 py-2">
{{ __('No results found') }}
</div>
</template>
</Popover>
</Combobox>
</div>
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
</div>
<div
v-if="attrs.onCreate"
class="p-1 bg-surface-white border-t rounded-b-lg"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate()"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
</div>
</Combobox>
<!-- Selected values -->
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
<div
v-for="value in values"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
:key="value"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
>
<span class="break-all">
{{ value }}
</span>
<span>{{ value }}</span>
<X
class="size-4 stroke-1.5 cursor-pointer"
@click="removeValue(value)"
/>
</div>
</div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
</div>
</template>
<script setup>
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick, useAttrs } from 'vue'
import { set, watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui'
import { ref, computed, useAttrs, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X, Plus } from 'lucide-vue-next'
const props = defineProps({
label: {
type: String,
},
size: {
type: String,
default: 'sm',
},
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: () => ({}),
},
validate: {
type: Function,
default: null,
},
label: String,
size: { type: String, default: 'sm' },
doctype: { type: String, required: true },
filters: { type: Object, default: () => ({}) },
validate: Function,
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
required: {
type: Boolean,
},
required: Boolean,
})
const values = defineModel()
const attrs = useAttrs()
const search = ref(null)
const error = ref(null)
const trigger = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const selectedValue = ref(null)
const error = ref(null)
const emit = defineEmits(['update:modelValue'])
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
val?.value && addValue(val.value)
showOptions.value = false
emit('update:modelValue', values.value)
},
watch(selectedValue, (val) => {
if (!val?.value) return
query.value = ''
addValue(val.value)
selectedValue.value = null
emit('update:modelValue', values.value)
})
watchDebounced(
@@ -188,7 +156,6 @@ watchDebounced(
const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
cache: [text.value, props.doctype],
auto: true,
params: {
txt: text.value,
@@ -197,7 +164,6 @@ const filterOptions = createResource({
})
const options = computed(() => {
setFocus()
const allOptions = filterOptions.data || []
return allOptions.filter((option) => !values.value?.includes(option.value))
})
@@ -212,52 +178,46 @@ function reload(val) {
filterOptions.reload()
}
const addValue = (value) => {
error.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
})
!error.value && (value = '')
function onFocus() {
if (!filterOptions.data?.length) {
reload('')
}
trigger.value?.$el.click()
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
function addValue(value) {
error.value = null
if (!value) return
const splitValues = value.split(',')
splitValues.forEach((val) => {
val = val.trim()
if (!val) return
if (values.value?.includes(val)) return
if (props.validate && !props.validate(val)) {
error.value = props.errorMessage(val)
return
}
if (!values.value) values.value = [val]
else values.value.push(val)
})
}
function removeValue(value) {
let indexToRemove = values.value.indexOf(value)
if (indexToRemove > -1) {
values.value.splice(indexToRemove, 1)
}
emit('update:modelValue', values.value)
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[props.size || 'sm'],
'text-ink-gray-5',
]
})
const labelClasses = computed(() => [
{ sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
'text-ink-gray-5',
])
</script>
@@ -93,7 +93,7 @@
<div class="space-y-4">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
:class="{ 'mt-8': !readOnlyMode }"
>
{{ __('This course has:') }}
</div>
+11
View File
@@ -112,6 +112,14 @@
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<NotebookPen
v-else-if="lesson.icon === 'icon-assignment'"
class="h-4 w-4 stroke-1 mr-2"
/>
<SquareCode
v-else-if="lesson.icon === 'icon-code'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
@@ -177,8 +185,11 @@ import {
FilePenLine,
HelpCircle,
MonitorPlay,
NotebookPen,
Plus,
SquareCode,
Trash2,
Notebook,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
+1 -1
View File
@@ -80,7 +80,7 @@ const props = defineProps({
required: true,
},
membership: {
type: Object,
type: Object || null,
required: false,
},
})
+59 -80
View File
@@ -93,11 +93,19 @@
</div>
</template>
<script setup>
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import {
call,
createResource,
TextEditor,
Button,
Dropdown,
toast,
} from 'frappe-ui'
import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, onUnmounted } from 'vue'
import { useTelemetry } from 'frappe-ui/frappe'
const showTopics = defineModel('showTopics')
const newReply = ref('')
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
const mentionUsers = ref([])
const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode
const { capture } = useTelemetry()
const props = defineProps({
topic: {
@@ -143,19 +152,6 @@ const replies = createResource({
auto: true,
})
const newReplyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
reply: newReply.value,
topic: props.topic.name,
},
}
},
})
const fetchMentionUsers = () => {
if (user.data?.is_student) {
renderEditor.value = true
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
}
const postReply = () => {
newReplyResource.submit(
{},
{
validate() {
if (!newReply.value) {
return 'Reply cannot be empty'
}
},
onSuccess() {
newReply.value = ''
replies.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const editReplyResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
if (!newReply.value) {
toast.error(__('Reply cannot be empty.'))
return
}
call('frappe.client.insert', {
doc: {
doctype: 'Discussion Reply',
name: values.name,
fieldname: 'reply',
value: values.reply,
}
},
})
reply: newReply.value,
topic: props.topic.name,
},
})
.then((data) => {
newReply.value = ''
replies.reload()
capture('discussion_reply_created')
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const postEdited = (reply) => {
editReplyResource.submit(
{
name: reply.name,
reply: reply.reply,
},
{
validate() {
if (!reply.reply) {
return 'Reply cannot be empty'
}
},
onSuccess() {
reply.editable = false
replies.reload()
},
}
)
if (!reply.reply) {
toast.error(__('Reply cannot be empty.'))
return
}
call('frappe.client.set_value', {
doctype: 'Discussion Reply',
name: reply.name,
fieldname: 'reply',
value: reply.reply,
})
.then(() => {
reply.editable = false
replies.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const deleteReplyResource = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
}
},
})
const deleteReply = (reply) => {
deleteReplyResource.submit(
{
name: reply.name,
},
{
onSuccess() {
replies.reload()
},
}
)
call('frappe.client.delete', {
doctype: 'Discussion Reply',
name: reply.name,
})
.then(() => {
replies.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
onUnmounted(() => {
@@ -15,20 +15,18 @@
>
<template #body-content>
<div class="flex flex-col gap-4">
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Subject') }}
<span class="text-ink-red-3">*</span>
</div>
<Input type="text" v-model="announcement.subject" />
</div>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Reply To') }}
<span class="text-ink-red-3">*</span>
</div>
<Input type="text" v-model="announcement.replyTo" />
</div>
<FormControl
:label="__('Subject')"
type="text"
v-model="announcement.subject"
:required="true"
/>
<FormControl
:label="__('Reply To')"
type="text"
v-model="announcement.replyTo"
:required="true"
/>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Announcement') }}
@@ -45,7 +43,13 @@
</Dialog>
</template>
<script setup>
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
import {
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { reactive } from 'vue'
const show = defineModel()
@@ -43,7 +43,7 @@
@change="(val) => (assignment.question = 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] max-h-[18rem] overflow-y-auto"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
title: __('Add a course'),
title: __('Add Course'),
size: 'sm',
actions: [
{
@@ -19,6 +19,7 @@
v-model="course"
:label="__('Course')"
:required="true"
:filters="{ published: 1 }"
:onCreate="
(value, close) => {
close()
@@ -26,7 +26,7 @@
@change="(val) => (topic.reply = 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
@@ -34,17 +34,13 @@
</Dialog>
</template>
<script setup>
import {
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { reactive } from 'vue'
import { singularize } from '@/utils'
import { useTelemetry } from 'frappe-ui/frappe'
const topics = defineModel('reloadTopics')
const { capture } = useTelemetry()
const props = defineProps({
title: {
@@ -66,64 +62,50 @@ const topic = reactive({
reply: '',
})
const topicResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Topic',
reference_doctype: props.doctype,
reference_docname: props.docname,
title: topic.title,
},
}
},
})
const replyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
topic: values.topic,
reply: topic.reply,
},
}
},
})
const submitTopic = (close) => {
topicResource.submit(
{},
{
validate() {
if (!topic.title) {
return 'Title cannot be empty.'
}
if (!topic.reply) {
return 'Reply cannot be empty.'
}
},
onSuccess(data) {
replyResource.submit(
{
topic: data.name,
},
{
onSuccess() {
topic.title = ''
topic.reply = ''
topics.value.reload()
close()
},
}
)
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
if (!topic.title) {
toast.error(__('Title cannot be empty.'))
return
}
if (!topic.reply) {
toast.error(__('Details cannot be empty.'))
return
}
call('frappe.client.insert', {
doc: {
doctype: 'Discussion Topic',
reference_doctype: props.doctype,
reference_docname: props.docname,
title: topic.title,
},
})
.then((data) => {
createReply(data.name, close)
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const createReply = (topicName, close) => {
call('frappe.client.insert', {
doc: {
doctype: 'Discussion Reply',
topic: topicName,
reply: topic.reply,
},
})
.then((data) => {
topic.title = ''
topic.reply = ''
topics.value.reload()
capture('discussion_topic_created')
close()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
</script>
@@ -67,7 +67,7 @@
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
)
"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@
@change="(val) => (question.question = 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="grid grid-cols-2 gap-8 mt-4">
+43 -36
View File
@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
title: __('Add a Student'),
title: __('Enroll a Student'),
size: 'sm',
actions: [
{
@@ -18,10 +18,24 @@
<Link
doctype="User"
v-model="student"
:filters="{ ignore_user_type: 1 }"
placeholder=" "
:label="__('Student')"
:onCreate="
(value, close) => {
openSettings('Members', close)
() => {
openSettings('Members')
show = false
}
"
:required="true"
/>
<Link
doctype="LMS Payment"
v-model="payment"
placeholder=" "
:label="__('Payment')"
:onCreate="
() => {
openSettings('Transactions')
show = false
}
"
@@ -31,15 +45,16 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, toast } from 'frappe-ui'
import { call, Dialog, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const students = defineModel('reloadStudents')
const batchModal = defineModel('batchModal')
const student = ref()
const student = ref(null)
const payment = ref(null)
const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning')
const show = defineModel()
@@ -51,36 +66,28 @@ const props = defineProps({
},
})
const studentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Batch Enrollment',
batch: props.batch,
member: student.value,
},
}
},
})
const addStudent = (close) => {
studentResource.submit(
{},
{
onSuccess() {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student')
call('frappe.client.insert', {
doc: {
doctype: 'LMS Batch Enrollment',
batch: props.batch,
member: student.value,
payment: payment.value,
},
})
.then(() => {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student')
students.value.reload()
batchModal.value.reload()
student.value = null
close()
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
students.value.reload()
batchModal.value.reload()
student.value = null
payment.value = null
close()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
</script>
+1 -1
View File
@@ -5,7 +5,7 @@
</div>
<div class="flex items-center space-x-2">
<slot name="prefix" />
<div class="font-semibold text-2xl">
<div class="font-semibold text-ink-gray-9 text-2xl">
{{ value }}
</div>
<slot name="suffix" />
+1 -1
View File
@@ -205,7 +205,7 @@
@change="(val) => (possibleAnswer = 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-4">
@@ -45,7 +45,7 @@
</div>
<div class="overflow-y-scroll">
<div class="divide-y space-y-2">
<div class="divide-y divide-outline-gray-modals space-y-2">
<div
v-for="(cat, index) in categories.data"
:key="cat.name"
@@ -53,9 +53,9 @@
>
<div
v-if="editing?.name !== cat.name"
class="flex items-center justify-between group text-sm"
class="flex items-center justify-between group text-sm text-ink-gray-9"
>
<div @dblclick="allowEdit(cat, index)">
<div class="text-ink-gray-9" @dblclick="allowEdit(cat, index)">
{{ cat.category }}
</div>
<Button
@@ -32,7 +32,7 @@
</template>
</FormControl>
<div class="overflow-auto h-[60vh]">
<div class="divide-y">
<div class="divide-y divide-outline-gray-modals">
<div
v-for="evaluator in evaluators.data"
:key="evaluator.evaluator"
+36 -30
View File
@@ -32,7 +32,7 @@
</template>
</FormControl>
<div class="overflow-y-scroll h-[60vh]">
<ul class="divide-y">
<ul class="divide-y divide-outline-gray-modals">
<li
v-for="member in memberList"
class="flex items-center justify-between py-2 cursor-pointer"
@@ -58,7 +58,7 @@
</div>
</div>
<div
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
class="flex items-center text-ink-gray-9 space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
v-if="member.role && member.role !== 'LMS Student'"
>
<Shield class="size-4 stroke-1.5" />
@@ -117,12 +117,21 @@
</Dialog>
</template>
<script setup lang="ts">
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
import {
Avatar,
Button,
call,
createResource,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
import { useTelemetry } from 'frappe-ui/frappe'
type Member = {
username: string
@@ -141,6 +150,7 @@ const hasNextPage = ref(false)
const showForm = ref(false)
const user = inject<User | null>('$user')
const { updateOnboardingStep } = useOnboarding('learning')
const { capture } = useTelemetry()
const member = reactive({
email: '',
@@ -184,34 +194,30 @@ const openProfile = (username: string) => {
})
}
const newMember = createResource({
url: 'frappe.client.insert',
makeParams() {
return {
doc: {
doctype: 'User',
first_name: member.first_name,
email: member.email,
},
}
},
auto: false,
onSuccess(data: Member) {
show.value = false
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
router.push({
name: 'ProfileRoles',
params: {
username: data.username,
},
})
},
})
const addMember = (close: () => void) => {
newMember.reload()
close()
call('frappe.client.insert', {
doc: {
doctype: 'User',
first_name: member.first_name,
email: member.email,
},
})
.then((data: Member) => {
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
capture('user_added')
show.value = false
router.push({
name: 'ProfileRoles',
params: {
username: data.username,
},
})
close()
})
.catch((err: any) => {
console.error(err)
toast.error(__(err.messages?.[0] || err))
})
}
watch(search, () => {
@@ -1,5 +1,5 @@
<template>
<div class="mb-5 divide-y overflow-y-auto">
<div class="mb-5 divide-y divide-outline-gray-modals overflow-y-auto">
<div v-for="(section, index) in sections" class="py-5">
<div v-if="section.label" class="font-semibold text-ink-gray-9 mb-4">
{{ section.label }}
@@ -65,7 +65,7 @@
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
>
<img
@@ -90,7 +90,7 @@
</div>
<X
@click="data[field.name] = null"
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
+1 -1
View File
@@ -17,7 +17,7 @@
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-surface-white shadow-xl"
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div v-for="app in apps.data" key="name">
<a
@@ -1,26 +1,48 @@
<template>
<div class="grid grid-cols-3 justify-between bg-surface-white">
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
<router-link
:to="{
name: 'DataImportList',
query: {
step: 'list',
},
}"
<Popover placement="right-start" trigger="hover" class="flex w-full">
<template #target="{ togglePopover }">
<button
:class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
]"
>
<div class="flex flex-col items-center space-y-1">
<ArrowDownToLine
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
/>
<div class="text-sm text-ink-gray-7">
{{ __('Import') }}
</div>
<div class="flex gap-2">
<Wrench class="size-4 stroke-1.5" />
<span class="whitespace-nowrap">
{{ __('Configuration') }}
</span>
</div>
</router-link>
</div>
</div>
<ChevronRight class="h-4 w-4 stroke-1.5" />
</button>
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
<router-link
:to="{
name: 'DataImportList',
query: {
step: 'list',
},
}"
>
<div class="flex flex-col items-center space-y-1">
<ArrowDownToLine
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
/>
<div class="text-sm text-ink-gray-7">
{{ __('Import') }}
</div>
</div>
</router-link>
</div>
</div>
</template>
</Popover>
</template>
<script setup lang="ts">
import { ArrowDownToLine } from 'lucide-vue-next'
import { Popover } from 'frappe-ui'
import { ArrowDownToLine, Wrench, ChevronRight } from 'lucide-vue-next'
</script>
@@ -85,7 +85,6 @@ import {
User,
Settings,
Sun,
Wrench,
Zap,
} from 'lucide-vue-next'
@@ -171,13 +170,7 @@ const userDropdownOptions = computed(() => {
},
},
{
label: 'Configuration',
icon: Wrench,
submenu: [
{
component: markRaw(Configuration),
},
],
component: markRaw(Configuration),
condition: () => {
return userResource.data?.is_moderator
},
+30 -10
View File
@@ -137,11 +137,12 @@ import {
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '@/utils'
import { Button, createResource, call } from 'frappe-ui'
import { Button, createListResource, call, toast } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const showEvalModal = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
@@ -165,12 +166,27 @@ const props = defineProps({
},
})
const upcoming_evals = createResource({
url: 'lms.lms.utils.get_upcoming_evals',
params: {
courses: props.courses.map((course) => course.course),
batch: props.batch,
const upcoming_evals = createListResource({
doctype: 'LMS Certificate Request',
filters: {
course: props.courses?.length
? ['in', props.courses.map((course) => course.course)]
: undefined,
batch_name: props.batch || undefined,
status: 'Upcoming',
member: user?.data?.name,
date: ['>=', dayjs().format('YYYY-MM-DD')],
},
fields: [
'name',
'date',
'start_time',
'evaluator_name',
'course_title',
'member',
'google_meet_link',
],
orderBy: 'date',
auto: true,
})
@@ -212,11 +228,15 @@ const cancelEvaluation = (evl) => {
theme: 'red',
variant: 'solid',
onClick(close) {
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
() => {
call('lms.lms.api.cancel_evaluation', { evaluation: evl })
.then(() => {
upcoming_evals.reload()
}
)
toast.success(__('Evaluation cancelled successfully'))
})
.catch((err) => {
toast.error(__(err.messages?.[0] || err))
console.error(err)
})
close()
},
},
+1 -1
View File
@@ -59,7 +59,7 @@ onMounted(() => {
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Submissions',
label: __('Submissions'),
route: { name: 'AssignmentSubmissionList' },
},
{
+1 -4
View File
@@ -140,9 +140,6 @@ const assignmentFilter = computed(() => {
if (typeFilter.value) {
filters.type = typeFilter.value
}
if (!user.data?.is_moderator) {
filters.owner = user.data?.email
}
return filters
})
@@ -203,7 +200,7 @@ const assignmentTypes = computed(() => {
const breadcrumbs = computed(() => [
{
label: 'Assignments',
label: __('Assignments'),
route: { name: 'Assignments' },
},
])
+1 -1
View File
@@ -59,7 +59,7 @@ const badge = createResource({
const breadcrumbs = computed(() => {
return [
{
label: 'Badges',
label: __('Badges'),
},
{
label: badge.data.badge,
+2 -2
View File
@@ -330,10 +330,10 @@ const batch = createResource({
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
if (!isStudent.value) {
crumbs.push({
label: 'Details',
label: __('Details'),
route: {
name: 'BatchDetail',
params: {
+3 -3
View File
@@ -120,12 +120,12 @@ const courses = createResource({
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
items.push({
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
crumbs.push({
label: batch?.data?.title,
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
})
return items
return crumbs
})
usePageMeta(() => {
+3 -3
View File
@@ -138,7 +138,7 @@
@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] max-h-[20rem] overflow-y-scroll mb-4"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
/>
</div>
</div>
@@ -559,7 +559,7 @@ const trashBatch = (close) => {
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Batches',
label: __('Batches'),
route: {
name: 'Batches',
},
@@ -577,7 +577,7 @@ const breadcrumbs = computed(() => {
})
}
crumbs.push({
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
label: props.batchName == 'new' ? __('New Batch') : __('Edit Batch'),
route: { name: 'BatchForm', params: { batchName: props.batchName } },
})
return crumbs
+11 -10
View File
@@ -155,7 +155,7 @@ const title = ref('')
const certification = ref(false)
const filters = ref({})
const is_student = computed(() => user.data?.is_student)
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
const currentTab = ref(is_student.value ? 'all' : 'upcoming')
const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode
const router = useRouter()
@@ -245,7 +245,7 @@ const updateTabFilter = () => {
if (!user.data) {
return
}
if (currentTab.value == 'Enrolled' && is_student.value) {
if (currentTab.value == 'enrolled' && is_student.value) {
filters.value['enrolled'] = 1
delete filters.value['start_date']
delete filters.value['published']
@@ -256,20 +256,20 @@ const updateTabFilter = () => {
delete filters.value['start_date']
delete filters.value['published']
orderBy.value = 'start_date desc'
if (currentTab.value == 'Upcoming') {
if (currentTab.value == 'upcoming') {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
filters.value['published'] = 1
orderBy.value = 'start_date'
} else if (currentTab.value == 'Archived') {
} else if (currentTab.value == 'archived') {
filters.value['start_date'] = ['<=', dayjs().format('YYYY-MM-DD')]
} else if (currentTab.value == 'Unpublished') {
} else if (currentTab.value == 'unpublished') {
filters.value['published'] = 0
}
}
}
const updateStudentFilter = () => {
if (!user.data || (is_student.value && currentTab.value != 'Enrolled')) {
if (!user.data || (is_student.value && currentTab.value != 'enrolled')) {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
filters.value['published'] = 1
}
@@ -319,6 +319,7 @@ const batchTabs = computed(() => {
let tabs = [
{
label: __('All'),
value: 'all',
},
]
@@ -327,11 +328,11 @@ const batchTabs = computed(() => {
user.data?.is_instructor ||
user.data?.is_evaluator
) {
tabs.push({ label: __('Upcoming') })
tabs.push({ label: __('Archived') })
tabs.push({ label: __('Unpublished') })
tabs.push({ label: __('Upcoming'), value: 'upcoming' })
tabs.push({ label: __('Archived'), value: 'archived' })
tabs.push({ label: __('Unpublished'), value: 'unpublished' })
} else if (user.data) {
tabs.push({ label: __('Enrolled') })
tabs.push({ label: __('Enrolled'), value: 'enrolled' })
}
return tabs
})
+76 -53
View File
@@ -1,6 +1,6 @@
<template>
<div class="p-5">
<div class="grid grid-cols-4 gap-5 mb-5">
<div class="grid grid-cols-4 gap-5 mb-5 text-ink-gray-9">
<NumberChartGraph
:title="__('Enrolled')"
:value="formatAmount(course.data?.enrollments)"
@@ -20,9 +20,9 @@
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
</div>
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
<div class="border rounded-lg py-3 px-4">
<div class="flex items-center justify-between mb-3">
<div class="text-lg font-semibold">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Students') }}
</div>
<div class="flex items-center space-x-2">
@@ -63,50 +63,52 @@
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
<ListRow
:row="row"
@click="
() => {
showProgressModal = true
currentStudent = row
}
"
class="cursor-pointer"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
<ProgressBar
v-else-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</template>
<div v-if="column.key == 'creation'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div
<ProgressBar
v-else-if="column.key == 'progress'"
class="text-xs !mx-0 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
/>
</template>
<div v-if="column.key == 'creation'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div
v-else-if="column.key == 'progress'"
class="text-xs !mx-0 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<div
@@ -127,10 +129,12 @@
<div class="text-ink-gray-5 mb-4">
{{ __('Progress Summary') }}
</div>
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
<div
class="grid grid-cols-[2fr_1fr] items-center justify-between text-ink-gray-9"
>
<div class="flex flex-col space-y-4 flex-1 text-sm">
<div
class="flex items-center"
class="flex items-center text-ink-gray-7"
v-for="row in chartDetails.data?.progress_distribution"
>
<div
@@ -142,6 +146,8 @@
? 'red'
: row.name.startsWith('In')
? 'amber'
: row.name.startsWith('Adv')
? 'blue'
: 'green'
][400],
}"
@@ -151,11 +157,13 @@
{{ row.name.split('(')[0] }}
</div>
</Tooltip>
<div class="ml-auto">
{{
Math.round((row.value / course.data?.enrollments) * 100)
}}%
</div>
<Tooltip :text="row.value">
<div class="ml-auto">
{{
Math.round((row.value / course.data?.enrollments) * 100)
}}%
</div>
</Tooltip>
</div>
</div>
<ECharts
@@ -205,10 +213,12 @@
class="!w-32"
/>
</div>
<div class="divide-y max-h-[43vh] overflow-y-auto">
<div
class="divide-y max-h-[43vh divide-outline-gray-modals text-ink-gray-7 overflow-y-auto"
>
<div
v-for="progress in lessonProgress.data"
class="flex justify-between text-sm py-2 my-1"
class="flex justify-between text-sm py-2 my-1 text-ink-gray-9"
>
<div class="">
<span class="mr-3 text-xs">
@@ -238,6 +248,14 @@
v-if="showEnrollmentModal"
v-model="showEnrollmentModal"
:course="course"
:students="progressList"
/>
<StudentCourseProgress
v-if="showProgressModal"
v-model="showProgressModal"
:course="course"
:student="currentStudent"
:lessons="lessonProgress"
/>
</template>
<script setup lang="ts">
@@ -260,12 +278,13 @@ import {
Tooltip,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
import { Plus, Star } from 'lucide-vue-next'
import { formatAmount } from '@/utils'
import colors from '@/utils/frappe-ui-colors.json'
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import StudentCourseProgress from '@/pages/Courses/StudentCourseProgress.vue'
const props = defineProps<{
course: any
@@ -273,6 +292,8 @@ const props = defineProps<{
const showEnrollmentModal = ref(false)
const searchFilter = ref<string | null>(null)
const showProgressModal = ref(false)
const currentStudent = ref<any>(null)
const theme = ref<'darkMode' | 'lightMode'>(
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
)
@@ -307,6 +328,7 @@ const progressList = createListResource({
],
pageLength: 100,
auto: true,
cache: ['courseProgress', props.course.data?.name],
})
const lessonProgress = createResource({
@@ -357,6 +379,7 @@ const progressColors = computed(() => {
let colorList = []
colorList.push(colors[theme.value]['red'][400])
colorList.push(colors[theme.value]['amber'][400])
colorList.push(colors[theme.value]['blue'][400])
colorList.push(colors[theme.value]['green'][400])
return colorList
})
+3 -3
View File
@@ -140,12 +140,12 @@ const isAdmin = computed(() => {
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
let crumbs = [{ label: __('Courses'), route: { name: 'Courses' } }]
crumbs.push({
label: course?.data?.title,
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
})
return items
return crumbs
})
usePageMeta(() => {
@@ -19,8 +19,7 @@
placeholder=" "
v-model="student"
:required="true"
:allowCreate="true"
@create="
:onCreate="
() => {
openSettings('Members')
show = false
@@ -33,8 +32,7 @@
:label="__('Payment')"
placeholder=" "
v-model="payment"
:allowCreate="true"
@create="
:onCreate="
() => {
openSettings('Transactions')
show = false
@@ -54,12 +52,13 @@
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { Link } from 'frappe-ui/frappe'
import { ref } from 'vue'
import { openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel<boolean>({ required: true, default: false })
const student = ref<string | null>(null)
const students = defineModel<any[]>('students')
const payment = ref<string | null>(null)
const purchasedCertificate = ref<boolean>(false)
@@ -81,6 +80,7 @@ const enrollStudent = (close: () => void) => {
},
})
.then(() => {
students.value?.reload()
toast.success(__('Student enrolled successfully'))
close()
})
+1 -1
View File
@@ -155,7 +155,7 @@
"
: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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
@@ -76,7 +76,7 @@
<CourseReviews
:courseName="course.data.name"
:avg_rating="course.data.rating"
:membership="course.data.membership"
:membership="course.data.membership || null"
/>
</div>
<div class="hidden md:block">
+14 -11
View File
@@ -147,7 +147,7 @@ const currentCategory = ref(null)
const title = ref('')
const certification = ref(false)
const filters = ref({})
const currentTab = ref('Live')
const currentTab = ref('live')
const { brand } = sessionStore()
const courseCount = ref(0)
const router = useRouter()
@@ -267,35 +267,35 @@ const updateTabFilter = () => {
delete filters.value['published_on']
delete filters.value['upcoming']
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
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') {
if (currentTab.value == 'live') {
filters.value['published'] = 1
filters.value['upcoming'] = 0
filters.value['live'] = 1
} else if (currentTab.value == 'Upcoming') {
} else if (currentTab.value == 'upcoming') {
filters.value['upcoming'] = 1
} else if (currentTab.value == 'New') {
} 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') {
} else if (currentTab.value == 'created') {
filters.value['created'] = 1
} else if (currentTab.value == 'Unpublished') {
} else if (currentTab.value == 'unpublished') {
filters.value['published'] = 0
}
}
}
const updateStudentFilter = () => {
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
if (!user.data || (user.data?.is_student && currentTab.value != 'enrolled')) {
filters.value['published'] = 1
}
}
@@ -345,12 +345,15 @@ const courseTabs = computed(() => {
let tabs = [
{
label: __('Live'),
value: 'live',
},
{
label: __('New'),
value: 'new',
},
{
label: __('Upcoming'),
value: 'upcoming',
},
]
if (
@@ -358,10 +361,10 @@ const courseTabs = computed(() => {
user.data?.is_instructor ||
user.data?.is_evaluator
) {
tabs.push({ label: __('Created') })
tabs.push({ label: __('Unpublished') })
tabs.push({ label: __('Created'), value: 'created' })
tabs.push({ label: __('Unpublished'), value: 'unpublished' })
} else if (user.data) {
tabs.push({ label: __('Enrolled') })
tabs.push({ label: __('Enrolled'), value: 'enrolled' })
}
return tabs
})
+11 -7
View File
@@ -58,7 +58,7 @@
@change="(val: string) => (course.description = 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-[10rem]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
/>
</div>
</div>
@@ -76,9 +76,9 @@
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { openSettings } from '@/utils'
import { cleanError, openSettings } from '@/utils'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Uploader from '@/components/Controls/Uploader.vue'
@@ -125,6 +125,10 @@ const saveCourse = (close: () => void = () => {}) => {
})
}
},
onError(err: any) {
toast.error(cleanError(err.messages?.[0]))
console.error(err)
},
}
)
}
@@ -144,13 +148,13 @@ const keyboardShortcut = (e: KeyboardEvent) => {
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
capture('course_form_opened')
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
watch(show, () => {
capture('course_form_opened')
capture('course_form_closed', {
data: course.value,
})
})
</script>
@@ -0,0 +1,220 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Student Progress'),
size: hasAssessmentData ? '3xl' : 'xl',
}"
>
<template #body-content>
<div class="text-base text-ink-gray-9 max-h-[70vh] overflow-y-auto">
<div class="flex justify-between mb-5 px-2">
<div class="flex items-center space-x-2">
<Avatar
:image="student?.member_image"
:label="student?.member_name"
size="xl"
/>
<div>
<div class="font-semibold">
{{ student?.member_name }}
</div>
<div class="text-ink-gray-5">
{{ student.member }}
</div>
</div>
</div>
<div class="w-25 space-y-2">
<div class="text-ink-gray-5 text-sm">
{{ Math.round(student.progress) }}% {{ __('completed') }}
</div>
<ProgressBar
:label="__('Course Progress')"
:progress="student.progress"
/>
</div>
</div>
<div class="grid gap-5" :class="hasAssessmentData ? 'grid-cols-2' : ''">
<div
v-if="lessons.data"
class="border border-outline-gray-modals rounded-lg px-3 pt-3 max-h-[60vh] overflow-y-auto"
>
<div>
<div class="text-ink-gray-5 mb-5">
{{ __('Lesson Progress') }}
</div>
</div>
<div
v-for="progress in lessons.data"
class="flex justify-between text-sm py-2 my-1"
>
<div class="">
<span class="mr-3 text-xs">
{{ progress.chapter_idx }}.{{ progress.idx }}
</span>
<span>
{{ progress.title }}
</span>
</div>
<Tooltip
v-if="getLessonStatus(progress) == 'Complete'"
:text="__('Complete')"
>
<Check class="text-ink-green-3 size-4" />
</Tooltip>
<Tooltip v-else :text="__('Pending')">
<Minus class="text-ink-amber-2 size-4" />
</Tooltip>
<!-- <Badge :theme="getLessonStatusTheme(progress)">
{{ getLessonStatus(progress) }}
</Badge> -->
</div>
</div>
<div class="space-y-3">
<div
v-if="assessmentProgress.data?.quizzes?.length"
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
>
<div>
<div class="text-ink-gray-5 mb-5">
{{ __('Quiz Progress') }}
</div>
</div>
<div
v-for="quiz in assessmentProgress.data.quizzes"
class="flex justify-between text-sm py-2 my-1"
>
<div>
{{ quiz.quiz_title }}
</div>
<div>
{{ quiz.score }}
</div>
<div>{{ quiz.percentage }}%</div>
</div>
</div>
<div
v-if="assessmentProgress.data?.assignments?.length"
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
>
<div>
<div class="text-ink-gray-5 mb-5">
{{ __('Assignment Progress') }}
</div>
</div>
<div
v-for="assignment in assessmentProgress.data.assignments"
class="flex justify-between text-sm py-2 my-1"
>
<div>
{{ assignment.assignment_title }}
</div>
<Badge :theme="getAssessmentStatusTheme(assignment.status)">
{{ assignment.status }}
</Badge>
</div>
</div>
<div
v-if="assessmentProgress.data?.exercises?.length"
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
>
<div>
<div class="text-ink-gray-5 mb-5">
{{ __('Programming Exercise Progress') }}
</div>
</div>
<div
v-for="exercise in assessmentProgress.data.exercises"
class="flex justify-between text-sm py-2 my-1"
>
<div>
{{ exercise.exercise_title }}
</div>
<Badge :theme="getAssessmentStatusTheme(exercise.status)">
{{ exercise.status }}
</Badge>
</div>
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Badge,
createListResource,
createResource,
Dialog,
Tooltip,
} from 'frappe-ui'
import ProgressBar from '@/components/ProgressBar.vue'
import { computed } from 'vue'
import { Check, Minus } from 'lucide-vue-next'
const show = defineModel<boolean>({ required: true, default: false })
const props = defineProps<{
course: any
student: any
lessons: any
}>()
const lessonProgress = createListResource({
doctype: 'LMS Course Progress',
filters: {
course: ['=', props.course.data?.name],
member: ['=', props.student?.member],
},
fields: ['name', 'lesson', 'status'],
auto: true,
})
const assessmentProgress = createResource({
url: 'lms.lms.api.get_course_assessment_progress',
params: {
course: props.course.data?.name,
member: props.student?.member,
},
auto: true,
})
const getLessonStatus = (lesson: any) => {
return (
lessonProgress.data?.find((lp: any) => lp.lesson === lesson.lesson)
?.status || __('Pending')
)
}
const getLessonStatusTheme = (lesson: any) => {
const status = getLessonStatus(lesson)
if (status === 'Complete') {
return 'green'
} else {
return 'orange'
}
}
const getAssessmentStatusTheme = (status: string) => {
if (status.includes('Pass')) return 'green'
else if (status.includes('Fail')) return 'red'
else return 'orange'
}
const hasAssessmentData = computed(() => {
return (
(assessmentProgress.data?.quizzes &&
assessmentProgress.data.quizzes.length > 0) ||
(assessmentProgress.data?.assignments &&
assessmentProgress.data.assignments.length > 0) ||
(assessmentProgress.data?.exercises &&
assessmentProgress.data.exercises.length > 0)
)
})
</script>
+9 -2
View File
@@ -60,8 +60,15 @@ const currentTab = ref<'student' | 'instructor'>('instructor')
const showStreakModal = ref(false)
onMounted(() => {
call('lms.lms.utils.get_upcoming_evals').then((data: any) => {
evalCount.value = data.length
call('frappe.client.get_count', {
doctype: 'LMS Certificate Request',
filters: {
member: user?.data?.name,
status: 'Upcoming',
date: ['>=', inject<any>('$dayjs')().format('YYYY-MM-DD')],
},
}).then((data: any) => {
evalCount.value = data
})
})
+73 -73
View File
@@ -1,6 +1,78 @@
<template>
<div>
<div v-if="myCourses.data?.length" class="mt-10">
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10">
<UpcomingEvaluations :forHome="true" />
<div v-if="myLiveClasses.data?.length">
<div class="font-semibold text-lg mb-3 text-ink-gray-9">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div
v-for="cls in myLiveClasses.data"
class="border rounded-md hover:border-outline-gray-3 p-2"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-5 leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-4 text-ink-gray-7">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
<div v-if="myCourses.data?.length">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
{{
@@ -63,78 +135,6 @@
</router-link>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10">
<UpcomingEvaluations :forHome="true" />
<div v-if="myLiveClasses.data?.length">
<div class="font-semibold text-lg mb-3 text-ink-gray-9">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div
v-for="cls in myLiveClasses.data"
class="border rounded-md hover:border-outline-gray-3 p-2"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
+1 -1
View File
@@ -141,7 +141,7 @@
@change="(val) => (emailForm.message = 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
+4 -4
View File
@@ -101,7 +101,7 @@
@change="(val) => (job.description = 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"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div>
@@ -298,11 +298,11 @@ const jobStatuses = computed(() => {
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Jobs',
label: __('Jobs'),
route: { name: 'Jobs' },
},
{
label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
label: props.jobName == 'new' ? __('New Job') : __('Edit Job'),
route: { name: 'JobForm' },
},
]
@@ -311,7 +311,7 @@ const breadcrumbs = computed(() => {
usePageMeta(() => {
return {
title: props.jobName == 'new' ? 'New Job' : jobDetail.data?.job_title,
title: props.jobName == 'new' ? __('New Job') : jobDetail.data?.job_title,
icon: brand.favicon,
}
})
+23 -19
View File
@@ -12,7 +12,7 @@
</template>
</Button>
</Tooltip>
<Button v-if="canSeeStats()" @click="showVideoStats()">
<Button v-if="isAdmin" @click="showVideoStats()">
<template #icon>
<TrendingUp class="size-4 stroke-1.5" />
</template>
@@ -326,7 +326,7 @@
@updateNotes="updateNotes"
/>
<VideoStatistics
v-if="showStatsDialog"
v-if="isAdmin"
v-model="showStatsDialog"
:lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title"
@@ -524,7 +524,14 @@ const renderEditor = (holder, content) => {
const markProgress = () => {
if (user.data && lesson.data && !lesson.data.progress) {
progress.submit()
progress.submit(
{},
{
onError(err) {
console.error(err)
},
}
)
}
}
@@ -559,12 +566,12 @@ const notes = createListResource({
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
let crumbs = [{ label: __('Courses'), route: { name: 'Courses' } }]
crumbs.push({
label: lesson?.data?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
})
items.push({
crumbs.push({
label: lesson?.data?.title,
route: {
name: 'Lesson',
@@ -575,7 +582,7 @@ const breadcrumbs = computed(() => {
},
},
})
return items
return crumbs
})
const switchLesson = (direction) => {
@@ -605,7 +612,6 @@ watch(
plyrSources.value = []
await nextTick()
resetLessonState(newChapterNumber, newLessonNumber)
startTimer()
updateNotes()
checkIfDiscussionsAllowed()
checkQuiz()
@@ -674,6 +680,7 @@ watch(
() => lesson.data,
async (data) => {
setupLesson(data)
startTimer()
getPlyrSource()
updateNotes()
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
@@ -769,17 +776,19 @@ const checkIfDiscussionsAllowed = () => {
}
}
const isAdmin = computed(() => {
let isInstructor = lesson.data?.instructors?.includes(user.data?.name)
return user.data?.is_moderator || isInstructor
})
const allowEdit = () => {
if (window.read_only_mode) return false
if (user.data?.is_moderator) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false
return isAdmin.value
}
const allowInstructorContent = () => {
if (user.data?.is_moderator) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false
if (window.read_only_mode) return false
return isAdmin.value
}
const enrollment = createResource({
@@ -819,11 +828,6 @@ const toggleInlineMenu = async () => {
}
}
const canSeeStats = () => {
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const showVideoStats = () => {
showStatsDialog.value = true
}
+5 -3
View File
@@ -466,7 +466,7 @@ const validateLesson = () => {
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Courses',
label: __('Courses'),
route: { name: 'Courses' },
},
{
@@ -493,7 +493,9 @@ const breadcrumbs = computed(() => {
})
}
crumbs.push({
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
label: lessonDetails?.data?.lesson
? __('Edit Lesson')
: __('Create Lesson'),
route: {
name: 'LessonForm',
params: {
@@ -510,7 +512,7 @@ usePageMeta(() => {
return {
title: lessonDetails?.data?.lesson
? lessonDetails.data.lesson.title
: 'New Lesson',
: __('New Lesson'),
icon: brand.favicon,
}
})
+10 -5
View File
@@ -263,12 +263,17 @@ const isEvaluatorOrModerator = () => {
}
const getTabButtons = () => {
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
let buttons = [
{ label: __('About'), value: 'About' },
{ label: __('Certificates'), value: 'Certificates' },
]
if ($user.data?.is_moderator) {
buttons.push({ label: __('Roles'), value: 'Roles' })
}
if (currentUserHasHigherAccess() && isEvaluatorOrModerator()) {
buttons.push({ label: 'Slots' })
buttons.push({ label: 'Schedule' })
buttons.push({ label: __('Slots'), value: 'Slots' })
buttons.push({ label: __('Schedule'), value: 'Schedule' })
}
return buttons
}
@@ -288,7 +293,7 @@ const navigateTo = (url) => {
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'People',
label: __('People'),
},
{
label: profile.data?.full_name,
@@ -51,7 +51,7 @@
@change="(val: string) => (exercise.problem_statement = 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] max-h-[21rem] overflow-y-auto"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[21rem] overflow-y-auto"
/>
</div>
</div>
+1 -1
View File
@@ -229,7 +229,7 @@ const setupSCORMAPI = () => {
const breadcrumbs = computed(() => {
return [
{
label: 'Courses',
label: __('Courses'),
route: { name: 'Courses' },
},
{
+1 -1
View File
@@ -150,7 +150,7 @@ const { brand } = sessionStore()
const breadcrumbs = computed(() => {
return [
{
label: 'Statistics',
label: __('Statistics'),
route: {
name: 'Statistics',
},
+15 -13
View File
@@ -162,20 +162,21 @@ export function getEditorTools() {
config: {
services: {
youtube: {
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
regex: /^(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)$/,
embedUrl: '<%= remote_id %>',
/* 'https://www.youtube.com/embed/<%= remote_id %>?origin=https://plyr.io&amp;iv_load_policy=3&amp;modestbranding=1&amp;playsinline=1&amp;showinfo=0&amp;rel=0&amp;enablejsapi=1' */
html: `<div class="video-player" data-plyr-provider="youtube"></div>`,
id: ([id]) => id,
},
vimeo: {
regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/,
embedUrl: '<%= remote_id %>',
regex: /^(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?(?:\?[^\s]*)?$/,
embedUrl:
'https://player.vimeo.com/video/<%= remote_id %>',
html: `<div class="video-player" data-plyr-provider="vimeo"></div>`,
id: ([id]) => id,
id: ([id, hash]) => (hash ? `${id}?h=${hash}` : id),
},
cloudflareStream: {
regex: /https:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/([a-f0-9]{32})\/watch/,
regex: /^https:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/([a-f0-9]{32})\/watch$/,
embedUrl:
'https://iframe.videodelivery.net/<%= remote_id %>',
html: `<iframe style="width:100%; height: ${
@@ -183,7 +184,7 @@ export function getEditorTools() {
};" frameborder="0" allowfullscreen></iframe>`,
},
bunnyStream: {
regex: /https:\/\/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/play\/([a-zA-Z0-9]+\/[a-zA-Z0-9-]+)/,
regex: /^https:\/\/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/play\/([a-zA-Z0-9]+\/[a-zA-Z0-9-]+)$/,
embedUrl:
'https://iframe.mediadelivery.net/embed/<%= remote_id %>',
html: `<iframe style="width:100%; height: ${
@@ -192,7 +193,7 @@ export function getEditorTools() {
},
codepen: true,
aparat: {
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
regex: /^(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?$/,
embedUrl:
'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame',
html: `<iframe style="margin: 0 auto; width: 100%; height: ${
@@ -201,7 +202,7 @@ export function getEditorTools() {
},
github: true,
slides: {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub/,
regex: /^https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub$/,
embedUrl:
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: `<iframe style='width: 100%; height: ${
@@ -209,7 +210,7 @@ export function getEditorTools() {
}; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0' frameborder='0' allowfullscreen='true'></iframe>`,
},
drive: {
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
regex: /^https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?$/,
embedUrl:
'https://drive.google.com/file/d/<%= remote_id %>/preview',
html: `<iframe style='width: 100%; height: ${
@@ -217,19 +218,19 @@ export function getEditorTools() {
}; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>`,
},
docsPublic: {
regex: /https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
regex: /^https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?$/,
embedUrl:
'https://docs.google.com/document/d/<%= remote_id %>/preview',
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
},
sheetsPublic: {
regex: /https:\/\/docs\.google\.com\/spreadsheets\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
regex: /^https:\/\/docs\.google\.com\/spreadsheets\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?$/,
embedUrl:
'https://docs.google.com/spreadsheets/d/<%= remote_id %>/preview',
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
},
slidesPublic: {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
regex: /^https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?$/,
embedUrl:
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
@@ -513,7 +514,8 @@ const getSidebarItems = () => {
: settings.data?.contact_us_email,
condition: () => {
return (
settings?.data?.contact_us_email ||
(settings?.data?.contact_us_email &&
userResource?.data) ||
settings?.data?.contact_us_url
)
},
+2 -20
View File
@@ -1,5 +1,6 @@
import { CodeXml } from 'lucide-vue-next'
import { createApp, h } from 'vue'
import { escapeHTML } from '@/utils'
export class Markdown {
constructor({ data, api, readOnly, config }) {
@@ -301,7 +302,7 @@ export class Markdown {
_parseInlineMarkdown(text) {
if (!text) return ''
let html = this._escapeHtml(text)
let html = escapeHTML(text)
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
@@ -316,15 +317,6 @@ export class Markdown {
return html
}
_escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
_togglePlaceholder() {
const blocks = document.querySelectorAll(
'.cdx-block.ce-paragraph[data-placeholder]'
@@ -429,16 +421,6 @@ export class Markdown {
return { alt: '', url: '' }
}
_isLink(text) {
return /\[.+?\]\(.+?\)/.test(text)
}
_extractLink(text) {
const match = text.match(/\[(.+?)\]\((.+?)\)/)
if (match) return { text: match[1], url: match[2] }
return { text: '', url: '' }
}
_isEmbed(text) {
return /^https?:\/\/.+/.test(text.trim())
}
+626 -633
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.44.0"
__version__ = "2.45.2"
+1
View File
@@ -47,6 +47,7 @@ ALLOWED_PATHS = [
"/api/method/frappe.core.doctype.user.user.reset_password",
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_as_read",
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_all_as_read",
"/api/method/frappe.sessions.clear",
]
+1 -1
View File
@@ -16,7 +16,7 @@ def search_sqlite(query: str):
return prepare_search_results(result)
def prepare_search_results(result):
def prepare_search_results(result: dict):
groups = get_grouped_results(result)
out = []
+2 -1
View File
@@ -3,7 +3,7 @@ import frappe
from . import __version__ as app_version
app_name = "frappe_lms"
app_title = "Frappe LMS"
app_title = "Learning"
app_publisher = "Frappe"
app_description = "Frappe LMS App"
app_icon_url = "/assets/lms/images/lms-logo.png"
@@ -277,3 +277,4 @@ add_to_apps_screen = [
sqlite_search = ["lms.sqlite.LearningSearch"]
auth_hooks = ["lms.auth.authenticate"]
require_type_annotated_api_methods = True
+34 -7
View File
@@ -7,6 +7,7 @@ from lms.lms.api import give_discussions_permission
def after_install():
create_batch_source()
give_discussions_permission()
give_user_list_permission()
def after_sync():
@@ -27,13 +28,6 @@ def create_lms_roles():
create_lms_student_role()
def delete_lms_roles():
roles = ["Course Creator", "Moderator"]
for role in roles:
if frappe.db.exists("Role", role):
frappe.db.delete("Role", role)
def create_course_creator_role():
if frappe.db.exists("Role", "Course Creator"):
frappe.db.set_value("Role", "Course Creator", "desk_access", 0)
@@ -185,3 +179,36 @@ def give_lms_roles_to_admin():
doc.parentfield = "roles"
doc.role = role
doc.save()
def give_user_list_permission():
doctype = "User"
roles = ["Course Creator", "Moderator", "Batch Evaluator"]
for role in roles:
permlevel = 0
create_role(doctype, role, permlevel)
create_role(doctype, "System Manager", 1)
def create_role(doctype, role, permlevel):
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel}):
doc = frappe.new_doc("Custom DocPerm")
doc.update(
{
"doctype": "Custom DocPerm",
"parent": doctype,
"role": role,
"read": 1,
"write": 1 if role in ["Moderator", "System Manager"] else 0,
"create": 1 if role == "Moderator" else 0,
"permlevel": permlevel,
}
)
doc.save()
def delete_lms_roles():
roles = ["Course Creator", "Moderator", "Batch Evaluator", "LMS Student"]
for role in roles:
if frappe.db.exists("Role", role):
frappe.db.delete("Role", role)
@@ -35,7 +35,7 @@ def update_job_openings():
@frappe.whitelist()
def report(job, reason):
def report(job: str, reason: str):
system_managers = get_system_managers(only_name=True)
user = frappe.db.get_value("User", frappe.session.user, "full_name")
subject = _("User {0} has reported the job post {1}").format(user, job)
+235 -101
View File
@@ -80,7 +80,7 @@ def get_translations():
@frappe.whitelist()
def validate_billing_access(billing_type, name):
def validate_billing_access(billing_type: str, name: str):
doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
access, message = verify_billing_access(doctype, name, billing_type)
@@ -160,7 +160,7 @@ def verify_billing_access(doctype, name, billing_type):
@frappe.whitelist(allow_guest=True)
def get_job_details(job):
def get_job_details(job: str):
return frappe.db.get_value(
"Job Opportunity",
job,
@@ -183,7 +183,7 @@ def get_job_details(job):
@frappe.whitelist(allow_guest=True)
def get_job_opportunities(filters=None, orFilters=None):
def get_job_opportunities(filters: dict = None, orFilters: dict = None):
if not filters:
filters = {}
@@ -257,7 +257,7 @@ def get_branding():
@frappe.whitelist()
def get_unsplash_photos(keyword=None):
def get_unsplash_photos(keyword: str = None):
from lms.unsplash import get_by_keyword, get_list
if keyword:
@@ -267,7 +267,7 @@ def get_unsplash_photos(keyword=None):
@frappe.whitelist()
def get_evaluator_details(evaluator):
def get_evaluator_details(evaluator: str):
frappe.only_for("Batch Evaluator")
if not frappe.db.exists("Google Calendar", {"user": evaluator}):
@@ -294,7 +294,7 @@ def get_evaluator_details(evaluator):
@frappe.whitelist()
def get_certified_participants(filters=None, start=0, page_length=100):
def get_certified_participants(filters: dict = None, start: int = 0, page_length: int = 100):
query = get_certification_query(filters)
query = query.orderby("issue_date", order=frappe.qb.desc).offset(start).limit(page_length)
participants = query.run(as_dict=True)
@@ -306,7 +306,7 @@ def get_certified_participants(filters=None, start=0, page_length=100):
return participants
def get_certified_participant_details(member):
def get_certified_participant_details(member: str):
count = frappe.db.count("LMS Certificate", {"member": member})
details = frappe.db.get_value(
"User",
@@ -318,13 +318,13 @@ def get_certified_participant_details(member):
return details
def get_certification_query(filters):
def get_certification_query(filters: dict = None):
Certificate = frappe.qb.DocType("LMS Certificate")
User = frappe.qb.DocType("User")
query = (
frappe.qb.from_(Certificate)
.select(Certificate.member)
.select(Certificate.member, Certificate.issue_date)
.distinct()
.join(User)
.on(Certificate.member == User.name)
@@ -348,7 +348,7 @@ def get_certification_query(filters):
@frappe.whitelist()
def get_count_of_certified_members(filters=None):
def get_count_of_certified_members(filters: dict = None):
query = get_certification_query(filters)
result = query.run(as_dict=True)
return len(result) or 0
@@ -424,7 +424,7 @@ def get_sidebar_settings():
@frappe.whitelist()
def update_sidebar_item(webpage, icon):
def update_sidebar_item(webpage: str, icon: str):
frappe.only_for("Moderator")
filters = {
"web_page": webpage,
@@ -443,7 +443,7 @@ def update_sidebar_item(webpage, icon):
@frappe.whitelist()
def delete_sidebar_item(webpage):
def delete_sidebar_item(webpage: str):
frappe.only_for("Moderator")
return frappe.db.delete(
"LMS Sidebar Item",
@@ -457,7 +457,7 @@ def delete_sidebar_item(webpage):
@frappe.whitelist()
def delete_lesson(lesson, chapter):
def delete_lesson(lesson: str, chapter: str):
course = frappe.db.get_value("Course Chapter", chapter, "course")
if not can_modify_course(course):
frappe.throw(_("You do not have permission to delete this lesson."), frappe.PermissionError)
@@ -477,7 +477,7 @@ def delete_lesson(lesson, chapter):
@frappe.whitelist()
def update_lesson_index(lesson, sourceChapter, targetChapter, idx):
def update_lesson_index(lesson: str, sourceChapter: str, targetChapter: str, idx: int):
course = frappe.db.get_value("Course Chapter", sourceChapter, "course")
if not can_modify_course(course):
frappe.throw(_("You do not have permission to modify this lesson."), frappe.PermissionError)
@@ -488,7 +488,7 @@ def update_lesson_index(lesson, sourceChapter, targetChapter, idx):
update_target_chapter(lesson, targetChapter, idx)
def update_source_chapter(lesson, chapter, idx, hasMoved=False):
def update_source_chapter(lesson: str, chapter: str, idx: int, hasMoved: bool = False):
lessons = frappe.get_all(
"Lesson Reference",
{
@@ -507,7 +507,7 @@ def update_source_chapter(lesson, chapter, idx, hasMoved=False):
update_index(lessons, chapter)
def update_target_chapter(lesson, chapter, idx):
def update_target_chapter(lesson: str, chapter: str, idx: int):
lessons = frappe.get_all(
"Lesson Reference",
{
@@ -531,7 +531,7 @@ def update_target_chapter(lesson, chapter, idx):
update_index(lessons, chapter)
def update_index(lessons, chapter):
def update_index(lessons: list, chapter: str):
for row in lessons:
frappe.db.set_value(
"Lesson Reference", {"lesson": row, "parent": chapter}, "idx", lessons.index(row) + 1
@@ -539,7 +539,7 @@ def update_index(lessons, chapter):
@frappe.whitelist()
def update_chapter_index(chapter, course, idx):
def update_chapter_index(chapter: str, course: str, idx: int):
"""Update the index of a chapter within a course"""
if not can_modify_course(course):
@@ -562,7 +562,7 @@ def update_chapter_index(chapter, course, idx):
@frappe.whitelist()
def get_members(start=0, search=""):
def get_members(start: int = 0, search: str = None):
frappe.only_for(["Moderator"])
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {}
@@ -616,16 +616,16 @@ def check_app_permission():
@frappe.whitelist()
def save_evaluation_details(
member,
course,
batch_name,
evaluator,
date,
start_time,
end_time,
status,
rating,
summary,
member: str,
course: str,
date: str,
start_time: str,
end_time: str,
status: str,
batch_name: str = None,
evaluator: str = None,
rating: float = 0,
summary: str = None,
):
"""
Save evaluation details for a member against a course.
@@ -662,14 +662,14 @@ def save_evaluation_details(
@frappe.whitelist()
def save_certificate_details(
member,
course,
batch_name,
evaluator,
issue_date,
expiry_date,
template,
published=True,
member: str,
issue_date: str,
template: str,
course: str = None,
batch_name: str = None,
evaluator: str = None,
expiry_date: str = None,
published: bool = True,
):
"""
Save certificate details for a member against a course.
@@ -703,14 +703,14 @@ def save_certificate_details(
@frappe.whitelist()
def delete_documents(doctype, documents):
def delete_documents(doctype: str, documents: list):
frappe.only_for("Moderator")
for doc in documents:
frappe.delete_doc(doctype, doc)
@frappe.whitelist()
def get_payment_gateway_details(payment_gateway):
def get_payment_gateway_details(payment_gateway: str):
frappe.only_for("Moderator")
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
@@ -741,7 +741,7 @@ def get_payment_gateway_details(payment_gateway):
}
def get_transformed_fields(meta, data=None):
def get_transformed_fields(meta: list, data: dict = None):
transformed_fields = []
for row in meta:
if row.fieldtype not in ["Column Break", "Section Break"]:
@@ -766,7 +766,7 @@ def get_transformed_fields(meta, data=None):
@frappe.whitelist()
def get_new_gateway_fields(doctype):
def get_new_gateway_fields(doctype: str):
frappe.only_for("Moderator")
try:
meta = frappe.get_meta(doctype).fields
@@ -797,7 +797,7 @@ def update_course_statistics():
@frappe.whitelist()
def get_announcements(batch):
def get_announcements(batch: str):
roles = frappe.get_roles()
is_batch_student = frappe.db.exists(
"LMS Batch Enrollment", {"batch": batch, "member": frappe.session.user}
@@ -835,7 +835,7 @@ def get_announcements(batch):
@frappe.whitelist()
def delete_course(course):
def delete_course(course: str):
if not can_modify_course(course):
frappe.throw(_("You do not have permission to delete this course."), frappe.PermissionError)
@@ -872,7 +872,7 @@ def delete_course(course):
@frappe.whitelist()
def delete_batch(batch):
def delete_batch(batch: str):
if not can_modify_batch(batch):
frappe.throw(_("You do not have permission to delete this batch."), frappe.PermissionError)
@@ -885,7 +885,7 @@ def delete_batch(batch):
frappe.db.delete("LMS Batch", batch)
def delete_batch_discussions(batch):
def delete_batch_discussions(batch: str):
topics = frappe.get_all(
"Discussion Topic",
{"reference_doctype": "LMS Batch", "reference_docname": batch},
@@ -914,11 +914,13 @@ def give_discussions_permission():
"delete": 1,
"if_owner": 0 if role == "Moderator" else 1,
}
).save(ignore_permissions=True)
).save()
@frappe.whitelist()
def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
def upsert_chapter(
title: str, course: str, is_scorm_package: bool, scorm_package: dict = None, name: str = None
):
if not can_modify_course(course):
frappe.throw(_("You do not have permission to modify this chapter."), frappe.PermissionError)
@@ -951,7 +953,7 @@ def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
return chapter
def extract_package(course, title, scorm_package):
def extract_package(course: str, title: str, scorm_package: dict):
package = frappe.get_doc("File", scorm_package.name)
zip_path = package.get_full_path()
# check_for_malicious_code(zip_path)
@@ -983,7 +985,7 @@ def check_for_malicious_code(zip_path):
frappe.throw(_("Suspicious pattern found in {0}: {1}").format(file_name, pattern))
def get_manifest_file(extract_path):
def get_manifest_file(extract_path: str):
manifest_file = None
for root, _dirs, files in os.walk(extract_path):
for file in files:
@@ -995,7 +997,7 @@ def get_manifest_file(extract_path):
return manifest_file
def get_launch_file(extract_path):
def get_launch_file(extract_path: str):
launch_file = None
manifest_file = get_manifest_file(extract_path)
@@ -1018,7 +1020,7 @@ def get_launch_file(extract_path):
return launch_file
def add_lesson(title, chapter, course, idx):
def add_lesson(title: str, chapter: str, course: str, idx: int):
lesson = frappe.new_doc("Course Lesson")
lesson.update(
{
@@ -1043,7 +1045,7 @@ def add_lesson(title, chapter, course, idx):
@frappe.whitelist()
def delete_chapter(chapter):
def delete_chapter(chapter: str):
course = frappe.db.get_value("Course Chapter", chapter, "course")
if not can_modify_course(course):
frappe.throw(_("You do not have permission to delete this chapter."), frappe.PermissionError)
@@ -1074,14 +1076,14 @@ def delete_chapter(chapter):
i += 1
def delete_scorm_package(scorm_package_path):
def delete_scorm_package(scorm_package_path: str):
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
if os.path.exists(scorm_package_path):
shutil.rmtree(scorm_package_path)
@frappe.whitelist()
def mark_lesson_progress(course, chapter_number, lesson_number):
def mark_lesson_progress(course: str, chapter_number: int, lesson_number: int):
chapter_name = frappe.get_value("Chapter Reference", {"parent": course, "idx": chapter_number}, "chapter")
lesson_name = frappe.get_value(
"Lesson Reference", {"parent": chapter_name, "idx": lesson_number}, "lesson"
@@ -1090,7 +1092,7 @@ def mark_lesson_progress(course, chapter_number, lesson_number):
@frappe.whitelist()
def get_heatmap_data(member, base_days=200):
def get_heatmap_data(member: str, base_days: int = 200):
if not (has_course_instructor_role() or has_moderator_role() or has_evaluator_role()):
frappe.throw(_("You do not have permission to access heatmap data."), frappe.PermissionError)
@@ -1114,7 +1116,7 @@ def get_heatmap_data(member, base_days=200):
}
def calculate_date_ranges(base_days):
def calculate_date_ranges(base_days: int):
today = format_date(now(), "YYYY-MM-dd")
day_today = get_datetime(today).strftime("%w")
padding_end = 6 - cint(day_today)
@@ -1128,11 +1130,11 @@ def calculate_date_ranges(base_days):
return base_date, start_date, number_of_days, days
def initialize_date_count(days):
def initialize_date_count(days: list):
return {format_date(day, "YYYY-MM-dd"): 0 for day in days}
def fetch_activity_data(member, start_date):
def fetch_activity_data(member: str, start_date: str):
lesson_completions = frappe.get_all(
"LMS Course Progress",
fields=["creation"],
@@ -1154,14 +1156,14 @@ def fetch_activity_data(member, start_date):
return lesson_completions, quiz_submissions, assignment_submissions
def count_dates(data, date_count):
def count_dates(data: list, date_count: dict):
for entry in data:
date = format_date(entry.creation, "YYYY-MM-dd")
if date in date_count:
date_count[date] += 1
def prepare_heatmap_data(start_date, number_of_days, date_count):
def prepare_heatmap_data(start_date: str, number_of_days: int, date_count: dict):
days_of_week = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
heatmap_data = {day: [] for day in days_of_week}
week_count = -(number_of_days // -7)
@@ -1198,13 +1200,13 @@ def prepare_heatmap_data(start_date, number_of_days, date_count):
return formatted_heatmap_data, labels, total_activities, week_count
def get_week_difference(start_date, current_date):
def get_week_difference(start_date: str, current_date: str) -> int:
diff_in_days = date_diff(current_date, start_date)
return diff_in_days // 7
@frappe.whitelist()
def get_notifications(filters):
def get_notifications(filters: dict = None):
filters = frappe._dict(filters or {})
filters.for_user = frappe.session.user
notifications = frappe.get_all(
@@ -1232,7 +1234,7 @@ def get_notifications(filters):
return notifications
def update_user_details(notification):
def update_user_details(notification: dict) -> dict:
if (
notification.document_details
and len(notification.document_details.get("instructors", []))
@@ -1247,7 +1249,7 @@ def update_user_details(notification):
return notification
def is_mention(notification):
def is_mention(notification: dict) -> bool:
if notification.type == "Mention":
return True
if "mentioned you" in notification.subject.lower():
@@ -1255,7 +1257,7 @@ def is_mention(notification):
return False
def update_document_details(notification):
def update_document_details(notification: dict) -> dict:
if notification.document_type == "LMS Course":
details = frappe.db.get_value(
"LMS Course", notification.document_name, ["title", "video_link", "short_introduction"], as_dict=1
@@ -1304,8 +1306,9 @@ def get_lms_settings():
@frappe.whitelist()
def cancel_evaluation(evaluation):
def cancel_evaluation(evaluation: dict):
evaluation = frappe._dict(evaluation)
print(evaluation.member, frappe.session.user)
if evaluation.member != frappe.session.user:
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
@@ -1336,7 +1339,7 @@ def cancel_evaluation(evaluation):
@frappe.whitelist()
def get_certification_details(course):
def get_certification_details(course: str):
membership = None
filters = {"course": course, "member": frappe.session.user}
@@ -1364,7 +1367,7 @@ def get_certification_details(course):
@frappe.whitelist()
def save_role(user, role, value):
def save_role(user: str, role: str, value: int):
frappe.only_for("Moderator")
if cint(value):
doc = frappe.get_doc(
@@ -1384,7 +1387,7 @@ def save_role(user, role, value):
@frappe.whitelist()
def add_an_evaluator(email):
def add_an_evaluator(email: str):
frappe.only_for("Moderator")
if not frappe.db.exists("User", email):
user = frappe.new_doc("User")
@@ -1406,7 +1409,7 @@ def add_an_evaluator(email):
@frappe.whitelist()
def capture_user_persona(responses):
def capture_user_persona(responses: str):
frappe.only_for("System Manager")
data = frappe.parse_json(responses)
data = json.dumps(data)
@@ -1420,7 +1423,7 @@ def capture_user_persona(responses):
@frappe.whitelist()
def get_meta_info(type, route):
def get_meta_info(type: str, route: str):
if frappe.db.exists("Website Meta Tag", {"parent": f"{type}/{route}"}):
meta_tags = frappe.get_all(
"Website Meta Tag",
@@ -1436,7 +1439,7 @@ def get_meta_info(type, route):
@frappe.whitelist()
def update_meta_info(meta_type, route, meta_tags):
def update_meta_info(meta_type: str, route: str, meta_tags: list):
frappe.only_for(["Course Creator", "Batch Evaluator", "Moderator"])
validate_meta_data_permissions(meta_type)
validate_meta_tags(meta_tags)
@@ -1473,12 +1476,12 @@ def update_meta_info(meta_type, route, meta_tags):
create_meta_tag(tag_properties)
def validate_meta_tags(meta_tags):
def validate_meta_tags(meta_tags: list):
if not isinstance(meta_tags, list):
frappe.throw(_("Meta tags should be a list."))
def create_meta(parent_name, tag_properties):
def create_meta(parent_name: str, tag_properties: dict):
route_meta = frappe.new_doc("Website Route Meta")
route_meta.update(
{
@@ -1489,13 +1492,13 @@ def create_meta(parent_name, tag_properties):
route_meta.insert()
def create_meta_tag(tag_properties):
def create_meta_tag(tag_properties: dict):
new_tag = frappe.new_doc("Website Meta Tag")
new_tag.update(tag_properties)
new_tag.insert()
def validate_meta_data_permissions(meta_type):
def validate_meta_data_permissions(meta_type: str):
roles = frappe.get_roles()
if meta_type == "courses":
@@ -1508,14 +1511,15 @@ def validate_meta_data_permissions(meta_type):
@frappe.whitelist()
def create_programming_exercise_submission(exercise, submission, code, test_cases):
def create_programming_exercise_submission(exercise: str, submission: str, code: str, test_cases: list):
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
if submission == "new":
return make_new_exercise_submission(exercise, code, test_cases)
else:
update_exercise_submission(submission, code, test_cases)
def make_new_exercise_submission(exercise, code, test_cases):
def make_new_exercise_submission(exercise: str, code: str, test_cases: list):
submission = frappe.new_doc("LMS Programming Exercise Submission")
submission.exercise = exercise
submission.member = frappe.session.user
@@ -1537,7 +1541,7 @@ def make_new_exercise_submission(exercise, code, test_cases):
return submission.name
def update_exercise_submission(submission, code, test_cases):
def update_exercise_submission(submission: str, code: str, test_cases: list):
member = frappe.db.get_value("LMS Programming Exercise Submission", submission, "member")
if member != frappe.session.user:
frappe.throw(_("You do not have permission to update this submission."), frappe.PermissionError)
@@ -1547,7 +1551,7 @@ def update_exercise_submission(submission, code, test_cases):
frappe.db.set_value("LMS Programming Exercise Submission", submission, {"status": status, "code": code})
def get_exercise_status(test_cases):
def get_exercise_status(test_cases: list):
if not test_cases:
return "Failed"
@@ -1557,7 +1561,7 @@ def get_exercise_status(test_cases):
return "Failed"
def update_test_cases(test_cases, submission):
def update_test_cases(test_cases: list, submission: str):
frappe.db.delete("LMS Test Case Submission", {"parent": submission})
for row in test_cases:
test_case = frappe.new_doc("LMS Test Case Submission")
@@ -1576,7 +1580,7 @@ def update_test_cases(test_cases, submission):
@frappe.whitelist()
def track_video_watch_duration(lesson, videos):
def track_video_watch_duration(lesson: str, videos: list):
"""
Track the watch duration of videos in a lesson.
"""
@@ -1603,7 +1607,7 @@ def track_video_watch_duration(lesson, videos):
track_new_watch_time(lesson, video)
def track_new_watch_time(lesson, video):
def track_new_watch_time(lesson: str, video: dict):
doc = frappe.new_doc("LMS Video Watch Duration")
doc.lesson = lesson
doc.source = video.get("source")
@@ -1613,7 +1617,7 @@ def track_new_watch_time(lesson, video):
@frappe.whitelist()
def get_course_progress_distribution(course):
def get_course_progress_distribution(course: str):
if not can_modify_course(course):
frappe.throw(
_("You do not have permission to access this course's progress data."), frappe.PermissionError
@@ -1636,14 +1640,14 @@ def get_course_progress_distribution(course):
}
def get_average_course_progress(progress_list):
def get_average_course_progress(progress_list: list):
if not progress_list:
return 0
average_progress = sum(progress_list) / len(progress_list)
return flt(average_progress, frappe.get_system_settings("float_precision") or 3)
def get_progress_distribution(progressList):
def get_progress_distribution(progressList: list):
distribution = [
{
"name": "Just Started (0-30%)",
@@ -1654,8 +1658,12 @@ def get_progress_distribution(progressList):
"value": len([p for p in progressList if 30 <= p < 60]),
},
{
"name": "Advanced (60-100%)",
"value": len([p for p in progressList if 60 <= p <= 100]),
"name": "Advanced (60-99%)",
"value": len([p for p in progressList if 60 <= p < 100]),
},
{
"name": "Completed (100%)",
"value": len([p for p in progressList if p == 100]),
},
]
@@ -1686,7 +1694,7 @@ def get_pwa_manifest():
@frappe.whitelist()
def get_profile_details(username):
def get_profile_details(username: str):
details = frappe.db.get_value(
"User",
{"username": username},
@@ -1725,7 +1733,7 @@ def get_streak_info():
}
def fetch_activity_dates(user):
def fetch_activity_dates(user: str):
doctypes = [
"LMS Course Progress",
"LMS Quiz Submission",
@@ -1740,7 +1748,7 @@ def fetch_activity_dates(user):
return sorted({d.date() if hasattr(d, "date") else d for d in all_dates})
def calculate_streaks(all_dates):
def calculate_streaks(all_dates: list):
streak = 0
longest_streak = 0
prev_day = None
@@ -1764,7 +1772,7 @@ def calculate_streaks(all_dates):
return streak, longest_streak
def calculate_current_streak(all_dates, streak):
def calculate_current_streak(all_dates: list, streak: int):
if not all_dates:
return 0
@@ -2030,14 +2038,14 @@ def get_upcoming_batches():
@frappe.whitelist()
def delete_programming_exercise(exercise):
frappe.only_for(["Moderator", "Course Creator"])
def delete_programming_exercise(exercise: str):
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
frappe.db.delete("LMS Programming Exercise Submission", {"exercise": exercise})
frappe.db.delete("LMS Programming Exercise", exercise)
@frappe.whitelist()
def get_lesson_completion_stats(course):
def get_lesson_completion_stats(course: str):
roles = frappe.get_roles()
if "Course Creator" not in roles and "Moderator" not in roles:
frappe.throw(_("You do not have permission to access lesson completion stats."))
@@ -2048,13 +2056,17 @@ def get_lesson_completion_stats(course):
Lesson = frappe.qb.DocType("Course Lesson")
rows = (
frappe.qb.from_(CourseProgress)
.join(LessonReference)
.on(CourseProgress.lesson == LessonReference.lesson)
frappe.qb.from_(LessonReference)
.join(ChapterReference)
.on(LessonReference.parent == ChapterReference.chapter)
.join(Lesson)
.on(CourseProgress.lesson == Lesson.name)
.on(LessonReference.lesson == Lesson.name)
.left_join(CourseProgress)
.on(
(CourseProgress.lesson == LessonReference.lesson)
& (CourseProgress.course == course)
& (CourseProgress.status == "Complete")
)
.select(
LessonReference.idx,
ChapterReference.idx.as_("chapter_idx"),
@@ -2063,10 +2075,132 @@ def get_lesson_completion_stats(course):
Lesson.name.as_("lesson_name"),
fn.Count(CourseProgress.name).as_("completion_count"),
)
.where((CourseProgress.course == course) & (CourseProgress.status == "Complete"))
.groupby(CourseProgress.lesson)
.where(ChapterReference.parent == course)
.groupby(LessonReference.lesson)
.orderby(ChapterReference.idx, LessonReference.idx)
.run(as_dict=True)
)
return rows
@frappe.whitelist()
def get_course_assessment_progress(course: str, member: str):
if not can_modify_course(course):
frappe.throw(
_("You do not have permission to access this course's assessment data."), frappe.PermissionError
)
quizzes = get_course_quiz_progress(course, member)
assignments = get_course_assignment_progress(course, member)
programming_exercises = get_course_programming_exercise_progress(course, member)
return {
"quizzes": quizzes,
"assignments": assignments,
"exercises": programming_exercises,
}
def get_course_quiz_progress(course: str, member: str):
quizzes = get_assessment_from_lesson(course, "quiz")
attempts = []
for quiz in quizzes:
submissions = frappe.get_all(
"LMS Quiz Submission",
{
"quiz": quiz,
"member": member,
},
["name", "score", "percentage", "quiz", "quiz_title"],
order_by="creation desc",
limit=1,
)
if len(submissions):
attempts.append(submissions[0])
else:
attempts.append(
{
"quiz": quiz,
"quiz_title": frappe.db.get_value("LMS Quiz", quiz, "title"),
"score": 0,
"percentage": 0,
}
)
return attempts
def get_course_assignment_progress(course: str, member: str):
assignments = get_assessment_from_lesson(course, "assignment")
submissions = []
for assignment in assignments:
assignment_subs = frappe.get_all(
"LMS Assignment Submission",
{
"assignment": assignment,
"member": member,
},
["name", "status", "assignment", "assignment_title"],
order_by="creation desc",
limit=1,
)
if len(assignment_subs):
submissions.append(assignment_subs[0])
else:
submissions.append(
{
"assignment": assignment,
"assignment_title": frappe.db.get_value("LMS Assignment", assignment, "title"),
"status": "Not Submitted",
}
)
return submissions
def get_course_programming_exercise_progress(course: str, member: str):
exercises = get_assessment_from_lesson(course, "program")
submissions = []
for exercise in exercises:
exercise_subs = frappe.get_all(
"LMS Programming Exercise Submission",
{
"exercise": exercise,
"member": member,
},
["name", "status", "exercise", "exercise_title"],
order_by="creation desc",
limit=1,
)
if len(exercise_subs):
submissions.append(exercise_subs[0])
else:
submissions.append(
{
"exercise": exercise,
"exercise_title": frappe.db.get_value("LMS Programming Exercise", exercise, "title"),
"status": "Not Attempted",
}
)
return submissions
def get_assessment_from_lesson(course: str, assessmentType: str):
assessments = []
lessons = frappe.get_all("Course Lesson", {"course": course}, ["name", "title", "content"])
for lesson in lessons:
if lesson.content:
content = json.loads(lesson.content)
for block in content.get("blocks", []):
if block.get("type") == assessmentType:
data_field = "exercise" if assessmentType == "program" else assessmentType
quiz_name = block.get("data", {}).get(data_field)
assessments.append(quiz_name)
return assessments
@@ -1,9 +1,39 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
import frappe
from lms.lms.api import delete_chapter
from lms.lms.test_helpers import BaseTestUtils
class TestCourseChapter(unittest.TestCase):
pass
class TestCourseChapter(BaseTestUtils):
def setUp(self):
super().setUp()
self.instructor = self._create_user(
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator"]
)
def tearDown(self):
return super().tearDown()
def test_chapter_deletion_and_renumbering(self):
course = self._create_course(f"Test Renumbering Course {frappe.generate_hash()[:8]}")
chapters = []
for i in range(1, 4):
chapter = self._create_chapter(f"Chapter {i}", course.name)
chapters.append(chapter)
self._create_chapter_reference(course.name, chapter.name, i)
self.assertEqual(self._get_chapter_index(course.name, chapter.name), i)
delete_chapter(chapters[1].name)
idx_ch1 = self._get_chapter_index(course.name, chapters[0].name)
idx_ch3 = self._get_chapter_index(course.name, chapters[2].name)
self.assertEqual(idx_ch1, 1, "Chapter 1 index should remain 1")
self.assertEqual(idx_ch3, 2, "Chapter 3 index should be renumbered to 2 after deleting Chapter 2")
def _get_chapter_index(self, course, chapter):
return frappe.db.get_value("Chapter Reference", {"parent": course, "chapter": chapter}, "idx")
@@ -58,7 +58,7 @@ class CourseEvaluator(Document):
@frappe.whitelist()
def get_schedule(course, batch=None):
def get_schedule(course: str, batch: str = None):
evaluator = get_evaluator(course, batch)
start_date = nowdate()
end_date = get_schedule_range_end_date(start_date, batch)
@@ -46,7 +46,7 @@ class CourseLesson(Document):
@frappe.whitelist()
def save_progress(lesson, course, scorm_details=None):
def save_progress(lesson: str, course: str, scorm_details: dict = None):
"""
Note: Pass the argument scorm_details as a dict if it is SCORM related save_progress
"""
@@ -103,7 +103,7 @@ def save_progress(lesson, course, scorm_details=None):
)
progress = get_course_progress(course)
capture_progress_for_analytics(progress, course)
capture_progress_for_analytics()
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necessary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership)
@@ -121,9 +121,8 @@ def save_progress(lesson, course, scorm_details=None):
return progress
def capture_progress_for_analytics(progress, course):
if progress in [25, 50, 75, 100]:
capture("course_progress", "lms", properties={"course": course, "progress": progress})
def capture_progress_for_analytics():
capture("course_progress", "lms")
def get_quiz_progress(lesson):
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format: ASG-{#####}",
"creation": "2023-05-26 19:41:26.025081",
@@ -79,8 +80,13 @@
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-12-19 16:30:58.531722",
"links": [
{
"link_doctype": "LMS Assignment Submission",
"link_fieldname": "assignment"
}
],
"modified": "2026-02-05 11:37:36.492016",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment",
@@ -104,6 +110,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -124,6 +131,7 @@
"create": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -135,6 +143,7 @@
"create": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -9,18 +9,3 @@ from lms.lms.utils import has_course_instructor_role, has_moderator_role
class LMSAssignment(Document):
pass
@frappe.whitelist()
def save_assignment(assignment, title, type, question):
if not has_moderator_role() or not has_course_instructor_role():
return
if assignment:
doc = frappe.get_doc("LMS Assignment", assignment)
else:
doc = frappe.get_doc({"doctype": "LMS Assignment"})
doc.update({"title": title, "type": type, "question": question})
doc.save(ignore_permissions=True)
return doc.name
@@ -42,7 +42,8 @@
"fieldname": "assignment",
"fieldtype": "Link",
"label": "Assignment",
"options": "LMS Assignment"
"options": "LMS Assignment",
"reqd": 1
},
{
"fieldname": "member",
@@ -150,7 +151,7 @@
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-12-17 14:47:22.944223",
"modified": "2026-02-05 11:38:03.792865",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment Submission",
@@ -78,79 +78,3 @@ class LMSAssignmentSubmission(Document):
}
)
make_notification_logs(notification, [self.member])
@frappe.whitelist()
def upload_assignment(
assignment_attachment=None,
answer=None,
assignment=None,
lesson=None,
status="Not Graded",
comments=None,
submission=None,
):
if frappe.session.user == "Guest":
return
assignment_details = frappe.db.get_value(
"LMS Assignment", assignment, ["type", "grade_assignment"], as_dict=1
)
assignment_type = assignment_details.type
if assignment_type in ["URL", "Text"] and not answer:
frappe.throw(_("Please enter the URL for assignment submission."))
if assignment_type == "File" and not assignment_attachment:
frappe.throw(_("Please upload the assignment file."))
if assignment_type == "URL" and not validate_url(answer):
frappe.throw(_("Please enter a valid URL."))
if submission:
doc = frappe.get_doc("LMS Assignment Submission", submission)
else:
doc = frappe.get_doc(
{
"doctype": "LMS Assignment Submission",
"assignment": assignment,
"lesson": lesson,
"member": frappe.session.user,
"type": assignment_type,
}
)
doc.update(
{
"assignment_attachment": assignment_attachment,
"status": "Not Applicable"
if assignment_type == "Text" and not assignment_details.grade_assignment
else status,
"comments": comments,
"answer": answer,
}
)
doc.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def get_assignment(lesson):
assignment = frappe.db.get_value(
"LMS Assignment Submission",
{"lesson": lesson, "member": frappe.session.user},
["name", "lesson", "member", "assignment_attachment", "comments", "status"],
as_dict=True,
)
assignment.file_name = frappe.db.get_value(
"File", {"file_url": assignment.assignment_attachment}, "file_name"
)
return assignment
@frappe.whitelist()
def grade_assignment(name, result, comments):
doc = frappe.get_doc("LMS Assignment Submission", name)
doc.status = result
doc.comments = comments
doc.save(ignore_permissions=True)
+17 -3
View File
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:title",
"creation": "2024-04-30 11:29:53.548647",
@@ -99,8 +100,8 @@
"link_fieldname": "badge"
}
],
"modified": "2025-07-04 13:02:19.048994",
"modified_by": "Administrator",
"modified": "2026-02-03 10:52:37.122370",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Badge",
"naming_rule": "By fieldname",
@@ -118,13 +119,26 @@
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"role": "LMS Student",
"share": 1
}
],
+1 -1
View File
@@ -61,7 +61,7 @@ def eval_condition(doc, condition):
@frappe.whitelist()
def assign_badge(badge):
def assign_badge(badge: str, user: str):
badge = frappe._dict(json.loads(badge))
if not badge.event == "Auto Assign":
return
+8
View File
@@ -12,6 +12,14 @@ frappe.ui.form.on("LMS Batch", {
};
});
frm.set_query("course", "courses", function () {
return {
filters: {
published: 1,
},
};
});
frm.set_query("assessment_type", "assessment", function () {
let doctypes = ["LMS Quiz", "LMS Assignment"];
return {
+10 -10
View File
@@ -203,15 +203,15 @@ def send_system_notification_for_published_batch(batch):
@frappe.whitelist()
def create_live_class(
batch_name,
zoom_account,
title,
duration,
date,
time,
timezone,
auto_recording,
description=None,
batch_name: str,
zoom_account: str,
title: str,
duration: int,
date: str,
time: str,
timezone: str,
auto_recording: str,
description: str = None,
):
payload = {
"topic": title,
@@ -280,7 +280,7 @@ def authenticate(zoom_account):
@frappe.whitelist()
def get_batch_timetable(batch):
def get_batch_timetable(batch: str):
timetable = frappe.get_all(
"LMS Batch Timetable",
filters={"parent": batch},
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2025-02-10 11:17:12.462368",
"doctype": "DocType",
@@ -73,7 +74,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-14 08:53:16.672825",
"modified": "2026-02-03 10:51:28.475356",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch Enrollment",
@@ -96,6 +97,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -114,6 +116,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -26,7 +26,7 @@ class LMSBatchEnrollment(Document):
if self.owner == self.member:
return
roles = frappe.get_roles(self.owner)
roles = frappe.get_roles()
if "Moderator" not in roles and "Batch Evaluator" not in roles:
frappe.throw(_("You must be a Moderator or Batch Evaluator to enroll users in a batch."))
@@ -106,7 +106,7 @@ class LMSBatchEnrollment(Document):
@frappe.whitelist()
def send_confirmation_email(doc):
def send_confirmation_email(doc: Document):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))
@@ -18,8 +18,8 @@ class LMSCertificate(Document):
self.name = make_autoname("hash", self.doctype)
def after_insert(self):
self.send_certification_email()
capture("certificate_issued", "lms")
self.send_certification_email()
def send_certification_email(self):
outgoing_email_account = frappe.get_cached_value(
@@ -123,7 +123,7 @@ def is_certified(course):
@frappe.whitelist()
def create_certificate(course):
def create_certificate(course: str):
if is_certified(course):
return frappe.db.get_value(
"LMS Certificate", certificate, ["name", "course", "template"], as_dict=True
@@ -25,7 +25,7 @@ def has_website_permission(doc, ptype, user, verbose=False):
@frappe.whitelist()
def create_lms_certificate(source_name, target_doc=None):
def create_lms_certificate(source_name: str, target_doc: dict = None):
doc = get_mapped_doc(
"LMS Certificate Evaluation",
source_name,
@@ -174,7 +174,7 @@ def schedule_evals():
@frappe.whitelist()
def setup_calendar_event(eval):
def setup_calendar_event(eval: str):
if isinstance(eval, str):
eval = frappe._dict(json.loads(eval))
@@ -186,7 +186,7 @@ def setup_calendar_event(eval):
update_meeting_details(eval, event, calendar)
def create_event(eval):
def create_event(eval: dict):
event = frappe.get_doc(
{
"doctype": "Event",
@@ -199,7 +199,7 @@ def create_event(eval):
return event
def add_participants(eval, event):
def add_participants(eval: dict, event: Document):
participants = [eval.member, eval.evaluator]
for participant in participants:
contact_name = frappe.db.get_value("Contact", {"email_id": participant}, "name")
@@ -216,7 +216,7 @@ def add_participants(eval, event):
).save()
def update_meeting_details(eval, event, calendar):
def update_meeting_details(eval: dict, event: Document, calendar: str):
event.reload()
event.update(
{
@@ -232,7 +232,7 @@ def update_meeting_details(eval, event, calendar):
@frappe.whitelist()
def create_lms_certificate_evaluation(source_name, target_doc=None):
def create_lms_certificate_evaluation(source_name: str, target_doc: dict = None):
frappe.only_for(["Moderator", "Batch Evaluator", "System Manager"])
doc = get_mapped_doc(
"LMS Certificate Request",
+5 -1
View File
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "hash",
"creation": "2025-10-11 21:39:11.456420",
@@ -113,7 +114,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-27 19:52:11.835042",
"modified": "2026-02-03 10:50:23.387175",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Coupon",
@@ -149,6 +150,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -161,6 +163,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -173,6 +176,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -7,15 +7,3 @@ from frappe.model.document import Document
class LMSCourseInterest(Document):
pass
@frappe.whitelist()
def capture_interest(course):
data = {
"doctype": "LMS Course Interest",
"course": course,
"user": frappe.session.user,
}
if not frappe.db.exists(data):
frappe.get_doc(data).save(ignore_permissions=True)
return "OK"
@@ -41,7 +41,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-29 16:10:47.787285",
"modified": "2026-02-23 16:21:18.503806",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Course Review",
@@ -20,16 +20,3 @@ class LMSCourseReview(Document):
def validate_if_already_reviewed(self):
if frappe.db.exists("LMS Course Review", {"course": self.course, "owner": self.owner}):
frappe.throw(_("You have already reviewed this course"))
@frappe.whitelist()
def submit_review(rating, review, course):
out_of_ratings = frappe.db.get_all(
"DocField", {"parent": "LMS Course Review", "fieldtype": "Rating"}, ["options"]
)
out_of_ratings = (len(out_of_ratings) and out_of_ratings[0].options) or 5
rating = cint(rating) / out_of_ratings
frappe.get_doc(
{"doctype": "LMS Course Review", "rating": rating, "review": review, "course": course}
).save(ignore_permissions=True)
return "OK"
@@ -11,6 +11,12 @@ class LMSEnrollment(Document):
def before_insert(self):
self.validate_duplicate_enrollment()
self.validate_course_enrollment_eligibility()
self.validate_owner()
def validate_owner(self):
"""Makes the member as the owner of the document so that users can update their progress"""
if self.owner != self.member:
self.owner = self.member
def on_update(self):
update_program_progress(self.member)
@@ -21,6 +27,7 @@ class LMSEnrollment(Document):
{
"course": self.course,
"member": self.member,
"name": ["!=", self.name],
},
)
@@ -45,7 +52,7 @@ class LMSEnrollment(Document):
if self.enrollment_from_batch:
return
if not course_details.published:
if not course_details.published and not is_admin():
frappe.throw(_("You cannot enroll in an unpublished course."))
if course_details.paid_course:
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2023-03-02 10:59:01.741349",
"default_view": "List",
@@ -177,7 +178,7 @@
"link_fieldname": "live_class"
}
],
"modified": "2026-01-14 08:54:07.684781",
"modified": "2026-02-03 10:54:39.198916",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Live Class",
@@ -200,6 +201,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -221,6 +223,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
+15 -1
View File
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2023-08-24 17:46:52.065763",
"default_view": "List",
@@ -201,7 +202,7 @@
"link_fieldname": "payment"
}
],
"modified": "2025-12-19 17:55:25.968384",
"modified": "2026-02-03 10:54:12.361407",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Payment",
@@ -218,6 +219,19 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
+4 -1
View File
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:title",
"creation": "2024-11-18 12:27:13.283169",
@@ -92,7 +93,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-12-04 12:56:14.249363",
"modified": "2026-02-03 10:51:50.616781",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Program",
@@ -116,6 +117,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -128,6 +130,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2025-06-18 15:02:36.198855",
"doctype": "DocType",
@@ -33,7 +34,7 @@
"fieldname": "language",
"fieldtype": "Select",
"label": "Language",
"options": "Python\nJavaScript",
"options": "Python\nJavaScript\nRust\nGo",
"reqd": 1
},
{
@@ -63,7 +64,7 @@
"link_fieldname": "exercise"
}
],
"modified": "2025-06-24 14:42:27.463492",
"modified": "2026-02-03 10:45:23.687185",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Programming Exercise",
@@ -74,6 +75,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -86,6 +88,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -98,6 +101,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -110,6 +114,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -93,18 +93,3 @@ def get_correct_options(question):
correct_options.append(field)
return correct_options
@frappe.whitelist()
def get_question_details(question):
if not has_course_instructor_role() or not has_moderator_role():
return
fields = ["question", "type", "name"]
for i in range(1, 5):
fields.append(f"option_{i}")
fields.append(f"is_correct_{i}")
fields.append(f"explanation_{i}")
fields.append(f"possibility_{i}")
return frappe.db.get_value("LMS Question", question, fields, as_dict=1)
+9 -23
View File
@@ -93,7 +93,7 @@ class LMSQuiz(Document):
return result[0]
def set_total_marks(questions):
def set_total_marks(questions: list) -> int:
marks = 0
for question in questions:
marks += question.get("marks")
@@ -101,7 +101,7 @@ def set_total_marks(questions):
@frappe.whitelist()
def quiz_summary(quiz, results):
def quiz_summary(quiz: str, results: str):
results = results and json.loads(results)
percentage = 0
@@ -141,7 +141,7 @@ def quiz_summary(quiz, results):
}
def process_results(results, quiz_details):
def process_results(results: list, quiz_details: dict):
score = 0
is_open_ended = False
@@ -188,7 +188,7 @@ def process_results(results, quiz_details):
}
def _save_file(match):
def _save_file(match: re.Match) -> str:
data = match.group(1).split("data:")[1]
headers, content = data.split(",")
mtype = headers.split(";", 1)[0]
@@ -231,7 +231,7 @@ def get_corrupted_image_msg():
return _("Image: Corrupted Data Stream")
def create_submission(quiz, results, score_out_of, passing_percentage):
def create_submission(quiz: str, results: list, score_out_of: int, passing_percentage: float):
submission = frappe.new_doc("LMS Quiz Submission")
# Score and percentage are calculated by the controller function
submission.update(
@@ -250,7 +250,7 @@ def create_submission(quiz, results, score_out_of, passing_percentage):
return submission
def save_progress_after_quiz(quiz_details, percentage):
def save_progress_after_quiz(quiz_details: dict, percentage: float):
if percentage >= quiz_details.passing_percentage and quiz_details.lesson and quiz_details.course:
save_progress(quiz_details.lesson, quiz_details.course)
elif not quiz_details.passing_percentage:
@@ -258,21 +258,7 @@ def save_progress_after_quiz(quiz_details, percentage):
@frappe.whitelist()
def get_question_details(question):
if frappe.db.exists("LMS Quiz Question", question):
fields = ["name", "question", "type"]
for num in range(1, 5):
fields.append(f"option_{cstr(num)}")
fields.append(f"is_correct_{cstr(num)}")
fields.append(f"explanation_{cstr(num)}")
fields.append(f"possibility_{cstr(num)}")
return frappe.db.get_value("LMS Quiz Question", question, fields, as_dict=1)
return
@frappe.whitelist()
def check_answer(question, type, answers):
def check_answer(question: str, type: str, answers: str):
answers = json.loads(answers)
if type == "Choices":
return check_choice_answers(question, answers)
@@ -280,7 +266,7 @@ def check_answer(question, type, answers):
return check_input_answers(question, answers[0])
def check_choice_answers(question, answers):
def check_choice_answers(question: str, answers: list):
fields = ["multiple"]
is_correct = []
for num in range(1, 5):
@@ -300,7 +286,7 @@ def check_choice_answers(question, answers):
return is_correct
def check_input_answers(question, answer):
def check_input_answers(question: str, answer: str):
fields = []
for num in range(1, 5):
fields.append(f"possibility_{cstr(num)}")

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