Merge upstream/develop into fix/dark-mode
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
pull_request: {}
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
4
.github/workflows/make_release_pr.yml
vendored
4
.github/workflows/make_release_pr.yml
vendored
@@ -18,9 +18,9 @@ jobs:
|
||||
owner: frappe
|
||||
repo: lms
|
||||
title: |-
|
||||
"chore: merge 'develop' into 'main'"
|
||||
"chore: merge 'main-hotfix' into 'main'"
|
||||
body: "Automated weekly release"
|
||||
base: main
|
||||
head: develop
|
||||
head: main-hotfix
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
5
.github/workflows/ui-tests.yml
vendored
5
.github/workflows/ui-tests.yml
vendored
@@ -4,7 +4,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
|
||||
permissions:
|
||||
# Do not change this as GITHUB_TOKEN is being used by roulette
|
||||
|
||||
30
.mergify.yml
Normal file
30
.mergify.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
pull_request_rules:
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label="backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label="backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
@@ -162,8 +162,12 @@ describe("Batch Creation", () => {
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Students").click();
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
|
||||
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
||||
cy.get('div[role="dialog"]')
|
||||
.first()
|
||||
.find("input[id^='headlessui-combobox-input-v-']")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("input[placeholder='Search']").type(randomEmail);
|
||||
cy.get("div").contains(randomEmail).click();
|
||||
cy.get("button").contains("Submit").click();
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("Course Creation", () => {
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
cy.get("input").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
|
||||
@@ -1,140 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Label -->
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||
</div>
|
||||
<Combobox
|
||||
v-model="selectedValue"
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen" class="">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals"
|
||||
>
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value == option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
class="form-input w-full"
|
||||
:class="inputClasses"
|
||||
type="text"
|
||||
:value="selectedValue"
|
||||
autocomplete="off"
|
||||
@click="onFocus"
|
||||
/>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
|
||||
<!-- Dropdown -->
|
||||
<ComboboxOptions
|
||||
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals shadow-lg"
|
||||
>
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Options -->
|
||||
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
|
||||
<template v-for="group in groups" :key="group.key">
|
||||
<div
|
||||
v-if="slots.footer"
|
||||
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
|
||||
<ComboboxOption
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value === option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="slots.footer"
|
||||
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
|
||||
>
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{
|
||||
value: selectedValue,
|
||||
close,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
@@ -145,15 +100,15 @@ import {
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
ComboboxButton,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
@@ -184,107 +139,93 @@ const props = defineProps({
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const trigger = ref(null)
|
||||
const search = ref(null)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
|
||||
const selectedValue = ref(props.modelValue)
|
||||
const query = ref('')
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
},
|
||||
watch(selectedValue, (val) => {
|
||||
query.value = ''
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
})
|
||||
|
||||
function close() {
|
||||
showOptions.value = false
|
||||
function clearValue() {
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options || props.options.length == 0) return []
|
||||
if (!props.options?.length) return []
|
||||
|
||||
let groups = props.options[0]?.group
|
||||
const normalized = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}
|
||||
})
|
||||
return normalized
|
||||
.map((group, i) => ({
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) {
|
||||
return options
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value]
|
||||
return searchTexts.some((text) =>
|
||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
if (!query.value) return options
|
||||
const q = query.value.toLowerCase()
|
||||
return options.filter((option) =>
|
||||
[option.label, option.value]
|
||||
.filter(Boolean)
|
||||
.some((text) => text.toString().toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
emit('update:query', val)
|
||||
},
|
||||
{ debounce: 300 }
|
||||
)
|
||||
|
||||
const onFocus = () => {
|
||||
trigger.value?.$el.click()
|
||||
nextTick(() => {
|
||||
search.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
let selectedOption = allOptions.find((o) => o.value === option)
|
||||
return selectedOption?.label || option
|
||||
}
|
||||
return option?.label
|
||||
const close = () => {
|
||||
selectedValue.value = null
|
||||
trigger.value?.$el.click()
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit('update:query', q)
|
||||
})
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
})
|
||||
const textColor = computed(() =>
|
||||
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
let sizeClasses = {
|
||||
const sizeClasses = {
|
||||
sm: 'text-base rounded h-7',
|
||||
md: 'text-base rounded h-8',
|
||||
lg: 'text-lg rounded-md h-10',
|
||||
xl: 'text-xl rounded-md h-10',
|
||||
}[props.size]
|
||||
|
||||
let paddingClasses = {
|
||||
const paddingClasses = {
|
||||
sm: 'py-1.5 px-2',
|
||||
md: 'py-1.5 px-2.5',
|
||||
lg: 'py-1.5 px-3',
|
||||
xl: 'py-1.5 px-3',
|
||||
}[props.size]
|
||||
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
const variant = props.disabled ? 'disabled' : props.variant
|
||||
|
||||
const variantClasses = {
|
||||
subtle:
|
||||
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
outline:
|
||||
@@ -305,6 +246,4 @@ const inputClasses = computed(() => {
|
||||
'transition-colors w-full',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
:readonly="attrs.readonly"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
@@ -96,8 +95,7 @@ const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
|
||||
:class="{ 'mt-8': !readOnlyMode }"
|
||||
>
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
type: Object,
|
||||
type: Object || null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="student"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
placeholder=" "
|
||||
:label="__('Student')"
|
||||
:onCreate="
|
||||
|
||||
@@ -117,7 +117,15 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
@@ -184,34 +192,29 @@ const openProfile = (username: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const newMember = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams() {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data: Member) {
|
||||
show.value = false
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
newMember.reload()
|
||||
close()
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
})
|
||||
.then((data: Member) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err)
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
})
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -137,7 +137,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Button, createResource, createListResource, call } from 'frappe-ui'
|
||||
import { Button, createListResource, call, toast } from 'frappe-ui'
|
||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
|
||||
@@ -183,6 +183,7 @@ const upcoming_evals = createListResource({
|
||||
'start_time',
|
||||
'evaluator_name',
|
||||
'course_title',
|
||||
'member',
|
||||
'google_meet_link',
|
||||
],
|
||||
orderBy: 'date',
|
||||
@@ -227,11 +228,15 @@ const cancelEvaluation = (evl) => {
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
|
||||
() => {
|
||||
call('lms.lms.api.cancel_evaluation', { evaluation: evl })
|
||||
.then(() => {
|
||||
upcoming_evals.reload()
|
||||
}
|
||||
)
|
||||
toast.success(__('Evaluation cancelled successfully'))
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
close()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
||||
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
||||
<div class="border rounded-lg py-3 px-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Students') }}
|
||||
@@ -248,6 +248,7 @@
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:course="course"
|
||||
:students="progressList"
|
||||
/>
|
||||
<StudentCourseProgress
|
||||
v-if="showProgressModal"
|
||||
|
||||
@@ -58,6 +58,7 @@ import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const student = ref<string | null>(null)
|
||||
const students = defineModel<any[]>('students')
|
||||
const payment = ref<string | null>(null)
|
||||
const purchasedCertificate = ref<boolean>(false)
|
||||
|
||||
@@ -79,6 +80,7 @@ const enrollStudent = (close: () => void) => {
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
students.value?.reload()
|
||||
toast.success(__('Student enrolled successfully'))
|
||||
close()
|
||||
})
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
:membership="course.data.membership || null"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
|
||||
1259
frontend/yarn.lock
1259
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ ALLOWED_PATHS = [
|
||||
"/api/method/frappe.core.doctype.user.user.reset_password",
|
||||
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_as_read",
|
||||
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_all_as_read",
|
||||
"/api/method/frappe.sessions.clear",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from lms.lms.api import give_discussions_permission
|
||||
def after_install():
|
||||
create_batch_source()
|
||||
give_discussions_permission()
|
||||
give_user_list_permission()
|
||||
|
||||
|
||||
def after_sync():
|
||||
@@ -27,13 +28,6 @@ def create_lms_roles():
|
||||
create_lms_student_role()
|
||||
|
||||
|
||||
def delete_lms_roles():
|
||||
roles = ["Course Creator", "Moderator"]
|
||||
for role in roles:
|
||||
if frappe.db.exists("Role", role):
|
||||
frappe.db.delete("Role", role)
|
||||
|
||||
|
||||
def create_course_creator_role():
|
||||
if frappe.db.exists("Role", "Course Creator"):
|
||||
frappe.db.set_value("Role", "Course Creator", "desk_access", 0)
|
||||
@@ -185,3 +179,36 @@ def give_lms_roles_to_admin():
|
||||
doc.parentfield = "roles"
|
||||
doc.role = role
|
||||
doc.save()
|
||||
|
||||
|
||||
def give_user_list_permission():
|
||||
doctype = "User"
|
||||
roles = ["Course Creator", "Moderator", "Batch Evaluator"]
|
||||
for role in roles:
|
||||
permlevel = 0
|
||||
create_role(doctype, role, permlevel)
|
||||
create_role(doctype, "System Manager", 1)
|
||||
|
||||
|
||||
def create_role(doctype, role, permlevel):
|
||||
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel}):
|
||||
doc = frappe.new_doc("Custom DocPerm")
|
||||
doc.update(
|
||||
{
|
||||
"doctype": "Custom DocPerm",
|
||||
"parent": doctype,
|
||||
"role": role,
|
||||
"read": 1,
|
||||
"write": 1 if role in ["Moderator", "System Manager"] else 0,
|
||||
"create": 1 if role == "Moderator" else 0,
|
||||
"permlevel": permlevel,
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
|
||||
|
||||
def delete_lms_roles():
|
||||
roles = ["Course Creator", "Moderator", "Batch Evaluator", "LMS Student"]
|
||||
for role in roles:
|
||||
if frappe.db.exists("Role", role):
|
||||
frappe.db.delete("Role", role)
|
||||
|
||||
@@ -1308,6 +1308,7 @@ def get_lms_settings():
|
||||
@frappe.whitelist()
|
||||
def cancel_evaluation(evaluation: dict):
|
||||
evaluation = frappe._dict(evaluation)
|
||||
print(evaluation.member, frappe.session.user)
|
||||
if evaluation.member != frappe.session.user:
|
||||
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
|
||||
|
||||
@@ -1511,6 +1512,7 @@ def validate_meta_data_permissions(meta_type: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_programming_exercise_submission(exercise: str, submission: str, code: str, test_cases: list):
|
||||
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
|
||||
if submission == "new":
|
||||
return make_new_exercise_submission(exercise, code, test_cases)
|
||||
else:
|
||||
@@ -2037,7 +2039,7 @@ def get_upcoming_batches():
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_programming_exercise(exercise: str):
|
||||
frappe.only_for(["Moderator", "Course Creator"])
|
||||
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
|
||||
frappe.db.delete("LMS Programming Exercise Submission", {"exercise": exercise})
|
||||
frappe.db.delete("LMS Programming Exercise", exercise)
|
||||
|
||||
|
||||
@@ -8,9 +8,15 @@ from frappe.utils import ceil
|
||||
|
||||
|
||||
class LMSEnrollment(Document):
|
||||
def before_insert(self):
|
||||
def validate(self):
|
||||
self.validate_duplicate_enrollment()
|
||||
self.validate_course_enrollment_eligibility()
|
||||
self.validate_owner()
|
||||
|
||||
def validate_owner(self):
|
||||
"""Makes the member as the owner of the document so that users can update their progress"""
|
||||
if self.owner != self.member:
|
||||
self.owner = self.member
|
||||
|
||||
def on_update(self):
|
||||
update_program_progress(self.member)
|
||||
@@ -45,7 +51,7 @@ class LMSEnrollment(Document):
|
||||
if self.enrollment_from_batch:
|
||||
return
|
||||
|
||||
if not course_details.published:
|
||||
if not course_details.published and not is_admin():
|
||||
frappe.throw(_("You cannot enroll in an unpublished course."))
|
||||
|
||||
if course_details.paid_course:
|
||||
|
||||
@@ -1010,12 +1010,7 @@ def get_lesson(course: str, chapter: int, lesson: int) -> dict:
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
if (
|
||||
not lesson_details.include_in_preview
|
||||
and not membership
|
||||
and not has_moderator_role()
|
||||
and not is_instructor(course)
|
||||
):
|
||||
if not lesson_details.include_in_preview and not membership and not can_modify_course(course):
|
||||
return {
|
||||
"no_preview": 1,
|
||||
"title": lesson_details.title,
|
||||
@@ -1669,8 +1664,10 @@ def create_discussion_topic(doctype: str, docname: str) -> str:
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_discussion_replies(topic: str):
|
||||
doctype = frappe.db.get_value("Discussion Topic", topic, "reference_doctype")
|
||||
if not can_access_topic(doctype, topic):
|
||||
topic_details = frappe.db.get_value(
|
||||
"Discussion Topic", topic, ["reference_doctype", "reference_docname"], as_dict=1
|
||||
)
|
||||
if not can_access_topic(topic_details.reference_doctype, topic_details.reference_docname):
|
||||
frappe.throw(_("You are not authorized to view the discussion replies for this topic."))
|
||||
|
||||
replies = frappe.get_all(
|
||||
@@ -2048,6 +2045,13 @@ def get_program_details(program_name: str) -> dict:
|
||||
if not guest_access_allowed():
|
||||
frappe.throw(_("Please login to view program details."))
|
||||
|
||||
is_published = frappe.db.get_value("LMS Program", program_name, "published")
|
||||
is_member = frappe.db.exists(
|
||||
"LMS Program Member", {"parent": program_name, "member": frappe.session.user}
|
||||
)
|
||||
if not is_published and not is_member:
|
||||
frappe.throw(_("You are not authorized to view the details of this program."))
|
||||
|
||||
program = frappe.db.get_value(
|
||||
"LMS Program",
|
||||
program_name,
|
||||
|
||||
@@ -115,4 +115,6 @@ lms.patches.v2_0.fix_scorm_lesson_reference_idx #02-09-2025
|
||||
lms.patches.v2_0.certified_members_to_certifications #05-10-2025
|
||||
lms.patches.v2_0.fix_job_application_resume_urls
|
||||
lms.patches.v2_0.open_to_opportunities
|
||||
lms.patches.v2_0.open_to_work
|
||||
lms.patches.v2_0.open_to_work
|
||||
lms.patches.v2_0.share_enrollment
|
||||
lms.patches.v2_0.give_user_list_permission #11-02-2026
|
||||
5
lms/patches/v2_0/give_user_list_permission.py
Normal file
5
lms/patches/v2_0/give_user_list_permission.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from lms.install import give_user_list_permission
|
||||
|
||||
|
||||
def execute():
|
||||
give_user_list_permission()
|
||||
20
lms/patches/v2_0/share_enrollment.py
Normal file
20
lms/patches/v2_0/share_enrollment.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
enrollments = frappe.get_all("LMS Enrollment", ["name", "member", "owner"])
|
||||
|
||||
for enrollment in enrollments:
|
||||
if enrollment.owner == enrollment.member:
|
||||
continue
|
||||
filters = {
|
||||
"user": enrollment.member,
|
||||
"share_doctype": "LMS Enrollment",
|
||||
"share_name": enrollment.name,
|
||||
}
|
||||
is_shared = frappe.db.exists("DocShare", filters)
|
||||
if not is_shared:
|
||||
share = frappe.new_doc("DocShare")
|
||||
filters.update({"read": 1, "write": 1, "notify_by_email": 0})
|
||||
share.update(filters)
|
||||
share.save()
|
||||
Reference in New Issue
Block a user