Merge pull request #1618 from pateljannat/member-list-refactor

fix: improved members and evaluators list and form
This commit is contained in:
Jannat Patel
2025-07-04 12:33:44 +05:30
committed by GitHub
11 changed files with 294 additions and 176 deletions

View File

@@ -23,8 +23,8 @@ describe("Batch Creation", () => {
const randomEmail = `testuser_${dateNow}@example.com`; const randomEmail = `testuser_${dateNow}@example.com`;
const randomName = `Test User ${dateNow}`; const randomName = `Test User ${dateNow}`;
cy.get("input[placeholder='Email']").type(randomEmail); cy.get("input[placeholder='jane@doe.com']").type(randomEmail);
cy.get("input[placeholder='First Name']").type(randomName); cy.get("input[placeholder='Jane']").type(randomName);
cy.get("button").contains("Add").click(); cy.get("button").contains("Add").click();
// Add evaluator // Add evaluator
@@ -39,7 +39,7 @@ describe("Batch Creation", () => {
.click(); .click();
const randomEvaluator = `evaluator${dateNow}@example.com`; const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='Email']").type(randomEvaluator); cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
cy.get("button").contains("Add").click(); cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click(); cy.get("div").contains(randomEvaluator).should("be.visible").click();

View File

@@ -206,7 +206,7 @@ const progressColumns = computed(() => {
{ {
label: __('Member'), label: __('Member'),
key: 'member_name', key: 'member_name',
width: '50%', width: '60%',
icon: 'user', icon: 'user',
}, },
{ {

View File

@@ -1,78 +1,129 @@
<template> <template>
<div class="flex min-h-0 flex-col text-base"> <div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9"> <div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }} {{ __(label) }}
</div> </div>
<!-- <div class="text-xs text-ink-gray-5"> <div class="text-ink-gray-6 leading-5">
{{ __(description) }} {{ __(description) }}
</div> --> </div>
</div> </div>
<div class="flex item-center space-x-2"> <div class="flex item-center space-x-2">
<FormControl <Button variant="solid" @click="() => (showForm = !showForm)">
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<template #prefix> <template #prefix>
<Plus v-if="!showForm" class="size-4 stroke-1.5" /> <Plus class="size-4 stroke-1.5" />
<X v-else class="size-4 stroke-1.5" />
</template> </template>
{{ showForm ? __('Close') : __('New') }} {{ __('New') }}
</Button> </Button>
</div> </div>
</div> </div>
<!-- Form to add new member --> <div class="mt-8 pb-5">
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<FormControl <FormControl
v-model="email" v-model="search"
:placeholder="__('Email')" :placeholder="__('Search')"
type="email" type="text"
class="w-full" :debounce="300"
@keydown.enter="addEvaluator" class="w-1/4 mb-4"
/> >
<Button @click="addEvaluator()" variant="subtle"> <template #prefix>
{{ __('Add') }} <Search class="size-4 stroke-1.5 text-ink-gray-5" />
</Button> </template>
</div> </FormControl>
<div class="overflow-auto h-[60vh]">
<div class="overflow-y-scroll"> <div class="divide-y">
<div class="divide-y"> <div
<div v-for="evaluator in evaluators.data"
v-for="evaluator in evaluators.data" :key="evaluator.evaluator"
@click="openProfile(evaluator.username)" class="cursor-pointer"
class="cursor-pointer" >
> <div class="flex items-center justify-between group py-3">
<div class="flex items-center justify-between py-3"> <div
<div class="flex items-center space-x-3"> class="flex items-center space-x-3"
<Avatar @click="openProfile(evaluator.username)"
:image="evaluator.user_image" >
:label="evaluator.full_name" <Avatar
size="lg" :image="evaluator.user_image"
/> :label="evaluator.full_name"
<div> size="xl"
<div class="text-base font-semibold text-ink-gray-9"> />
{{ evaluator.full_name }} <div class="space-y-1">
</div> <div class="text-base font-semibold text-ink-gray-9">
<div class="text-xs text-ink-gray-5"> {{ evaluator.full_name }}
{{ evaluator.evaluator }} </div>
<div class="text-xs text-ink-gray-5">
{{ evaluator.evaluator }}
</div>
</div> </div>
</div> </div>
<div class="invisible group-hover:visible">
<Button
variant="ghost"
@click="deleteEvaluator(evaluator.evaluator)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-3" />
</template>
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div
v-if="evaluators.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="evaluators.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
<Dialog
v-model="showForm"
:options="{
size: 'xl',
title: __('Add Evaluator'),
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addEvaluator(close)
},
}]
}"
>
<template #body-content>
<div v-if="showForm" class="flex items-center">
<FormControl
v-model="email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
@keydown.enter="addEvaluator"
/>
</div>
</template>
</Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui' import {
Avatar,
Button,
call,
createListResource,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { Plus, X } from 'lucide-vue-next' import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const show = defineModel('show') const show = defineModel('show')
@@ -95,33 +146,39 @@ const props = defineProps({
}, },
}) })
const evaluators = createResource({ const evaluators = createListResource({
url: 'frappe.client.get_list', doctype: 'Course Evaluator',
makeParams: () => { fields: ['evaluator', 'username', 'full_name', 'user_image'],
return {
doctype: 'Course Evaluator',
fields: ['evaluator', 'full_name', 'user_image', 'username'],
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
}
},
auto: true, auto: true,
orderBy: 'creation desc',
}) })
const addEvaluator = () => { const addEvaluator = (close: () => void) => {
call('lms.lms.api.add_an_evaluator', { call('lms.lms.api.add_an_evaluator', {
email: email.value, email: email.value,
}).then((data) => {
showForm.value = false
email.value = ''
evaluators.reload()
}) })
.then(() => {
email.value = ''
evaluators.reload()
toast.success(__('Evaluator added successfully'))
close()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error adding evaluator:', error)
})
} }
watch(search, () => { watch(search, () => {
evaluators.update({
filters: {
full_name: ['like', `%${search.value}%`],
},
})
evaluators.reload() evaluators.reload()
}) })
const openProfile = (username) => { const openProfile = (username: string) => {
show.value = false show.value = false
router.push({ router.push({
name: 'Profile', name: 'Profile',
@@ -130,4 +187,18 @@ const openProfile = (username) => {
}, },
}) })
} }
const deleteEvaluator = (evaluator: string) => {
call('lms.lms.api.delete_evaluator', {
evaluator: evaluator,
})
.then(() => {
toast.success(__('Evaluator deleted successfully'))
evaluators.reload()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error deleting evaluator:', error)
})
}
</script> </script>

View File

@@ -5,53 +5,37 @@
<div class="text-xl font-semibold mb-1 text-ink-gray-9"> <div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }} {{ __(label) }}
</div> </div>
<!-- <div class="text-xs text-ink-gray-5"> <div class="text-ink-gray-6 leading-5">
{{ __(description) }} {{ __(description) }}
</div> --> </div>
</div> </div>
<div class="flex item-center space-x-2"> <div class="flex item-center space-x-2">
<FormControl <Button variant="solid" @click="() => (showForm = !showForm)">
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<template #prefix> <template #prefix>
<Plus v-if="!showForm" class="size-4 stroke-1.5" /> <Plus class="size-4 stroke-1.5" />
<X v-else class="size-4 stroke-1.5" />
</template> </template>
{{ showForm ? __('Close') : __('New') }} {{ __('New') }}
</Button> </Button>
</div> </div>
</div> </div>
<!-- Form to add new member --> <div class="mt-8 pb-10">
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<FormControl <FormControl
v-model="member.email" v-model="search"
:placeholder="__('Email')" :placeholder="__('Search')"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:placeholder="__('First Name')"
type="text" type="text"
class="w-full" :debounce="300"
/> class="w-1/4 mb-4"
<Button @click="addMember()" variant="subtle"> >
{{ __('Add') }} <template #prefix>
</Button> <Search class="size-4 stroke-1.5 text-ink-gray-5" />
</div> </template>
</FormControl>
<div class="mt-2 pb-10 overflow-auto"> <div class="overflow-y-scroll h-[60vh]">
<!-- Member list -->
<div class="overflow-y-scroll">
<ul class="divide-y"> <ul class="divide-y">
<li <li
v-for="member in memberList" v-for="member in memberList"
class="grid grid-cols-3 gap-10 py-2 cursor-pointer" class="flex items-center justify-between py-2 cursor-pointer"
> >
<div <div
@click="openProfile(member.username)" @click="openProfile(member.username)"
@@ -60,27 +44,13 @@
<Avatar <Avatar
:image="member.user_image" :image="member.user_image"
:label="member.full_name" :label="member.full_name"
size="lg" size="xl"
/> />
<div class="space-y-1"> <div class="space-y-1">
<div class="flex"> <div class="flex">
<div class="text-ink-gray-9"> <div class="text-ink-gray-9">
{{ member.full_name }} {{ member.full_name }}
</div> </div>
<div
class="px-1"
v-if="member.role && getRole(member.role) !== 'Student'"
>
<Badge
:variant="'subtle'"
:ref_for="true"
theme="blue"
size="sm"
label="Badge"
>
{{ getRole(member.role) }}
</Badge>
</div>
</div> </div>
<div class="text-sm text-ink-gray-7"> <div class="text-sm text-ink-gray-7">
{{ member.name }} {{ member.name }}
@@ -88,43 +58,92 @@
</div> </div>
</div> </div>
<div <div
class="flex items-center justify-center text-ink-gray-7 text-sm" class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
v-if="member.role && member.role !== 'LMS Student'"
> >
<div v-if="member.last_active"> <Shield class="size-4 stroke-1.5" />
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }} <span class="text-sm">
</div> {{ getRole(member.role) }}
<div v-else>-</div> </span>
</div> </div>
</li> </li>
</ul> </ul>
</div> <div
<div v-if="memberList.length && hasNextPage"
v-if="memberList.length && hasNextPage" class="flex justify-center mt-4"
class="flex justify-center mt-4" >
> <Button @click="members.reload()">
<Button @click="members.reload()"> <template #prefix>
<template #prefix> <RefreshCw class="h-3 w-3 stroke-1.5" />
<RefreshCw class="h-3 w-3 stroke-1.5" /> </template>
</template> {{ __('Load More') }}
{{ __('Load More') }} </Button>
</Button> </div>
</div> </div>
</div> </div>
</div> </div>
<Dialog
v-model="showForm"
:options="{
title: __('Add a new member'),
size: 'lg',
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addMember(close)
}
}]
}"
>
<template #body-content>
<div class="flex items-center space-x-2">
<FormControl
v-model="member.email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:label="__('First Name')"
placeholder="Jane"
type="text"
class="w-full"
/>
</div>
</template>
</Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui' import {
Avatar,
Badge,
Button,
createResource,
Dialog,
FormControl,
} from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue' import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next' import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types' import type { User } from '@/components/Settings/types'
type Member = {
username: string
full_name: string
name: string
role?: string
user_image?: string
}
const router = useRouter() const router = useRouter()
const show = defineModel('show') const show = defineModel('show')
const search = ref('') const search = ref('')
const start = ref(0) const start = ref(0)
const memberList = ref([]) const memberList = ref<Member[]>([])
const hasNextPage = ref(false) const hasNextPage = ref(false)
const showForm = ref(false) const showForm = ref(false)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -158,7 +177,7 @@ const members = createResource({
start: start.value, start: start.value,
} }
}, },
onSuccess(data) { onSuccess(data: Member[]) {
memberList.value = memberList.value.concat(data) memberList.value = memberList.value.concat(data)
start.value = start.value + 20 start.value = start.value + 20
hasNextPage.value = data.length === 20 hasNextPage.value = data.length === 20
@@ -166,7 +185,7 @@ const members = createResource({
auto: true, auto: true,
}) })
const openProfile = (username) => { const openProfile = (username: string) => {
show.value = false show.value = false
router.push({ router.push({
name: 'Profile', name: 'Profile',
@@ -178,7 +197,7 @@ const openProfile = (username) => {
const newMember = createResource({ const newMember = createResource({
url: 'frappe.client.insert', url: 'frappe.client.insert',
makeParams(values) { makeParams() {
return { return {
doc: { doc: {
doctype: 'User', doctype: 'User',
@@ -188,13 +207,12 @@ const newMember = createResource({
} }
}, },
auto: false, auto: false,
onSuccess(data) { onSuccess(data: Member) {
show.value = false show.value = false
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students') if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
router.push({ router.push({
name: 'Profile', name: 'ProfileRoles',
params: { params: {
username: data.username, username: data.username,
}, },
@@ -202,8 +220,9 @@ const newMember = createResource({
}, },
}) })
const addMember = () => { const addMember = (close: () => void) => {
newMember.reload() newMember.reload()
close()
} }
watch(search, () => { watch(search, () => {
@@ -212,8 +231,8 @@ watch(search, () => {
members.reload() members.reload()
}) })
const getRole = (role) => { const getRole = (role: string) => {
const map = { const map: Record<string, string> = {
'LMS Student': 'Student', 'LMS Student': 'Student',
'Course Creator': 'Instructor', 'Course Creator': 'Instructor',
Moderator: 'Moderator', Moderator: 'Moderator',

View File

@@ -242,13 +242,16 @@ const tabsStructure = computed(() => {
items: [ items: [
{ {
label: 'Members', label: 'Members',
description: 'Manage the members of your learning system', description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus', icon: 'UserRoundPlus',
}, },
{ {
label: 'Evaluators', label: 'Evaluators',
description: 'Manage the evaluators of your learning system', description: '',
icon: 'UserCheck', icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
}, },
{ {
label: 'Categories', label: 'Categories',

View File

@@ -80,7 +80,7 @@
v-model="activeTab" v-model="activeTab"
/> />
</div> </div>
<router-view :profile="profile" /> <router-view :profile="profile" :key="profile.data?.name" />
</div> </div>
</div> </div>
<EditProfile <EditProfile

View File

@@ -45,7 +45,7 @@
</template> </template>
<script setup> <script setup>
import { FormControl, createResource, toast } from 'frappe-ui' import { FormControl, createResource, toast } from 'frappe-ui'
import { ref } from 'vue' import { ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils' import { convertToTitleCase } from '@/utils'
import { CircleAlert } from 'lucide-vue-next' import { CircleAlert } from 'lucide-vue-next'
@@ -66,10 +66,9 @@ const roles = createResource({
url: 'lms.lms.utils.get_roles', url: 'lms.lms.utils.get_roles',
makeParams(values) { makeParams(values) {
return { return {
name: props.profile.data?.name, name: values.member,
} }
}, },
auto: true,
onSuccess(data) { onSuccess(data) {
let roles = [ let roles = [
'moderator', 'moderator',
@@ -83,6 +82,16 @@ const roles = createResource({
}, },
}) })
watch(
() => props.profile,
(newValue) => {
roles.reload({
member: newValue.data?.name,
})
},
{ immediate: true }
)
const updateRole = createResource({ const updateRole = createResource({
url: 'lms.lms.api.save_role', url: 'lms.lms.api.save_role',
makeParams(values) { makeParams(values) {
@@ -97,7 +106,10 @@ const updateRole = createResource({
const changeRole = (role) => { const changeRole = (role) => {
updateRole.submit( updateRole.submit(
{ {
role: convertToTitleCase(role.split('_').join(' ')), role:
role == 'lms_student'
? 'LMS Student'
: convertToTitleCase(role.split('_').join(' ')),
value: eval(role).value, value: eval(role).value,
}, },
{ {

View File

@@ -696,15 +696,6 @@ def get_categories(doctype, filters):
@frappe.whitelist() @frappe.whitelist()
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index.
Args: start (int): Start index for the query.
<<<<<<< HEAD
search (str): Search term to filter the results.
=======
search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members.
"""
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {} or_filters = {}
@@ -723,7 +714,14 @@ def get_members(start=0, search=""):
) )
for member in members: for member in members:
roles = frappe.get_roles(member.name) roles = frappe.get_all(
"Has Role",
{
"parent": member.name,
"parenttype": "User",
},
pluck="role",
)
if "Moderator" in roles: if "Moderator" in roles:
member.role = "Moderator" member.role = "Moderator"
elif "Course Creator" in roles: elif "Course Creator" in roles:
@@ -1394,6 +1392,7 @@ def save_role(user, role, value):
@frappe.whitelist() @frappe.whitelist()
def add_an_evaluator(email): def add_an_evaluator(email):
frappe.only_for("Moderator")
if not frappe.db.exists("User", email): if not frappe.db.exists("User", email):
user = frappe.new_doc("User") user = frappe.new_doc("User")
user.update( user.update(
@@ -1413,6 +1412,16 @@ def add_an_evaluator(email):
return evaluator return evaluator
@frappe.whitelist()
def delete_evaluator(evaluator):
frappe.only_for("Moderator")
if not frappe.db.exists("Course Evaluator", evaluator):
frappe.throw(_("Evaluator does not exist."))
frappe.db.delete("Has Role", {"parent": evaluator, "role": "Batch Evaluator"})
frappe.db.delete("Course Evaluator", evaluator)
@frappe.whitelist() @frappe.whitelist()
def capture_user_persona(responses): def capture_user_persona(responses):
frappe.only_for("System Manager") frappe.only_for("System Manager")
@@ -1574,7 +1583,7 @@ def track_video_watch_duration(lesson, videos):
existing_record = frappe.db.get_value( existing_record = frappe.db.get_value(
"LMS Video Watch Duration", filters, ["name", "watch_time"], as_dict=True "LMS Video Watch Duration", filters, ["name", "watch_time"], as_dict=True
) )
if existing_record and existing_record.watch_time < video.get("watch_time"): if existing_record and flt(existing_record.watch_time) < flt(video.get("watch_time")):
frappe.db.set_value( frappe.db.set_value(
"LMS Video Watch Duration", "LMS Video Watch Duration",
filters, filters,

View File

@@ -58,8 +58,7 @@
"fetch_from": "evaluator.full_name", "fetch_from": "evaluator.full_name",
"fieldname": "full_name", "fieldname": "full_name",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Full Name", "label": "Full Name"
"read_only": 1
}, },
{ {
"fieldname": "column_break_casg", "fieldname": "column_break_casg",
@@ -73,21 +72,19 @@
"fetch_from": "evaluator.user_image", "fetch_from": "evaluator.user_image",
"fieldname": "user_image", "fieldname": "user_image",
"fieldtype": "Attach Image", "fieldtype": "Attach Image",
"label": "User Image", "label": "User Image"
"read_only": 1
}, },
{ {
"fetch_from": "evaluator.username", "fetch_from": "evaluator.username",
"fieldname": "username", "fieldname": "username",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Username", "label": "Username"
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-06-05 11:04:32.475711", "modified": "2025-07-04 12:04:11.007945",
"modified_by": "sayali@frappe.io", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Evaluator", "name": "Course Evaluator",
"naming_rule": "By fieldname", "naming_rule": "By fieldname",

View File

@@ -1,6 +1,7 @@
{ {
"attach_print": 0, "attach_print": 0,
"channel": "Email", "channel": "Email",
"condition": "doc.status == \"Upcoming\"",
"creation": "2022-06-03 11:51:02.681803", "creation": "2022-06-03 11:51:02.681803",
"date_changed": "date", "date_changed": "date",
"days_in_advance": 1, "days_in_advance": 1,
@@ -13,7 +14,8 @@
"is_standard": 1, "is_standard": 1,
"message": "<p> {{ _(\"Hey {0}\").format(doc.member_name) }} </p>\n<br>\n<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(doc.course_title, frappe.utils.format_date(doc.date, \"medium\"), frappe.utils.format_time(doc.start_time, \"short\"), doc.timezone) }}</p>\n<br>\n<p> {{ _(\"{0} is your evaluator\").format(doc.evaluator_name) }} </p>\n<br>\n<p> {{ _(\"Please prepare well and be on time for the evaluations.\") }} </p>\n", "message": "<p> {{ _(\"Hey {0}\").format(doc.member_name) }} </p>\n<br>\n<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(doc.course_title, frappe.utils.format_date(doc.date, \"medium\"), frappe.utils.format_time(doc.start_time, \"short\"), doc.timezone) }}</p>\n<br>\n<p> {{ _(\"{0} is your evaluator\").format(doc.evaluator_name) }} </p>\n<br>\n<p> {{ _(\"Please prepare well and be on time for the evaluations.\") }} </p>\n",
"message_type": "HTML", "message_type": "HTML",
"modified": "2024-09-05 16:33:42.212842", "minutes_offset": 0,
"modified": "2025-07-04 10:47:58.448814",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Certificate Request Reminder", "name": "Certificate Request Reminder",
@@ -22,6 +24,12 @@
{ {
"receiver_by_document_field": "member" "receiver_by_document_field": "member"
}, },
{
"receiver_by_document_field": "member"
},
{
"receiver_by_document_field": "evaluator"
},
{ {
"receiver_by_document_field": "evaluator" "receiver_by_document_field": "evaluator"
} }
@@ -29,4 +37,4 @@
"send_system_notification": 0, "send_system_notification": 0,
"send_to_all_assignees": 0, "send_to_all_assignees": 0,
"subject": "Reminder for Certificate Evaluation" "subject": "Reminder for Certificate Evaluation"
} }

View File

@@ -537,11 +537,10 @@ def has_course_evaluator_role(member=None):
def has_student_role(member=None): def has_student_role(member=None):
roles = frappe.get_roles(member or frappe.session.user) return frappe.db.get_value(
return ( "Has Role",
"Moderator" not in roles {"parent": member or frappe.session.user, "role": "LMS Student"},
and "Course Creator" not in roles "name",
and "Batch Evaluator" not in roles
) )