From 3752d587b62f21091368737cf2c98bf6ad15fe22 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 14 Oct 2024 12:47:27 +0530 Subject: [PATCH 01/18] feat: file uploader --- crm/api/__init__.py | 18 + .../FilesUploader/FilesUploader.vue | 152 +++++++++ .../FilesUploader/FilesUploaderArea.vue | 321 ++++++++++++++++++ .../FilesUploader/filesUploaderHandler.ts | 129 +++++++ .../src/components/Icons/FileAudioIcon.vue | 20 ++ .../src/components/Icons/FileVideoIcon.vue | 19 ++ 6 files changed, 659 insertions(+) create mode 100644 frontend/src/components/FilesUploader/FilesUploader.vue create mode 100644 frontend/src/components/FilesUploader/FilesUploaderArea.vue create mode 100644 frontend/src/components/FilesUploader/filesUploaderHandler.ts create mode 100644 frontend/src/components/Icons/FileAudioIcon.vue create mode 100644 frontend/src/components/Icons/FileVideoIcon.vue diff --git a/crm/api/__init__.py b/crm/api/__init__.py index 141da446..58739de0 100644 --- a/crm/api/__init__.py +++ b/crm/api/__init__.py @@ -3,6 +3,7 @@ import frappe from frappe.translate import get_all_translations from frappe.utils import validate_email_address, split_emails, cstr from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD +from frappe.core.api.file import get_max_file_size @frappe.whitelist(allow_guest=True) @@ -107,3 +108,20 @@ def invite_by_email(emails: str, role: str): for email in to_invite: frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True) + + +@frappe.whitelist() +def get_file_uploader_defaults(doctype: str): + max_number_of_files = None + make_attachments_public = False + if doctype: + meta = frappe.get_meta(doctype) + max_number_of_files = meta.get("max_attachments") + make_attachments_public = meta.get("make_attachments_public") + + return { + 'allowed_file_types': frappe.get_system_settings("allowed_file_extensions"), + 'max_file_size': get_max_file_size(), + 'max_number_of_files': max_number_of_files, + 'make_attachments_public': bool(make_attachments_public), + } \ No newline at end of file diff --git a/frontend/src/components/FilesUploader/FilesUploader.vue b/frontend/src/components/FilesUploader/FilesUploader.vue new file mode 100644 index 00000000..df20d145 --- /dev/null +++ b/frontend/src/components/FilesUploader/FilesUploader.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/src/components/FilesUploader/FilesUploaderArea.vue b/frontend/src/components/FilesUploader/FilesUploaderArea.vue new file mode 100644 index 00000000..5d301254 --- /dev/null +++ b/frontend/src/components/FilesUploader/FilesUploaderArea.vue @@ -0,0 +1,321 @@ + + diff --git a/frontend/src/components/FilesUploader/filesUploaderHandler.ts b/frontend/src/components/FilesUploader/filesUploaderHandler.ts new file mode 100644 index 00000000..59253b9d --- /dev/null +++ b/frontend/src/components/FilesUploader/filesUploaderHandler.ts @@ -0,0 +1,129 @@ +interface UploadOptions { + file?: File + private?: boolean + fileUrl?: string + folder?: string + doctype?: string + docname?: string + type?: string +} + +type EventListenerOption = 'start' | 'progress' | 'finish' | 'error' + +declare global { + interface Window { + csrf_token?: string + } +} + +class FilesUploadHandler { + listeners: { [event: string]: Function[] } + failed: boolean + + constructor() { + this.listeners = {} + this.failed = false + } + + on(event: EventListenerOption, handler: Function) { + this.listeners[event] = this.listeners[event] || [] + this.listeners[event].push(handler) + } + + trigger(event: string, data?: any) { + let handlers = this.listeners[event] || [] + handlers.forEach((handler) => { + handler.call(this, data) + }) + } + + upload(file: File | null, options: UploadOptions): Promise { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest() + xhr.upload.addEventListener('loadstart', () => { + this.trigger('start') + }) + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + this.trigger('progress', { + uploaded: e.loaded, + total: e.total, + }) + } + }) + xhr.upload.addEventListener('load', () => { + this.trigger('finish') + }) + xhr.addEventListener('error', () => { + this.trigger('error') + reject() + }) + xhr.onreadystatechange = () => { + if (xhr.readyState == XMLHttpRequest.DONE) { + let error: any = null + if (xhr.status === 200) { + let r: any = null + try { + r = JSON.parse(xhr.responseText) + } catch (e) { + r = xhr.responseText + } + let out = r.message || r + resolve(out) + } else if (xhr.status === 403) { + error = JSON.parse(xhr.responseText) + } else if (xhr.status === 413) { + this.failed = true + error = 'Size exceeds the maximum allowed file size.' + } else { + this.failed = true + try { + error = JSON.parse(xhr.responseText) + } catch (e) { + // pass + } + } + if (error && error.exc) { + console.error(JSON.parse(error.exc)[0]) + } + reject(error) + } + } + + xhr.open('POST', '/api/method/upload_file', true) + xhr.setRequestHeader('Accept', 'application/json') + + if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') { + xhr.setRequestHeader('X-Frappe-CSRF-Token', window.csrf_token) + } + + let formData = new FormData() + + if (options.file && file) { + formData.append('file', options.file, file.name) + } + formData.append('is_private', options.private || false ? '1' : '0') + formData.append('folder', options.folder || 'Home') + + if (options.fileUrl) { + formData.append('file_url', options.fileUrl) + } + + if (options.doctype) { + formData.append('doctype', options.doctype) + } + + if (options.docname) { + formData.append('docname', options.docname) + } + + if (options.type) { + formData.append('type', options.type) + } + + xhr.send(formData) + }) + } +} + +export default FilesUploadHandler diff --git a/frontend/src/components/Icons/FileAudioIcon.vue b/frontend/src/components/Icons/FileAudioIcon.vue new file mode 100644 index 00000000..c93d8ca5 --- /dev/null +++ b/frontend/src/components/Icons/FileAudioIcon.vue @@ -0,0 +1,20 @@ + diff --git a/frontend/src/components/Icons/FileVideoIcon.vue b/frontend/src/components/Icons/FileVideoIcon.vue new file mode 100644 index 00000000..36b4fb2a --- /dev/null +++ b/frontend/src/components/Icons/FileVideoIcon.vue @@ -0,0 +1,19 @@ + From 8f6d74d8c4ffb19dc20b74be39d1e4a37aa68129 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 14 Oct 2024 12:48:09 +0530 Subject: [PATCH 02/18] fix: moved csrf_token into boot object --- crm/www/crm.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crm/www/crm.py b/crm/www/crm.py index 44347cf3..22b2ed58 100644 --- a/crm/www/crm.py +++ b/crm/www/crm.py @@ -9,11 +9,9 @@ no_cache = 1 def get_context(): - csrf_token = frappe.sessions.get_csrf_token() frappe.db.commit() context = frappe._dict() context.boot = get_boot() - context.boot.csrf_token = csrf_token if frappe.session.user != "Guest": capture("active_site", "crm") return context @@ -33,6 +31,7 @@ def get_boot(): "default_route": get_default_route(), "site_name": frappe.local.site, "read_only_mode": frappe.flags.read_only, + "csrf_token": frappe.sessions.get_csrf_token(), } ) From a9d7a04fadf43607afee86740bd297ac60d6604b Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 14 Oct 2024 12:49:23 +0530 Subject: [PATCH 03/18] fix: statuses loading issue --- frontend/src/stores/statuses.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/stores/statuses.js b/frontend/src/stores/statuses.js index d8f381a3..8697e5b2 100644 --- a/frontend/src/stores/statuses.js +++ b/frontend/src/stores/statuses.js @@ -105,15 +105,15 @@ export const statusesStore = defineStore('crm-statuses', () => { let options = [] for (const status in statusesByName) { options.push({ - label: statusesByName[status].name, - value: statusesByName[status].name, + label: statusesByName[status]?.name, + value: statusesByName[status]?.name, icon: () => h(IndicatorIcon, { - class: statusesByName[status].iconColorClass, + class: statusesByName[status]?.iconColorClass, }), onClick: () => { capture('status_changed', { doctype, status }) - action && action('status', statusesByName[status].name) + action && action('status', statusesByName[status]?.name) }, }) } From beba601e8f933bb5aa1647029421b4097791b6ce Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 14 Oct 2024 12:49:55 +0530 Subject: [PATCH 04/18] fix: added attach button in lead/deal page --- frontend/src/pages/Deal.vue | 14 ++++++++++++++ frontend/src/pages/Lead.vue | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/frontend/src/pages/Deal.vue b/frontend/src/pages/Deal.vue index 95caf0c4..1500ffe1 100644 --- a/frontend/src/pages/Deal.vue +++ b/frontend/src/pages/Deal.vue @@ -100,6 +100,11 @@ /> + + + @@ -299,6 +304,12 @@ doctype="CRM Deal" @reload="() => fieldsLayout.reload()" /> + diff --git a/frontend/src/components/FilesUploader/filesUploaderHandler.ts b/frontend/src/components/FilesUploader/filesUploaderHandler.ts index 59253b9d..91b30b66 100644 --- a/frontend/src/components/FilesUploader/filesUploaderHandler.ts +++ b/frontend/src/components/FilesUploader/filesUploaderHandler.ts @@ -99,7 +99,7 @@ class FilesUploadHandler { let formData = new FormData() - if (options.file && file) { + if (options.file && file?.name) { formData.append('file', options.file, file.name) } formData.append('is_private', options.private || false ? '1' : '0') From 7afdf9769728ff1e0894df7398917de8fba31215 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 14 Oct 2024 18:58:01 +0530 Subject: [PATCH 07/18] feat: file upload via camera --- .../FilesUploader/FilesUploader.vue | 42 +++++++++- .../FilesUploader/FilesUploaderArea.vue | 76 ++++++++++++++++--- .../FilesUploader/filesUploaderHandler.ts | 6 +- 3 files changed, 108 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/FilesUploader/FilesUploader.vue b/frontend/src/components/FilesUploader/FilesUploader.vue index 42164c88..8713688b 100644 --- a/frontend/src/components/FilesUploader/FilesUploader.vue +++ b/frontend/src/components/FilesUploader/FilesUploader.vue @@ -16,7 +16,7 @@ @@ -105,6 +136,9 @@ function removeAllFiles() { } const disableAttachButton = computed(() => { + if (filesUploaderArea.value?.showCamera) { + return !filesUploaderArea.value.cameraImage + } if (filesUploaderArea.value?.showWebLink) { return !filesUploaderArea.value.webLink } @@ -141,7 +175,7 @@ const fileUploadStarted = ref(false) function attachFile(file, i) { const args = { - file: file?.fileObj || {}, + fileObj: file.fileObj || {}, type: file.type, private: file.private, fileUrl: file.fileUrl, diff --git a/frontend/src/components/FilesUploader/FilesUploaderArea.vue b/frontend/src/components/FilesUploader/FilesUploaderArea.vue index 828d730d..cfb562b4 100644 --- a/frontend/src/components/FilesUploader/FilesUploaderArea.vue +++ b/frontend/src/components/FilesUploader/FilesUploaderArea.vue @@ -2,6 +2,15 @@
+
+ + +
{{ __('Link') }}
-
@@ -117,7 +126,7 @@ import FileTextIcon from '@/components/Icons/FileTextIcon.vue' import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue' import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue' -import { createToast } from '@/utils' +import { createToast, dateFormat } from '@/utils' import { FormControl, CircularProgressBar, createResource } from 'frappe-ui' import { ref, onMounted } from 'vue' @@ -138,13 +147,17 @@ const fileInput = ref(null) const isDragging = ref(false) const showWebLink = ref(false) const showFileBrowser = ref(false) +const showCamera = ref(false) const webLink = ref('') +const cameraImage = ref(null) const allowMultiple = ref(props.options.allowMultiple == false ? false : true) const disableFileBrowser = ref(props.options.disableFileBrowser || false) const allowWebLink = ref(props.options.allowWebLink == false ? false : true) -const allowTakePhoto = ref(props.options.allowTakePhoto || false) +const allowTakePhoto = ref( + props.options.allowTakePhoto || window.navigator.mediaDevices || false, +) const restrictions = ref(props.options.restrictions || {}) const makeAttachmentsPublic = ref(props.options.makeAttachmentsPublic || false) @@ -186,15 +199,56 @@ function onFileInput(event) { addFiles(fileInput.value.files) } +const video = ref(null) +const facingMode = ref('environment') + +async function startCamera() { + showCamera.value = true + + let stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: facingMode.value, + }, + audio: false, + }) + video.value.srcObject = stream +} + +const canvas = ref(null) + function captureImage() { - // + const width = video.value.videoWidth + const height = video.value.videoHeight + + canvas.value.width = width + canvas.value.height = height + + canvas.value.getContext('2d').drawImage(video.value, 0, 0, width, height) + + cameraImage.value = canvas.value.toDataURL('image/png') +} + +function uploadViaCamera() { + const nowDatetime = dateFormat(new Date(), 'YYYY_MM_DD_HH_mm_ss') + let filename = `capture_${nowDatetime}.png` + urlToFile(cameraImage.value, filename, 'image/png').then((file) => { + addFiles([file]) + showCamera.value = false + cameraImage.value = null + }) +} + +function urlToFile(url, filename, mime_type) { + return fetch(url) + .then((res) => res.arrayBuffer()) + .then((buffer) => new File([buffer], filename, { type: mime_type })) } function addFiles(fileArray) { let _files = Array.from(fileArray) .filter(checkRestrictions) .map((file, i) => { - let isImage = file.type.startsWith('image') + let isImage = file.type?.startsWith('image') let sizeKb = file.size / 1024 return { index: i, @@ -203,7 +257,7 @@ function addFiles(fileArray) { cropperFile: file, cropBoxData: null, type: file.type, - optimize: sizeKb > 200 && isImage && !file.type.includes('svg'), + optimize: sizeKb > 200 && isImage && !file.type?.includes('svg'), name: file.name, doc: null, progress: 0, @@ -314,13 +368,13 @@ function convertSize(size) { size /= 1024 unitIndex++ } - return `${size.toFixed(2)} ${units[unitIndex]}` + return `${size?.toFixed(2)} ${units[unitIndex]}` } function fileIcon(type) { - if (type.startsWith('audio')) { + if (type?.startsWith('audio')) { return FileAudioIcon - } else if (type.startsWith('video')) { + } else if (type?.startsWith('video')) { return FileVideoIcon } return FileTextIcon @@ -330,5 +384,9 @@ defineExpose({ showFileBrowser, showWebLink, webLink, + showCamera, + cameraImage, + captureImage, + uploadViaCamera, }) diff --git a/frontend/src/components/FilesUploader/filesUploaderHandler.ts b/frontend/src/components/FilesUploader/filesUploaderHandler.ts index 91b30b66..ae25f6aa 100644 --- a/frontend/src/components/FilesUploader/filesUploaderHandler.ts +++ b/frontend/src/components/FilesUploader/filesUploaderHandler.ts @@ -1,5 +1,5 @@ interface UploadOptions { - file?: File + fileObj?: File private?: boolean fileUrl?: string folder?: string @@ -99,8 +99,8 @@ class FilesUploadHandler { let formData = new FormData() - if (options.file && file?.name) { - formData.append('file', options.file, file.name) + if (options.fileObj && file?.name) { + formData.append('file', options.fileObj, file.name) } formData.append('is_private', options.private || false ? '1' : '0') formData.append('folder', options.folder || 'Home') From 5fa4c640e34a7f356483b1750c256701f13ecc21 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 14 Oct 2024 19:02:48 +0530 Subject: [PATCH 08/18] fix: switch camera --- frontend/src/components/FilesUploader/FilesUploader.vue | 7 +++++++ .../src/components/FilesUploader/FilesUploaderArea.vue | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/frontend/src/components/FilesUploader/FilesUploader.vue b/frontend/src/components/FilesUploader/FilesUploader.vue index 8713688b..d5b68f04 100644 --- a/frontend/src/components/FilesUploader/FilesUploader.vue +++ b/frontend/src/components/FilesUploader/FilesUploader.vue @@ -42,6 +42,13 @@ + @@ -55,7 +61,8 @@ import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue' import FileTextIcon from '@/components/Icons/FileTextIcon.vue' import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue' -import { Tooltip } from 'frappe-ui' +import { globalStore } from '@/stores/global' +import { call, Tooltip } from 'frappe-ui' import { dateFormat, timeAgo, @@ -63,22 +70,68 @@ import { convertSize, isImage, } from '@/utils' -import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue' const props = defineProps({ attachment: Object, }) +const emit = defineEmits(['reload']) + +const { $dialog } = globalStore() + function openFile() { window.open(props.attachment.file_url, '_blank') } -function togglePrivate() { - // FilesUploadHandler.togglePrivate(attachment) +function togglePrivate(fileName, isPrivate) { + let changeTo = isPrivate ? __('public') : __('private') + let title = __('Make attachment {0}', [changeTo]) + let message = __('Are you sure you want to make this attachment {0}?', [ + changeTo, + ]) + $dialog({ + title, + message, + actions: [ + { + label: __('Make {0}', [changeTo]), + variant: 'solid', + onClick: async (close) => { + await call('frappe.client.set_value', { + doctype: 'File', + name: fileName, + fieldname: { + is_private: !isPrivate, + }, + }) + emit('reload') + close() + }, + }, + ], + }) } -function deleteAttachment() { - // FilesUploadHandler.deleteAttachment(attachment) +function deleteAttachment(fileName) { + $dialog({ + title: __('Delete attachment'), + message: __('Are you sure you want to delete this attachment?'), + actions: [ + { + label: __('Delete'), + variant: 'solid', + theme: 'red', + onClick: async (close) => { + await call('frappe.client.delete', { + doctype: 'File', + name: fileName, + }) + emit('reload') + close() + }, + }, + ], + }) } function fileIcon(type) { From 3c7ba5e079df733a29e91cbc098b4667b43ad01f Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 17 Oct 2024 16:52:13 +0530 Subject: [PATCH 16/18] fix: show attachment log in activity --- crm/api/activities.py | 46 +++++++++++++++++++ .../src/components/Activities/Activities.vue | 37 +++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/crm/api/activities.py b/crm/api/activities.py index dbb04ed1..8cc714eb 100644 --- a/crm/api/activities.py +++ b/crm/api/activities.py @@ -1,5 +1,6 @@ import json +from bs4 import BeautifulSoup import frappe from frappe import _ from frappe.utils.caching import redis_cache @@ -132,6 +133,17 @@ def get_deal_activities(name): } activities.append(activity) + for attachment_log in docinfo.attachment_logs: + activity = { + "name": attachment_log.name, + "activity_type": "attachment_log", + "creation": attachment_log.creation, + "owner": attachment_log.owner, + "data": parse_attachment_log(attachment_log.content, attachment_log.comment_type), + "is_lead": False, + } + activities.append(activity) + calls = calls + get_linked_calls(name) notes = notes + get_linked_notes(name) tasks = tasks + get_linked_tasks(name) @@ -247,6 +259,17 @@ def get_lead_activities(name): } activities.append(activity) + for attachment_log in docinfo.attachment_logs: + activity = { + "name": attachment_log.name, + "activity_type": "attachment_log", + "creation": attachment_log.creation, + "owner": attachment_log.owner, + "data": parse_attachment_log(attachment_log.content, attachment_log.comment_type), + "is_lead": True, + } + activities.append(activity) + calls = get_linked_calls(name) notes = get_linked_notes(name) tasks = get_linked_tasks(name) @@ -345,3 +368,26 @@ def get_linked_tasks(name): ], ) return tasks or [] + +def parse_attachment_log(html, type): + soup = BeautifulSoup(html, "html.parser") + a_tag = soup.find("a") + type = "added" if type == "Attachment" else "removed" + if not a_tag: + return { + "type": type, + "file_name": html.replace("Removed ", ""), + "file_url": "", + "is_private": False, + } + + is_private = False + if "private/files" in a_tag["href"]: + is_private = True + + return { + "type": type, + "file_name": a_tag.text, + "file_url": a_tag["href"], + "is_private": is_private, + } \ No newline at end of file diff --git a/frontend/src/components/Activities/Activities.vue b/frontend/src/components/Activities/Activities.vue index 39be4bff..4f24f3ce 100644 --- a/frontend/src/components/Activities/Activities.vue +++ b/frontend/src/components/Activities/Activities.vue @@ -192,6 +192,40 @@ > +
+
+
+ {{ activity.owner_name }} + {{ __(activity.data.type) }} + + {{ activity.data.file_name }} + + {{ activity.data.file_name }} + +
+
+ +
+ {{ __(timeAgo(activity.creation)) }} +
+
+
+
+
- +
@@ -104,19 +98,14 @@
-
-
-
- -
-
+
+
-
-
+
+
- - -
-
-
- {{ attachment.file_name }} -
-
- {{ convertSize(attachment.file_size) }} -
-
-
-
- -
- {{ __(timeAgo(attachment.creation)) }} -
-
-
- - - - - - + +
+
+
+ {{ attachment.file_name }} +
+
+ {{ convertSize(attachment.file_size) }} +
+
+
+
+ +
+ {{ __(timeAgo(attachment.creation)) }} +
+
+
+ + + + + + +
+
+
@@ -72,15 +88,15 @@ import { } from '@/utils' const props = defineProps({ - attachment: Object, + attachments: Array, }) const emit = defineEmits(['reload']) const { $dialog } = globalStore() -function openFile() { - window.open(props.attachment.file_url, '_blank') +function openFile(attachment) { + window.open(attachment.file_url, '_blank') } function togglePrivate(fileName, isPrivate) { diff --git a/frontend/src/components/Activities/TaskArea.vue b/frontend/src/components/Activities/TaskArea.vue index 526271ae..b8d2fae8 100644 --- a/frontend/src/components/Activities/TaskArea.vue +++ b/frontend/src/components/Activities/TaskArea.vue @@ -1,6 +1,6 @@