Merge pull request #2280 from frappe/main-hotfix

chore: merge 'main-hotfix' into 'main'
This commit is contained in:
Jannat Patel
2026-04-01 11:04:15 +05:30
committed by GitHub
54 changed files with 19520 additions and 11987 deletions

View File

@@ -1,4 +1,4 @@
name: UI
name: UI Tests
on:
pull_request:
@@ -16,13 +16,14 @@ permissions:
jobs:
test:
name: UI Tests (Cypress) - ${{ matrix.containers }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
name: UI Tests (Cypress)
matrix:
containers: [1, 2]
services:
mariadb:
@@ -114,6 +115,15 @@ jobs:
env:
CYPRESS_BASE_URL: http://lms.test:8000
CYPRESS_RECORD_KEY: 095366ec-7b9f-41bd-aeec-03bb76d627fe
SPLIT: ${{ strategy.job-total }}
SPLIT_INDEX: ${{ strategy.job-index }}
- name: Upload Cypress screenshots if tests fail
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots-${{ matrix.containers }}
path: cypress/screenshots
- name: Stop server and wait for coverage file
run: |

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "cypress";
import cypressSplit from "cypress-split";
export default defineConfig({
projectId: "vandxn",
@@ -14,5 +15,12 @@ export default defineConfig({
},
e2e: {
baseUrl: "http://pertest:8000",
setupNodeEvents(on, config) {
// Splitting tests only works when Cypress Cloud is not orchestrating parallel runs.
if (process.env.CYPRESS_CLOUD_PARALLEL !== "1") {
cypressSplit(on, config);
}
return config;
},
},
});

View File

@@ -9,6 +9,7 @@
:fileTypes="[fileType]"
:validateFile="(file: File) => validateFile(file, true, type)"
@success="(file: File) => saveFile(file)"
@failure="onUploadFailure"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
@@ -19,8 +20,8 @@
/>
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `${__('Uploading')} ${progress}%` : __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ __(description) }}
@@ -62,7 +63,7 @@
</template>
<script setup lang="ts">
import { validateFile } from '@/utils'
import { Button, FileUploader } from 'frappe-ui'
import { Button, FileUploader, toast } from 'frappe-ui'
import { Image, Video } from 'lucide-vue-next'
import { computed } from 'vue'
@@ -100,4 +101,14 @@ const saveFile = (file: any) => {
const removeImage = () => {
emit('update:modelValue', '')
}
const onUploadFailure = (error: any) => {
let message = __('Error Uploading File')
if (error?._server_messages) {
message = JSON.parse(JSON.parse(error._server_messages)[0]).message
} else if (error?.exc) {
message = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
}
toast.error(message)
}
</script>

View File

@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '4xl',
size: '5xl',
}"
>
<template #body>
@@ -19,10 +19,17 @@
rowHeight: 'h-16',
selectable: false,
}"
class="border rounded-lg py-2 px-3"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none !px-0"
>
<ListHeaderItem :item="item" v-for="item in feedbackColumns">
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
@@ -41,7 +48,7 @@
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
size="xl"
/>
</div>
</template>
@@ -63,9 +70,11 @@
<script setup lang="ts">
import {
Dialog,
ListView,
Avatar,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
@@ -89,27 +98,43 @@ const feedbackColumns = computed(() => {
label: 'Member',
key: 'member_name',
width: '10rem',
align: 'left',
icon: 'user',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
align: 'left',
icon: 'message-square',
},
{
label: 'Content',
key: 'content',
width: '9rem',
width: '10rem',
align: 'center',
icon: 'book',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
width: '10rem',
align: 'center',
icon: 'users',
},
{
label: 'Value',
key: 'value',
width: '9rem',
width: '10rem',
align: 'center',
icon: 'dollar-sign',
},
]
})
</script>
<style>
.feedback-list > button > div {
padding: 0.2rem 0;
margin-bottom: 0.2rem;
}
</style>

View File

@@ -228,14 +228,6 @@
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
@change="markForReview($event, activeQuestion)"
/>
<!-- <div class="text-sm text-ink-gray-5">
{{
__('Question {0} of {1}').format(
activeQuestion,
questions.length
)
}}
</div> -->
<div
v-if="!quiz.data.show_answers"
class="flex items-center space-x-2"
@@ -257,11 +249,13 @@
'cursor-pointer': item !== '...',
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
activeQuestion == item,
'bg-surface-gray-3 text-ink-gray-6':
activeQuestion != item && item !== '...',
'text-ink-gray-5': item === '...',
'bg-surface-blue-3 text-ink-white':
attemptedQuestions.includes(item) && activeQuestion != item,
'bg-surface-gray-3 text-ink-gray-6':
activeQuestion != item &&
item !== '...' &&
!attemptedQuestions.includes(item),
}"
@click="item !== '...' && switchQuestion(item)"
>
@@ -315,7 +309,7 @@
<div class="flex items-center space-x-2 mt-2">
<div
v-for="index in reviewQuestions"
@click="activeQuestion = index"
@click="switchQuestion(index)"
class="w-6 h-6 rounded-full flex items-center justify-center text-sm cursor-pointer bg-surface-gray-3"
>
{{ index }}

View File

@@ -328,8 +328,7 @@ import {
updateMetaInfo,
} from '@/utils'
import { useRouter } from 'vue-router'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '@/stores/session'
import { useTelemetry } from 'frappe-ui/frappe'
import Uploader from '@/components/Controls/Uploader.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
@@ -340,8 +339,6 @@ import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()

View File

@@ -78,13 +78,14 @@
</div>
</div>
<Switch
size="sm"
v-model="certification"
:label="__('Certification')"
:description="__('Only show batches that offer a certificate.')"
@change="updateBatches()"
/>
<Tooltip :text="__('Only show batches that offer a certificate')">
<FormControl
type="checkbox"
v-model="certification"
:label="__('Certification')"
@change="updateBatches()"
/>
</Tooltip>
</div>
</div>
<div
@@ -123,7 +124,7 @@ import {
Dropdown,
FormControl,
Select,
Switch,
Tooltip,
TabButtons,
usePageMeta,
} from 'frappe-ui'

View File

@@ -183,9 +183,3 @@ const isAdmin = computed(() => {
return user.data?.is_moderator || user.data?.is_evaluator
})
</script>
<style>
.feedback-list > button > div {
align-items: start;
padding: 0.15rem 0;
}
</style>

View File

@@ -1,100 +1,108 @@
<template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<Badge
v-if="batch.data.seat_count && batch.data.seats_left > 0"
variant="subtle"
theme="green"
size="md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
:label="
batch.data.seats_left +
' ' +
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
"
<div v-if="batch.data" class="border-2 rounded-md lg:w-72">
<video
v-if="batch.data.video_link"
:src="batch.data.video_link"
controls
class="rounded-t-md w-full"
/>
<Badge
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
variant="subtle"
theme="red"
size="md"
class="float-right"
:label="__('Sold Out')"
/>
<div
v-if="batch.data.amount"
class="text-lg font-semibold mb-5 text-ink-gray-9"
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
<div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div v-if="batch.data.timezone" class="flex items-center text-ink-gray-7">
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<div class="p-5">
<Badge
v-if="batch.data.seat_count && batch.data.seats_left > 0"
variant="subtle"
theme="green"
size="md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
:label="
batch.data.seats_left +
' ' +
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
"
/>
<Badge
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
variant="subtle"
theme="red"
size="md"
class="float-right"
:label="__('Sold Out')"
/>
<div
v-if="batch.data.amount"
class="text-lg font-semibold mb-5 text-ink-gray-9"
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
<div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div v-if="batch.data.timezone" class="flex items-center text-ink-gray-7">
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<div v-if="!readOnlyMode && !canAccessBatch">
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button class="w-full mt-4" variant="solid">
<div v-if="!readOnlyMode && !canAccessBatch">
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
<GraduationCap class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
{{ __('Enroll Now') }}
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Enroll Now') }}
</Button>
</div>
</div>
</div>
</template>

View File

@@ -45,30 +45,35 @@
@update:modelValue="makeFormDirty()"
/>
<div>
<div class="text-xs text-ink-gray-5">
<label class="block mb-1 text-xs text-ink-gray-5">
{{ __('Tags') }}
</div>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
:class="['w-full', 'flex-1', 'my-1']"
@keyup.enter="updateTags()"
id="tags"
/>
<div>
<div class="flex items-center flex-wrap gap-2">
<div
v-if="courseResource.doc.tags"
v-for="tag in courseResource.doc.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
</div>
</label>
<div
class="flex flex-wrap items-center gap-1.5 w-full rounded-lg border border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1.5 cursor-text transition-colors focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3"
@click="$refs.tagInput?.focus()"
>
<button
v-for="tag in parsedTags"
:key="tag"
class="inline-flex items-center gap-1 bg-surface-white border border-outline-gray-2 text-ink-gray-7 pl-2 pr-1.5 py-0.5 rounded text-base leading-5"
@click.stop="removeTag(tag)"
>
<span>{{ tag }}</span>
<X class="size-3.5 stroke-1.5 shrink-0" />
</button>
<input
id="tags"
ref="tagInput"
v-model="newTag"
type="text"
:placeholder="
!parsedTags.length
? __('Add a keyword and press enter')
: ''
"
class="flex-1 min-w-[4rem] border-none outline-none bg-transparent p-0 text-base focus:ring-0"
@keyup.enter="updateTags()"
/>
</div>
</div>
</div>
@@ -355,11 +360,9 @@ import {
sanitizeHTML,
updateMetaInfo,
createLMSCategory,
cleanError,
} from '@/utils'
import { Trash2, X } from 'lucide-vue-next'
import { X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { sessionStore } from '../../stores/session'
import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -369,7 +372,6 @@ import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
const user = inject('$user')
const newTag = ref('')
const { brand } = sessionStore()
const router = useRouter()
const instructors = ref([])
const related_courses = ref([])
@@ -412,6 +414,11 @@ const courseResource = createDocumentResource({
auto: true,
})
const parsedTags = computed(() => {
const tags = courseResource.doc?.tags
return tags ? tags.split(', ').filter(Boolean) : []
})
watch(
() => courseResource.doc,
() => {
@@ -595,13 +602,6 @@ const makeFormDirty = () => {
isDirty.value = true
}
usePageMeta(() => {
return {
title: courseResource.doc?.title,
icon: brand.favicon,
}
})
defineExpose({
submitCourse,
trashCourse,

View File

@@ -157,7 +157,7 @@ import {
createResource,
usePageMeta,
} from 'frappe-ui'
import { inject, ref, computed } from 'vue'
import { inject, ref, computed, watch, nextTick } from 'vue'
import { sessionStore } from '../stores/session'
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
import {
@@ -193,17 +193,11 @@ const job = createResource({
},
cache: ['job', props.job],
auto: true,
onSuccess: (data) => {
if (user.data?.name) {
jobApplication.submit()
applicationCount.submit()
}
},
})
const jobApplication = createResource({
url: 'frappe.client.get_list',
makeParams(values) {
makeParams() {
return {
doctype: 'LMS Job Application',
filters: {
@@ -216,7 +210,7 @@ const jobApplication = createResource({
const applicationCount = createResource({
url: 'frappe.client.get_count',
makeParams(values) {
makeParams() {
return {
doctype: 'LMS Job Application',
filters: {
@@ -226,6 +220,18 @@ const applicationCount = createResource({
},
})
const stopWatch = watch(
() => [job.data?.name, user.data?.name],
([jobName, userName]) => {
if (jobName && userName) {
jobApplication.submit()
applicationCount.submit()
nextTick(() => stopWatch())
}
},
{ immediate: true }
)
const openApplicationModal = () => {
showApplicationModal.value = true
}

View File

@@ -151,7 +151,7 @@
</template>
<script setup>
import { createResource, FormControl, Button, Badge, toast } from 'frappe-ui'
import { computed, reactive, ref, onMounted, inject } from 'vue'
import { computed, reactive, ref, onMounted, inject, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
@@ -195,16 +195,30 @@ const evaluator = createResource({
evaluator: props.profile.data?.name,
},
auto: true,
onSuccess(data) {
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
watch(evaluator, () => {
if (evaluator.data?.slots?.unavailable_from)
from.value = evaluator.data.slots.unavailable_from
if (evaluator.data?.slots?.unavailable_to)
to.value = evaluator.data.slots.unavailable_to
evaluator.data?.slots?.schedule.forEach((slot) => {
slot.start_time = formatTime(slot.start_time)
slot.end_time = formatTime(slot.end_time)
})
})
const formatTime = (time) => {
if (!time) return ''
const [hour, minute] = time.split(':')
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
}
const createSlot = createResource({
url: 'frappe.client.insert',
makeParams(values) {

View File

@@ -17,7 +17,7 @@
size="sm"
:label="__('Student')"
:description="
__('Can browse courses, enroll in batches, and view content.')
__('Access courses, join batches, and track learning progress')
"
v-model="lms_student"
@update:modelValue="saveRole('lms_student')"
@@ -25,27 +25,21 @@
<Switch
size="sm"
:label="__('Course Creator')"
:description="
__('Can create, edit, and manage courses, chapters, and lessons.')
"
:description="__('Build and manage courses, chapters, and lessons')"
v-model="course_creator"
@update:modelValue="saveRole('course_creator')"
/>
<Switch
size="sm"
:label="__('Evaluator')"
:description="
__('Can create batches/live classes and grade student assignments.')
"
:description="__('Manage batches, review and grade submissions')"
v-model="batch_evaluator"
@update:modelValue="saveRole('batch_evaluator')"
/>
<Switch
size="sm"
:label="__('Moderator')"
:description="
__('Full access to all content, users, and system-wide settings.')
"
:description="__('Oversee all users, content, and system settings')"
v-model="moderator"
@update:modelValue="saveRole('moderator')"
/>

View File

@@ -49,11 +49,14 @@
"
variant="solid"
@click="submitCode"
:loading="running"
:disabled="running"
class="text-ink-gray-9"
>
<template #prefix>
<Play class="size-3" />
</template>
{{ __('Run') }}
{{ running ? __('Running') : __('Run') }}
</Button>
</div>
</div>
@@ -172,8 +175,9 @@ const { brand } = sessionStore()
const { settings } = useSettings()
const router = useRouter()
const fromLesson = ref(false)
const falconURL = ref<string>('https://falcon.frappe.io/')
const falconURL = ref<string>('https://falcon.frappe.io')
const falconError = ref<string | null>(null)
const running = ref<boolean>(false)
const props = withDefaults(
defineProps<{
@@ -308,8 +312,10 @@ const loadFalcon = () => {
}
const submitCode = async () => {
running.value = true
await runCode()
createSubmission()
running.value = false
}
const runCode = async () => {
@@ -406,6 +412,7 @@ const execute = (stdin = ''): Promise<string> => {
setTimeout(() => {
if (!hasExited) {
running.value = false
error.value = true
errorMessage.value = 'Execution timed out.'
reject('Execution timed out.')

View File

@@ -71,8 +71,13 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
@@ -134,13 +139,14 @@ import {
call,
createListResource,
dayjs,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
FeatherIcon,
ListSelectBanner,
toast,
usePageMeta,
@@ -272,17 +278,20 @@ const columns = computed(() => {
label: __('Title'),
key: 'title',
width: 3,
icon: 'file-text',
},
{
label: __('Language'),
key: 'language',
width: 2,
align: 'left',
icon: 'code',
},
{
label: __('Updated On'),
key: 'modified',
width: 1,
icon: 'clock',
},
]
})

View File

@@ -10,10 +10,10 @@
{{ __('Create') }}
</Button>
</header>
<div class="py-5 mx-5">
<div class="flex items-center justify-between mb-4">
<div class="pt-5 mx-5">
<div class="flex items-center justify-between mb-5">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Quizzes').format(quizzes.data.length) }}
{{ __('{0} Quizzes').format(quizzes.data?.length) }}
</div>
<FormControl v-model="search" type="text" placeholder="Search">
<template #prefix>
@@ -27,9 +27,10 @@
:rows="quizzes.data"
row-key="name"
:options="{ showTooltip: false, selectable: true }"
class="h-[79vh] border-b"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem :item="item" v-for="item in quizColumns">
<template #prefix="{ item }">
@@ -47,7 +48,7 @@
},
}"
>
<ListRow :row="row">
<ListRow :row="row" class="hover:bg-surface-gray-2">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'show_answers'">
@@ -59,7 +60,7 @@
</div>
<div
v-else-if="column.key == 'modified'"
class="text-xs text-ink-gray-5"
class="text-sm text-ink-gray-5"
>
{{ row[column.key] }}
</div>
@@ -71,7 +72,7 @@
</ListRow>
</router-link>
</ListRows>
<ListSelectBanner>
<ListSelectBanner class="bottom-50">
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
@@ -85,10 +86,14 @@
</ListSelectBanner>
</ListView>
<EmptyState v-else type="Quizzes" />
<div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
<Button @click="quizzes.next()">
<div class="flex items-center justify-end space-x-3 mt-3">
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
{{ __('Load More') }}
</Button>
<div v-if="quizzes.hasNextPage" class="h-8 border-l"></div>
<div class="text-ink-gray-5">
{{ quizzes.data?.length }} {{ __('of') }} {{ totalQuizzes.data }}
</div>
</div>
</div>
<Dialog
@@ -123,6 +128,7 @@ import {
Breadcrumbs,
Button,
createListResource,
createResource,
Dialog,
FeatherIcon,
FormControl,
@@ -157,10 +163,12 @@ const showForm = ref(false)
const title = ref('')
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
if (
!user.data?.is_moderator &&
!user.data?.is_instructor &&
!user.data?.is_evaluator
) {
router.push({ name: 'Courses' })
} else if (!user.data?.is_moderator) {
quizFilters.value['owner'] = user.data?.name
}
if (route.query.new === 'true') {
showForm.value = true
@@ -173,6 +181,9 @@ watch(search, () => {
filters: quizFilters.value,
})
quizzes.reload()
totalQuizzes.update({
filters: quizFilters.value,
})
})
const quizzes = createListResource({
@@ -194,12 +205,26 @@ const quizzes = createListResource({
return data.map((quiz) => {
return {
...quiz,
modified: dayjs(quiz.modified).fromNow(),
modified: dayjs(quiz.modified).fromNow(true),
}
})
},
})
const totalQuizzes = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Quiz',
filters: quizFilters.value,
},
auto: true,
cache: ['quizzes_count', user.data?.name],
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
const validateTitle = () => {
title.value = escapeHTML(title.value.trim())
}
@@ -249,7 +274,7 @@ const quizColumns = computed(() => {
{
label: __('Total Marks'),
key: 'total_marks',
width: 1,
width: 0.5,
align: 'center',
icon: 'hash',
},
@@ -263,7 +288,7 @@ const quizColumns = computed(() => {
{
label: __('Max Attempts'),
key: 'max_attempts',
width: 1,
width: 0.5,
align: 'center',
icon: 'repeat',
},
@@ -275,7 +300,7 @@ const quizColumns = computed(() => {
icon: 'eye',
},
{
label: __('Modified'),
label: __('Updated On'),
key: 'modified',
width: 1,
align: 'center',

View File

@@ -86,12 +86,21 @@ def create_instructor():
pluck="name",
limit=1,
)[0]
return frappe.get_doc("User", user)
instructor = frappe.get_doc("User", user)
instructor.user_image = "/assets/lms/images/instructor.png"
instructor.add_roles("Moderator")
instructor.save()
return instructor
return create_user("Jannat", "Patel", "jannat@example.com", "/assets/lms/images/instructor.png")
return create_user(
"Jannat", "Patel", "jannat@example.com", "/assets/lms/images/instructor.png", ["Moderator"]
)
def create_user(first_name, last_name, email, user_image):
def create_user(first_name, last_name, email, user_image, roles=None):
if roles is None:
roles = ["LMS Student"]
filters = {"first_name": first_name, "last_name": last_name, "email": email}
if frappe.db.exists("User", filters):
return frappe.get_doc("User", filters)
@@ -101,6 +110,8 @@ def create_user(first_name, last_name, email, user_image):
user.last_name = last_name
user.user_image = user_image
user.email = email
user.send_welcome_email = False
user.add_roles(*roles)
user.save()
return user

View File

@@ -335,7 +335,8 @@ def get_evaluator_details(evaluator: str):
doc = frappe.new_doc("Course Evaluator")
doc.evaluator = evaluator
doc.insert()
for slot in doc.schedule:
print(slot.start_time, slot.end_time)
return {
"slots": doc.as_dict(),
"calendar": calendar.name,

View File

@@ -159,7 +159,7 @@
"link_fieldname": "quiz"
}
],
"modified": "2025-06-27 20:00:15.660323",
"modified": "2026-03-25 20:22:22.124828",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Quiz",
@@ -209,6 +209,18 @@
"report": 1,
"role": "LMS Student",
"share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",

View File

@@ -85,7 +85,7 @@
],
"fields": [
{
"default": "https://falcon.frappe.io/",
"default": "https://falcon.frappe.io",
"fieldname": "livecode_url",
"fieldtype": "Data",
"label": "LiveCode URL"
@@ -512,8 +512,8 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-18 15:32:56.259783",
"modified_by": "sayali@frappe.io",
"modified": "2026-03-19 11:31:14.951880",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Settings",
"owner": "Administrator",
@@ -544,4 +544,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -1087,7 +1087,7 @@ def get_neighbour_lesson(course: str, chapter: int, lesson: int) -> dict:
}
@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
@rate_limit(limit=500, seconds=60 * 60)
def get_batch_details(batch: str):
if not guest_access_allowed():
@@ -1129,6 +1129,7 @@ def get_batch_details(batch: str):
"zoom_account",
"conferencing_provider",
"google_meet_account",
"video_link",
],
as_dict=True,
)

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -122,4 +122,5 @@ lms.patches.v2_0.rename_badge_assignment_event
lms.patches.v2_0.enable_allow_job_posting
lms.patches.v2_0.set_conferencing_provider_for_zoom
lms.patches.v2_0.sync_evaluator_roles
lms.patches.v2_0.give_event_permission #10-03-2026
lms.patches.v2_0.fix_livecode_url_default
lms.patches.v2_0.give_event_permission #10-03-2026

View File

@@ -0,0 +1,7 @@
import frappe
def execute():
livecode_url = frappe.db.get_single_value("LMS Settings", "livecode_url")
if livecode_url == "https://falcon.frappe.io/":
frappe.db.set_single_value("LMS Settings", "livecode_url", "https://falcon.frappe.io")

View File

@@ -29,7 +29,8 @@
"@semantic-release/git": "^10.0.1",
"cypress": "^14.5.4",
"cypress-file-upload": "^5.0.8",
"cypress-real-events": "^1.14.0"
"cypress-real-events": "^1.14.0",
"cypress-split": "^1.24.28"
},
"dependencies": {
"pre-commit": "^1.2.2"

1477
yarn.lock

File diff suppressed because it is too large Load Diff