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
+1
View File
@@ -42,6 +42,7 @@
<script>
window.csrf_token = '{{ csrf_token }}'
window.setup_complete = '{{ setup_complete }}'
document.getElementById('seo-content').style.display = 'none';
</script>
<script type="module" src="/src/main.js"></script>
+29 -20
View File
@@ -62,25 +62,34 @@
</div>
</div>
</div>
<SidebarLink
:link="{
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
}"
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="toggleSidebar()"
class="m-2"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<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>
<TrialBanner
v-if="
userResource.data?.user_type == 'System User' &&
userResource.data?.is_fc_site
"
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
/>
<SidebarLink
:link="{
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
}"
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="toggleSidebar()"
class="m-2"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<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>
<PageModal
v-model="showPageModal"
@@ -101,7 +110,7 @@ import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings'
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'
const { user, sidebarSettings } = sessionStore()
+28 -10
View File
@@ -1,7 +1,7 @@
<template>
<div
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 }"
>
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
@@ -81,8 +81,8 @@
</template>
</FileUploader>
<div v-else>
<div class="flex items-center text-ink-gray-7">
<div class="border rounded-md p-2 mr-2">
<div class="flex text-ink-gray-7">
<div class="border self-start rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<a
@@ -90,7 +90,7 @@
target="_blank"
class="flex flex-col cursor-pointer !no-underline"
>
<span>
<span class="text-sm leading-5">
{{ submissionFile.file_name }}
</span>
<span class="text-sm text-ink-gray-5 mt-1">
@@ -155,12 +155,23 @@
type="select"
:options="submissionStatusOptions"
/>
<FormControl
v-if="submissionResource.doc"
v-model="submissionResource.doc.comments"
:label="__('Comments')"
type="textarea"
/>
<div>
<div class="text-sm text-ink-gray-5 mb-1">
{{ __('Comments') }}
</div>
<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>
@@ -184,6 +195,7 @@ import { useRouter } from 'vue-router'
const submissionFile = ref(null)
const answer = ref(null)
const comments = ref(null)
const router = useRouter()
const user = inject('$user')
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
@@ -281,6 +293,9 @@ watch(submissionResource, () => {
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (showUploader() && !submissionFile.value) {
@@ -305,11 +320,14 @@ const submitAssignment = () => {
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
assignment_attachment: submissionFile.value?.file_url,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
},
{
onSuccess(data) {
+2 -2
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.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 }}
<span v-if="seats_left > 1">
@@ -14,7 +14,7 @@
</div>
<div
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') }}
</div>
@@ -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>
+23 -5
View File
@@ -207,7 +207,7 @@
</Button>
<Button
v-else-if="activeQuestion != questions.length"
@click="nextQuetion()"
@click="nextQuestion()"
>
<span>
{{ __('Next') }}
@@ -258,14 +258,22 @@
</Button>
</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"
>
<ListView
:columns="getSubmissionColumns()"
:rows="attempts?.data"
row-key="name"
:options="{ selectable: false, showTooltip: false }"
:options="{
selectable: false,
showTooltip: false,
emptyState: { title: __('No Quiz submissions found') },
}"
>
</ListView>
</div>
@@ -282,7 +290,7 @@ import {
FormControl,
} from 'frappe-ui'
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 { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
@@ -536,7 +544,7 @@ const addToLocalStorage = () => {
localStorage.setItem(quiz.data.title, JSON.stringify(quizData))
}
const nextQuetion = () => {
const nextQuestion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer()
} else {
@@ -574,6 +582,16 @@ const createSubmission = () => {
if (quiz.data && quiz.data.max_attempts) attempts.reload()
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)
}
},
}
)
}
+80 -14
View File
@@ -59,13 +59,22 @@
v-if="userResource.data?.is_moderator"
v-model="showSettingsModal"
/>
<FCVerfiyCodeModal v-if="showFCLoginDialog" :email="verificationEmail" />
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui'
import { call, Dropdown } from 'frappe-ui'
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 {
ChevronDown,
LogIn,
@@ -74,13 +83,8 @@ import {
User,
Settings,
Sun,
LogInIcon,
} 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 { logout, branding } = sessionStore()
@@ -89,6 +93,11 @@ const settingsStore = useSettings()
let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const theme = ref('light')
const $dialog = createDialog
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
const showFCLoginDialog = ref(false)
const verificationEmail = ref(null)
const props = defineProps({
isCollapsed: {
@@ -130,6 +139,13 @@ const userDropdownOptions = computed(() => {
return isLoggedIn
},
},
{
icon: theme.value === 'light' ? Moon : Sun,
label: 'Toggle Theme',
onClick: () => {
toggleTheme()
},
},
{
component: markRaw(Apps),
condition: () => {
@@ -139,13 +155,6 @@ const userDropdownOptions = computed(() => {
else return false
},
},
{
icon: theme.value === 'light' ? Moon : Sun,
label: 'Toggle Theme',
onClick: () => {
toggleTheme()
},
},
{
icon: Settings,
label: 'Settings',
@@ -156,6 +165,19 @@ const userDropdownOptions = computed(() => {
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,
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>
+6 -3
View File
@@ -6,7 +6,7 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center space-x-2">
<Button
v-if="user.data?.is_moderator"
v-if="user.data?.is_moderator && batch.data?.certification"
@click="openCertificateDialog = true"
>
{{ __('Generate Certificates') }}
@@ -21,7 +21,10 @@
</Button>
</div>
</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">
<Tabs
v-model="tabIndex"
@@ -310,7 +313,7 @@ const tabs = computed(() => {
})
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/batches`
window.location.href = `/login?redirect-to=/lms/batches/${props.batchName}`
}
const openAnnouncementModal = () => {
+65 -43
View File
@@ -13,15 +13,14 @@
<div class="text-lg font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2">
<div>
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
/>
</div>
<div class="flex flex-col space-y-2">
<div class="space-y-4 mb-4">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<div class="flex items-center space-x-5">
<FormControl
v-model="batch.published"
type="checkbox"
@@ -32,6 +31,11 @@
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
</div>
</div>
@@ -90,30 +94,8 @@
:required="true"
:filters="{ ignore_user_type: 1 }"
/>
<div class="mb-4">
<FormControl
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="my-10">
<div class="text-lg font-semibold mb-4">
{{ __('Date and Time') }}
</div>
@@ -133,6 +115,14 @@
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div>
<div>
<FormControl
@@ -149,18 +139,11 @@
class="mb-4"
: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 class="mb-4">
<div class="mb-10">
<div class="text-lg font-semibold mb-4">
{{ __('Settings') }}
</div>
@@ -179,6 +162,11 @@
type="date"
class="mb-4"
/>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
/>
</div>
<div>
<FormControl
@@ -230,6 +218,33 @@
/>
</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>
</template>
@@ -278,10 +293,12 @@ const batch = reactive({
end_time: '',
timezone: '',
evaluation_end_date: '',
confirmation_email_template: '',
seat_count: '',
medium: '',
category: '',
allow_self_enrollment: false,
certification: false,
image: null,
paid_batch: false,
currency: '',
@@ -351,7 +368,12 @@ const batchDetail = createResource({
batch[key] = `${hours}:${minutes}`
} 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) {
let key = checkboxes[idx]
batch[key] = batch[key] ? true : false
+19 -1
View File
@@ -26,13 +26,19 @@
{{ __('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"
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
>
<TabButtons
v-if="user.data"
:buttons="batchTabs"
v-model="currentTab"
/>
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateBatches()"
/>
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="title"
@@ -111,6 +117,7 @@ const pageLength = ref(20)
const categories = ref([])
const currentCategory = ref(null)
const title = ref('')
const certification = ref(false)
const filters = ref({})
const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming')
const orderBy = ref('start_date')
@@ -130,6 +137,7 @@ const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search)
title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false
}
const batches = createListResource({
@@ -161,6 +169,7 @@ const updateBatches = () => {
const updateFilters = () => {
updateCategoryFilter()
updateTitleFilter()
updateCertificationFilter()
updateTabFilter()
updateStudentFilter()
setQueryParams()
@@ -182,6 +191,14 @@ const updateTitleFilter = () => {
}
}
const updateCertificationFilter = () => {
if (certification.value) {
filters.value['certification'] = 1
} else {
delete filters.value['certification']
}
}
const updateTabFilter = () => {
orderBy.value = 'start_date'
if (!user.data) {
@@ -222,6 +239,7 @@ const setQueryParams = () => {
let filterKeys = {
title: title.value,
category: currentCategory.value,
certification: certification.value,
}
Object.keys(filterKeys).forEach((key) => {
+1 -1
View File
@@ -55,7 +55,7 @@
<FormControl
type="number"
v-model="quiz.max_attempts"
:label="__('Maximun Attempts')"
:label="__('Maximum Attempts')"
/>
<FormControl
type="number"
+1 -1
View File
@@ -219,7 +219,7 @@ let router = createRouter({
router.beforeEach(async (to, from, next) => {
const { userResource } = usersStore()
const { isLoggedIn } = sessionStore()
let { isLoggedIn } = sessionStore()
const { allowGuestAccess } = useSettings()
try {
+2
View File
@@ -4,6 +4,7 @@ import { createApp, h } from 'vue'
import { usersStore } from '../stores/user'
import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next'
import router from '@/router'
export class Quiz {
constructor({ data, api, readOnly }) {
@@ -46,6 +47,7 @@ export class Quiz {
quiz: quiz,
})
app.use(translationPlugin)
app.use(router)
const { userResource } = usersStore()
app.provide('$user', userResource)
app.mount(this.wrapper)
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig({
}),
],
server: {
allowedHosts: ['fs'],
allowedHosts: ['fs', 'bs'],
},
resolve: {
alias: {
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.22.0"
__version__ = "2.23.0"
+2
View File
@@ -116,6 +116,8 @@ scheduler_events = {
"daily": [
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
"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",
],
}
+2 -1
View File
@@ -22,7 +22,7 @@ from frappe.utils import (
from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString
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()
@@ -175,6 +175,7 @@ def get_user_info():
user.is_moderator = "Moderator" in user.roles
user.is_evaluator = "Batch Evaluator" in user.roles
user.is_student = "LMS Student" in user.roles
user.is_fc_site = is_fc_site()
return user
@@ -13,11 +13,12 @@
"column_break_3",
"member",
"member_name",
"evaluator",
"section_break_dlzh",
"assignment_attachment",
"answer",
"section_break_ydgh",
"column_break_oqqy",
"evaluator",
"status",
"comments",
"section_break_rqal",
@@ -80,7 +81,7 @@
},
{
"fieldname": "comments",
"fieldtype": "Small Text",
"fieldtype": "Text Editor",
"label": "Comments"
},
{
@@ -139,12 +140,16 @@
{
"fieldname": "column_break_oqqy",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ydgh",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2024-12-24 21:22:35.212732",
"modified": "2025-02-17 18:40:53.374932",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Assignment Submission",
+45 -9
View File
@@ -8,25 +8,31 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"published",
"section_break_earo",
"title",
"start_date",
"end_date",
"column_break_4",
"allow_self_enrollment",
"start_time",
"end_time",
"timezone",
"section_break_rgfj",
"medium",
"category",
"column_break_flwy",
"seat_count",
"evaluation_end_date",
"section_break_cssv",
"published",
"column_break_wfkz",
"allow_self_enrollment",
"column_break_vnrp",
"certification",
"section_break_6",
"description",
"column_break_hlqw",
"instructors",
"section_break_rgfj",
"medium",
"category",
"confirmation_email_template",
"column_break_flwy",
"seat_count",
"evaluation_end_date",
"meta_image",
"section_break_khcn",
"batch_details",
@@ -206,6 +212,7 @@
"default": "0",
"fieldname": "published",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Published"
},
{
@@ -318,6 +325,35 @@
{
"fieldname": "section_break_khcn",
"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,
@@ -335,7 +371,7 @@
"link_fieldname": "batch_name"
}
],
"modified": "2025-02-12 11:59:35.312487",
"modified": "2025-02-18 15:43:18.512504",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch",
+38 -1
View File
@@ -8,7 +8,7 @@ import json
from frappe import _
from datetime import timedelta
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 (
get_lessons,
get_lesson_index,
@@ -405,3 +405,40 @@ def is_milestone_complete(idx, batch):
return False
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"],
)
@@ -79,13 +79,20 @@ def send_mail(doc):
batch = frappe.db.get_value(
"LMS Batch",
doc.batch,
["name", "title", "start_date", "start_time", "medium"],
[
"name",
"title",
"start_date",
"start_time",
"medium",
"confirmation_email_template",
],
as_dict=1,
)
subject = _("Enrollment Confirmation for {0}").format(batch.title)
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"
)
@@ -2,9 +2,10 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
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):
@@ -56,8 +57,48 @@ class LMSLiveClass(Document):
{
"sync_with_google_calendar": 1,
"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()
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"],
)
+11 -2
View File
@@ -139,8 +139,17 @@
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-02-11 14:48:27.801895",
"links": [
{
"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",
"module": "LMS",
"name": "LMS Payment",
@@ -10,12 +10,29 @@ from frappe.desk.doctype.notification_log.notification_log import make_notificat
class LMSQuizSubmission(Document):
def validate(self):
self.validate_if_max_attempts_exceeded()
self.validate_marks()
self.set_percentage()
def on_update(self):
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):
self.score = 0
for row in self.result:
@@ -52,3 +69,7 @@ class LMSQuizSubmission(Document):
)
make_notification_logs(notification, [self.member])
class MaximumAttemptsExceededError(frappe.DuplicateEntryError):
pass
+10 -4
View File
@@ -1194,7 +1194,14 @@ def get_neighbour_lesson(course, chapter, lesson):
@frappe.whitelist(allow_guest=True)
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
batch_details = frappe.db.get_value(
@@ -1218,6 +1225,7 @@ def get_batch_details(batch):
"paid_batch",
"evaluation_end_date",
"allow_self_enrollment",
"certification",
"timezone",
"category",
],
@@ -1229,9 +1237,7 @@ def get_batch_details(batch):
batch_details.courses = frappe.get_all(
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
)
batch_details.students = frappe.get_all(
"LMS Batch Enrollment", {"batch": batch}, pluck="member"
)
batch_details.students = batch_students
if batch_details.paid_batch and batch_details.start_date >= getdate():
batch_details.amount, batch_details.currency = check_multicurrency(
+216 -191
View File
File diff suppressed because it is too large Load Diff
+355 -330
View File
File diff suppressed because it is too large Load Diff
+217 -192
View File
File diff suppressed because it is too large Load Diff
+218 -193
View File
File diff suppressed because it is too large Load Diff
+218 -193
View File
File diff suppressed because it is too large Load Diff
+221 -196
View File
File diff suppressed because it is too large Load Diff
+216 -191
View File
File diff suppressed because it is too large Load Diff
+216 -191
View File
File diff suppressed because it is too large Load Diff
+216 -191
View File
File diff suppressed because it is too large Load Diff
+216 -191
View File
File diff suppressed because it is too large Load Diff
+217 -192
View File
File diff suppressed because it is too large Load Diff
+219 -194
View File
File diff suppressed because it is too large Load Diff
+217 -192
View File
File diff suppressed because it is too large Load Diff
+216 -191
View File
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -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>
+5 -3
View File
@@ -1,8 +1,9 @@
import frappe
from frappe.utils.telemetry import capture
from frappe import _
from bs4 import BeautifulSoup
import re
from bs4 import BeautifulSoup
from frappe import _
from frappe.utils.telemetry import capture
from frappe.utils import cint
no_cache = 1
@@ -17,6 +18,7 @@ def get_context():
csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit() # nosemgrep
context.csrf_token = csrf_token
context.setup_complete = cint(frappe.get_system_settings("setup_complete"))
capture("active_site", "lms")
context.favicon = frappe.db.get_single_value("Website Settings", "favicon")
return context