Merge upstream/develop into fix/dark-mode

This commit is contained in:
raizasafeel
2026-02-17 14:20:28 +05:30
25 changed files with 941 additions and 895 deletions

View File

@@ -3,6 +3,8 @@ on:
push:
branches:
- main
- develop
- main-hotfix
pull_request: {}
jobs:
tests:

View File

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

View File

@@ -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
View 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 }}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ const props = defineProps({
required: true,
},
membership: {
type: Object,
type: Object || null,
required: false,
},
})

View File

@@ -18,7 +18,6 @@
<Link
doctype="User"
v-model="student"
:filters="{ ignore_user_type: 1 }"
placeholder=" "
:label="__('Student')"
:onCreate="

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
from lms.install import give_user_list_permission
def execute():
give_user_list_permission()

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