Merge pull request #1326 from frappe/develop

chore: merge 'develop' into 'main'
This commit is contained in:
Jannat Patel
2025-02-19 11:06:43 +05:30
committed by GitHub
42 changed files with 3791 additions and 2957 deletions

View File

@@ -42,6 +42,7 @@
<script> <script>
window.csrf_token = '{{ csrf_token }}' window.csrf_token = '{{ csrf_token }}'
window.setup_complete = '{{ setup_complete }}'
document.getElementById('seo-content').style.display = 'none'; document.getElementById('seo-content').style.display = 'none';
</script> </script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>

View File

@@ -62,25 +62,34 @@
</div> </div>
</div> </div>
</div> </div>
<SidebarLink <div>
:link="{ <TrialBanner
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse', v-if="
}" userResource.data?.user_type == 'System User' &&
:isCollapsed="sidebarStore.isSidebarCollapsed" userResource.data?.is_fc_site
@click="toggleSidebar()" "
class="m-2" :isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
> />
<template #icon> <SidebarLink
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> :link="{
<CollapseSidebar label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
class="h-4.5 w-4.5 text-ink-gray-7 duration-300 ease-in-out" }"
:class="{ :isCollapsed="sidebarStore.isSidebarCollapsed"
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed, @click="toggleSidebar()"
}" class="m-2"
/> >
</span> <template #icon>
</template> <span class="grid h-5 w-6 flex-shrink-0 place-items-center">
</SidebarLink> <CollapseSidebar
class="h-4.5 w-4.5 text-ink-gray-7 duration-300 ease-in-out"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/>
</span>
</template>
</SidebarLink>
</div>
</div> </div>
<PageModal <PageModal
v-model="showPageModal" v-model="showPageModal"
@@ -101,7 +110,7 @@ import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { ChevronRight, Plus } from 'lucide-vue-next' import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui' import { Button, createResource, TrialBanner } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue' import PageModal from '@/components/Modals/PageModal.vue'
const { user, sidebarSettings } = sessionStore() const { user, sidebarSettings } = sessionStore()

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="assignment.data" v-if="assignment.data"
class="grid grid-cols-[68%,32%] h-full" class="grid grid-cols-[65%,35%] h-full"
:class="{ 'border rounded-lg': !showTitle }" :class="{ 'border rounded-lg': !showTitle }"
> >
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"> <div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
@@ -81,8 +81,8 @@
</template> </template>
</FileUploader> </FileUploader>
<div v-else> <div v-else>
<div class="flex items-center text-ink-gray-7"> <div class="flex text-ink-gray-7">
<div class="border rounded-md p-2 mr-2"> <div class="border self-start rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" /> <FileText class="h-5 w-5 stroke-1.5" />
</div> </div>
<a <a
@@ -90,7 +90,7 @@
target="_blank" target="_blank"
class="flex flex-col cursor-pointer !no-underline" class="flex flex-col cursor-pointer !no-underline"
> >
<span> <span class="text-sm leading-5">
{{ submissionFile.file_name }} {{ submissionFile.file_name }}
</span> </span>
<span class="text-sm text-ink-gray-5 mt-1"> <span class="text-sm text-ink-gray-5 mt-1">
@@ -155,12 +155,23 @@
type="select" type="select"
:options="submissionStatusOptions" :options="submissionStatusOptions"
/> />
<FormControl <div>
v-if="submissionResource.doc" <div class="text-sm text-ink-gray-5 mb-1">
v-model="submissionResource.doc.comments" {{ __('Comments') }}
:label="__('Comments')" </div>
type="textarea" <TextEditor
/> :content="comments"
@change="
(val) => {
comments = val
isDirty = true
}
"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -184,6 +195,7 @@ import { useRouter } from 'vue-router'
const submissionFile = ref(null) const submissionFile = ref(null)
const answer = ref(null) const answer = ref(null)
const comments = ref(null)
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission' const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
@@ -281,6 +293,9 @@ watch(submissionResource, () => {
if (submissionResource.doc.answer) { if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer answer.value = submissionResource.doc.answer
} }
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
if (submissionResource.isDirty) { if (submissionResource.isDirty) {
isDirty.value = true isDirty.value = true
} else if (showUploader() && !submissionFile.value) { } else if (showUploader() && !submissionFile.value) {
@@ -305,11 +320,14 @@ const submitAssignment = () => {
submissionResource.doc && submissionResource.doc.owner != user.data?.name submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name ? user.data?.name
: null : null
submissionResource.setValue.submit( submissionResource.setValue.submit(
{ {
...submissionResource.doc, ...submissionResource.doc,
assignment_attachment: submissionFile.value?.file_url, assignment_attachment: submissionFile.value?.file_url,
evaluator: evaluator, evaluator: evaluator,
comments: comments.value,
answer: answer.value,
}, },
{ {
onSuccess(data) { onSuccess(data) {

View File

@@ -2,7 +2,7 @@
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72"> <div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div <div
v-if="batch.data.seat_count && seats_left > 0" v-if="batch.data.seat_count && seats_left > 0"
class="text-xs bg-green-200 text-green-800 float-right px-2 py-0.5 rounded-md" class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md"
> >
{{ seats_left }} {{ seats_left }}
<span v-if="seats_left > 1"> <span v-if="seats_left > 1">
@@ -14,7 +14,7 @@
</div> </div>
<div <div
v-else-if="batch.data.seat_count && seats_left <= 0" v-else-if="batch.data.seat_count && seats_left <= 0"
class="text-xs bg-red-200 text-red-900 float-right px-2 py-0.5 rounded-md" class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
> >
{{ __('Sold Out') }} {{ __('Sold Out') }}
</div> </div>

View File

@@ -0,0 +1,94 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
title: __('Login to Frappe Cloud'),
actions: [
{
label: __('Verify'),
variant: 'solid',
onClick: (close) => {
verifyCode(close)
},
},
],
}"
>
<template #body-content>
<div>
<p>
{{ __('We have sent the verificaton code to your email id ') }}
<b>{{ props.email }}</b>
</p>
<FormControl
v-model="code"
:label="__('Verification Code')"
class="mb-4"
/>
<p>
{{ __("Didn't receive the code?") }}
<a href="#" @click="resendCode">{{ __('Resend') }}</a>
</p>
</div>
</template>
</Dialog>
</template>
<script setup>
import { call, Dialog } from 'frappe-ui'
import { showToast } from '@/utils'
const show = defineModel()
const code = ref('')
const props = defineProps({
email: {
type: String,
required: true,
},
})
const verifyCode = (close) => {
if (!code.value) {
return
}
call(
'frappe.integrations.frappe_providers.frappecloud_billing.verify_verification_code',
{
verification_code: code.value,
route: window.route,
}
)
.then((data) => {
if (data.message.login_token) {
close()
window.open(
`${frappeCloudBaseEndpoint}/api/method/press.api.developer.saas.login_to_fc?token=${data.message.login_token}`,
'_blank'
)
showToast(
__('Frappe Cloud Login Successful'),
`<p>${__('You will be redirected to Frappe Cloud soon.')}</p><p>${__(
"If you haven't been redirected,"
)} <a href="${frappeCloudBaseEndpoint}/api/method/press.api.developer.saas.login_to_fc?token=${
data.message.login_token
}" target="_blank">${__('Click here to login')}</a></p>`,
'check'
)
} else {
showToast(__('Login failed'), __('Please try again'), 'x')
}
})
.catch((err) => {
showToast(__('Login failed'), __('Please try again'), 'x')
})
}
const resendCode = () => {
call(
'frappe.integrations.frappe_providers.frappecloud_billing.send_verification_code'
).catch((err) => {
showToast(__('Failed to resend code'), __('Please try again'), 'x')
})
}
</script>

View File

@@ -207,7 +207,7 @@
</Button> </Button>
<Button <Button
v-else-if="activeQuestion != questions.length" v-else-if="activeQuestion != questions.length"
@click="nextQuetion()" @click="nextQuestion()"
> >
<span> <span>
{{ __('Next') }} {{ __('Next') }}
@@ -258,14 +258,22 @@
</Button> </Button>
</div> </div>
<div <div
v-if="quiz.data.show_submission_history && attempts?.data" v-if="
quiz.data.show_submission_history &&
attempts?.data &&
attempts.data.length > 0
"
class="mt-10" class="mt-10"
> >
<ListView <ListView
:columns="getSubmissionColumns()" :columns="getSubmissionColumns()"
:rows="attempts?.data" :rows="attempts?.data"
row-key="name" row-key="name"
:options="{ selectable: false, showTooltip: false }" :options="{
selectable: false,
showTooltip: false,
emptyState: { title: __('No Quiz submissions found') },
}"
> >
</ListView> </ListView>
</div> </div>
@@ -282,7 +290,7 @@ import {
FormControl, FormControl,
} from 'frappe-ui' } from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue' import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast } from '@/utils/' import { createToast, showToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next' import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -536,7 +544,7 @@ const addToLocalStorage = () => {
localStorage.setItem(quiz.data.title, JSON.stringify(quizData)) localStorage.setItem(quiz.data.title, JSON.stringify(quizData))
} }
const nextQuetion = () => { const nextQuestion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') { if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer() checkAnswer()
} else { } else {
@@ -574,6 +582,16 @@ const createSubmission = () => {
if (quiz.data && quiz.data.max_attempts) attempts.reload() if (quiz.data && quiz.data.max_attempts) attempts.reload()
if (quiz.data.duration) clearInterval(timerInterval) if (quiz.data.duration) clearInterval(timerInterval)
}, },
onError(err) {
const errorTitle = err?.message || ''
if (errorTitle.includes('MaximumAttemptsExceededError')) {
const errorMessage = err.messages?.[0] || err
showToast(__('Error'), __(errorMessage), 'x')
setTimeout(() => {
window.location.reload()
}, 3000)
}
},
} }
) )
} }

View File

@@ -59,13 +59,22 @@
v-if="userResource.data?.is_moderator" v-if="userResource.data?.is_moderator"
v-model="showSettingsModal" v-model="showSettingsModal"
/> />
<FCVerfiyCodeModal v-if="showFCLoginDialog" :email="verificationEmail" />
</template> </template>
<script setup> <script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue' import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui' import { call, Dropdown } from 'frappe-ui'
import Apps from '@/components/Apps.vue' import Apps from '@/components/Apps.vue'
import { useRouter } from 'vue-router'
import { convertToTitleCase, showToast } from '@/utils'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
import { createDialog } from '@/utils/dialogs'
import FCVerfiyCodeModal from './Modals/FCVerfiyCodeModal.vue'
import { import {
ChevronDown, ChevronDown,
LogIn, LogIn,
@@ -74,13 +83,8 @@ import {
User, User,
Settings, Settings,
Sun, Sun,
LogInIcon,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter() const router = useRouter()
const { logout, branding } = sessionStore() const { logout, branding } = sessionStore()
@@ -89,6 +93,11 @@ const settingsStore = useSettings()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false) const showSettingsModal = ref(false)
const theme = ref('light') const theme = ref('light')
const $dialog = createDialog
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
const showFCLoginDialog = ref(false)
const verificationEmail = ref(null)
const props = defineProps({ const props = defineProps({
isCollapsed: { isCollapsed: {
@@ -130,6 +139,13 @@ const userDropdownOptions = computed(() => {
return isLoggedIn return isLoggedIn
}, },
}, },
{
icon: theme.value === 'light' ? Moon : Sun,
label: 'Toggle Theme',
onClick: () => {
toggleTheme()
},
},
{ {
component: markRaw(Apps), component: markRaw(Apps),
condition: () => { condition: () => {
@@ -139,13 +155,6 @@ const userDropdownOptions = computed(() => {
else return false else return false
}, },
}, },
{
icon: theme.value === 'light' ? Moon : Sun,
label: 'Toggle Theme',
onClick: () => {
toggleTheme()
},
},
{ {
icon: Settings, icon: Settings,
label: 'Settings', label: 'Settings',
@@ -156,6 +165,19 @@ const userDropdownOptions = computed(() => {
return userResource.data?.is_moderator return userResource.data?.is_moderator
}, },
}, },
{
icon: LogInIcon,
label: 'Login to Frappe Cloud',
onClick: () => {
initiateRequestForLoginToFrappeCloud()
},
condition: () => {
return (
userResource.data?.user_type == 'System User' &&
userResource.data?.is_fc_site
)
},
},
{ {
icon: LogOut, icon: LogOut,
label: 'Log out', label: 'Log out',
@@ -180,4 +202,48 @@ const userDropdownOptions = computed(() => {
}, },
] ]
}) })
const initiateRequestForLoginToFrappeCloud = () => {
$dialog({
title: __('Login to Frappe Cloud?'),
message: __(
'Are you sure you want to login to your Frappe Cloud dashboard?'
),
actions: [
{
label: __('Confirm'),
variant: 'solid',
onClick(close) {
requestLoginToFC()
close()
},
},
],
})
}
const requestLoginToFC = () => {
call(
'frappe.integrations.frappe_providers.frappecloud_billing.send_verification_code'
)
.then((data) => {
if (data.message.is_user_logged_in) {
window.open(
`${frappeCloudBaseEndpoint}${data.message.redirect_to}`,
'_blank'
)
return
} else {
showFCLoginDialog.value = true
verificationEmail.value = data.message.email
}
})
.catch((err) => {
showToast(
__('Failed to login to Frappe Cloud'),
__('Please try again'),
'x'
)
})
}
</script> </script>

View File

@@ -6,7 +6,7 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" /> <Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Button <Button
v-if="user.data?.is_moderator" v-if="user.data?.is_moderator && batch.data?.certification"
@click="openCertificateDialog = true" @click="openCertificateDialog = true"
> >
{{ __('Generate Certificates') }} {{ __('Generate Certificates') }}
@@ -21,7 +21,10 @@
</Button> </Button>
</div> </div>
</header> </header>
<div v-if="batch.data" class="grid grid-cols-[75%,25%]"> <div
v-if="batch.data"
class="grid grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
>
<div class="border-r"> <div class="border-r">
<Tabs <Tabs
v-model="tabIndex" v-model="tabIndex"
@@ -310,7 +313,7 @@ const tabs = computed(() => {
}) })
const redirectToLogin = () => { const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/batches` window.location.href = `/login?redirect-to=/lms/batches/${props.batchName}`
} }
const openAnnouncementModal = () => { const openAnnouncementModal = () => {

View File

@@ -13,15 +13,14 @@
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2"> <div class="space-y-4 mb-4">
<div> <FormControl
<FormControl v-model="batch.title"
v-model="batch.title" :label="__('Title')"
:label="__('Title')" :required="true"
:required="true" class="w-full"
/> />
</div> <div class="flex items-center space-x-5">
<div class="flex flex-col space-y-2">
<FormControl <FormControl
v-model="batch.published" v-model="batch.published"
type="checkbox" type="checkbox"
@@ -32,6 +31,11 @@
type="checkbox" type="checkbox"
:label="__('Allow self enrollment')" :label="__('Allow self enrollment')"
/> />
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -90,30 +94,8 @@
:required="true" :required="true"
:filters="{ ignore_user_type: 1 }" :filters="{ ignore_user_type: 1 }"
/> />
<div class="mb-4">
<FormControl <div class="my-10">
v-model="batch.description"
:label="__('Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div>
<div class="mb-4">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Date and Time') }} {{ __('Date and Time') }}
</div> </div>
@@ -133,6 +115,14 @@
class="mb-4" class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div> </div>
<div> <div>
<FormControl <FormControl
@@ -149,18 +139,11 @@
class="mb-4" class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4">
<div class="mb-10">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
@@ -179,6 +162,11 @@
type="date" type="date"
class="mb-4" class="mb-4"
/> />
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
/>
</div> </div>
<div> <div>
<FormControl <FormControl
@@ -230,6 +218,33 @@
/> />
</div> </div>
</div> </div>
<div class="my-10">
<div class="text-lg font-semibold mb-4">
{{ __('Description') }}
</div>
<FormControl
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -278,10 +293,12 @@ const batch = reactive({
end_time: '', end_time: '',
timezone: '', timezone: '',
evaluation_end_date: '', evaluation_end_date: '',
confirmation_email_template: '',
seat_count: '', seat_count: '',
medium: '', medium: '',
category: '', category: '',
allow_self_enrollment: false, allow_self_enrollment: false,
certification: false,
image: null, image: null,
paid_batch: false, paid_batch: false,
currency: '', currency: '',
@@ -351,7 +368,12 @@ const batchDetail = createResource({
batch[key] = `${hours}:${minutes}` batch[key] = `${hours}:${minutes}`
} else if (Object.hasOwn(batch, key)) batch[key] = data[key] } else if (Object.hasOwn(batch, key)) batch[key] = data[key]
}) })
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment'] let checkboxes = [
'published',
'paid_batch',
'allow_self_enrollment',
'certification',
]
for (let idx in checkboxes) { for (let idx in checkboxes) {
let key = checkboxes[idx] let key = checkboxes[idx]
batch[key] = batch[key] ? true : false batch[key] = batch[key] ? true : false

View File

@@ -26,13 +26,19 @@
{{ __('All Batches') }} {{ __('All Batches') }}
</div> </div>
<div <div
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-2" class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
> >
<TabButtons <TabButtons
v-if="user.data" v-if="user.data"
:buttons="batchTabs" :buttons="batchTabs"
v-model="currentTab" v-model="currentTab"
/> />
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateBatches()"
/>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<FormControl <FormControl
v-model="title" v-model="title"
@@ -111,6 +117,7 @@ const pageLength = ref(20)
const categories = ref([]) const categories = ref([])
const currentCategory = ref(null) const currentCategory = ref(null)
const title = ref('') const title = ref('')
const certification = ref(false)
const filters = ref({}) const filters = ref({})
const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming') const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming')
const orderBy = ref('start_date') const orderBy = ref('start_date')
@@ -130,6 +137,7 @@ const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
title.value = queries.get('title') || '' title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false
} }
const batches = createListResource({ const batches = createListResource({
@@ -161,6 +169,7 @@ const updateBatches = () => {
const updateFilters = () => { const updateFilters = () => {
updateCategoryFilter() updateCategoryFilter()
updateTitleFilter() updateTitleFilter()
updateCertificationFilter()
updateTabFilter() updateTabFilter()
updateStudentFilter() updateStudentFilter()
setQueryParams() setQueryParams()
@@ -182,6 +191,14 @@ const updateTitleFilter = () => {
} }
} }
const updateCertificationFilter = () => {
if (certification.value) {
filters.value['certification'] = 1
} else {
delete filters.value['certification']
}
}
const updateTabFilter = () => { const updateTabFilter = () => {
orderBy.value = 'start_date' orderBy.value = 'start_date'
if (!user.data) { if (!user.data) {
@@ -222,6 +239,7 @@ const setQueryParams = () => {
let filterKeys = { let filterKeys = {
title: title.value, title: title.value,
category: currentCategory.value, category: currentCategory.value,
certification: certification.value,
} }
Object.keys(filterKeys).forEach((key) => { Object.keys(filterKeys).forEach((key) => {

View File

@@ -55,7 +55,7 @@
<FormControl <FormControl
type="number" type="number"
v-model="quiz.max_attempts" v-model="quiz.max_attempts"
:label="__('Maximun Attempts')" :label="__('Maximum Attempts')"
/> />
<FormControl <FormControl
type="number" type="number"

View File

@@ -219,7 +219,7 @@ let router = createRouter({
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const { userResource } = usersStore() const { userResource } = usersStore()
const { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const { allowGuestAccess } = useSettings() const { allowGuestAccess } = useSettings()
try { try {

View File

@@ -4,6 +4,7 @@ import { createApp, h } from 'vue'
import { usersStore } from '../stores/user' import { usersStore } from '../stores/user'
import translationPlugin from '../translation' import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next' import { CircleHelp } from 'lucide-vue-next'
import router from '@/router'
export class Quiz { export class Quiz {
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly }) {
@@ -46,6 +47,7 @@ export class Quiz {
quiz: quiz, quiz: quiz,
}) })
app.use(translationPlugin) app.use(translationPlugin)
app.use(router)
const { userResource } = usersStore() const { userResource } = usersStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.mount(this.wrapper) app.mount(this.wrapper)

View File

@@ -15,7 +15,7 @@ export default defineConfig({
}), }),
], ],
server: { server: {
allowedHosts: ['fs'], allowedHosts: ['fs', 'bs'],
}, },
resolve: { resolve: {
alias: { alias: {

View File

@@ -1 +1 @@
__version__ = "2.22.0" __version__ = "2.23.0"

View File

@@ -116,6 +116,8 @@ scheduler_events = {
"daily": [ "daily": [
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings", "lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
"lms.lms.doctype.lms_payment.lms_payment.send_payment_reminder", "lms.lms.doctype.lms_payment.lms_payment.send_payment_reminder",
"lms.lms.doctype.lms_batch.lms_batch.send_batch_start_reminder",
"lms.lms.doctype.lms_live_class.lms_live_class.send_live_class_reminder",
], ],
} }

View File

@@ -22,7 +22,7 @@ from frappe.utils import (
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress from lms.lms.doctype.course_lesson.course_lesson import save_progress
from frappe.core.doctype.communication.email import make from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
@frappe.whitelist() @frappe.whitelist()
@@ -175,6 +175,7 @@ def get_user_info():
user.is_moderator = "Moderator" in user.roles user.is_moderator = "Moderator" in user.roles
user.is_evaluator = "Batch Evaluator" in user.roles user.is_evaluator = "Batch Evaluator" in user.roles
user.is_student = "LMS Student" in user.roles user.is_student = "LMS Student" in user.roles
user.is_fc_site = is_fc_site()
return user return user

View File

@@ -13,11 +13,12 @@
"column_break_3", "column_break_3",
"member", "member",
"member_name", "member_name",
"evaluator",
"section_break_dlzh", "section_break_dlzh",
"assignment_attachment", "assignment_attachment",
"answer", "answer",
"section_break_ydgh",
"column_break_oqqy", "column_break_oqqy",
"evaluator",
"status", "status",
"comments", "comments",
"section_break_rqal", "section_break_rqal",
@@ -80,7 +81,7 @@
}, },
{ {
"fieldname": "comments", "fieldname": "comments",
"fieldtype": "Small Text", "fieldtype": "Text Editor",
"label": "Comments" "label": "Comments"
}, },
{ {
@@ -139,12 +140,16 @@
{ {
"fieldname": "column_break_oqqy", "fieldname": "column_break_oqqy",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "section_break_ydgh",
"fieldtype": "Section Break"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2024-12-24 21:22:35.212732", "modified": "2025-02-17 18:40:53.374932",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Assignment Submission", "name": "LMS Assignment Submission",

View File

@@ -8,25 +8,31 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"published", "section_break_earo",
"title", "title",
"start_date", "start_date",
"end_date", "end_date",
"column_break_4", "column_break_4",
"allow_self_enrollment",
"start_time", "start_time",
"end_time", "end_time",
"timezone", "timezone",
"section_break_rgfj", "section_break_cssv",
"medium", "published",
"category", "column_break_wfkz",
"column_break_flwy", "allow_self_enrollment",
"seat_count", "column_break_vnrp",
"evaluation_end_date", "certification",
"section_break_6", "section_break_6",
"description", "description",
"column_break_hlqw", "column_break_hlqw",
"instructors", "instructors",
"section_break_rgfj",
"medium",
"category",
"confirmation_email_template",
"column_break_flwy",
"seat_count",
"evaluation_end_date",
"meta_image", "meta_image",
"section_break_khcn", "section_break_khcn",
"batch_details", "batch_details",
@@ -206,6 +212,7 @@
"default": "0", "default": "0",
"fieldname": "published", "fieldname": "published",
"fieldtype": "Check", "fieldtype": "Check",
"in_standard_filter": 1,
"label": "Published" "label": "Published"
}, },
{ {
@@ -318,6 +325,35 @@
{ {
"fieldname": "section_break_khcn", "fieldname": "section_break_khcn",
"fieldtype": "Section Break" "fieldtype": "Section Break"
},
{
"fieldname": "confirmation_email_template",
"fieldtype": "Link",
"label": "Confirmation Email Template",
"options": "Email Template"
},
{
"fieldname": "column_break_wfkz",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_vnrp",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "certification",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Certification"
},
{
"fieldname": "section_break_earo",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_cssv",
"fieldtype": "Section Break"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
@@ -335,7 +371,7 @@
"link_fieldname": "batch_name" "link_fieldname": "batch_name"
} }
], ],
"modified": "2025-02-12 11:59:35.312487", "modified": "2025-02-18 15:43:18.512504",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",

View File

@@ -8,7 +8,7 @@ import json
from frappe import _ from frappe import _
from datetime import timedelta from datetime import timedelta
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint, format_datetime, get_time from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
from lms.lms.utils import ( from lms.lms.utils import (
get_lessons, get_lessons,
get_lesson_index, get_lesson_index,
@@ -405,3 +405,40 @@ def is_milestone_complete(idx, batch):
return False return False
return True return True
def send_batch_start_reminder():
batches = frappe.get_all(
"LMS Batch",
{"start_date": add_days(nowdate(), 1), "published": 1},
["name", "title", "start_date", "start_time", "medium"],
)
for batch in batches:
students = frappe.get_all(
"LMS Batch Enrollment", {"batch": batch}, ["member", "member_name"]
)
for student in students:
send_mail(batch, student)
def send_mail(batch, student):
subject = _("Batch Start Reminder")
template = "batch_start_reminder"
args = {
"student_name": student.member_name,
"title": batch.title,
"start_date": batch.start_date,
"start_time": batch.start_time,
"medium": batch.medium,
"name": batch.name,
}
frappe.sendmail(
recipients=student.member,
subject=subject,
template=template,
args=args,
header=[_(f"Batch Start Reminder: {batch.title}"), "orange"],
)

View File

@@ -79,13 +79,20 @@ def send_mail(doc):
batch = frappe.db.get_value( batch = frappe.db.get_value(
"LMS Batch", "LMS Batch",
doc.batch, doc.batch,
["name", "title", "start_date", "start_time", "medium"], [
"name",
"title",
"start_date",
"start_time",
"medium",
"confirmation_email_template",
],
as_dict=1, as_dict=1,
) )
subject = _("Enrollment Confirmation for {0}").format(batch.title) subject = _("Enrollment Confirmation for {0}").format(batch.title)
template = "batch_confirmation" template = "batch_confirmation"
custom_template = frappe.db.get_single_value( custom_template = batch.confirmation_email_template or frappe.db.get_single_value(
"LMS Settings", "batch_confirmation_template" "LMS Settings", "batch_confirmation_template"
) )

View File

@@ -2,9 +2,10 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from datetime import timedelta from datetime import timedelta
from frappe.utils import cint, get_datetime from frappe.utils import cint, get_datetime, format_date, nowdate, format_time
class LMSLiveClass(Document): class LMSLiveClass(Document):
@@ -56,8 +57,48 @@ class LMSLiveClass(Document):
{ {
"sync_with_google_calendar": 1, "sync_with_google_calendar": 1,
"google_calendar": calendar, "google_calendar": calendar,
"description": f"A Live Class has been scheduled on {frappe.utils.format_date(self.date, 'medium')} at { frappe.utils.format_time(self.time, 'hh:mm a')}. Click on this link to join. {self.join_url}. {self.description}", "description": f"A Live Class has been scheduled on {format_date(self.date, 'medium')} at {format_time(self.time, 'hh:mm a')}. Click on this link to join. {self.join_url}. {self.description}",
} }
) )
event.save() event.save()
def send_live_class_reminder():
classes = frappe.get_all(
"LMS Live Class",
{
"date": nowdate(),
},
["name", "batch_name", "title", "date", "time"],
)
for live_class in classes:
students = frappe.get_all(
"LMS Batch Enrollment",
{"batch": live_class.batch_name},
["member", "member_name"],
)
for student in students:
send_mail(live_class, student)
def send_mail(live_class, student):
subject = f"Your class on {live_class.title} is tomorrow"
template = "live_class_reminder"
args = {
"student_name": student.member_name,
"title": live_class.title,
"date": live_class.date,
"time": live_class.time,
"batch_name": live_class.batch_name,
}
frappe.sendmail(
recipients=student.member,
subject=subject,
template=template,
args=args,
header=[_(f"Class Reminder: {live_class.title}"), "orange"],
)

View File

@@ -139,8 +139,17 @@
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2025-02-11 14:48:27.801895", {
"link_doctype": "LMS Batch Enrollment",
"link_fieldname": "payment"
},
{
"link_doctype": "LMS Enrollment",
"link_fieldname": "payment"
}
],
"modified": "2025-02-18 15:54:25.383353",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Payment", "name": "LMS Payment",

View File

@@ -10,12 +10,29 @@ from frappe.desk.doctype.notification_log.notification_log import make_notificat
class LMSQuizSubmission(Document): class LMSQuizSubmission(Document):
def validate(self): def validate(self):
self.validate_if_max_attempts_exceeded()
self.validate_marks() self.validate_marks()
self.set_percentage() self.set_percentage()
def on_update(self): def on_update(self):
self.notify_member() self.notify_member()
def validate_if_max_attempts_exceeded(self):
max_attempts = frappe.db.get_value("LMS Quiz", self.quiz, ["max_attempts"])
if max_attempts == 0:
return
current_user_submission_count = frappe.db.count(
self.doctype, filters={"quiz": self.quiz, "member": frappe.session.user}
)
if current_user_submission_count >= max_attempts:
frappe.throw(
_("You have exceeded the maximum number of attempts ({0}) for this quiz").format(
max_attempts
),
MaximumAttemptsExceededError,
)
def validate_marks(self): def validate_marks(self):
self.score = 0 self.score = 0
for row in self.result: for row in self.result:
@@ -52,3 +69,7 @@ class LMSQuizSubmission(Document):
) )
make_notification_logs(notification, [self.member]) make_notification_logs(notification, [self.member])
class MaximumAttemptsExceededError(frappe.DuplicateEntryError):
pass

View File

@@ -1194,7 +1194,14 @@ def get_neighbour_lesson(course, chapter, lesson):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_batch_details(batch): def get_batch_details(batch):
if not frappe.db.get_value("LMS Batch", batch, "published") and has_student_role(): batch_students = frappe.get_all(
"LMS Batch Enrollment", {"batch": batch}, pluck="member"
)
if (
not frappe.db.get_value("LMS Batch", batch, "published")
and has_student_role()
and frappe.session.user not in batch_students
):
return return
batch_details = frappe.db.get_value( batch_details = frappe.db.get_value(
@@ -1218,6 +1225,7 @@ def get_batch_details(batch):
"paid_batch", "paid_batch",
"evaluation_end_date", "evaluation_end_date",
"allow_self_enrollment", "allow_self_enrollment",
"certification",
"timezone", "timezone",
"category", "category",
], ],
@@ -1229,9 +1237,7 @@ def get_batch_details(batch):
batch_details.courses = frappe.get_all( batch_details.courses = frappe.get_all(
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"] "Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
) )
batch_details.students = frappe.get_all( batch_details.students = batch_students
"LMS Batch Enrollment", {"batch": batch}, pluck="member"
)
if batch_details.paid_batch and batch_details.start_date >= getdate(): if batch_details.paid_batch and batch_details.start_date >= getdate():
batch_details.amount, batch_details.currency = check_multicurrency( batch_details.amount, batch_details.currency = check_multicurrency(

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

@@ -0,0 +1,35 @@
<p>
{{ _("Dear ") }} {{ student_name }},
</p>
<br>
<p>
{{ _("The batch you have enrolled for is starting tomorrow. Please be prepared and be on time for the session.") }}
</p>
<br>
<p>
<b>{{ _("Batch:") }}</b> {{ title }}
</p>
<br>
<p>
<b>{{ _("Start Date:") }}</b> {{ frappe.utils.format_date(start_date, "medium") }}
</p>
<br>
<p>
<b>{{ _("Timings:") }}</b> {{ frappe.utils.format_time(start_time, "hh:mm a") }}
</p>
<br>
<p>
<b>{{ _("Medium:") }}</b> {{ medium }}
</p>
<br>
<p>
{{ _("Visit the following link to view your ") }}
<a href="/lms/batches/{{ name }}">{{ _("Batch Details") }}</a>
</p>
<p>
{{ _("If you have any questions or require assistance, feel free to contact us.") }}
</p>
<br>
<p>
{{ _("Best Regards") }}
</p>

View File

@@ -0,0 +1,31 @@
<p>
{{ _("Dear ") }} {{ student_name }},
</p>
<br>
<p>
{{ _("You have a live class scheduled tomorrow. Please be prepared and be on time for the session.") }}
</p>
<br>
<p>
<b>{{ _("Class:") }}</b> {{ title }}
</p>
<br>
<p>
<b>{{ _("Date:") }}</b> {{ frappe.utils.format_date(date, "medium") }}
</p>
<br>
<p>
<b>{{ _("Timings:") }}</b> {{ frappe.utils.format_time(time, "hh:mm a") }}
</p>
<br>
<p>
{{ _("Visit the following link to view your ") }}
<a href="/lms/live_classes/{{ batch_name }}">{{ _("Batch Details") }}</a>
</p>
<p>
{{ _("If you have any questions or require assistance, feel free to contact us.") }}
</p>
<br>
<p>
{{ _("Best Regards") }}
</p>

View File

@@ -1,8 +1,9 @@
import frappe import frappe
from frappe.utils.telemetry import capture
from frappe import _
from bs4 import BeautifulSoup
import re import re
from bs4 import BeautifulSoup
from frappe import _
from frappe.utils.telemetry import capture
from frappe.utils import cint
no_cache = 1 no_cache = 1
@@ -17,6 +18,7 @@ def get_context():
csrf_token = frappe.sessions.get_csrf_token() csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit() # nosemgrep frappe.db.commit() # nosemgrep
context.csrf_token = csrf_token context.csrf_token = csrf_token
context.setup_complete = cint(frappe.get_system_settings("setup_complete"))
capture("active_site", "lms") capture("active_site", "lms")
context.favicon = frappe.db.get_single_value("Website Settings", "favicon") context.favicon = frappe.db.get_single_value("Website Settings", "favicon")
return context return context