Compare commits

..

77 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
Jannat Patel
e45b33a809 feat: batch feedback 2025-01-08 11:22:07 +05:30
58 changed files with 4636 additions and 2647 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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" />
<title>Frappe Learning</title>
<meta name="title" content="{{ meta.title }}" />

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

@@ -383,7 +383,7 @@ const getChartOptions = (categories) => {
},
rotate: 0,
formatter: function (value) {
return value.length > 30 ? `${value.substring(0, 30)}...` : value // Trim long labels
return value.length > 30 ? `${value.substring(0, 30)}...` : value
},
},
},

View File

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

View File

@@ -1,71 +1,41 @@
<template>
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
<div class="flex w-3/5 md:w-2/5">
<img
:src="job.company_logo"
class="w-12 h-12 rounded-lg object-contain mr-4"
:alt="job.company_name"
/>
<div>
<div class="font-medium mb-1">
<div class="flex space-x-4 border rounded-md p-2">
<Avatar :image="job.company_logo" :label="job.job_title" size="2xl" />
<div class="flex flex-col space-y-2 flex-1">
<div class="flex items-center justify-between">
<span class="font-semibold">
{{ 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>
</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>
</template>
<script setup>
import { MapPin } from 'lucide-vue-next'
import { Badge } from 'frappe-ui'
import { Building2, Calendar, MapPin, Shapes } from 'lucide-vue-next'
import { inject } from 'vue'
import { Avatar } from 'frappe-ui'
const dayjs = inject('$dayjs')
const props = defineProps({

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</Button>
</div>
</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">
<Tabs
v-model="tabIndex"
@@ -65,7 +65,7 @@
<div v-else-if="tab.label == 'Dashboard'">
<BatchStudents :batch="batch.data" />
</div>
<div v-else-if="tab.label == 'Live Class'">
<div v-else-if="tab.label == 'Classes'">
<LiveClass :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Assessments'">
@@ -81,9 +81,12 @@
:title="__('Discussions')"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="true"
:scrollToBottom="false"
/>
</div>
<div v-else-if="tab.label == 'Feedback'">
<BatchFeedback :batch="batch.data.name" />
</div>
</div>
</template>
</Tabs>
@@ -190,12 +193,11 @@ import {
BookOpen,
Laptop,
BookOpenCheck,
Contact2,
Mail,
SendIcon,
MessageCircle,
Globe,
ShieldCheck,
ClipboardPen,
} from 'lucide-vue-next'
import { formatTime, updateDocumentTitle } from '@/utils'
import BatchDashboard from '@/components/BatchDashboard.vue'
@@ -208,6 +210,7 @@ import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue'
const user = inject('$user')
const showAnnouncementModal = ref(false)
@@ -271,7 +274,7 @@ const tabs = computed(() => {
})
batchTabs.push({
label: 'Live Class',
label: 'Classes',
icon: Laptop,
})
@@ -291,6 +294,11 @@ const tabs = computed(() => {
label: 'Discussions',
icon: MessageCircle,
})
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs
})

View File

@@ -1,254 +1,296 @@
<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
<header
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" />
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<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')"
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</router-link>
</header>
<div class="p-5 pb-10">
<div
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>
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<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 class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40">
<Select
v-if="categories.length"
v-model="currentCategory"
:options="categories"
:placeholder="__('Category')"
@change="updateBatches()"
/>
</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
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>
</template>
<script setup>
import {
createResource,
Breadcrumbs,
Button,
Tabs,
Badge,
createListResource,
FormControl,
Select,
TabButtons,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
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 BatchCard from '@/components/BatchCard.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
const start = ref(0)
const pageLength = ref(20)
const categories = ref([])
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(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
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({
setFiltersFromQuery()
updateBatches()
categories.value = [
{
label: '',
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)
let tabs
const updateBatches = () => {
updateFilters()
batches.update({
filters: filters.value,
orderBy: orderBy.value,
})
batches.reload()
}
const makeTabs = computed(() => {
tabs = []
addToTabs('Upcoming')
const updateFilters = () => {
updateCategoryFilter()
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) {
addToTabs('Archived')
addToTabs('Private')
types.push({ label: __('Unpublished'), value: 'Unpublished' })
}
return types
})
if (user.data) {
addToTabs('Enrolled')
const batchTabs = computed(() => {
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
})
const getBatches = (type) => {
if (currentCategory.value && currentCategory.value != '') {
return batches.data[type].filter(
(batch) => batch.category == currentCategory.value
)
}
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 breadcrumbs = computed(() => [
{
label: __('Batches'),
route: { name: 'Batches' },
},
])
const pageMeta = computed(() => {
return {
title: 'Batches',
description: 'All batches divided by categories',
description: 'All upcoming batches.',
}
})

View File

@@ -1,93 +1,175 @@
<template>
<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" />
<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>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
<div class="p-5 lg:w-3/4 mx-auto">
<div
v-if="participants.data?.length"
v-for="participant in participantsList"
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5"
>
<router-link
:to="{
name: 'Profile',
params: { username: participant.username },
}"
>
<div class="flex shadow rounded-md h-full p-2">
<UserAvatar :user="participant" size="3xl" class="mr-2" />
<div>
<router-link
:to="{
name: 'Profile',
params: { username: participant.username },
}"
>
<div class="text-lg font-semibold mb-2">
<div class="text-lg font-semibold">
{{ __('All Certified Participants') }}
</div>
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="nameFilter"
:placeholder="__('Search by Name')"
type="text"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateParticipants()"
/>
<div
v-if="categories.data?.length"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
>
<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 }}
</div>
</router-link>
<div class="leading-5" v-for="course in participant.courses">
{{ course }}
<div
v-if="participant.headline"
class="headline text-sm text-gray-700"
>
{{ participant.headline }}
</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>
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
import { ref, computed } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { Search } from 'lucide-vue-next'
import {
Avatar,
Breadcrumbs,
Button,
createListResource,
FormControl,
Select,
} from 'frappe-ui'
import { computed, onMounted, ref } from 'vue'
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',
method: 'GET',
cache: 'certified-participants',
auto: true,
cache: ['certified_participants'],
start: 0,
pageLength: 30,
})
const breadcrumbs = computed(() => {
return [{ label: 'Certified Participants', to: '/certified-participants' }]
const categories = createListResource({
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(() => {
return {
title: 'Certified Participants',
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)
</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 }">
<div
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
v-for="course in tab.courses.value"

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,14 @@
<div
v-for="certificate in certificates.data"
: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)"
>
<div class="font-medium leading-5">
{{ certificate.course_title }}
<div class="font-medium leading-5 mb-2">
{{ certificate.course_title || certificate.batch_title }}
</div>
<div class="mt-2">
<span class="text-xs text-gray-700"> {{ __('issued on') }}: </span>
<div class="text-sm text-gray-700 font-medium mt-auto">
<span> {{ __('Issued on') }}: </span>
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
</div>
</div>
@@ -22,8 +22,8 @@
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { inject } from 'vue'
import { createListResource } from 'frappe-ui'
import { inject, onMounted } from 'vue'
const dayjs = inject('$dayjs')
const props = defineProps({
@@ -33,12 +33,19 @@ const props = defineProps({
},
})
const certificates = createResource({
url: 'lms.lms.api.get_certificates',
params: {
member: props.profile.data.name,
onMounted(() => {
if (props.profile.data?.name) {
certificates.reload()
}
})
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) => {

View File

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

View File

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

View File

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

View File

@@ -160,7 +160,10 @@ export function getEditorTools() {
upload: Upload,
markdown: Markdown,
image: SimpleImage,
table: Table,
table: {
class: Table,
inlineToolbar: true,
},
paragraph: {
class: Paragraph,
inlineToolbar: true,
@@ -179,6 +182,7 @@ export function getEditorTools() {
},
list: {
class: NestedList,
inlineToolbar: true,
config: {
defaultStyle: 'ordered',
},
@@ -529,3 +533,21 @@ export const validateFile = (file) => {
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

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

View File

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

View File

@@ -4,6 +4,9 @@
frappe.ui.form.on("Job Opportunity", {
refresh: (frm) => {
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": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format: JOB-{#####}",
"creation": "2022-02-07 12:01:41.074418",
"doctype": "DocType",
"editable_grid": 1,
@@ -117,11 +116,10 @@
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2024-02-07 23:02:06.102120",
"modified": "2025-01-17 12:38:57.134919",
"modified_by": "Administrator",
"module": "Job",
"name": "Job Opportunity",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -6,8 +6,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import get_link_to_form, add_months, getdate
from frappe.utils.user import get_system_managers
from lms.lms.utils import validate_image
from lms.lms.utils import validate_image, generate_slug
class JobOpportunity(Document):
@@ -18,6 +17,10 @@ class JobOpportunity(Document):
def validate_urls(self):
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():
old_jobs = frappe.get_all(

View File

@@ -361,34 +361,59 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True)
def get_certified_participants():
LMSCertificate = DocType("LMS Certificate")
participants = (
frappe.qb.from_(LMSCertificate)
.select(LMSCertificate.member)
.distinct()
.where(LMSCertificate.published == 1)
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
.run(as_dict=1)
def get_certified_participants(filters=None, start=0, page_length=30, search=None):
or_filters = {}
if not filters:
filters = {}
filters.update({"published": 1})
category = filters.get("category")
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:
details = frappe.db.get_value(
"User",
participant.member,
["name", "full_name", "username", "user_image"],
as_dict=True,
["full_name", "user_image", "username", "country", "headline"],
as_dict=1,
)
course_names = frappe.get_all(
"LMS Certificate", {"member": participant.member}, pluck="course"
)
courses = []
for course in course_names:
courses.append(frappe.db.get_value("LMS Course", course, "title"))
details["courses"] = courses
participant_details.append(details)
return participant_details
participant.update(details)
return participants
@frappe.whitelist()
def get_certification_categories():
categories = []
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()
@@ -407,19 +432,9 @@ def get_assigned_badges(member):
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()
def get_all_users():
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
users = frappe.get_all(
"User",
{
@@ -603,9 +618,13 @@ def get_categories(doctype, filters):
@frappe.whitelist()
def get_members(start=0, search=""):
"""Get members for the given search term and start index.
Args: start (int): Start index for the query.
search (str): Search term to filter the results.
Returns: List of members.
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.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members.
"""
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
@@ -1171,3 +1190,21 @@ def prepare_heatmap_data(start_date, number_of_days, date_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"]
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()
def save_progress(lesson, course):

View File

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

View File

@@ -48,7 +48,10 @@ frappe.ui.form.on("LMS Batch", {
},
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": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format: CLS-{#####}",
"creation": "2022-11-09 16:14:05.876933",
"default_view": "List",
"doctype": "DocType",
@@ -330,11 +329,10 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-11-18 16:28:41.336928",
"modified": "2025-01-17 10:23:10.580311",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -16,6 +16,7 @@ from lms.lms.utils import (
get_quiz_details,
get_assignment_details,
update_payment_record,
generate_slug,
)
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.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):
if self.end_date < self.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,
"engine": "InnoDB",
"field_order": [
"course",
"course_title",
"member",
"member_name",
"column_break_vwbn",
"issue_date",
"template",
"published",
"section_break_scyf",
"evaluator",
"evaluator_name",
"column_break_slaw",
"column_break_vwbn",
"issue_date",
"expiry_date",
"batch_name"
"template",
"published",
"section_break_unwn",
"course",
"course_title",
"column_break_ywee",
"batch_name",
"batch_title"
],
"fields": [
{
@@ -32,11 +33,9 @@
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"reqd": 1
"options": "LMS Course"
},
{
"fieldname": "expiry_date",
@@ -46,7 +45,6 @@
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member",
"options": "User",
@@ -56,6 +54,8 @@
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member Name",
"read_only": 1
},
@@ -90,14 +90,6 @@
"fieldname": "column_break_vwbn",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_scyf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_slaw",
"fieldtype": "Column Break"
},
{
"fieldname": "evaluator",
"fieldtype": "Link",
@@ -108,13 +100,29 @@
"fetch_from": "evaluator.full_name",
"fieldname": "evaluator_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Evaluator Name",
"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,
"links": [],
"modified": "2024-09-11 11:37:20.419956",
"modified": "2025-01-17 11:57:02.859109",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate",

View File

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

View File

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

View File

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

View File

@@ -450,24 +450,6 @@ def get_signup_optin_checks():
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):
amount_reduced = amount / 1000
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.
exchange_rate = get_current_exchange_rate(currency, "USD")
amount = amount * exchange_rate
amount = flt(amount * exchange_rate, 2)
currency = "USD"
# Check if the amount should be rounded and then apply rounding
@@ -1210,59 +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_card_details(batch.name))
batches = categorize_batches(batches)
return batches
def get_batch_card_details(batchname):
batch = frappe.db.get_value(
"LMS Batch",
batchname,
[
"name",
"title",
"description",
"seat_count",
"paid_batch",
"amount",
"amount_usd",
"currency",
"start_date",
"end_date",
"start_time",
"end_time",
"timezone",
"published",
],
as_dict=True,
)
batch.instructors = get_instructors(batchname)
students_count = frappe.db.count("Batch Student", {"parent": batchname})
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 batch
@frappe.whitelist(allow_guest=True)
def get_batch_details(batch):
batch_details = frappe.db.get_value(
@@ -1901,3 +1830,96 @@ def enroll_in_program_course(program, course):
)
enrollment.save()
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 %}
{% if enrolled | length %}
<div class="cards-parent">
{% for course in enrolled %}
{{ widgets.CourseCard(course=course) }}
{% endfor %}
</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 %}

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

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