Merge pull request #1953 from pateljannat/open-to-hiring

feat: open to hiring
This commit is contained in:
Jannat Patel
2025-12-30 10:46:57 +05:30
committed by GitHub
14 changed files with 406 additions and 201 deletions

View File

@@ -80,6 +80,7 @@ onMounted(() => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
destructureSidebarLinks()
filterLinksToShow(data) filterLinksToShow(data)
addOtherLinks() addOtherLinks()
}, },
@@ -103,6 +104,16 @@ watch(showMenu, (val) => {
} }
}) })
const destructureSidebarLinks = () => {
let links = []
sidebarLinks.value.forEach((link) => {
link.items?.forEach((item) => {
links.push(item)
})
})
sidebarLinks.value = links
}
const filterLinksToShow = (data) => { const filterLinksToShow = (data) => {
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) { if (!parseInt(data[key])) {

View File

@@ -1,72 +1,70 @@
<template> <template>
<Dialog <Dialog
:options="{ :options="{
title: 'Edit your profile',
size: '3xl', size: '3xl',
}" }"
> >
<template #body-content> <template #body-header>
<div> <div class="flex items-center mb-5">
<div class="grid grid-cols-2 gap-10"> <div class="text-2xl font-semibold leading-6 text-ink-gray-9">
<div> {{ __('Edit Profile') }}
<div class="text-xs text-ink-gray-5 mb-1">
{{ __('Profile Image') }}
</div>
<FileUploader
v-if="!profile.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? `Uploading ${progress}%`
: 'Upload a profile image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="profile.image?.file_url"
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
/>
<div class="text-base flex flex-col ml-2">
<span>
{{ profile.image?.file_name }}
</span>
<span class="text-sm text-ink-gray-4 mt-1">
{{ getFileSize(profile.image?.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<Switch
v-model="profile.looking_for_job"
:label="__('Open to Opportunities')"
:description="
__('Show recruiters and others that you are open to work.')
"
class="!px-0"
/>
</div> </div>
<Badge v-if="isDirty" class="ml-4" theme="orange">
{{ __('Not Saved') }}
</Badge>
</div>
</template>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-2 gap-10"> <div class="grid grid-cols-2 gap-10">
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-4"> <div class="space-y-4">
<div>
<div class="text-xs text-ink-gray-5 mb-1">
{{ __('Profile Image') }}
</div>
<FileUploader
v-if="!profile.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? `Uploading ${progress}%`
: 'Upload a profile image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="profile.image?.file_url"
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
/>
<div class="text-base flex flex-col ml-2">
<span>
{{ profile.image?.file_name }}
</span>
<span class="text-sm text-ink-gray-4 mt-1">
{{ getFileSize(profile.image?.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<FormControl <FormControl
v-model="profile.first_name" v-model="profile.first_name"
:label="__('First Name')" :label="__('First Name')"
@@ -89,6 +87,13 @@
</div> </div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<FormControl
v-model="profile.open_to"
type="select"
:options="[' ', 'Opportunities', 'Hiring']"
:label="__('Open to')"
:placeholder="__('Looking for new work or hiring talent?')"
/>
<Link <Link
:label="__('Language')" :label="__('Language')"
v-model="profile.language" v-model="profile.language"
@@ -103,7 +108,7 @@
@change="(val) => (profile.bio = val)" @change="(val) => (profile.bio = val)"
:content="profile.bio" :content="profile.bio"
:rows="15" :rows="15"
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3" editorClass="prose-sm py-2 px-2 min-h-[280px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
/> />
</div> </div>
</div> </div>
@@ -121,12 +126,12 @@
</template> </template>
<script setup> <script setup>
import { import {
Badge,
Button, Button,
createResource, createResource,
Dialog, Dialog,
FormControl, FormControl,
FileUploader, FileUploader,
Switch,
TextEditor, TextEditor,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
@@ -137,6 +142,7 @@ import Link from '@/components/Controls/Link.vue'
const reloadProfile = defineModel('reloadProfile') const reloadProfile = defineModel('reloadProfile')
const hasLanguageChanged = ref(false) const hasLanguageChanged = ref(false)
const isDirty = ref(false)
const props = defineProps({ const props = defineProps({
profile: { profile: {
@@ -151,7 +157,7 @@ const profile = reactive({
headline: '', headline: '',
bio: '', bio: '',
image: '', image: '',
looking_for_job: false, open_to: '',
linkedin: '', linkedin: '',
github: '', github: '',
twitter: '', twitter: '',
@@ -222,6 +228,27 @@ const removeImage = () => {
profile.image = null profile.image = null
} }
watch(
() => profile,
(newVal) => {
if (!props.profile.data) return
let keys = Object.keys(newVal)
keys.splice(keys.indexOf('image'), 1)
for (let key of keys) {
if (newVal[key] !== props.profile.data[key]) {
isDirty.value = true
return
}
}
if (profile.image?.file_url !== props.profile.data.user_image) {
isDirty.value = true
return
}
isDirty.value = false
},
{ deep: true }
)
watch( watch(
() => props.profile.data, () => props.profile.data,
(newVal) => { (newVal) => {
@@ -231,11 +258,12 @@ watch(
profile.headline = newVal.headline profile.headline = newVal.headline
profile.language = newVal.language profile.language = newVal.language
profile.bio = newVal.bio profile.bio = newVal.bio
profile.looking_for_job = newVal.looking_for_job profile.open_to = newVal.open_to
profile.linkedin = newVal.linkedin profile.linkedin = newVal.linkedin
profile.github = newVal.github profile.github = newVal.github
profile.twitter = newVal.twitter profile.twitter = newVal.twitter
if (newVal.user_image) imageResource.submit({ image: newVal.user_image }) if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
isDirty.value = false
} }
} }
) )

View File

@@ -1,42 +1,50 @@
<template> <template>
<div class="border rounded-md w-1/3 mx-auto my-32"> <div class="bg-surface-white w-full h-full">
<div class="border-b px-5 py-3 font-medium text-ink-gray-9"> <div class="w-fit mx-auto mt-56 text-center p-4">
<span <div class="text-3xl font-semibold text-ink-gray-5 pb-4 mb-2 border-b">
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2" {{ __('Not Permitted') }}
></span>
{{ __('Not Permitted') }}
</div>
<div v-if="user.data" class="px-5 py-3">
<div class="text-ink-gray-7">
{{ __('You do not have permission to access this page.') }}
</div> </div>
<router-link <div v-if="user.data" class="px-5 py-3">
:to="{ <div class="text-ink-gray-5">
name: 'Courses', {{ __('You do not have permission to access this page.') }}
}" </div>
> <router-link
<Button variant="solid" class="mt-2"> :to="{
{{ __('Checkout Courses') }} name: 'Courses',
}"
>
<Button variant="solid" class="mt-2 w-full">
{{ __('Checkout Courses') }}
</Button>
</router-link>
</div>
<div class="px-5 py-3">
<div class="text-ink-gray-5">
{{ __('You are not permitted to access this page.') }}
</div>
<Button @click="redirectToLogin()" class="mt-4 w-full" variant="solid">
{{ __('Login') }}
</Button> </Button>
</router-link>
</div>
<div class="px-5 py-3">
<div class="text-ink-gray-7">
{{ __('Please login to access this page.') }}
</div> </div>
<Button @click="redirectToLogin()" class="mt-4">
{{ __('Login') }}
</Button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue' import { inject } from 'vue'
import { Button } from 'frappe-ui' import { Button, usePageMeta } from 'frappe-ui'
import { sessionStore } from '../stores/session'
const user = inject('$user') const user = inject('$user')
const { brand } = sessionStore()
const redirectToLogin = () => { const redirectToLogin = () => {
window.location.href = '/login' window.location.href = '/login'
} }
usePageMeta(() => {
return {
title: __('Not Permitted'),
icon: brand.favicon,
}
})
</script> </script>

View File

@@ -7,13 +7,20 @@
:size="size" :size="size"
v-bind="$attrs" v-bind="$attrs"
> >
<template v-if="user.looking_for_job" #indicator> <template v-if="user.open_to === 'Opportunities'" #indicator>
<Tooltip :text="__('Open to Opportunities')" placement="right"> <Tooltip :text="__('Open to Opportunities')" placement="right">
<div class="rounded-full bg-surface-green-3 w-fit"> <div class="rounded-full bg-surface-green-3 w-fit">
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" /> <BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
</div> </div>
</Tooltip> </Tooltip>
</template> </template>
<template v-else-if="user.open_to === 'Hiring'" #indicator>
<Tooltip :text="__('Hiring')" placement="right">
<div class="rounded-full bg-purple-500 w-fit">
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
</div>
</Tooltip>
</template>
</Avatar> </Avatar>
</template> </template>
<script setup> <script setup>

View File

@@ -13,27 +13,45 @@
</router-link> </router-link>
</header> </header>
<div class="mx-auto w-full max-w-4xl pt-6 pb-10"> <div class="mx-auto w-full max-w-4xl pt-6 pb-10">
<div class="flex flex-col md:flex-row justify-between mb-4 px-3"> <div class="flex flex-col md:flex-row justify-between mb-8 px-3">
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"> <div class="text-xl font-semibold text-ink-gray-9 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }} {{ memberCount }} {{ __('certified members') }}
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div
<FormControl class="flex flex-col md:flex-row md:items-center space-y-4 md:space-y-0 md:space-x-4"
v-model="nameFilter" >
:placeholder="__('Search by Name')" <div class="flex items-center space-x-4">
type="text" <FormControl
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40" v-model="nameFilter"
@input="updateParticipants()" :placeholder="__('Search by Name')"
/> type="text"
<div class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
v-if="categories.data?.length" @input="updateParticipants()"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40" />
> <div
<Select v-if="categories.data?.length"
v-model="currentCategory" class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:options="categories.data" >
:placeholder="__('Category')" <Select
@update:modelValue="updateParticipants()" v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
@update:modelValue="updateParticipants()"
/>
</div>
</div>
<div class="flex items-center space-x-4">
<FormControl
v-model="openToOpportunities"
:label="__('Open to Opportunities')"
type="checkbox"
@change="updateParticipants()"
/>
<FormControl
v-model="hiring"
:label="__('Hiring')"
type="checkbox"
@change="updateParticipants()"
/> />
</div> </div>
</div> </div>
@@ -42,15 +60,15 @@
<template v-for="(participant, index) in participants.data"> <template v-for="(participant, index) in participants.data">
<router-link <router-link
:to="{ :to="{
name: 'ProfileCertificates', name: 'ProfileAbout',
params: { params: {
username: participant.username, username: participant.username,
}, },
}" }"
class="flex h-15 rounded-md hover:bg-surface-gray-2 px-3" class="flex rounded-md hover:bg-surface-gray-2 px-3"
> >
<div <div
class="flex items-center w-full space-x-3 py-2" class="flex w-full space-x-3 py-2"
:class="{ :class="{
'border-b': index < participants.data.length - 1, 'border-b': index < participants.data.length - 1,
}" }"
@@ -118,9 +136,11 @@ import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
const currentCategory = ref('')
const filters = ref({}) const filters = ref({})
const currentCategory = ref('')
const nameFilter = ref('') const nameFilter = ref('')
const openToOpportunities = ref(false)
const hiring = ref(false)
const { brand } = sessionStore() const { brand } = sessionStore()
const memberCount = ref(0) const memberCount = ref(0)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -152,7 +172,7 @@ const categories = createListResource({
cache: ['certification_categories'], cache: ['certification_categories'],
auto: true, auto: true,
transform(data) { transform(data) {
data.unshift({ label: __(''), value: '' }) data.unshift({ label: __(' '), value: ' ' })
return data return data
}, },
}) })
@@ -169,16 +189,19 @@ const updateParticipants = () => {
} }
const updateFilters = () => { const updateFilters = () => {
if (currentCategory.value) { filters.value = {
filters.value.category = currentCategory.value ...(currentCategory.value && {
} else { category: currentCategory.value,
delete filters.value.category }),
} ...(nameFilter.value && {
member_name: ['like', `%${nameFilter.value}%`],
if (nameFilter.value) { }),
filters.value.member_name = ['like', `%${nameFilter.value}%`] ...(openToOpportunities.value && {
} else { open_to_opportunities: true,
delete filters.value.member_name }),
...(hiring.value && {
hiring: true,
}),
} }
} }
@@ -187,10 +210,12 @@ const setQueryParams = () => {
let filterKeys = { let filterKeys = {
category: currentCategory.value, category: currentCategory.value,
name: nameFilter.value, name: nameFilter.value,
'open-to-opportunities': openToOpportunities.value,
hiring: hiring.value,
} }
Object.keys(filterKeys).forEach((key) => { Object.keys(filterKeys).forEach((key) => {
if (filterKeys[key]) { if (filterKeys[key] && hasValue(filterKeys[key])) {
queries.set(key, filterKeys[key]) queries.set(key, filterKeys[key])
} else { } else {
queries.delete(key) queries.delete(key)
@@ -203,10 +228,19 @@ const setQueryParams = () => {
) )
} }
const hasValue = (value) => {
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
const setFiltersFromQuery = () => { const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
nameFilter.value = queries.get('name') || '' nameFilter.value = queries.get('name') || ''
currentCategory.value = queries.get('category') || '' currentCategory.value = queries.get('category') || ''
openToOpportunities.value = queries.get('open-to-opportunities') === 'true'
hiring.value = queries.get('hiring') === 'true'
} }
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [

View File

@@ -26,56 +26,72 @@
</header> </header>
<div> <div>
<div <div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto mb-2 p-5"
> >
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"> <div class="flex items-center justify-between">
{{ __('{0} Open Jobs').format(jobCount) }} <div class="text-xl font-semibold text-ink-gray-9 md:mb-0">
</div> {{ __('{0} {1} Jobs').format(jobCount, activeTab) }}
</div>
<div class="flex items-center justify-between space-x-4">
<TabButtons <TabButtons
v-if="tabs.length > 1" v-if="tabs.length > 1"
v-model="activeTab" v-model="activeTab"
:buttons="tabs" :buttons="tabs"
class="lg:hidden"
@change="updateJobs" @change="updateJobs"
/> />
<FormControl </div>
type="text"
:placeholder="__('Search')" <div
v-model="searchQuery" class="flex flex-col md:flex-row md:items-center md:space-x-4 space-y-4 md:space-y-0"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40" >
@input="updateJobs" <TabButtons
> v-if="tabs.length > 1"
<template #prefix> v-model="activeTab"
<Search :buttons="tabs"
class="w-4 h-4 stroke-1.5 text-ink-gray-5" class="hidden lg:block"
name="search"
/>
</template>
</FormControl>
<Link
v-if="user.data"
doctype="Country"
v-model="country"
:placeholder="__('Country')"
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
/>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
:placeholder="__('Type')"
@change="updateJobs"
/>
<FormControl
v-model="workMode"
type="select"
:options="workModes"
class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
:placeholder="__('Work Mode')"
@change="updateJobs" @change="updateJobs"
/> />
<div class="grid grid-cols-2 gap-4">
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="w-full max-w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
<Link
v-if="user.data"
doctype="Country"
v-model="country"
:placeholder="__('Country')"
class="w-full"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="w-full"
:placeholder="__('Type')"
@change="updateJobs"
/>
<FormControl
v-model="workMode"
type="select"
:options="workModes"
class="w-full"
:placeholder="__('Work Mode')"
@change="updateJobs"
/>
</div>
</div> </div>
</div> </div>
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0"> <div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">

View File

@@ -56,15 +56,32 @@
:src="profile.data.user_image" :src="profile.data.user_image"
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover" class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
/> />
<div
v-else
class="flex items-center justify-center h-[100px] w-[100px] rounded-full border-4 border-white bg-surface-gray-2 text-3xl font-semibold text-ink-gray-7"
>
{{ profile.data.full_name.charAt(0).toUpperCase() }}
</div>
<Tooltip <Tooltip
v-if="profile.data.looking_for_job" v-if="profile.data.open_to"
:text="__('Open to Opportunities')" :text="
profile.data.open_to === 'Opportunities'
? __('Open to Opportunities')
: __('Hiring')
"
placement="right" placement="right"
> >
<div <div
class="absolute bottom-3 right-1 p-0.5 bg-surface-white rounded-full" class="absolute bottom-3 right-1 p-0.5 bg-surface-white rounded-full"
> >
<div class="rounded-full bg-surface-green-3 w-fit"> <div
class="rounded-full w-fit"
:class="
profile.data.open_to === 'Opportunities'
? 'bg-surface-green-3'
: 'bg-purple-500'
"
>
<BadgeCheckIcon class="text-ink-white size-5" /> <BadgeCheckIcon class="text-ink-white size-5" />
</div> </div>
</div> </div>

View File

@@ -238,8 +238,8 @@
"dt": "User", "dt": "User",
"fetch_from": null, "fetch_from": null,
"fetch_if_empty": 0, "fetch_if_empty": 0,
"fieldname": "looking_for_job", "fieldname": "open_to",
"fieldtype": "Check", "fieldtype": "Select",
"hidden": 0, "hidden": 0,
"hide_border": 0, "hide_border": 0,
"hide_days": 0, "hide_days": 0,
@@ -253,16 +253,16 @@
"insert_after": "verify_terms", "insert_after": "verify_terms",
"is_system_generated": 1, "is_system_generated": 1,
"is_virtual": 0, "is_virtual": 0,
"label": "Open to Opportunities", "label": "Open to",
"length": 0, "length": 0,
"link_filters": null, "link_filters": null,
"mandatory_depends_on": null, "mandatory_depends_on": null,
"modified": "2021-12-31 12:56:32.110405", "modified": "2025-12-24 12:56:32.110405",
"module": null, "module": null,
"name": "User-looking_for_job", "name": "User-open_to",
"no_copy": 0, "no_copy": 0,
"non_negative": 0, "non_negative": 0,
"options": null, "options": "\nOpportunities\nHiring",
"permlevel": 0, "permlevel": 0,
"precision": "", "precision": "",
"print_hide": 0, "print_hide": 0,

View File

@@ -136,7 +136,7 @@ def delete_custom_fields():
"medium", "medium",
"linkedin", "linkedin",
"profession", "profession",
"looking_for_job", "open_to",
"cover_image" "work_environment", "cover_image" "work_environment",
"dream_companies", "dream_companies",
"career_preference_column", "career_preference_column",

View File

@@ -281,10 +281,34 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_certified_participants(filters=None, start=0, page_length=100): def get_certified_participants(filters=None, start=0, page_length=100):
filters, or_filters, open_to_opportunities, hiring = update_certification_filters(filters)
participants = frappe.db.get_all(
"LMS Certificate",
filters=filters,
or_filters=or_filters,
fields=["member", "issue_date", "batch_name", "course", "name"],
group_by="member",
order_by="issue_date desc",
start=start,
page_length=page_length,
)
for participant in participants:
details = get_certified_participant_details(participant.member)
participant.update(details)
participants = filter_by_open_to_criteria(participants, open_to_opportunities, hiring)
return participants
def update_certification_filters(filters):
open_to_opportunities = False
hiring = False
or_filters = {} or_filters = {}
if not filters: if not filters:
filters = {} filters = {}
filters.update({"published": 1}) filters.update({"published": 1})
category = filters.get("category") category = filters.get("category")
@@ -293,27 +317,38 @@ def get_certified_participants(filters=None, start=0, page_length=100):
or_filters["course_title"] = ["like", f"%{category}%"] or_filters["course_title"] = ["like", f"%{category}%"]
or_filters["batch_title"] = ["like", f"%{category}%"] or_filters["batch_title"] = ["like", f"%{category}%"]
participants = frappe.db.get_all( if filters.get("open_to_opportunities"):
"LMS Certificate", del filters["open_to_opportunities"]
filters=filters, open_to_opportunities = True
or_filters=or_filters,
fields=["member", "issue_date"],
group_by="member",
order_by="issue_date desc",
start=start,
page_length=page_length,
)
for participant in participants: if filters.get("hiring"):
count = frappe.db.count("LMS Certificate", {"member": participant.member}) del filters["hiring"]
details = frappe.db.get_value( hiring = True
"User",
participant.member, return filters, or_filters, open_to_opportunities, hiring
["full_name", "user_image", "username", "country", "headline", "looking_for_job"],
as_dict=1,
) def get_certified_participant_details(member):
details["certificate_count"] = count count = frappe.db.count("LMS Certificate", {"member": member})
participant.update(details) details = frappe.db.get_value(
"User",
member,
["full_name", "user_image", "username", "country", "headline", "open_to"],
as_dict=1,
)
details["certificate_count"] = count
return details
def filter_by_open_to_criteria(participants, open_to_opportunities, hiring):
if not open_to_opportunities and not hiring:
return participants
if open_to_opportunities:
participants = [participant for participant in participants if participant.open_to == "Opportunities"]
if hiring:
participants = [participant for participant in participants if participant.open_to == "Hiring"]
return participants return participants
@@ -1635,7 +1670,7 @@ def get_profile_details(username):
"headline", "headline",
"language", "language",
"cover_image", "cover_image",
"looking_for_job", "open_to",
"linkedin", "linkedin",
"github", "github",
"twitter", "twitter",

View File

@@ -52,8 +52,14 @@ class TestCourseEvaluator(UnitTestCase):
return first_date return first_date
def calculated_last_date_of_schedule(self, first_date): def calculated_last_date_of_schedule(self, first_date):
last_date = add_days(first_date, 56) # 8 weeks course last_day = add_days(first_date, 56)
return last_date offset_monday = (0 - last_day.weekday() + 7) % 7 # 0 for Monday
offset_wednesday = (2 - last_day.weekday() + 7) % 7 # 2 for Wednesday
if offset_monday > offset_wednesday and offset_monday < 4:
last_day = add_days(last_day, offset_monday)
else:
last_day = add_days(last_day, offset_wednesday)
return last_day
def test_unavailability_dates(self): def test_unavailability_dates(self):
unavailable_from = getdate(self.evaluator.unavailable_from) unavailable_from = getdate(self.evaluator.unavailable_from)

View File

@@ -2,6 +2,7 @@ import frappe
from frappe.tests import UnitTestCase from frappe.tests import UnitTestCase
from frappe.utils import add_days, nowdate from frappe.utils import add_days, nowdate
from lms.lms.api import get_certified_participants
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template, is_certified from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template, is_certified
from .utils import ( from .utils import (
@@ -147,6 +148,7 @@ class TestUtils(UnitTestCase):
certificate.member = member certificate.member = member
certificate.issue_date = frappe.utils.nowdate() certificate.issue_date = frappe.utils.nowdate()
certificate.template = get_default_certificate_template() certificate.template = get_default_certificate_template()
certificate.published = 1
certificate.save() certificate.save()
return certificate return certificate
@@ -265,6 +267,36 @@ class TestUtils(UnitTestCase):
self.assertIsNone(is_certified(self.course.name)) self.assertIsNone(is_certified(self.course.name))
frappe.session.user = "Administrator" frappe.session.user = "Administrator"
def test_certified_participants_with_category(self):
filters = {"category": "Utility Course"}
certified_participants = get_certified_participants(filters=filters)
self.assertEqual(len(certified_participants), 1)
self.assertEqual(certified_participants[0].member, self.student1.email)
filters = {"category": "Nonexistent Category"}
certified_participants_no_match = get_certified_participants(filters=filters)
self.assertEqual(len(certified_participants_no_match), 0)
def test_certified_participants_with_open_to_opportunities(self):
filters = {"open_to_opportunities": 1}
certified_participants_open_to_oppo = get_certified_participants(filters=filters)
self.assertEqual(len(certified_participants_open_to_oppo), 0)
frappe.db.set_value("User", self.student1.email, "open_to", "Opportunities")
certified_participants_open_to_oppo = get_certified_participants(filters=filters)
self.assertEqual(len(certified_participants_open_to_oppo), 1)
frappe.db.set_value("User", self.student1.email, "open_to", "")
def test_certified_participants_with_open_to_hiring(self):
filters = {"hiring": 1}
certified_participants_hiring = get_certified_participants(filters=filters)
self.assertEqual(len(certified_participants_hiring), 0)
frappe.db.set_value("User", self.student1.email, "open_to", "Hiring")
certified_participants_hiring = get_certified_participants(filters=filters)
self.assertEqual(len(certified_participants_hiring), 1)
frappe.db.set_value("User", self.student1.email, "open_to", "")
def test_rating_validation(self): def test_rating_validation(self):
student3 = self.create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"]) student3 = self.create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
with self.assertRaises(frappe.exceptions.ValidationError): with self.assertRaises(frappe.exceptions.ValidationError):

View File

@@ -113,4 +113,5 @@ lms.patches.v2_0.enable_programming_exercises_in_sidebar
lms.patches.v2_0.count_in_program lms.patches.v2_0.count_in_program
lms.patches.v2_0.fix_scorm_lesson_reference_idx #02-09-2025 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.certified_members_to_certifications #05-10-2025
lms.patches.v2_0.fix_job_application_resume_urls lms.patches.v2_0.fix_job_application_resume_urls
lms.patches.v2_0.open_to_opportunities

View File

@@ -0,0 +1,10 @@
import frappe
def execute():
looking_for_job = frappe.get_all("User", {"looking_for_job": 1}, ["name"])
for user in looking_for_job:
frappe.db.set_value("User", user.name, "open_to", "Opportunities")
frappe.db.delete("Custom Field", {"dt": "User", "fieldname": "looking_for_job"})