Compare commits

..

71 Commits

Author SHA1 Message Date
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
52 changed files with 4814 additions and 2283 deletions

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

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100"> <div class="flex rounded p-1 lg:px-2 lg:py-4 hover:bg-gray-100">
<div class="flex w-3/5 md:w-2/5"> <div class="flex w-3/5 md:w-2/5">
<img <img
:src="job.company_logo" :src="job.company_logo"

View File

@@ -15,27 +15,31 @@
<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" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }} {{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span> </span>
</div> </div>
<div class="flex items-center mb-5"> <div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" /> <Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2"> <span>
{{ formatTime(cls.time) }} {{ formatTime(cls.time) }}
</span> </span>
</div> </div>
<div class="flex items-center space-x-2 text-gray-900 mt-auto"> <div
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
class="flex items-center space-x-2 text-gray-900 mt-auto"
>
<a <a
v-if="user.data?.is_moderator || user.data?.is_evaluator" v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url" :href="cls.start_url"
@@ -46,7 +50,6 @@
{{ __('Start') }} {{ __('Start') }}
</a> </a>
<a <a
v-if="cls.date <= dayjs().format('YYYY-MM-DD')"
:href="cls.join_url" :href="cls.join_url"
target="_blank" 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" 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"
@@ -55,6 +58,13 @@
{{ __('Join') }} {{ __('Join') }}
</a> </a>
</div> </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 v-else class="text-sm italic text-gray-600"> <div v-else class="text-sm italic text-gray-600">
@@ -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>
<div class="space-y-8">
<!-- Assessments --> <!-- Assessments -->
<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%] border-b pl-2 pb-1 mb-2 text-xs text-gray-700 font-medium"
>
<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

@@ -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">
<div class="text-lg font-semibold">
{{ __('Upcoming Evaluations') }}
</div>
<Button @click="openEvalModal">
{{ __('Schedule Evaluation') }} {{ __('Schedule Evaluation') }}
</Button> </Button>
<div class="text-lg font-semibold mb-4">
{{ __('Upcoming Evaluations') }}
</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,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,21 +1,8 @@
<template> <template>
<div class="">
<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 <Breadcrumbs :items="breadcrumbs" />
class="h-7"
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
/>
<div class="flex space-x-2">
<div class="w-44">
<Select
v-if="categories.data?.length"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<router-link <router-link
v-if="user.data?.is_moderator" v-if="user.data?.is_moderator"
:to="{ :to="{
@@ -30,227 +17,251 @@
{{ __('New') }} {{ __('New') }}
</Button> </Button>
</router-link> </router-link>
</div>
</header> </header>
<div v-if="batches.data" class="pb-5"> <div class="p-5 pb-10">
<div <div class="flex items-center justify-between mb-5">
v-if="batches.data.length == 0 && batches.list.loading" <div class="text-lg font-semibold">
class="p-5 text-base text-gray-700" {{ __('All Batches') }}
>
{{ __('Loading Batches...') }}
</div> </div>
<Tabs <div class="flex items-center space-x-2">
v-if="hasBatches" <TabButtons
v-model="tabIndex" v-if="user.data && user.data?.is_student"
:tabs="makeTabs" :buttons="batchTabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap" v-model="currentTab"
> />
<template #tab="{ tab, selected }"> <FormControl
<div> v-model="title"
<button :placeholder="__('Search by Title')"
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" type="text"
:class="{ 'text-gray-900': selected }" @input="updateBatches()"
> />
<component v-if="tab.icon" :is="tab.icon" class="h-5" /> <div v-if="user.data && !user.data?.is_student" class="w-44">
{{ __(tab.label) }} <Select
<Badge v-model="currentDuration"
:class=" :options="batchType"
selected :placeholder="__('Type')"
? 'text-gray-800 border border-gray-800' @change="updateBatches()"
: 'border border-gray-500' />
"
variant="subtle"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
</div> </div>
</template> <div class="w-44">
<template #default="{ tab }"> <Select
<div v-if="categories.length"
v-if="tab.batches && tab.batches.value.length" v-model="currentCategory"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5" :options="categories"
> :placeholder="__('Category')"
@change="updateBatches()"
/>
</div>
</div>
</div>
<div v-if="batches.data?.length" class="grid grid-cols-4 gap-5">
<router-link <router-link
v-for="batch in tab.batches.value" v-for="batch in batches.data"
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }" :to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
> >
<BatchCard :batch="batch" /> <BatchCard :batch="batch" />
</router-link> </router-link>
</div> </div>
<div v-else class="p-5 italic text-gray-500">
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div <div
v-else-if=" v-else
!batches.loading && class="flex flex-col items-center justify-center text-sm text-gray-600 italic mt-48"
!hasBatches &&
(user.data?.is_instructor || user.data?.is_moderator)
"
class="grid grid-cols-3 p-5"
> >
<router-link <BookOpen class="size-10 mx-auto stroke-1.5 text-gray-500" />
:to="{ <div class="text-xl font-medium mb-2">
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') }} {{ __('No batches found') }}
</div> </div>
<div> <div class="leading-5 w-2/5 text-center">
{{ {{
__( __(
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!' 'There are no batches matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
) )
}} }}
</div> </div>
</div> </div>
<div
v-if="!batches.loading && batches.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="batches.next()">
{{ __('Load More') }}
</Button>
</div> </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 BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
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 currentDuration = ref(null)
const currentTab = ref('All')
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
currentDuration.value = queries.get('type') || 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,
})
batches.reload()
}
const makeTabs = computed(() => { const updateFilters = () => {
tabs = [] if (currentCategory.value) {
addToTabs('Upcoming') filters.value['category'] = currentCategory.value
} else {
delete filters.value['category']
}
if (title.value) {
filters.value['title'] = ['like', `%${title.value}%`]
} else {
delete filters.value['title']
}
if (currentDuration.value) {
delete filters.value['start_date']
delete filters.value['published']
if (currentDuration.value == 'Upcoming') {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
} else if (currentDuration.value == 'Archived') {
filters.value['start_date'] = ['<', dayjs().format('YYYY-MM-DD')]
} else if (currentDuration.value == 'Unpublished') {
filters.value['published'] = 0
}
} else {
delete filters.value['start_date']
delete filters.value['published']
}
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
filters.value['enrolled'] = 1
} else {
delete filters.value['enrolled']
}
if (!user.data || user.data?.is_student) {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
filters.value['published'] = 1
}
setQueryParams()
}
const setQueryParams = () => {
let queries = new URLSearchParams(location.search)
let filterKeys = {
title: title.value,
category: currentCategory.value,
type: currentDuration.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')
}
if (user.data) {
addToTabs('Enrolled')
} }
return types
})
const batchTabs = computed(() => {
let tabs = [
{
label: __('All'),
},
{
label: __('Enrolled'),
},
]
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(() => {
return {
title: 'Batches',
description: 'All batches divided by categories',
}
})
updateDocumentTitle(pageMeta)
</script> </script>

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

@@ -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

@@ -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

@@ -228,8 +228,7 @@ router.beforeEach(async (to, from, next) => {
isLoggedIn && isLoggedIn &&
(to.name == 'Lesson' || (to.name == 'Lesson' ||
to.name == 'Batch' || to.name == 'Batch' ||
to.name == 'Notifications' || to.name == 'Notifications')
to.name == 'Badge')
) { ) {
await allUsers.promise await allUsers.promise
} }

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.20.0"

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
@@ -594,7 +604,11 @@ def get_categories(doctype, filters):
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.
=======
search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members. Returns: List of members.
""" """
@@ -1043,3 +1057,121 @@ 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

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

@@ -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

@@ -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

@@ -935,7 +935,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 +1030,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 +1049,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 +1210,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 +1484,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 +1735,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": current_count + 1, "idx": len(batch_doc.students) + 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 +1848,58 @@ 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):
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"]
del filters["published"]
del filters["start_date"]
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="start_date desc",
start=start,
page_length=page_length,
)
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

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

@@ -96,3 +96,4 @@ 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))