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

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()

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) {

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>

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
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)
}
},
}
)
}

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>

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 = () => {

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

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) => {

View File

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

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 {

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)

View File

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

View File

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

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",
],
}

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

View File

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

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",

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"],
)

View File

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

View File

@@ -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"],
)

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",

View File

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

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(

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