feat: google calendar integration
This commit is contained in:
@@ -1,2 +1,109 @@
|
||||
<template></template>
|
||||
<script setup></script>
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ isOpen, togglePopover }" class="flex w-full">
|
||||
<slot v-bind="{ isOpen, togglePopover }"></slot>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="search by keyword"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
class="flex-1"
|
||||
/>
|
||||
<FileUploader
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div
|
||||
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
|
||||
>
|
||||
<button
|
||||
v-for="image in images.data"
|
||||
:key="image.id"
|
||||
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
|
||||
@click="$emit('select', image.urls.raw)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
image.urls.raw +
|
||||
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="images.data"
|
||||
class="mt-2 text-center text-sm text-gray-500"
|
||||
>
|
||||
{{ __('Image search powered by') }}
|
||||
<a class="underline" target="_blank" href="https://unsplash.com">
|
||||
{{ __('Unsplash') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Popover,
|
||||
TextInput,
|
||||
FileUploader,
|
||||
Button,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const search = ref(null)
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const images = createResource({
|
||||
url: 'lms.lms.api.get_unsplash_photos',
|
||||
makeParams: () => {
|
||||
return {
|
||||
keyword: search.value,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
debounce: 500,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => search.value,
|
||||
() => {
|
||||
images.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const saveImage = (file) => {
|
||||
emit('select', file.file_url)
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,63 +6,73 @@
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="relative">
|
||||
<div class="group relative h-[130px] w-full">
|
||||
<img
|
||||
v-if="profile.data.cover_image"
|
||||
:src="profile.data.cover_image"
|
||||
class="h-[130px] w-full"
|
||||
class="h-[130px] w-full object-cover object-center"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="{ 'bg-gray-50': !profile.data.cover_image }"
|
||||
:class="{ 'bg-gray-100': !profile.data.cover_image }"
|
||||
class="h-[130px] w-full"
|
||||
></div>
|
||||
<div class="absolute top-0 right-0" v-if="isSessionUser()">
|
||||
<Button variant="outline">
|
||||
<template #icon>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
<div
|
||||
class="absolute bottom-0 left-1/2 mb-4 flex -translate-x-1/2 space-x-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
|
||||
v-if="isSessionUser()"
|
||||
>
|
||||
<EditCoverImage
|
||||
@select="(imageUrl) => coverImage.submit({ url: imageUrl })"
|
||||
>
|
||||
<template v-slot="{ togglePopover }">
|
||||
<Button variant="outline" @click="togglePopover()">
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Button>
|
||||
</EditCoverImage>
|
||||
</div>
|
||||
<div class="mx-auto -mt-4 max-w-4xl translate-x-0 sm:px-5">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<img
|
||||
v-if="profile.data.user_image"
|
||||
:src="profile.data.user_image"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:user="profile.data"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-6">
|
||||
<h2 class="mt-2 text-3xl font-semibold text-gray-900">
|
||||
{{ profile.data.full_name }}
|
||||
</h2>
|
||||
<div class="mt-2 text-base text-gray-700">
|
||||
{{ profile.data.headline }}
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="isSessionUser()" class="ml-auto" @click="editProfile()">
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
{{ __('Edit Profile') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-6">
|
||||
<TabButtons
|
||||
class="inline-block"
|
||||
:buttons="getTabButtons()"
|
||||
v-model="activeTab"
|
||||
</div>
|
||||
<div class="mx-auto -mt-4 max-w-4xl translate-x-0 sm:px-5">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<img
|
||||
v-if="profile.data.user_image"
|
||||
:src="profile.data.user_image"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:user="profile.data"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<router-view :profile="profile" />
|
||||
<div class="ml-6">
|
||||
<h2 class="mt-2 text-3xl font-semibold text-gray-900">
|
||||
{{ profile.data.full_name }}
|
||||
</h2>
|
||||
<div class="mt-2 text-base text-gray-700">
|
||||
{{ profile.data.headline }}
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="isSessionUser()" class="ml-auto" @click="editProfile()">
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
{{ __('Edit Profile') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-6">
|
||||
<TabButtons
|
||||
class="inline-block"
|
||||
:buttons="getTabButtons()"
|
||||
v-model="activeTab"
|
||||
/>
|
||||
</div>
|
||||
<router-view :profile="profile" />
|
||||
</div>
|
||||
</div>
|
||||
<EditProfile
|
||||
@@ -70,7 +80,6 @@
|
||||
v-model:reloadProfile="profile"
|
||||
:profile="profile"
|
||||
/>
|
||||
<EditCoverImage />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createResource, Button, TabButtons } from 'frappe-ui'
|
||||
@@ -114,6 +123,21 @@ const profile = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const coverImage = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'User',
|
||||
name: profile.data?.name,
|
||||
fieldname: 'cover_image',
|
||||
value: values.url,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
profile.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const setActiveTab = () => {
|
||||
let fragments = route.path.split('/')
|
||||
let sections = ['certificates', 'roles', 'evaluations']
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{{ __('My availability') }}
|
||||
</h2>
|
||||
|
||||
<div class="w-3/4">
|
||||
<div class="">
|
||||
<div class="grid grid-cols-4 gap-4 text-sm text-gray-700 mb-4">
|
||||
<div>
|
||||
{{ __('Day') }}
|
||||
@@ -18,8 +18,8 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.data"
|
||||
v-for="slot in slots.data.schedule"
|
||||
v-if="evaluator.data"
|
||||
v-for="slot in evaluator.data.slots.schedule"
|
||||
class="grid grid-cols-4 gap-4 mb-4 group"
|
||||
>
|
||||
<FormControl
|
||||
@@ -70,7 +70,7 @@
|
||||
{{ __('Add Slot') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-10 w-3/4">
|
||||
<div class="my-10">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ __('I am unavailable') }}
|
||||
</h2>
|
||||
@@ -103,13 +103,28 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ __('My calendar') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
|
||||
class="flex items-center bg-green-100 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
|
||||
>
|
||||
<Check class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
{{ __('Your calendar is set.') }}
|
||||
</div>
|
||||
<Button @click="() => authorizeCalendar.submit()">
|
||||
{{ __('Authorize Google Calendar Access') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, FormControl, Button } from 'frappe-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { showToast, convertToTitleCase } from '@/utils'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Plus, X, Check } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
@@ -128,12 +143,16 @@ const newSlot = reactive({
|
||||
end_time: '',
|
||||
})
|
||||
|
||||
const slots = createResource({
|
||||
const evaluator = createResource({
|
||||
url: 'lms.lms.api.get_evaluator_details',
|
||||
params: {
|
||||
evaluator: props.profile.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
|
||||
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
|
||||
},
|
||||
})
|
||||
|
||||
const createSlot = createResource({
|
||||
@@ -142,7 +161,7 @@ const createSlot = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Evaluator Schedule',
|
||||
parent: slots.data?.name,
|
||||
parent: evaluator.data?.slots.name,
|
||||
parentfield: 'schedule',
|
||||
parenttype: 'Course Evaluator',
|
||||
...newSlot,
|
||||
@@ -151,7 +170,7 @@ const createSlot = createResource({
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Slot added successfully', 'check')
|
||||
slots.reload()
|
||||
evaluator.reload()
|
||||
showSlotsTemplate.value = 0
|
||||
newSlot.day = ''
|
||||
newSlot.start_time = ''
|
||||
@@ -190,7 +209,7 @@ const deleteSlot = createResource({
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Slot deleted successfully', 'check')
|
||||
slots.reload()
|
||||
evaluator.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
@@ -202,7 +221,7 @@ const updateUnavailability = createResource({
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Course Evaluator',
|
||||
name: slots.data?.name,
|
||||
name: evaluator.data?.slots.name,
|
||||
fieldname: values.field,
|
||||
value: values.value,
|
||||
}
|
||||
@@ -243,6 +262,19 @@ const deleteRow = (name) => {
|
||||
deleteSlot.submit({ name })
|
||||
}
|
||||
|
||||
const authorizeCalendar = createResource({
|
||||
url: 'frappe.integrations.doctype.google_calendar.google_calendar.authorize_access',
|
||||
makeParams() {
|
||||
return {
|
||||
g_calendar: evaluator.data?.calendar,
|
||||
reauthorize: 1,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
window.open(data.url)
|
||||
},
|
||||
})
|
||||
|
||||
const days = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -81,7 +81,7 @@ export function showToast(title, text, icon) {
|
||||
? 'bg-green-600 text-white rounded-md p-px'
|
||||
: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: icon == 'check' ? 'bottom-right' : 'top-center',
|
||||
timeout: icon == 'check' ? 5 : 10,
|
||||
timeout: 5,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -304,10 +304,24 @@ def get_unsplash_photos(keyword=None):
|
||||
def get_evaluator_details(evaluator):
|
||||
frappe.only_for("Batch Evaluator")
|
||||
|
||||
if not frappe.db.exists("Google Calendar", {"user": evaluator}):
|
||||
calendar = frappe.new_doc("Google Calendar")
|
||||
calendar.update({"user": evaluator, "calendar_name": evaluator})
|
||||
calendar.insert()
|
||||
else:
|
||||
calendar = frappe.db.get_value(
|
||||
"Google Calendar", {"user": evaluator}, ["name", "authorization_code"], as_dict=1
|
||||
)
|
||||
|
||||
if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}):
|
||||
return frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
|
||||
doc = frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
|
||||
else:
|
||||
doc = frappe.new_doc("Course Evaluator")
|
||||
doc.evaluator = evaluator
|
||||
doc.insert()
|
||||
return doc.as_dict()
|
||||
|
||||
return {
|
||||
"slots": doc.as_dict(),
|
||||
"calendar": calendar.name,
|
||||
"is_authorised": calendar.authorization_code,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"force_profile_completion",
|
||||
"is_onboarding_complete",
|
||||
"column_break_zdel",
|
||||
"unsplash_access_key",
|
||||
"livecode_url",
|
||||
"course_settings_section",
|
||||
"search_placeholder",
|
||||
@@ -96,6 +97,7 @@
|
||||
"default": "0",
|
||||
"fieldname": "force_profile_completion",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Force users to complete their Profile"
|
||||
},
|
||||
{
|
||||
@@ -348,12 +350,17 @@
|
||||
"fieldname": "show_day_view",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Day View in Timetable"
|
||||
},
|
||||
{
|
||||
"fieldname": "unsplash_access_key",
|
||||
"fieldtype": "Data",
|
||||
"label": "Unsplash Access Key"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2023-12-12 10:32:13.638368",
|
||||
"modified": "2024-04-16 12:18:14.670978",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Settings",
|
||||
|
||||
@@ -23,8 +23,9 @@ def get_random(params=None):
|
||||
|
||||
|
||||
def make_unsplash_request(path):
|
||||
if not "unsplash_access_key" in frappe.conf:
|
||||
frappe.throw("Please set unsplash_access_key in site_config.json")
|
||||
unsplash_access_key = frappe.db.get_single_value("LMS Settings", "unsplash_access_key")
|
||||
if not unsplash_access_key:
|
||||
return
|
||||
|
||||
import requests
|
||||
|
||||
@@ -34,7 +35,7 @@ def make_unsplash_request(path):
|
||||
url,
|
||||
headers={
|
||||
"Accept-Version": "v1",
|
||||
"Authorization": f"Client-ID {frappe.conf.unsplash_access_key}",
|
||||
"Authorization": f"Client-ID {unsplash_access_key}",
|
||||
},
|
||||
)
|
||||
res.raise_for_status()
|
||||
|
||||
Reference in New Issue
Block a user