diff --git a/crm/api/__init__.py b/crm/api/__init__.py index f0909118..141da446 100644 --- a/crm/api/__init__.py +++ b/crm/api/__init__.py @@ -1,7 +1,7 @@ from bs4 import BeautifulSoup import frappe from frappe.translate import get_all_translations -from frappe.utils import cstr +from frappe.utils import validate_email_address, split_emails, cstr from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD @@ -47,6 +47,7 @@ def get_user_signature(): content = f'

{signature}

' return content + @frappe.whitelist() def get_posthog_settings(): return { @@ -54,4 +55,55 @@ def get_posthog_settings(): "posthog_host": frappe.conf.get(POSTHOG_HOST_FIELD), "enable_telemetry": frappe.get_system_settings("enable_telemetry"), "telemetry_site_age": frappe.utils.telemetry.site_age(), - } \ No newline at end of file + } + + +def check_app_permission(): + if frappe.session.user == "Administrator": + return True + + roles = frappe.get_roles() + if any(role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles): + return True + + return False + + +@frappe.whitelist(allow_guest=True) +def accept_invitation(key: str = None): + if not key: + frappe.throw("Invalid or expired key") + + result = frappe.db.get_all("CRM Invitation", filters={"key": key}, pluck="name") + if not result: + frappe.throw("Invalid or expired key") + + invitation = frappe.get_doc("CRM Invitation", result[0]) + invitation.accept() + invitation.reload() + + if invitation.status == "Accepted": + frappe.local.login_manager.login_as(invitation.email) + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = "/crm" + + +@frappe.whitelist() +def invite_by_email(emails: str, role: str): + if not emails: + return + email_string = validate_email_address(emails, throw=False) + email_list = split_emails(email_string) + if not email_list: + return + existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email") + existing_invites = frappe.db.get_all( + "CRM Invitation", + filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]}, + pluck="email", + ) + + to_invite = list(set(email_list) - set(existing_members) - set(existing_invites)) + + for email in to_invite: + frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True) diff --git a/crm/fcrm/doctype/crm_invitation/__init__.py b/crm/fcrm/doctype/crm_invitation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_invitation/crm_invitation.js b/crm/fcrm/doctype/crm_invitation/crm_invitation.js new file mode 100644 index 00000000..6e0485d8 --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/crm_invitation.js @@ -0,0 +1,12 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Invitation", { + refresh(frm) { + if (frm.doc.status != "Accepted") { + frm.add_custom_button(__("Accept Invitation"), () => { + return frm.call("accept_invitation"); + }); + } + }, +}); diff --git a/crm/fcrm/doctype/crm_invitation/crm_invitation.json b/crm/fcrm/doctype/crm_invitation/crm_invitation.json new file mode 100644 index 00000000..f5902d6e --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/crm_invitation.json @@ -0,0 +1,112 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-09-03 12:19:18.933810", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "email", + "role", + "key", + "invited_by", + "column_break_dsuz", + "status", + "email_sent_at", + "accepted_at" + ], + "fields": [ + { + "fieldname": "email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email", + "reqd": 1 + }, + { + "fieldname": "role", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Role", + "options": "\nSales User\nSales Manager", + "reqd": 1 + }, + { + "fieldname": "key", + "fieldtype": "Data", + "label": "Key" + }, + { + "fieldname": "invited_by", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Invited By", + "options": "User" + }, + { + "fieldname": "column_break_dsuz", + "fieldtype": "Column Break" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "\nPending\nAccepted\nExpired" + }, + { + "fieldname": "email_sent_at", + "fieldtype": "Datetime", + "label": "Email Sent At" + }, + { + "fieldname": "accepted_at", + "fieldtype": "Datetime", + "label": "Accepted At" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-09-03 14:59:29.450018", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Invitation", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_invitation/crm_invitation.py b/crm/fcrm/doctype/crm_invitation/crm_invitation.py new file mode 100644 index 00000000..f4c9a8f9 --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/crm_invitation.py @@ -0,0 +1,79 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class CRMInvitation(Document): + def before_insert(self): + frappe.utils.validate_email_address(self.email, True) + + self.key = frappe.generate_hash(length=12) + self.invited_by = frappe.session.user + self.status = "Pending" + + def after_insert(self): + self.invite_via_email() + + def invite_via_email(self): + invite_link = frappe.utils.get_url(f"/api/method/crm.api.accept_invitation?key={self.key}") + if frappe.local.dev_server: + print(f"Invite link for {self.email}: {invite_link}") + + title = f"Frappe CRM" + template = "crm_invitation" + + frappe.sendmail( + recipients=self.email, + subject=f"You have been invited to join {title}", + template=template, + args={"title": title, "invite_link": invite_link}, + now=True, + ) + self.db_set("email_sent_at", frappe.utils.now()) + + @frappe.whitelist() + def accept_invitation(self): + frappe.only_for("System Manager") + self.accept() + + def accept(self): + if self.status == "Expired": + frappe.throw("Invalid or expired key") + + user = self.create_user_if_not_exists() + user.append_roles(self.role) + user.save(ignore_permissions=True) + + self.status = "Accepted" + self.accepted_at = frappe.utils.now() + self.save(ignore_permissions=True) + + def create_user_if_not_exists(self): + if not frappe.db.exists("User", self.email): + first_name = self.email.split("@")[0].title() + user = frappe.get_doc( + doctype="User", + user_type="System User", + email=self.email, + send_welcome_email=0, + first_name=first_name, + ).insert(ignore_permissions=True) + else: + user = frappe.get_doc("User", self.email) + return user + + +def expire_invitations(): + """expire invitations after 3 days""" + from frappe.utils import add_days, now + + days = 3 + invitations_to_expire = frappe.db.get_all( + "CRM Invitation", filters={"status": "Pending", "creation": ["<", add_days(now(), -days)]} + ) + for invitation in invitations_to_expire: + invitation = frappe.get_doc("CRM Invitation", invitation.name) + invitation.status = "Expired" + invitation.save(ignore_permissions=True) diff --git a/crm/fcrm/doctype/crm_invitation/test_crm_invitation.py b/crm/fcrm/doctype/crm_invitation/test_crm_invitation.py new file mode 100644 index 00000000..e2b3dc24 --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/test_crm_invitation.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestCRMInvitation(FrappeTestCase): + pass diff --git a/crm/hooks.py b/crm/hooks.py index ce6f23e8..e53fc8aa 100644 --- a/crm/hooks.py +++ b/crm/hooks.py @@ -18,7 +18,7 @@ add_to_apps_screen = [ "logo": "/assets/crm/manifest/apple-icon-180.png", "title": "CRM", "route": "/crm", - # "has_permission": "crm.api.permission.has_app_permission" + "has_permission": "crm.api.check_app_permission", } ] diff --git a/crm/templates/emails/crm_invitation.html b/crm/templates/emails/crm_invitation.html new file mode 100644 index 00000000..3e74b1d1 --- /dev/null +++ b/crm/templates/emails/crm_invitation.html @@ -0,0 +1,4 @@ +

You have been invited to join Frappe CRM

+

+ Accept Invitation +

diff --git a/frontend/src/components/Controls/MultiValueInput.vue b/frontend/src/components/Controls/MultiValueInput.vue new file mode 100644 index 00000000..d2f385ee --- /dev/null +++ b/frontend/src/components/Controls/MultiValueInput.vue @@ -0,0 +1,120 @@ + + + diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue new file mode 100644 index 00000000..8516b2f8 --- /dev/null +++ b/frontend/src/components/Icon.vue @@ -0,0 +1,21 @@ + + diff --git a/frontend/src/components/Icons/GroupByIcon.vue b/frontend/src/components/Icons/GroupByIcon.vue new file mode 100644 index 00000000..e1b03a15 --- /dev/null +++ b/frontend/src/components/Icons/GroupByIcon.vue @@ -0,0 +1,16 @@ + diff --git a/frontend/src/components/Icons/KanbanIcon.vue b/frontend/src/components/Icons/KanbanIcon.vue index bb12b401..fc4c965f 100644 --- a/frontend/src/components/Icons/KanbanIcon.vue +++ b/frontend/src/components/Icons/KanbanIcon.vue @@ -1,18 +1,16 @@ diff --git a/frontend/src/components/Icons/ListIcon.vue b/frontend/src/components/Icons/ListIcon.vue new file mode 100644 index 00000000..f194dcc4 --- /dev/null +++ b/frontend/src/components/Icons/ListIcon.vue @@ -0,0 +1,16 @@ + diff --git a/frontend/src/components/LayoutHeader.vue b/frontend/src/components/LayoutHeader.vue index c72d9084..14183680 100644 --- a/frontend/src/components/LayoutHeader.vue +++ b/frontend/src/components/LayoutHeader.vue @@ -1,7 +1,7 @@ + diff --git a/frontend/src/components/ViewControls.vue b/frontend/src/components/ViewControls.vue index b0f4c06c..eac26611 100644 --- a/frontend/src/components/ViewControls.vue +++ b/frontend/src/components/ViewControls.vue @@ -3,43 +3,6 @@ v-if="isMobileView" class="flex flex-col justify-between gap-2 sm:px-5 px-3 py-4" > -
-
- - - - - - -
- -
@@ -59,6 +22,11 @@
+
-
- - - - - - -
-
{ viewUpdated = false reloadView() + list.reload() }, }" /> @@ -268,8 +208,9 @@