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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,9 +81,12 @@
: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>
@@ -190,12 +193,11 @@ import {
BookOpen, BookOpen,
Laptop, Laptop,
BookOpenCheck, BookOpenCheck,
Contact2,
Mail, Mail,
SendIcon, SendIcon,
MessageCircle, MessageCircle,
Globe, Globe,
ShieldCheck, ClipboardPen,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { formatTime, updateDocumentTitle } from '@/utils' import { formatTime, updateDocumentTitle } from '@/utils'
import BatchDashboard from '@/components/BatchDashboard.vue' import BatchDashboard from '@/components/BatchDashboard.vue'
@@ -208,6 +210,7 @@ import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
import Discussions from '@/components/Discussions.vue' import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue' import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue'
const user = inject('$user') const user = inject('$user')
const showAnnouncementModal = ref(false) const showAnnouncementModal = ref(false)
@@ -271,7 +274,7 @@ const tabs = computed(() => {
}) })
batchTabs.push({ batchTabs.push({
label: 'Live Class', label: 'Classes',
icon: Laptop, icon: Laptop,
}) })
@@ -291,6 +294,11 @@ const tabs = computed(() => {
label: 'Discussions', label: 'Discussions',
icon: MessageCircle, icon: MessageCircle,
}) })
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs return batchTabs
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -361,34 +361,59 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_certified_participants(): def get_certified_participants(filters=None, start=0, page_length=30, search=None):
LMSCertificate = DocType("LMS Certificate") or_filters = {}
participants = ( if not filters:
frappe.qb.from_(LMSCertificate) filters = {}
.select(LMSCertificate.member)
.distinct() filters.update({"published": 1})
.where(LMSCertificate.published == 1)
.orderby(LMSCertificate.creation, order=frappe.qb.desc) category = filters.get("category")
.run(as_dict=1) if category:
del filters["category"]
or_filters["course_title"] = ["like", f"%{category}%"]
or_filters["batch_title"] = ["like", f"%{category}%"]
participants = frappe.get_all(
"LMS Certificate",
filters=filters,
or_filters=or_filters,
fields=["member"],
group_by="member",
order_by="creation desc",
start=start,
page_length=page_length,
) )
participant_details = []
for participant in participants: for participant in participants:
details = frappe.db.get_value( details = frappe.db.get_value(
"User", "User",
participant.member, participant.member,
["name", "full_name", "username", "user_image"], ["full_name", "user_image", "username", "country", "headline"],
as_dict=True, as_dict=1,
) )
course_names = frappe.get_all( participant.update(details)
"LMS Certificate", {"member": participant.member}, pluck="course"
) return participants
courses = []
for course in course_names:
courses.append(frappe.db.get_value("LMS Course", course, "title")) @frappe.whitelist()
details["courses"] = courses def get_certification_categories():
participant_details.append(details) categories = []
return participant_details docs = frappe.get_all(
"LMS Certificate",
filters={
"published": 1,
},
fields=["course_title", "batch_title"],
)
for doc in docs:
category = doc.course_title if doc.course_title else doc.batch_title
if category not in categories:
categories.append(category)
return categories
@frappe.whitelist() @frappe.whitelist()
@@ -407,19 +432,9 @@ def get_assigned_badges(member):
return assigned_badges return assigned_badges
@frappe.whitelist()
def get_certificates(member):
"""Get certificates for a member."""
return frappe.get_all(
"LMS Certificate",
filters={"member": member},
fields=["name", "course", "course_title", "issue_date", "template"],
order_by="creation desc",
)
@frappe.whitelist() @frappe.whitelist()
def get_all_users(): def get_all_users():
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
users = frappe.get_all( users = frappe.get_all(
"User", "User",
{ {
@@ -603,9 +618,13 @@ def get_categories(doctype, filters):
@frappe.whitelist() @frappe.whitelist()
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index. """Get members for the given search term and start index.
Args: start (int): Start index for the query. Args: start (int): Start index for the query.
search (str): Search term to filter the results. <<<<<<< HEAD
Returns: List of members. 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"]]} 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): def get_week_difference(start_date, current_date):
diff_in_days = date_diff(current_date, start_date) diff_in_days = date_diff(current_date, start_date)
return diff_in_days // 7 return diff_in_days // 7
@frappe.whitelist()
def get_notifications(filters):
notifications = frappe.get_all(
"Notification Log",
filters,
["subject", "from_user", "link", "read", "name"],
order_by="creation desc",
)
for notification in notifications:
from_user_details = frappe.db.get_value(
"User", notification.from_user, ["full_name", "user_image"], as_dict=1
)
notification.update(from_user_details)
return notifications

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -450,24 +450,6 @@ def get_signup_optin_checks():
return (", ").join(links) return (", ").join(links)
def get_popular_courses():
courses = frappe.get_all("LMS Course", {"published": 1, "upcoming": 0})
course_membership = []
for course in courses:
course_membership.append(
{
"course": course.name,
"members": cint(frappe.db.count("LMS Enrollment", {"course": course.name})),
}
)
course_membership = sorted(
course_membership, key=lambda x: x.get("members"), reverse=True
)
return course_membership[:3]
def format_amount(amount, currency): def format_amount(amount, currency):
amount_reduced = amount / 1000 amount_reduced = amount / 1000
if amount_reduced < 1: if amount_reduced < 1:
@@ -935,7 +917,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
# Conversion logic starts here. Exchange rate is fetched and amount is converted. # Conversion logic starts here. Exchange rate is fetched and amount is converted.
exchange_rate = get_current_exchange_rate(currency, "USD") exchange_rate = get_current_exchange_rate(currency, "USD")
amount = amount * exchange_rate amount = flt(amount * exchange_rate, 2)
currency = "USD" currency = "USD"
# Check if the amount should be rounded and then apply rounding # Check if the amount should be rounded and then apply rounding
@@ -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) @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(
@@ -1901,3 +1830,96 @@ def enroll_in_program_course(program, course):
) )
enrollment.save() enrollment.save()
return enrollment return enrollment
@frappe.whitelist(allow_guest=True)
def get_batches(filters=None, start=0, page_length=20, order_by="start_date"):
if not filters:
filters = {}
if filters.get("enrolled"):
enrolled_batches = frappe.get_all(
"Batch Student", {"student": frappe.session.user}, pluck="parent"
)
filters.update({"name": ["in", enrolled_batches]})
del filters["enrolled"]
batches = frappe.get_all(
"LMS Batch",
filters=filters,
fields=[
"name",
"title",
"description",
"seat_count",
"paid_batch",
"amount",
"amount_usd",
"currency",
"start_date",
"end_date",
"start_time",
"end_time",
"timezone",
"published",
"category",
],
order_by=order_by,
start=start,
page_length=page_length,
)
batches = filter_batches_based_on_start_time(batches, filters)
batches = get_batch_card_details(batches)
return batches
def filter_batches_based_on_start_time(batches, filters):
batchType = get_batch_type(filters)
if batchType == "upcoming":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate()
and get_time_str(batch.start_time) < nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
elif batchType == "archived":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate()
and get_time_str(batch.start_time) >= nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
return batches
def get_batch_type(filters):
start_date_filter = filters.get("start_date")
batchType = None
if start_date_filter:
sign = start_date_filter[0]
if ">" in sign:
batchType = "upcoming"
elif "<" in sign:
batchType = "archived"
return batchType
def get_batch_card_details(batches):
for batch in batches:
batch.instructors = get_instructors(batch.name)
students_count = frappe.db.count("Batch Student", {"parent": batch.name})
if batch.seat_count:
batch.seats_left = batch.seat_count - students_count
if batch.paid_batch and batch.start_date >= getdate():
batch.amount, batch.currency = check_multicurrency(
batch.amount, batch.currency, None, batch.amount_usd
)
batch.price = fmt_money(batch.amount, 0, batch.currency)
return batches

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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