Compare commits

..

115 Commits

Author SHA1 Message Date
Frappe PR Bot
ed8baf3327 chore(release): Bumped to Version 2.21.0 2025-01-22 07:09:58 +00:00
Jannat Patel
15dd4c4350 Merge pull request #1261 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-22 10:53:00 +05:30
Jannat Patel
c986089e77 chore: Persian translations 2025-01-21 16:41:39 +05:30
Jannat Patel
17dc77f061 chore: Turkish translations 2025-01-21 16:41:38 +05:30
Jannat Patel
189f353de0 chore: Esperanto translations 2025-01-20 16:43:19 +05:30
Jannat Patel
845e7174f0 chore: Bosnian translations 2025-01-20 16:43:18 +05:30
Jannat Patel
8c6e4ad3ee chore: Persian translations 2025-01-20 16:43:17 +05:30
Jannat Patel
5dfddc890c chore: Chinese Simplified translations 2025-01-20 16:43:15 +05:30
Jannat Patel
1ebabc23d3 chore: Turkish translations 2025-01-20 16:43:14 +05:30
Jannat Patel
1bf8c1c763 chore: Swedish translations 2025-01-20 16:43:12 +05:30
Jannat Patel
c5a59b6370 chore: Russian translations 2025-01-20 16:43:11 +05:30
Jannat Patel
4a5a777478 chore: Polish translations 2025-01-20 16:43:09 +05:30
Jannat Patel
4fd7dcd5b2 chore: Hungarian translations 2025-01-20 16:43:08 +05:30
Jannat Patel
55920d9e3f chore: German translations 2025-01-20 16:43:07 +05:30
Jannat Patel
6d0c3c9cd8 chore: Arabic translations 2025-01-20 16:43:05 +05:30
Jannat Patel
7b20c3fe03 chore: Spanish translations 2025-01-20 16:43:04 +05:30
Jannat Patel
efbe35c836 chore: French translations 2025-01-20 16:43:02 +05:30
Jannat Patel
e591cd74ab Merge pull request #1260 from frappe/pot_develop_2025-01-17
chore: update POT file
2025-01-20 09:57:38 +05:30
Jannat Patel
669b9c73be Merge pull request #1257 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-20 09:57:26 +05:30
frappe-pr-bot
52e1dd6d33 chore: update POT file 2025-01-17 16:04:20 +00:00
Jannat Patel
828e195b81 Merge pull request #1259 from pateljannat/issues-69
perf: misc performance improvements
2025-01-17 17:57:28 +05:30
Jannat Patel
145342bb72 perf: misc performance improvements 2025-01-17 17:17:02 +05:30
Jannat Patel
58abfd004d Merge pull request #1256 from pateljannat/issues-68
fix: changed the naming for certificate and job opportunity
2025-01-17 14:30:56 +05:30
Jannat Patel
9dc8322270 fix: don't check assignment submission status if doc is new 2025-01-17 14:24:14 +05:30
Jannat Patel
4f0a6a7d57 chore: removed print statement 2025-01-17 14:18:07 +05:30
Jannat Patel
2fb8ae00b9 chore: Chinese Simplified translations 2025-01-17 14:11:26 +05:30
Jannat Patel
63da1e384d fix: changed the naming for certificate and job opportunity 2025-01-17 13:00:35 +05:30
Jannat Patel
34685ebdb2 Merge pull request #1255 from pateljannat/batch-url
refactor: changed batch naming to be a slug of the title
2025-01-17 10:54:31 +05:30
Jannat Patel
215ae941e1 Merge pull request #1254 from pateljannat/jobs-page-responsive
fix: improved jobs page ui
2025-01-17 10:36:48 +05:30
Jannat Patel
9d1211e872 fix: changed batch naming to be a slug of the title 2025-01-17 10:35:26 +05:30
Jannat Patel
cd4f2b1039 fix: clarified the posting date 2025-01-17 10:21:49 +05:30
Jannat Patel
9881b7b498 fix: improved jobs page ui 2025-01-17 10:15:58 +05:30
Jannat Patel
28a687f6bf Merge pull request #1252 from pateljannat/refactor-certified-participants-page
refactor: improved ui and performance for certified participants page
2025-01-16 17:01:28 +05:30
Jannat Patel
bd43ed0e88 fix: responsive design for certified participants page 2025-01-16 16:49:03 +05:30
Jannat Patel
17b59ce4e5 refactor: improved ui and performance for certified participants page 2025-01-16 16:39:48 +05:30
Jannat Patel
7acc1864c8 Merge pull request #1251 from pateljannat/issues-67
fix: misc issues
2025-01-16 13:07:23 +05:30
Jannat Patel
5a6fdfcbc3 fix: simplfied logic to filter current day batches 2025-01-16 12:57:13 +05:30
Jannat Patel
23d465d4a1 fix: batch enrolled filter logic 2025-01-16 12:52:17 +05:30
Jannat Patel
27ae014fcb fix: course is no longer mandatory to generate a certificate 2025-01-16 12:35:13 +05:30
Jannat Patel
b4c7338b76 fix: batch listing for current day batches 2025-01-16 11:43:56 +05:30
Jannat Patel
0d1464c5e9 Merge pull request #1249 from pateljannat/batches-responsive
fix: batch list responsive cards
2025-01-15 16:30:03 +05:30
Jannat Patel
f4421d362c fix: batch list responsive cards 2025-01-15 16:15:04 +05:30
Jannat Patel
5c8378f2d4 fix: changed sorting order of batch list 2025-01-15 12:31:23 +05:30
Jannat Patel
8401e86acb feat: batch tabs for moderators 2025-01-15 11:17:07 +05:30
Frappe PR Bot
e16101813c chore(release): Bumped to Version 2.20.0 2025-01-15 05:39:34 +00:00
Jannat Patel
bbd3ac6451 Merge pull request #1246 from pateljannat/batch-refactor
refactor: improved performance and ui batch list
2025-01-15 10:57:11 +05:30
Jannat Patel
c6a26e5260 fix: amount rounding issue 2025-01-14 18:45:57 +05:30
Jannat Patel
a87fda6b84 Merge pull request #1245 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-14 17:48:38 +05:30
Jannat Patel
b42c635cdb refactor: improved performance and ui batch list 2025-01-14 17:41:46 +05:30
Jannat Patel
a9c6b71e19 chore: Persian translations 2025-01-14 12:05:13 +05:30
Jannat Patel
282441e0e7 chore: Swedish translations 2025-01-14 12:05:10 +05:30
Jannat Patel
6020d5f5c2 Merge pull request #1244 from pateljannat/issues-65
fix: removed delivery parameter from batch feedback
2025-01-14 11:59:53 +05:30
Jannat Patel
9a395cbda0 Merge pull request #1243 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-14 11:29:40 +05:30
Jannat Patel
61e41180dd fix: removed delivery parameter from batch feedback 2025-01-14 11:29:13 +05:30
Jannat Patel
26bde996ac chore: Esperanto translations 2025-01-13 12:01:25 +05:30
Jannat Patel
6f78ac06c2 chore: Bosnian translations 2025-01-13 12:01:24 +05:30
Jannat Patel
8e498f4fbe chore: Persian translations 2025-01-13 12:01:22 +05:30
Jannat Patel
8105e606c9 chore: Chinese Simplified translations 2025-01-13 12:01:21 +05:30
Jannat Patel
7df6e5fe64 chore: Turkish translations 2025-01-13 12:01:19 +05:30
Jannat Patel
909c9b446b chore: Swedish translations 2025-01-13 12:01:18 +05:30
Jannat Patel
29639d59c3 chore: Russian translations 2025-01-13 12:01:16 +05:30
Jannat Patel
a13dac6dd4 chore: Polish translations 2025-01-13 12:01:15 +05:30
Jannat Patel
31257e588f chore: Hungarian translations 2025-01-13 12:01:13 +05:30
Jannat Patel
52ab419040 chore: German translations 2025-01-13 12:01:12 +05:30
Jannat Patel
7dbc35977f chore: Arabic translations 2025-01-13 12:01:10 +05:30
Jannat Patel
ce9aafadd9 chore: Spanish translations 2025-01-13 12:01:08 +05:30
Jannat Patel
13da79488f chore: French translations 2025-01-13 12:01:07 +05:30
Jannat Patel
2c999e2037 Merge pull request #1242 from frappe/pot_develop_2025-01-10
chore: update POT file
2025-01-13 11:47:52 +05:30
Jannat Patel
c096c176e3 Merge pull request #1241 from pateljannat/batch-feedback
feat: batch feedback
2025-01-13 11:47:00 +05:30
Jannat Patel
8fe0b62bb3 feat: batch feedback for moderators 2025-01-13 11:31:18 +05:30
frappe-pr-bot
e3b53efd2c chore: update POT file 2025-01-10 16:04:43 +00:00
Jannat Patel
2ecb93e925 feat: show submitted feedback as readonly 2025-01-10 19:05:59 +05:30
Jannat Patel
5d14d6f1aa chore: merged conflicts 2025-01-10 11:03:44 +05:30
Jannat Patel
4869bba7bb Merge pull request #1239 from pateljannat/issues-64
fix: made course list responsive for bigger screen sizes
2025-01-09 18:53:00 +05:30
Jannat Patel
ecc12d783a fix: list and table formatting in lesson 2025-01-09 17:07:57 +05:30
Jannat Patel
54b7f811f7 fix: made course list responsive for bigger screen sizes 2025-01-09 12:24:21 +05:30
Frappe PR Bot
bb6e97992b chore(release): Bumped to Version 2.19.0 2025-01-08 14:21:07 +00:00
Jannat Patel
64fac451f3 Merge pull request #1236 from FahidLatheef/fix/days_diff_function_name
fix: fixed typo in spelling in frappe.utils.date_diff import
2025-01-08 12:43:10 +05:30
Jannat Patel
e45b33a809 feat: batch feedback 2025-01-08 11:22:07 +05:30
Jannat Patel
eb6b72515e Merge pull request #1235 from FahidLatheef/fix/assignment-popup-on-edit-quiz
fix: fix issue where assignment form is popped up on add quiz button in Lesson Edit form
2025-01-08 10:45:42 +05:30
Fahid Latheef A
0550d3aea3 fix: fixed typo in spelling in frappe.utils.date_diff import 2025-01-07 21:07:23 +05:30
Fahid Latheef A
f6577acbff refactor: fixed linting issue 2025-01-07 20:41:54 +05:30
Fahid Latheef A
09c494f38a Added quiz type prop for AssessmentPlugin component 2025-01-07 20:29:07 +05:30
Fahid Latheef A
6c600d747e Added assignement type prop for AssessmentPlugin component 2025-01-07 20:27:39 +05:30
Jannat Patel
9dcfc347d9 Merge pull request #1234 from pateljannat/batch-dashboard-23
feat: student activities display in a heatmap
2025-01-07 19:31:20 +05:30
Jannat Patel
fb40b627fc feat: show student progress heatmap on moderators dashboard 2025-01-07 18:15:59 +05:30
Jannat Patel
c597f96375 Merge pull request #1233 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-07 10:27:39 +05:30
Jannat Patel
f1961ab614 chore: Esperanto translations 2025-01-07 09:39:40 +05:30
Jannat Patel
c2c7b7b250 chore: Bosnian translations 2025-01-07 09:39:38 +05:30
Jannat Patel
c20c272f8e chore: Persian translations 2025-01-07 09:39:37 +05:30
Jannat Patel
85e4115306 chore: Chinese Simplified translations 2025-01-07 09:39:36 +05:30
Jannat Patel
10c2bc589a chore: Turkish translations 2025-01-07 09:39:34 +05:30
Jannat Patel
a30244cb4a chore: Swedish translations 2025-01-07 09:39:33 +05:30
Jannat Patel
5691fcdca4 chore: Russian translations 2025-01-07 09:39:31 +05:30
Jannat Patel
f5848207e2 chore: Polish translations 2025-01-07 09:39:30 +05:30
Jannat Patel
ad224161d8 chore: Hungarian translations 2025-01-07 09:39:28 +05:30
Jannat Patel
5837a1ffab chore: German translations 2025-01-07 09:39:27 +05:30
Jannat Patel
1cfd7cdb98 chore: Arabic translations 2025-01-07 09:39:25 +05:30
Jannat Patel
56a4aa2a3f chore: Spanish translations 2025-01-07 09:39:24 +05:30
Jannat Patel
d91d2ded77 chore: French translations 2025-01-07 09:39:22 +05:30
Jannat Patel
6a48d44b14 Merge pull request #1232 from pateljannat/issues-63
fix: misc issues
2025-01-06 16:25:21 +05:30
Jannat Patel
31c5d423d0 fix: misc issues 2025-01-06 16:00:48 +05:30
Jannat Patel
79177b5f5b feat: students heatmap 2025-01-06 15:42:44 +05:30
Jannat Patel
74658b2054 Merge pull request #1231 from pateljannat/refactor-batch-list
refactor: fetch minimal information for batch cards
2025-01-06 12:44:28 +05:30
Jannat Patel
052fffccef refactor: badge page data 2025-01-06 12:36:44 +05:30
Jannat Patel
bd2b558154 refactor: fetch minimal information for batch cards 2025-01-06 12:05:29 +05:30
Jannat Patel
65ee6b62ea Merge pull request #1230 from pateljannat/issues-62
refactor: duration field in quiz should be in minutes
2025-01-06 11:24:13 +05:30
Jannat Patel
26266a22e8 fix: add description to indicate that duration should be in minutes 2025-01-06 11:02:46 +05:30
Jannat Patel
e52ca63075 refactor: duration field in quiz should be in minutes 2025-01-06 11:01:01 +05:30
Jannat Patel
4d8b2eb5b4 Merge pull request #1229 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-01-06 10:41:42 +05:30
Jannat Patel
2d81a1ce31 Merge pull request #1227 from frappe/pot_develop_2025-01-03
chore: update POT file
2025-01-06 10:41:30 +05:30
Jannat Patel
052a85fbc0 chore: Swedish translations 2025-01-06 09:43:51 +05:30
frappe-pr-bot
fa0e84c671 chore: update POT file 2025-01-03 16:04:23 +00:00
Jannat Patel
4759736571 Merge pull request #1226 from pateljannat/issues-61
fix: misc batch issues
2025-01-03 17:57:42 +05:30
Jannat Patel
f77686feaa fix: misc batch issues 2025-01-03 17:37:22 +05:30
75 changed files with 7560 additions and 3781 deletions

View File

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

View File

@@ -11,7 +11,7 @@
{{ __('Add') }} {{ __('Add') }}
</Button> </Button>
</div> </div>
<div v-if="assessments.data?.length"> <div v-if="assessments.data?.length" class="text-sm">
<ListView <ListView
:columns="getAssessmentColumns()" :columns="getAssessmentColumns()"
:rows="assessments.data" :rows="assessments.data"

View File

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

View File

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

View File

@@ -8,10 +8,10 @@
<div class="grid grid-cols-3 gap-5 mb-8"> <div class="grid grid-cols-3 gap-5 mb-8">
<div class="flex items-center shadow py-2 px-3 rounded-md"> <div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3"> <div class="p-2 rounded-md bg-gray-100 mr-3">
<User class="w-18 h-18 stroke-1.5 text-gray-700" /> <User class="w-5 h-5 stroke-1.5 text-gray-700" />
</div> </div>
<div class="flex flex-col"> <div class="flex items-center space-x-2">
<span class="text-xl font-semibold mb-1"> <span class="font-semibold">
{{ students.data?.length }} {{ students.data?.length }}
</span> </span>
<span class="text-gray-700"> <span class="text-gray-700">
@@ -22,10 +22,10 @@
<div class="flex items-center shadow py-2 px-3 rounded-md"> <div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3"> <div class="p-2 rounded-md bg-gray-100 mr-3">
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" /> <BookOpen class="w-5 h-5 stroke-1.5 text-gray-700" />
</div> </div>
<div class="flex flex-col"> <div class="flex items-center space-x-2">
<span class="text-xl font-semibold mb-1"> <span class="font-semibold">
{{ batch.courses?.length }} {{ batch.courses?.length }}
</span> </span>
<span class="text-gray-700"> <span class="text-gray-700">
@@ -36,10 +36,10 @@
<div class="flex items-center shadow py-2 px-3 rounded-md"> <div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3"> <div class="p-2 rounded-md bg-gray-100 mr-3">
<ShieldCheck class="w-18 h-18 stroke-1.5 text-gray-700" /> <ShieldCheck class="w-5 h-5 stroke-1.5 text-gray-700" />
</div> </div>
<div class="flex flex-col"> <div class="flex items-center space-x-2">
<span class="text-xl font-semibold mb-1"> <span class="font-semibold">
{{ assessmentCount }} {{ assessmentCount }}
</span> </span>
<span class="text-gray-700"> <span class="text-gray-700">
@@ -48,28 +48,33 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-8"> <div v-if="showProgressChart" class="mb-8">
<div class="text-gray-600 font-medium"> <div class="text-gray-600 font-medium">
{{ __('Progress') }} {{ __('Progress') }}
</div> </div>
<ApexChart <ApexChart
v-if="showProgressChart"
:options="chartOptions" :options="chartOptions"
:series="chartData" :series="chartData"
type="bar" type="bar"
height="350" height="200"
/> />
<div <div
class="flex items-center justify-center text-sm text-gray-700 space-x-4" class="flex items-center justify-center text-sm text-gray-700 space-x-4"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-3 h-3" style="background-color: #0f736b"></div> <div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.green[600] }"
></div>
<div> <div>
{{ __('Courses') }} {{ __('Courses') }}
</div> </div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-3 h-3" style="background-color: #0070cc"></div> <div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.blue[600] }"
></div>
<div> <div>
{{ __('Assessments') }} {{ __('Assessments') }}
</div> </div>
@@ -125,7 +130,11 @@
@click="openStudentProgressModal(row)" @click="openStudentProgressModal(row)"
> >
<template #default="{ column, item }"> <template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align"> <ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix> <template #prefix>
<div v-if="column.key == 'full_name'"> <div v-if="column.key == 'full_name'">
<Avatar <Avatar
@@ -141,16 +150,7 @@
class="flex items-center space-x-4 w-full" class="flex items-center space-x-4 w-full"
> >
<ProgressBar :progress="row[column.key]" size="sm" /> <ProgressBar :progress="row[column.key]" size="sm" />
</div> <div class="text-xs">{{ row[column.key] }}%</div>
<div
v-else-if="column.key == 'copy'"
class="invisible group-hover:visible"
>
<Button variant="ghost" @click="copyEmail(row)">
<template #icon>
<Clipboard class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div> </div>
<div v-else> <div v-else>
{{ row[column.key] }} {{ row[column.key] }}
@@ -216,6 +216,7 @@ import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue' import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts' import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme'
const showStudentModal = ref(false) const showStudentModal = ref(false)
const showStudentProgressModal = ref(false) const showStudentProgressModal = ref(false)
@@ -256,20 +257,16 @@ const getStudentColumns = () => {
{ {
label: 'Progress', label: 'Progress',
key: 'progress', key: 'progress',
width: '10rem', width: '15rem',
icon: 'activity', icon: 'activity',
}, },
{ {
label: 'Last Active', label: 'Last Active',
key: 'last_active', key: 'last_active',
width: '15rem', width: '10rem',
align: 'center', align: 'center',
icon: 'clock', icon: 'clock',
}, },
{
label: '',
key: 'copy',
},
] ]
return columns return columns
@@ -352,8 +349,8 @@ const getChartData = () => {
} }
const getChartOptions = (categories) => { const getChartOptions = (categories) => {
const courseColor = '#0F736B' const courseColor = theme.colors.green[700]
const assessmentColor = '#0070CC' const assessmentColor = theme.colors.blue[700]
const maxY = const maxY =
students.data?.length % 5 students.data?.length % 5
? students.data?.length + (5 - (students.data?.length % 5)) ? students.data?.length + (5 - (students.data?.length % 5))
@@ -362,7 +359,6 @@ const getChartOptions = (categories) => {
return { return {
chart: { chart: {
type: 'bar', type: 'bar',
height: 50,
toolbar: { toolbar: {
show: false, show: false,
}, },
@@ -370,9 +366,10 @@ const getChartOptions = (categories) => {
plotOptions: { plotOptions: {
bar: { bar: {
distributed: true, distributed: true,
borderRadius: 0, borderRadius: 3,
borderRadiusApplication: 'end',
horizontal: true, horizontal: true,
barHeight: '30%', barHeight: '40%',
}, },
}, },
colors: Object.values(categories).map((item) => colors: Object.values(categories).map((item) =>
@@ -386,7 +383,7 @@ const getChartOptions = (categories) => {
}, },
rotate: 0, rotate: 0,
formatter: function (value) { formatter: function (value) {
return value.length > 20 ? `${value.substring(0, 20)}...` : value // Trim long labels return value.length > 30 ? `${value.substring(0, 30)}...` : value
}, },
}, },
}, },
@@ -395,15 +392,11 @@ const getChartOptions = (categories) => {
min: 0, min: 0,
stepSize: 10, stepSize: 10,
tickAmount: maxY / 5, tickAmount: maxY / 5,
/* reversed: true */
}, },
} }
} }
const copyEmail = (row) => {
navigator.clipboard.writeText(row.email)
showToast(__('Success'), __('Email copied to clipboard'), 'check')
}
watch(students, () => { watch(students, () => {
if (students.data?.length) { if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length assessmentCount.value = Object.keys(students.data?.[0].assessments).length

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
<template> <template>
<Dialog v-model="show" :options="{}"> <Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body> <template #body>
<div class="p-5 space-y-8 text-base"> <div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" /> <Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1"> <div class="space-y-1">
@@ -19,13 +24,11 @@
</div> </div>
</div> </div>
<!-- Assessments --> <div class="space-y-8">
<div> <!-- Assessments -->
<div> <div class="space-y-2 text-sm">
<div <div class="flex items-center border-b pb-1 font-medium">
class="grid grid-cols-[70%,30%] border-b pl-2 pb-1 mb-2 text-xs text-gray-700 font-medium" <span class="flex-1">
>
<span>
{{ __('Assessment') }} {{ __('Assessment') }}
</span> </span>
<span> <span>
@@ -34,9 +37,9 @@
</div> </div>
<div <div
v-for="assessment in Object.keys(student.assessments)" v-for="assessment in Object.keys(student.assessments)"
class="grid grid-cols-[70%,30%] pl-2 mb-2 text-gray-700 font-medium" class="flex items-center text-gray-700 font-medium"
> >
<span> <span class="flex-1">
{{ assessment }} {{ assessment }}
</span> </span>
<span v-if="isAssignment(student.assessments[assessment])"> <span v-if="isAssignment(student.assessments[assessment])">
@@ -49,15 +52,11 @@
</span> </span>
</div> </div>
</div> </div>
</div>
<!-- Courses --> <!-- Courses -->
<div> <div class="space-y-2 text-sm">
<div> <div class="flex items-center border-b pb-1 font-medium">
<div <span class="flex-1">
class="grid grid-cols-[70%,30%] mb-2 text-xs text-gray-700 border-b pl-2 pb-1 font-medium"
>
<span>
{{ __('Courses') }} {{ __('Courses') }}
</span> </span>
<span> <span>
@@ -66,9 +65,9 @@
</div> </div>
<div <div
v-for="course in Object.keys(student.courses)" v-for="course in Object.keys(student.courses)"
class="grid grid-cols-[70%,30%] pl-2 mb-2 text-gray-700 font-medium" class="flex items-center text-gray-700 font-medium"
> >
<span> <span class="flex-1">
{{ course }} {{ course }}
</span> </span>
<span> <span>
@@ -78,16 +77,15 @@
</div> </div>
</div> </div>
<!-- <span class="mt-4"> <!-- Heatmap -->
{{ student }} <StudentHeatmap :member="student.email" :base_days="120" />
</span> -->
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui' import { Avatar, Badge, Dialog } from 'frappe-ui'
import ProgressBar from '@/components/ProgressBar.vue' import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel() const show = defineModel()
const props = defineProps({ const props = defineProps({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</Button> </Button>
</div> </div>
</header> </header>
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen"> <div v-if="batch.data" class="grid grid-cols-[75%,25%] h-screen">
<div class="border-r"> <div class="border-r">
<Tabs <Tabs
v-model="tabIndex" v-model="tabIndex"
@@ -65,7 +65,7 @@
<div v-else-if="tab.label == 'Dashboard'"> <div v-else-if="tab.label == 'Dashboard'">
<BatchStudents :batch="batch.data" /> <BatchStudents :batch="batch.data" />
</div> </div>
<div v-else-if="tab.label == 'Live Class'"> <div v-else-if="tab.label == 'Classes'">
<LiveClass :batch="batch.data.name" /> <LiveClass :batch="batch.data.name" />
</div> </div>
<div v-else-if="tab.label == 'Assessments'"> <div v-else-if="tab.label == 'Assessments'">
@@ -81,18 +81,21 @@
:title="__('Discussions')" :title="__('Discussions')"
:key="batch.data.name" :key="batch.data.name"
:singleThread="true" :singleThread="true"
:scrollToBottom="true" :scrollToBottom="false"
/> />
</div> </div>
<div v-else-if="tab.label == 'Feedback'">
<BatchFeedback :batch="batch.data.name" />
</div>
</div> </div>
</template> </template>
</Tabs> </Tabs>
</div> </div>
<div class="p-5"> <div class="p-5">
<div class="text-xl font-semibold mb-2"> <div class="text-gray-700 font-semibold mb-4">
{{ batch.data.title }} {{ __('About this batch') }}:
</div> </div>
<div v-html="batch.data.description" class="leading-5 mb-2"></div> <div v-html="batch.data.description" class="leading-5 mb-4"></div>
<div class="flex items-center avatar-group overlap mb-5"> <div class="flex items-center avatar-group overlap mb-5">
<div <div
@@ -190,12 +193,11 @@ import {
BookOpen, BookOpen,
Laptop, Laptop,
BookOpenCheck, BookOpenCheck,
Contact2,
Mail, Mail,
SendIcon, SendIcon,
MessageCircle, MessageCircle,
Globe, Globe,
ShieldCheck, ClipboardPen,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { formatTime, updateDocumentTitle } from '@/utils' import { formatTime, updateDocumentTitle } from '@/utils'
import BatchDashboard from '@/components/BatchDashboard.vue' import BatchDashboard from '@/components/BatchDashboard.vue'
@@ -208,6 +210,7 @@ import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
import Discussions from '@/components/Discussions.vue' import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue' import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue'
const user = inject('$user') const user = inject('$user')
const showAnnouncementModal = ref(false) const showAnnouncementModal = ref(false)
@@ -271,7 +274,7 @@ const tabs = computed(() => {
}) })
batchTabs.push({ batchTabs.push({
label: 'Live Class', label: 'Classes',
icon: Laptop, icon: Laptop,
}) })
@@ -291,6 +294,11 @@ const tabs = computed(() => {
label: 'Discussions', label: 'Discussions',
icon: MessageCircle, icon: MessageCircle,
}) })
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs return batchTabs
}) })

View File

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

View File

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

View File

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

View File

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

View File

@@ -475,7 +475,8 @@ updateDocumentTitle(pageMeta)
font-weight: 500; font-weight: 500;
} }
.embed-tool__caption { .embed-tool__caption,
.cdx-simple-image__caption {
display: none; display: none;
} }
@@ -585,4 +586,8 @@ iframe {
border-top: 3px solid theme('colors.gray.700'); border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700'); border-bottom: 3px solid theme('colors.gray.700');
} }
.tc-table {
border-left: 1px solid #e8e8eb;
}
</style> </style>

View File

@@ -619,4 +619,8 @@ iframe {
border-top: 3px solid theme('colors.gray.700'); border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700'); border-bottom: 3px solid theme('colors.gray.700');
} }
.tc-table {
border-left: 1px solid #e8e8eb;
}
</style> </style>

View File

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

View File

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

View File

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

View File

@@ -217,22 +217,13 @@ let router = createRouter({
}) })
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const { userResource, allUsers } = usersStore() const { userResource } = usersStore()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
try { try {
if (isLoggedIn) { if (isLoggedIn) {
await userResource.promise await userResource.promise
} }
if (
isLoggedIn &&
(to.name == 'Lesson' ||
to.name == 'Batch' ||
to.name == 'Notifications' ||
to.name == 'Badge')
) {
await allUsers.promise
}
} catch (error) { } catch (error) {
isLoggedIn = false isLoggedIn = false
} }

View File

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

View File

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

View File

@@ -66,6 +66,7 @@ export class Assignment {
return return
} }
const app = createApp(AssessmentPlugin, { const app = createApp(AssessmentPlugin, {
type: 'assignment',
onAddition: (assignment) => { onAddition: (assignment) => {
this.data.assignment = assignment this.data.assignment = assignment
this.renderAssignment(assignment) this.renderAssignment(assignment)

View File

@@ -160,7 +160,10 @@ export function getEditorTools() {
upload: Upload, upload: Upload,
markdown: Markdown, markdown: Markdown,
image: SimpleImage, image: SimpleImage,
table: Table, table: {
class: Table,
inlineToolbar: true,
},
paragraph: { paragraph: {
class: Paragraph, class: Paragraph,
inlineToolbar: true, inlineToolbar: true,
@@ -179,6 +182,7 @@ export function getEditorTools() {
}, },
list: { list: {
class: NestedList, class: NestedList,
inlineToolbar: true,
config: { config: {
defaultStyle: 'ordered', defaultStyle: 'ordered',
}, },
@@ -529,3 +533,21 @@ export const validateFile = (file) => {
return __('Only image file is allowed.') return __('Only image file is allowed.')
} }
} }
export const escapeHTML = (text) => {
if (!text) return ''
let escape_html_mapping = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'`': '&#x60;',
'=': '&#x3D;',
}
return String(text).replace(
/[&<>"'`=]/g,
(char) => escape_html_mapping[char] || char
)
}

View File

@@ -64,6 +64,7 @@ export class Quiz {
return return
} }
const app = createApp(AssessmentPlugin, { const app = createApp(AssessmentPlugin, {
type: 'quiz',
onAddition: (quiz) => { onAddition: (quiz) => {
this.data.quiz = quiz this.data.quiz = quiz
this.renderQuiz(quiz) this.renderQuiz(quiz)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,16 +72,6 @@ class CourseLesson(Document):
exercises = [value for name, value in macros if name == "Exercise"] exercises = [value for name, value in macros if name == "Exercise"]
return [frappe.get_doc("LMS Exercise", name) for name in exercises] return [frappe.get_doc("LMS Exercise", name) for name in exercises]
def get_progress(self):
return frappe.db.get_value(
"LMS Course Progress", {"lesson": self.name, "owner": frappe.session.user}, "status"
)
def get_slugified_class(self):
if self.get_progress():
return ("").join([s for s in self.get_progress().lower().split()])
return
@frappe.whitelist() @frappe.whitelist()
def save_progress(lesson, course): def save_progress(lesson, course):

View File

@@ -72,9 +72,12 @@ class LMSAssignmentSubmission(Document):
) )
def validate_status(self): def validate_status(self):
doc_before_save = self.get_doc_before_save() if not self.is_new():
if doc_before_save.status != self.status or doc_before_save.comments != self.comments: doc_before_save = self.get_doc_before_save()
self.trigger_update_notification() if (
doc_before_save.status != self.status or doc_before_save.comments != self.comments
):
self.trigger_update_notification()
def trigger_update_notification(self): def trigger_update_notification(self):
notification = frappe._dict( notification = frappe._dict(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -450,24 +450,6 @@ def get_signup_optin_checks():
return (", ").join(links) return (", ").join(links)
def get_popular_courses():
courses = frappe.get_all("LMS Course", {"published": 1, "upcoming": 0})
course_membership = []
for course in courses:
course_membership.append(
{
"course": course.name,
"members": cint(frappe.db.count("LMS Enrollment", {"course": course.name})),
}
)
course_membership = sorted(
course_membership, key=lambda x: x.get("members"), reverse=True
)
return course_membership[:3]
def format_amount(amount, currency): def format_amount(amount, currency):
amount_reduced = amount / 1000 amount_reduced = amount / 1000
if amount_reduced < 1: if amount_reduced < 1:
@@ -935,7 +917,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
# Conversion logic starts here. Exchange rate is fetched and amount is converted. # Conversion logic starts here. Exchange rate is fetched and amount is converted.
exchange_rate = get_current_exchange_rate(currency, "USD") exchange_rate = get_current_exchange_rate(currency, "USD")
amount = amount * exchange_rate amount = flt(amount * exchange_rate, 2)
currency = "USD" currency = "USD"
# Check if the amount should be rounded and then apply rounding # Check if the amount should be rounded and then apply rounding
@@ -1030,6 +1012,7 @@ def get_course_details(course):
course_details.tags = course_details.tags.split(",") if course_details.tags else [] course_details.tags = course_details.tags.split(",") if course_details.tags else []
course_details.instructors = get_instructors(course_details.name) course_details.instructors = get_instructors(course_details.name)
# course_details.is_instructor = is_instructor(course_details.name)
if course_details.paid_course: if course_details.paid_course:
"""course_details.course_price, course_details.currency = check_multicurrency( """course_details.course_price, course_details.currency = check_multicurrency(
course_details.course_price, course_details.currency, None, course_details.amount_usd course_details.course_price, course_details.currency, None, course_details.amount_usd
@@ -1048,7 +1031,6 @@ def get_course_details(course):
["name", "course", "current_lesson", "progress", "member"], ["name", "course", "current_lesson", "progress", "member"],
as_dict=1, as_dict=1,
) )
course_details.is_instructor = is_instructor(course_details.name)
if course_details.membership and course_details.membership.current_lesson: if course_details.membership and course_details.membership.current_lesson:
course_details.current_lesson = get_lesson_index( course_details.current_lesson = get_lesson_index(
@@ -1210,21 +1192,6 @@ def get_neighbour_lesson(course, chapter, lesson):
} }
@frappe.whitelist(allow_guest=True)
def get_batches():
batches = []
filters = {}
if frappe.session.user == "Guest":
filters.update({"start_date": [">=", getdate()], "published": 1})
batch_list = frappe.get_all("LMS Batch", filters)
for batch in batch_list:
batches.append(get_batch_details(batch.name))
batches = categorize_batches(batches)
return batches
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_batch_details(batch): def get_batch_details(batch):
batch_details = frappe.db.get_value( batch_details = frappe.db.get_value(
@@ -1499,7 +1466,7 @@ def get_batch_students(batch):
detail.progress = 0 detail.progress = 0
students.append(detail) students.append(detail)
students = sorted(students, key=lambda x: x.progress, reverse=True)
return students return students
@@ -1750,31 +1717,31 @@ def enroll_in_batch(batch, payment_name=None):
if not frappe.db.exists( if not frappe.db.exists(
"Batch Student", {"parent": batch, "student": frappe.session.user} "Batch Student", {"parent": batch, "student": frappe.session.user}
): ):
student = frappe.new_doc("Batch Student") batch_doc = frappe.get_doc("LMS Batch", batch)
current_count = frappe.db.count("Batch Student", {"parent": batch}) if batch_doc.seat_count and len(batch_doc.students) >= batch_doc.seat_count:
frappe.throw(_("The batch is full. Please contact the Administrator."))
student.update( new_student = {
{ "student": frappe.session.user,
"student": frappe.session.user, "parent": batch,
"parent": batch, "parenttype": "LMS Batch",
"parenttype": "LMS Batch", "parentfield": "students",
"parentfield": "students", "idx": len(batch_doc.students) + 1,
"idx": current_count + 1, }
}
)
if payment_name: if payment_name:
payment = frappe.db.get_value( payment = frappe.db.get_value(
"LMS Payment", payment_name, ["name", "source"], as_dict=True "LMS Payment", payment_name, ["name", "source"], as_dict=True
) )
student.update( new_student.update(
{ {
"payment": payment.name, "payment": payment.name,
"source": payment.source, "source": payment.source,
} }
) )
student.save(ignore_permissions=True) batch_doc.append("students", new_student)
batch_doc.save(ignore_permissions=True)
@frappe.whitelist() @frappe.whitelist()
@@ -1863,3 +1830,96 @@ def enroll_in_program_course(program, course):
) )
enrollment.save() enrollment.save()
return enrollment return enrollment
@frappe.whitelist(allow_guest=True)
def get_batches(filters=None, start=0, page_length=20, order_by="start_date"):
if not filters:
filters = {}
if filters.get("enrolled"):
enrolled_batches = frappe.get_all(
"Batch Student", {"student": frappe.session.user}, pluck="parent"
)
filters.update({"name": ["in", enrolled_batches]})
del filters["enrolled"]
batches = frappe.get_all(
"LMS Batch",
filters=filters,
fields=[
"name",
"title",
"description",
"seat_count",
"paid_batch",
"amount",
"amount_usd",
"currency",
"start_date",
"end_date",
"start_time",
"end_time",
"timezone",
"published",
"category",
],
order_by=order_by,
start=start,
page_length=page_length,
)
batches = filter_batches_based_on_start_time(batches, filters)
batches = get_batch_card_details(batches)
return batches
def filter_batches_based_on_start_time(batches, filters):
batchType = get_batch_type(filters)
if batchType == "upcoming":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate()
and get_time_str(batch.start_time) < nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
elif batchType == "archived":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate()
and get_time_str(batch.start_time) >= nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
return batches
def get_batch_type(filters):
start_date_filter = filters.get("start_date")
batchType = None
if start_date_filter:
sign = start_date_filter[0]
if ">" in sign:
batchType = "upcoming"
elif "<" in sign:
batchType = "archived"
return batchType
def get_batch_card_details(batches):
for batch in batches:
batch.instructors = get_instructors(batch.name)
students_count = frappe.db.count("Batch Student", {"parent": batch.name})
if batch.seat_count:
batch.seats_left = batch.seat_count - students_count
if batch.paid_batch and batch.start_date >= getdate():
batch.amount, batch.currency = check_multicurrency(
batch.amount, batch.currency, None, batch.amount_usd
)
batch.price = fmt_money(batch.amount, 0, batch.currency)
return batches

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -95,4 +95,5 @@ lms.patches.v2_0.add_course_statistics #21-10-2024
lms.patches.v2_0.give_discussions_permissions lms.patches.v2_0.give_discussions_permissions
lms.patches.v2_0.delete_web_forms lms.patches.v2_0.delete_web_forms
lms.patches.v2_0.update_desk_access_for_lms_roles lms.patches.v2_0.update_desk_access_for_lms_roles
lms.patches.v2_0.update_quiz_submission_data lms.patches.v2_0.update_quiz_submission_data
lms.patches.v2_0.convert_quiz_duration_to_minutes

View File

@@ -0,0 +1,10 @@
import frappe
from frappe.utils import ceil, flt
def execute():
quizzes = frappe.get_all(
"LMS Quiz", fields=["name", "duration"], filters={"duration": [">", 0]}
)
for quiz in quizzes:
frappe.db.set_value("LMS Quiz", quiz.name, "duration", ceil(flt(quiz.duration) / 60))

View File

@@ -18,6 +18,7 @@ def get_context():
frappe.db.commit() # nosemgrep frappe.db.commit() # nosemgrep
context.csrf_token = csrf_token context.csrf_token = csrf_token
capture("active_site", "lms") capture("active_site", "lms")
context.favicon = frappe.db.get_single_value("Website Settings", "favicon")
return context return context