-
Notifications
You must be signed in to change notification settings - Fork 168
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #325 from shariquerik/invite-members
feat: Invite Members
- Loading branch information
Showing
12 changed files
with
526 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"); | ||
}); | ||
} | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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": [] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
<h2>You have been invited to join Frappe CRM</h2> | ||
<p> | ||
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a> | ||
</p> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
<template> | ||
<div> | ||
<div | ||
class="flex flex-wrap gap-1 min-h-20 p-1.5 cursor-text rounded h-7 text-base border border-gray-300 bg-white hover:border-gray-400 focus:border-gray-500 focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-800 transition-colors w-full" | ||
@click="setFocus" | ||
> | ||
<Button | ||
ref="emails" | ||
v-for="value in values" | ||
:key="value" | ||
:label="value" | ||
theme="gray" | ||
variant="subtle" | ||
class="rounded" | ||
@keydown.delete.capture.stop="removeLastValue" | ||
> | ||
<template #suffix> | ||
<FeatherIcon | ||
class="h-3.5" | ||
name="x" | ||
@click.stop="removeValue(value)" | ||
/> | ||
</template> | ||
</Button> | ||
<div class="flex-1"> | ||
<TextInput | ||
ref="search" | ||
class="w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0" | ||
type="text" | ||
v-model="query" | ||
placeholder="[email protected]" | ||
@keydown.enter.capture.stop="addValue()" | ||
@keydown.delete.capture.stop="removeLastValue" | ||
/> | ||
</div> | ||
</div> | ||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> | ||
</div> | ||
</template> | ||
|
||
<script setup> | ||
import { TextInput } from 'frappe-ui' | ||
import { ref, nextTick } from 'vue' | ||
const props = defineProps({ | ||
validate: { | ||
type: Function, | ||
default: null, | ||
}, | ||
errorMessage: { | ||
type: Function, | ||
default: (value) => `${value} is an Invalid value`, | ||
}, | ||
}) | ||
const values = defineModel() | ||
const emails = ref([]) | ||
const search = ref(null) | ||
const error = ref(null) | ||
const query = ref('') | ||
const addValue = () => { | ||
let value = query.value | ||
error.value = null | ||
if (value) { | ||
const splitValues = value.split(',') | ||
splitValues.forEach((value) => { | ||
value = value.trim() | ||
if (value) { | ||
// check if value is not already in the values array | ||
if (!values.value?.includes(value)) { | ||
// check if value is valid | ||
if (value && props.validate && !props.validate(value)) { | ||
error.value = props.errorMessage(value) | ||
return | ||
} | ||
// add value to values array | ||
if (!values.value) { | ||
values.value = [value] | ||
} else { | ||
values.value.push(value) | ||
} | ||
value = value.replace(value, '') | ||
} | ||
} | ||
}) | ||
!error.value && (query.value = '') | ||
} | ||
} | ||
const removeValue = (value) => { | ||
values.value = values.value.filter((v) => v !== value) | ||
} | ||
const removeLastValue = () => { | ||
if (query.value) return | ||
let emailRef = emails.value[emails.value.length - 1]?.$el | ||
if (document.activeElement === emailRef) { | ||
values.value.pop() | ||
nextTick(() => { | ||
if (values.value.length) { | ||
emailRef = emails.value[emails.value.length - 1].$el | ||
emailRef?.focus() | ||
} else { | ||
setFocus() | ||
} | ||
}) | ||
} else { | ||
emailRef?.focus() | ||
} | ||
} | ||
function setFocus() { | ||
search.value.el.focus() | ||
} | ||
defineExpose({ setFocus }) | ||
</script> |
Oops, something went wrong.