From 164acc845a0b1aa1473bfa475c6728834933c260 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Tue, 24 Aug 2021 19:15:56 +0530 Subject: [PATCH 1/4] refactor: social media post fixes (#24664) * fix: social media post fixes * feat: post metrics and some fixes * fix: sider issues * fix: sider issue * fix: reverting optional chaning statements * fix: sider issues * fix: review chnages * fix: text trigger check * fix: sider issue (cherry picked from commit f7e0edecc9ce45ba7baa4c17b2b35f487dffccf2) --- .../linkedin_settings/linkedin_settings.js | 11 +- .../linkedin_settings/linkedin_settings.json | 3 +- .../linkedin_settings/linkedin_settings.py | 112 +++++++--- .../social_media_post/social_media_post.js | 196 ++++++++++++------ .../social_media_post/social_media_post.json | 92 +++----- .../social_media_post/social_media_post.py | 54 +++-- .../social_media_post_list.js | 17 +- .../twitter_settings/twitter_settings.js | 13 +- .../twitter_settings/twitter_settings.json | 3 +- .../twitter_settings/twitter_settings.py | 50 +++-- 10 files changed, 349 insertions(+), 202 deletions(-) diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js index 263005ef6c5..7aa0b777596 100644 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js @@ -2,8 +2,8 @@ // For license information, please see license.txt frappe.ui.form.on('LinkedIn Settings', { - onload: function(frm){ - if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret){ + onload: function(frm) { + if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret) { frappe.confirm( __('Session not valid, Do you want to login?'), function(){ @@ -14,8 +14,9 @@ frappe.ui.form.on('LinkedIn Settings', { } ); } + frm.dashboard.set_headline(__("For more information, {0}.", [`${__('Click here')}`])); }, - refresh: function(frm){ + refresh: function(frm) { if (frm.doc.session_status=="Expired"){ let msg = __("Session Not Active. Save doc to login."); frm.dashboard.set_headline_alert( @@ -53,7 +54,7 @@ frappe.ui.form.on('LinkedIn Settings', { ); } }, - login: function(frm){ + login: function(frm) { if (frm.doc.consumer_key && frm.doc.consumer_secret){ frappe.dom.freeze(); frappe.call({ @@ -67,7 +68,7 @@ frappe.ui.form.on('LinkedIn Settings', { }); } }, - after_save: function(frm){ + after_save: function(frm) { frm.trigger("login"); } }); diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json index 9eacb0011c5..f882e36c32a 100644 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json @@ -2,6 +2,7 @@ "actions": [], "creation": "2020-01-30 13:36:39.492931", "doctype": "DocType", + "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/linkedin-settings", "editable_grid": 1, "engine": "InnoDB", "field_order": [ @@ -87,7 +88,7 @@ ], "issingle": 1, "links": [], - "modified": "2020-04-16 23:22:51.966397", + "modified": "2021-02-18 15:19:21.920725", "modified_by": "Administrator", "module": "CRM", "name": "LinkedIn Settings", diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py index d8c6fb4f90f..9b88d78c1ff 100644 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py @@ -3,11 +3,12 @@ # For license information, please see license.txt from __future__ import unicode_literals -import frappe, requests, json +import frappe +import requests from frappe import _ -from frappe.utils import get_site_url, get_url_to_form, get_link_to_form +from frappe.utils import get_url_to_form from frappe.model.document import Document -from frappe.utils.file_manager import get_file, get_file_path +from frappe.utils.file_manager import get_file_path from six.moves.urllib.parse import urlencode class LinkedInSettings(Document): @@ -42,11 +43,7 @@ class LinkedInSettings(Document): self.db_set("access_token", response["access_token"]) def get_member_profile(self): - headers = { - "Authorization": "Bearer {}".format(self.access_token) - } - url = "https://api.linkedin.com/v2/me" - response = requests.get(url=url, headers=headers) + response = requests.get(url="https://api.linkedin.com/v2/me", headers=self.get_headers()) response = frappe.parse_json(response.content.decode()) frappe.db.set_value(self.doctype, self.name, { @@ -55,16 +52,16 @@ class LinkedInSettings(Document): "session_status": "Active" }) frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("LinkedIn Settings","LinkedIn Settings") + frappe.local.response["location"] = get_url_to_form("LinkedIn Settings", "LinkedIn Settings") - def post(self, text, media=None): + def post(self, text, title, media=None): if not media: - return self.post_text(text) + return self.post_text(text, title) else: media_id = self.upload_image(media) if media_id: - return self.post_text(text, media_id=media_id) + return self.post_text(text, title, media_id=media_id) else: frappe.log_error("Failed to upload media.","LinkedIn Upload Error") @@ -82,9 +79,7 @@ class LinkedInSettings(Document): }] } } - headers = { - "Authorization": "Bearer {}".format(self.access_token) - } + headers = self.get_headers() response = self.http_post(url=register_url, body=body, headers=headers) if response.status_code == 200: @@ -100,24 +95,33 @@ class LinkedInSettings(Document): return None - def post_text(self, text, media_id=None): + def post_text(self, text, title, media_id=None): url = "https://api.linkedin.com/v2/shares" - headers = { - "X-Restli-Protocol-Version": "2.0.0", - "Authorization": "Bearer {}".format(self.access_token), - "Content-Type": "application/json; charset=UTF-8" - } + headers = self.get_headers() + headers["X-Restli-Protocol-Version"] = "2.0.0" + headers["Content-Type"] = "application/json; charset=UTF-8" + body = { "distribution": { "linkedInDistributionTarget": {} }, "owner":"urn:li:organization:{0}".format(self.company_id), - "subject": "Test Share Subject", + "subject": title, "text": { "text": text } } + reference_url = self.get_reference_url(text) + if reference_url: + body["content"] = { + "contentEntities": [ + { + "entityLocation": reference_url + } + ] + } + if media_id: body["content"]= { "contentEntities": [{ @@ -141,20 +145,60 @@ class LinkedInSettings(Document): raise except Exception as e: - content = json.loads(response.content) - - if response.status_code == 401: - self.db_set("session_status", "Expired") - frappe.db.commit() - frappe.throw(content["message"], title="LinkedIn Error - Unauthorized") - elif response.status_code == 403: - frappe.msgprint(_("You Didn't have permission to access this API")) - frappe.throw(content["message"], title="LinkedIn Error - Access Denied") - else: - frappe.throw(response.reason, title=response.status_code) - + self.api_error(response) + return response + def get_headers(self): + return { + "Authorization": "Bearer {}".format(self.access_token) + } + + def get_reference_url(self, text): + import re + regex_url = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" + urls = re.findall(regex_url, text) + if urls: + return urls[0] + + def delete_post(self, post_id): + try: + response = requests.delete(url="https://api.linkedin.com/v2/shares/urn:li:share:{0}".format(post_id), headers=self.get_headers()) + if response.status_code !=200: + raise + except Exception: + self.api_error(response) + + def get_post(self, post_id): + url = "https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=urn:li:organization:{0}&shares[0]=urn:li:share:{1}".format(self.company_id, post_id) + + try: + response = requests.get(url=url, headers=self.get_headers()) + if response.status_code !=200: + raise + + except Exception: + self.api_error(response) + + response = frappe.parse_json(response.content.decode()) + if len(response.elements): + return response.elements[0] + + return None + + def api_error(self, response): + content = frappe.parse_json(response.content.decode()) + + if response.status_code == 401: + self.db_set("session_status", "Expired") + frappe.db.commit() + frappe.throw(content["message"], title=_("LinkedIn Error - Unauthorized")) + elif response.status_code == 403: + frappe.msgprint(_("You didn't have permission to access this API")) + frappe.throw(content["message"], title=_("LinkedIn Error - Access Denied")) + else: + frappe.throw(response.reason, title=response.status_code) + @frappe.whitelist(allow_guest=True) def callback(code=None, error=None, error_description=None): if not error: diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.js b/erpnext/crm/doctype/social_media_post/social_media_post.js index 6fb0f975f46..a8f5deea535 100644 --- a/erpnext/crm/doctype/social_media_post/social_media_post.js +++ b/erpnext/crm/doctype/social_media_post/social_media_post.js @@ -1,67 +1,139 @@ // Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt frappe.ui.form.on('Social Media Post', { - validate: function(frm){ - if (frm.doc.twitter === 0 && frm.doc.linkedin === 0){ - frappe.throw(__("Select atleast one Social Media from Share on.")) - } - if (frm.doc.scheduled_time) { - let scheduled_time = new Date(frm.doc.scheduled_time); - let date_time = new Date(); - if (scheduled_time.getTime() < date_time.getTime()){ - frappe.throw(__("Invalid Scheduled Time")); - } - } - if (frm.doc.text?.length > 280){ - frappe.throw(__("Length Must be less than 280.")) - } - }, - refresh: function(frm){ - if (frm.doc.docstatus === 1){ - if (frm.doc.post_status != "Posted"){ - add_post_btn(frm); - } - else if (frm.doc.post_status == "Posted"){ - frm.set_df_property('sheduled_time', 'read_only', 1); - } + validate: function(frm) { + if (frm.doc.twitter === 0 && frm.doc.linkedin === 0) { + frappe.throw(__("Select atleast one Social Media Platform to Share on.")); + } + if (frm.doc.scheduled_time) { + let scheduled_time = new Date(frm.doc.scheduled_time); + let date_time = new Date(); + if (scheduled_time.getTime() < date_time.getTime()) { + frappe.throw(__("Scheduled Time must be a future time.")); + } + } + frm.trigger('validate_tweet_length'); + }, - let html=''; - if (frm.doc.twitter){ - let color = frm.doc.twitter_post_id ? "green" : "red"; - let status = frm.doc.twitter_post_id ? "Posted" : "Not Posted"; - html += `
- Twitter : ${status} -
` ; - } - if (frm.doc.linkedin){ - let color = frm.doc.linkedin_post_id ? "green" : "red"; - let status = frm.doc.linkedin_post_id ? "Posted" : "Not Posted"; - html += `
- LinkedIn : ${status} -
` ; - } - html = `
${html}
`; - frm.dashboard.set_headline_alert(html); - } - } + text: function(frm) { + if (frm.doc.text) { + frm.set_df_property('text', 'description', `${frm.doc.text.length}/280`); + frm.refresh_field('text'); + frm.trigger('validate_tweet_length'); + } + }, + + validate_tweet_length: function(frm) { + if (frm.doc.text && frm.doc.text.length > 280) { + frappe.throw(__("Tweet length Must be less than 280.")); + } + }, + + onload: function(frm) { + frm.trigger('make_dashboard'); + }, + + make_dashboard: function(frm) { + if (frm.doc.post_status == "Posted") { + frappe.call({ + doc: frm.doc, + method: 'get_post', + freeze: true, + callback: (r) => { + if (!r.message) { + return; + } + + let datasets = [], colors = []; + if (r.message && r.message.twitter) { + colors.push('#1DA1F2'); + datasets.push({ + name: 'Twitter', + values: [r.message.twitter.favorite_count, r.message.twitter.retweet_count] + }); + } + if (r.message && r.message.linkedin) { + colors.push('#0077b5'); + datasets.push({ + name: 'LinkedIn', + values: [r.message.linkedin.totalShareStatistics.likeCount, r.message.linkedin.totalShareStatistics.shareCount] + }); + } + + if (datasets.length) { + frm.dashboard.render_graph({ + data: { + labels: ['Likes', 'Retweets/Shares'], + datasets: datasets + }, + + title: __("Post Metrics"), + type: 'bar', + height: 300, + colors: colors + }); + } + } + }); + } + }, + + refresh: function(frm) { + frm.trigger('text'); + + if (frm.doc.docstatus === 1) { + if (!['Posted', 'Deleted'].includes(frm.doc.post_status)) { + frm.trigger('add_post_btn'); + } + if (frm.doc.post_status !='Deleted') { + frm.add_custom_button(('Delete Post'), function() { + frappe.confirm(__('Are you sure want to delete the Post from Social Media platforms?'), + function() { + frappe.call({ + doc: frm.doc, + method: 'delete_post', + freeze: true, + callback: () => { + frm.reload_doc(); + } + }); + } + ); + }); + } + + if (frm.doc.post_status !='Deleted') { + let html=''; + if (frm.doc.twitter) { + let color = frm.doc.twitter_post_id ? "green" : "red"; + let status = frm.doc.twitter_post_id ? "Posted" : "Not Posted"; + html += `
+ Twitter : ${status} +
` ; + } + if (frm.doc.linkedin) { + let color = frm.doc.linkedin_post_id ? "green" : "red"; + let status = frm.doc.linkedin_post_id ? "Posted" : "Not Posted"; + html += `
+ LinkedIn : ${status} +
` ; + } + html = `
${html}
`; + frm.dashboard.set_headline_alert(html); + } + } + }, + + add_post_btn: function(frm) { + frm.add_custom_button(__('Post Now'), function() { + frappe.call({ + doc: frm.doc, + method: 'post', + freeze: true, + callback: function() { + frm.reload_doc(); + } + }); + }); + } }); -var add_post_btn = function(frm){ - frm.add_custom_button(('Post Now'), function(){ - post(frm); - }); -} -var post = function(frm){ - frappe.dom.freeze(); - frappe.call({ - method: "erpnext.crm.doctype.social_media_post.social_media_post.publish", - args: { - doctype: frm.doc.doctype, - name: frm.doc.name - }, - callback: function(r) { - frm.reload_doc(); - frappe.dom.unfreeze(); - } - }) - -} diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.json b/erpnext/crm/doctype/social_media_post/social_media_post.json index 0a00dca2808..98e78f949e8 100644 --- a/erpnext/crm/doctype/social_media_post/social_media_post.json +++ b/erpnext/crm/doctype/social_media_post/social_media_post.json @@ -3,9 +3,11 @@ "autoname": "format: CRM-SMP-{YYYY}-{MM}-{DD}-{###}", "creation": "2020-01-30 11:53:13.872864", "doctype": "DocType", + "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/social-media-post", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "title", "campaign_name", "scheduled_time", "post_status", @@ -30,32 +32,24 @@ "fieldname": "text", "fieldtype": "Small Text", "label": "Tweet", - "mandatory_depends_on": "eval:doc.twitter ==1", - "show_days": 1, - "show_seconds": 1 + "mandatory_depends_on": "eval:doc.twitter ==1" }, { "fieldname": "image", "fieldtype": "Attach Image", - "label": "Image", - "show_days": 1, - "show_seconds": 1 + "label": "Image" }, { - "default": "0", + "default": "1", "fieldname": "twitter", "fieldtype": "Check", - "label": "Twitter", - "show_days": 1, - "show_seconds": 1 + "label": "Twitter" }, { - "default": "0", + "default": "1", "fieldname": "linkedin", "fieldtype": "Check", - "label": "LinkedIn", - "show_days": 1, - "show_seconds": 1 + "label": "LinkedIn" }, { "fieldname": "amended_from", @@ -64,27 +58,22 @@ "no_copy": 1, "options": "Social Media Post", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "depends_on": "eval:doc.twitter ==1", "fieldname": "content", "fieldtype": "Section Break", - "label": "Twitter", - "show_days": 1, - "show_seconds": 1 + "label": "Twitter" }, { "allow_on_submit": 1, "fieldname": "post_status", "fieldtype": "Select", "label": "Post Status", - "options": "\nScheduled\nPosted\nError", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "no_copy": 1, + "options": "\nScheduled\nPosted\nCancelled\nDeleted\nError", + "read_only": 1 }, { "allow_on_submit": 1, @@ -92,9 +81,8 @@ "fieldtype": "Data", "hidden": 1, "label": "Twitter Post Id", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "no_copy": 1, + "read_only": 1 }, { "allow_on_submit": 1, @@ -102,82 +90,69 @@ "fieldtype": "Data", "hidden": 1, "label": "LinkedIn Post Id", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "no_copy": 1, + "read_only": 1 }, { "fieldname": "campaign_name", "fieldtype": "Link", "in_list_view": 1, "label": "Campaign", - "options": "Campaign", - "show_days": 1, - "show_seconds": 1 + "options": "Campaign" }, { "fieldname": "column_break_6", "fieldtype": "Column Break", - "label": "Share On", - "show_days": 1, - "show_seconds": 1 + "label": "Share On" }, { "fieldname": "column_break_14", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "tweet_preview", - "fieldtype": "HTML", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "HTML" }, { "collapsible": 1, "depends_on": "eval:doc.linkedin==1", "fieldname": "linkedin_section", "fieldtype": "Section Break", - "label": "LinkedIn", - "show_days": 1, - "show_seconds": 1 + "label": "LinkedIn" }, { "collapsible": 1, "fieldname": "attachments_section", "fieldtype": "Section Break", - "label": "Attachments", - "show_days": 1, - "show_seconds": 1 + "label": "Attachments" }, { "fieldname": "linkedin_post", "fieldtype": "Text", "label": "Post", - "mandatory_depends_on": "eval:doc.linkedin ==1", - "show_days": 1, - "show_seconds": 1 + "mandatory_depends_on": "eval:doc.linkedin ==1" }, { "fieldname": "column_break_15", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "allow_on_submit": 1, "fieldname": "scheduled_time", "fieldtype": "Datetime", "label": "Scheduled Time", - "read_only_depends_on": "eval:doc.post_status == \"Posted\"", - "show_days": 1, - "show_seconds": 1 + "read_only_depends_on": "eval:doc.post_status == \"Posted\"" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-14 10:31:33.961381", + "modified": "2021-04-14 14:24:59.821223", "modified_by": "Administrator", "module": "CRM", "name": "Social Media Post", @@ -228,5 +203,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "title_field": "title", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.py b/erpnext/crm/doctype/social_media_post/social_media_post.py index ed1b5839446..95320bff535 100644 --- a/erpnext/crm/doctype/social_media_post/social_media_post.py +++ b/erpnext/crm/doctype/social_media_post/social_media_post.py @@ -10,17 +10,51 @@ import datetime class SocialMediaPost(Document): def validate(self): + if (not self.twitter and not self.linkedin): + frappe.throw(_("Select atleast one Social Media Platform to Share on.")) + if self.scheduled_time: current_time = frappe.utils.now_datetime() scheduled_time = frappe.utils.get_datetime(self.scheduled_time) if scheduled_time < current_time: - frappe.throw(_("Invalid Scheduled Time")) + frappe.throw(_("Scheduled Time must be a future time.")) + + if self.text and len(self.text) > 280: + frappe.throw(_("Tweet length must be less than 280.")) def submit(self): if self.scheduled_time: self.post_status = "Scheduled" super(SocialMediaPost, self).submit() + + def on_cancel(self): + self.db_set('post_status', 'Cancelled') + @frappe.whitelist() + def delete_post(self): + if self.twitter and self.twitter_post_id: + twitter = frappe.get_doc("Twitter Settings") + twitter.delete_tweet(self.twitter_post_id) + + if self.linkedin and self.linkedin_post_id: + linkedin = frappe.get_doc("LinkedIn Settings") + linkedin.delete_post(self.linkedin_post_id) + + self.db_set('post_status', 'Deleted') + + @frappe.whitelist() + def get_post(self): + response = {} + if self.linkedin and self.linkedin_post_id: + linkedin = frappe.get_doc("LinkedIn Settings") + response['linkedin'] = linkedin.get_post(self.linkedin_post_id) + if self.twitter and self.twitter_post_id: + twitter = frappe.get_doc("Twitter Settings") + response['twitter'] = twitter.get_tweet(self.twitter_post_id) + + return response + + @frappe.whitelist() def post(self): try: if self.twitter and not self.twitter_post_id: @@ -29,28 +63,22 @@ class SocialMediaPost(Document): self.db_set("twitter_post_id", twitter_post.id) if self.linkedin and not self.linkedin_post_id: linkedin = frappe.get_doc("LinkedIn Settings") - linkedin_post = linkedin.post(self.linkedin_post, self.image) - self.db_set("linkedin_post_id", linkedin_post.headers['X-RestLi-Id'].split(":")[-1]) + linkedin_post = linkedin.post(self.linkedin_post, self.title, self.image) + self.db_set("linkedin_post_id", linkedin_post.headers['X-RestLi-Id']) self.db_set("post_status", "Posted") except: self.db_set("post_status", "Error") title = _("Error while POSTING {0}").format(self.name) - traceback = frappe.get_traceback() - frappe.log_error(message=traceback , title=title) + frappe.log_error(message=frappe.get_traceback(), title=title) def process_scheduled_social_media_posts(): - posts = frappe.get_list("Social Media Post", filters={"post_status": "Scheduled", "docstatus":1}, fields= ["name", "scheduled_time","post_status"]) + posts = frappe.get_list("Social Media Post", filters={"post_status": "Scheduled", "docstatus":1}, fields= ["name", "scheduled_time"]) start = frappe.utils.now_datetime() end = start + datetime.timedelta(minutes=10) for post in posts: if post.scheduled_time: post_time = frappe.utils.get_datetime(post.scheduled_time) if post_time > start and post_time <= end: - publish('Social Media Post', post.name) - -@frappe.whitelist() -def publish(doctype, name): - sm_post = frappe.get_doc(doctype, name) - sm_post.post() - frappe.db.commit() + sm_post = frappe.get_doc('Social Media Post', post.name) + sm_post.post() diff --git a/erpnext/crm/doctype/social_media_post/social_media_post_list.js b/erpnext/crm/doctype/social_media_post/social_media_post_list.js index c60b91a9a02..a8c8272ad08 100644 --- a/erpnext/crm/doctype/social_media_post/social_media_post_list.js +++ b/erpnext/crm/doctype/social_media_post/social_media_post_list.js @@ -1,10 +1,11 @@ frappe.listview_settings['Social Media Post'] = { - add_fields: ["status","post_status"], - get_indicator: function(doc) { - return [__(doc.post_status), { - "Scheduled": "orange", - "Posted": "green", - "Error": "red" - }[doc.post_status]]; - } + add_fields: ["status", "post_status"], + get_indicator: function(doc) { + return [__(doc.post_status), { + "Scheduled": "orange", + "Posted": "green", + "Error": "red", + "Deleted": "red" + }[doc.post_status]]; + } } diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.js b/erpnext/crm/doctype/twitter_settings/twitter_settings.js index f6f431ca5c9..112f3d4d1c3 100644 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.js +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on('Twitter Settings', { - onload: function(frm){ + onload: function(frm) { if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret){ frappe.confirm( __('Session not valid, Do you want to login?'), @@ -14,10 +14,11 @@ frappe.ui.form.on('Twitter Settings', { } ); } + frm.dashboard.set_headline(__("For more information, {0}.", [`${__('Click here')}`])); }, - refresh: function(frm){ + refresh: function(frm) { let msg, color, flag=false; - if (frm.doc.session_status == "Active"){ + if (frm.doc.session_status == "Active") { msg = __("Session Active"); color = 'green'; flag = true; @@ -28,7 +29,7 @@ frappe.ui.form.on('Twitter Settings', { flag = true; } - if (flag){ + if (flag) { frm.dashboard.set_headline_alert( `
@@ -38,7 +39,7 @@ frappe.ui.form.on('Twitter Settings', { ); } }, - login: function(frm){ + login: function(frm) { if (frm.doc.consumer_key && frm.doc.consumer_secret){ frappe.dom.freeze(); frappe.call({ @@ -52,7 +53,7 @@ frappe.ui.form.on('Twitter Settings', { }); } }, - after_save: function(frm){ + after_save: function(frm) { frm.trigger("login"); } }); diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.json b/erpnext/crm/doctype/twitter_settings/twitter_settings.json index 36776e5c202..8d05877f060 100644 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.json +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.json @@ -2,6 +2,7 @@ "actions": [], "creation": "2020-01-30 10:29:08.562108", "doctype": "DocType", + "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/twitter-settings", "editable_grid": 1, "engine": "InnoDB", "field_order": [ @@ -77,7 +78,7 @@ "image_field": "profile_pic", "issingle": 1, "links": [], - "modified": "2020-05-13 17:50:47.934776", + "modified": "2021-02-18 15:18:07.900031", "modified_by": "Administrator", "module": "CRM", "name": "Twitter Settings", diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.py b/erpnext/crm/doctype/twitter_settings/twitter_settings.py index 1e1beab2d25..47756560ec5 100644 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.py +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.py @@ -32,7 +32,9 @@ class TwitterSettings(Document): try: auth.get_access_token(oauth_verifier) - api = self.get_api(auth.access_token, auth.access_token_secret) + self.access_token = auth.access_token + self.access_token_secret = auth.access_token_secret + api = self.get_api() user = api.me() profile_pic = (user._json["profile_image_url"]).replace("_normal","") @@ -50,11 +52,11 @@ class TwitterSettings(Document): frappe.msgprint(_("Error! Failed to get access token.")) frappe.throw(_('Invalid Consumer Key or Consumer Secret Key')) - def get_api(self, access_token, access_token_secret): - # authentication of consumer key and secret - auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret")) - # authentication of access token and secret - auth.set_access_token(access_token, access_token_secret) + def get_api(self): + # authentication of consumer key and secret + auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret")) + # authentication of access token and secret + auth.set_access_token(self.access_token, self.access_token_secret) return tweepy.API(auth) @@ -68,13 +70,13 @@ class TwitterSettings(Document): def upload_image(self, media): media = get_file_path(media) - api = self.get_api(self.access_token, self.access_token_secret) + api = self.get_api() media = api.media_upload(media) return media.media_id def send_tweet(self, text, media_id=None): - api = self.get_api(self.access_token, self.access_token_secret) + api = self.get_api() try: if media_id: response = api.update_status(status = text, media_ids = [media_id]) @@ -84,12 +86,32 @@ class TwitterSettings(Document): return response except TweepError as e: - content = json.loads(e.response.content) - content = content["errors"][0] - if e.response.status_code == 401: - self.db_set("session_status", "Expired") - frappe.db.commit() - frappe.throw(content["message"],title="Twitter Error {0} {1}".format(e.response.status_code, e.response.reason)) + self.api_error(e) + + def delete_tweet(self, tweet_id): + api = self.get_api() + try: + api.destroy_status(tweet_id) + except TweepError as e: + self.api_error(e) + + def get_tweet(self, tweet_id): + api = self.get_api() + try: + response = api.get_status(tweet_id, trim_user=True, include_entities=True) + except TweepError as e: + self.api_error(e) + + return response._json + + def api_error(self, e): + content = json.loads(e.response.content) + content = content["errors"][0] + if e.response.status_code == 401: + self.db_set("session_status", "Expired") + frappe.db.commit() + frappe.throw(content["message"],title=_("Twitter Error {0} : {1}").format(e.response.status_code, e.response.reason)) + @frappe.whitelist(allow_guest=True) def callback(oauth_token = None, oauth_verifier = None): From f1313302c98f8a1a63d222aca4321fbbfb30ba03 Mon Sep 17 00:00:00 2001 From: Saqib Date: Tue, 24 Aug 2021 19:45:21 +0530 Subject: [PATCH 2/4] fix: pos invoice test (#27113) --- erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 6172796129f..d2527fb2e50 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -320,7 +320,8 @@ class TestPOSInvoice(unittest.TestCase): pos2.get("items")[0].serial_no = serial_nos[0] pos2.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - _TC', 'amount': 1000}) - self.assertRaises(frappe.ValidationError, pos2.insert) + pos2.insert() + self.assertRaises(frappe.ValidationError, pos2.submit) def test_delivered_serialized_item_transaction(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item @@ -348,7 +349,8 @@ class TestPOSInvoice(unittest.TestCase): pos2.get("items")[0].serial_no = serial_nos[0] pos2.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - _TC', 'amount': 1000}) - self.assertRaises(frappe.ValidationError, pos2.insert) + pos2.insert() + self.assertRaises(frappe.ValidationError, pos2.submit) def test_loyalty_points(self): from erpnext.accounts.doctype.loyalty_program.test_loyalty_program import create_records From 1c279af0b30b4dcafe091d5183fbf13532c05e60 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Tue, 24 Aug 2021 20:06:04 +0530 Subject: [PATCH 3/4] fix: lead name issue (#26999) * fix: lead name issue * fix: change lead name wrt first name, middle and last name Co-authored-by: Jannat Patel <31363128+pateljannat@users.noreply.github.com> --- erpnext/crm/doctype/lead/lead.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index c0ce6badbf5..cad17a3bee8 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -36,7 +36,8 @@ class Lead(SellingController): }) def set_full_name(self): - self.lead_name = " ".join(filter(None, [self.first_name, self.middle_name, self.last_name])) + if self.first_name: + self.lead_name = " ".join(filter(None, [self.first_name, self.middle_name, self.last_name])) def validate_email_id(self): if self.email_id: From 255b99ebdc1ce3be6de02d9641fc72685de1a6a6 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Tue, 24 Aug 2021 20:19:22 +0530 Subject: [PATCH 4/4] feat: Employee reminders (#25735) (#27115) * feat: Add reminders section to HR Settings * refactor: Extract generic function for getting Employees * feat: Employee Work Anniversary Reminder * feat: Daily Holiday Reminder * fix: Unnecessary params and replace [] with .get() * test: Daily Holiday Reminders * test: is_holiday basic tests * refactor: Move employee reminders code to separate module * feat: Add advance reminder to HR settings * feat: Advance Holiday Reminders * refactor: get_holidays_for_employee * feat: Email holiday reminders in advance + tests * fix: Remove unused import * refactor: HR Setting Reminder Section * refactor: Remove Daily Holiday Reminders feat * feat: Reminder miss warning * fix: Failing test and function name change * chore: Add patch for field rename * chore: Rename frequency label * fix: Failing patch test * fix: sider and removed description of fields * fix: email alignment Co-authored-by: pateljannat Co-authored-by: Jannat Patel <31363128+pateljannat@users.noreply.github.com> (cherry picked from commit 24b2a315818d08ad4cb03347ccf5297df916a5ac) Co-authored-by: Mohammad Hussain Nagaria <34810212+NagariaHussain@users.noreply.github.com> Co-authored-by: Jannat Patel <31363128+pateljannat@users.noreply.github.com> --- erpnext/hooks.py | 9 +- .../compensatory_leave_request.py | 4 +- erpnext/hr/doctype/employee/employee.py | 127 +++------ .../hr/doctype/employee/employee_reminders.py | 247 ++++++++++++++++++ erpnext/hr/doctype/employee/test_employee.py | 25 -- .../employee/test_employee_reminders.py | 173 ++++++++++++ .../hr/doctype/hr_settings/hr_settings.json | 57 +++- erpnext/hr/doctype/hr_settings/hr_settings.py | 66 ++++- .../upload_attendance/upload_attendance.py | 4 +- erpnext/hr/utils.py | 47 +++- erpnext/patches.txt | 1 + .../rename_stop_to_send_birthday_reminders.py | 23 ++ .../employee_benefit_application.py | 4 +- .../doctype/payroll_period/payroll_period.py | 4 +- .../doctype/salary_slip/salary_slip.py | 16 +- erpnext/public/js/utils.js | 11 + .../emails/anniversary_reminder.html | 25 ++ .../templates/emails/holiday_reminder.html | 16 ++ 18 files changed, 691 insertions(+), 168 deletions(-) create mode 100644 erpnext/hr/doctype/employee/employee_reminders.py create mode 100644 erpnext/hr/doctype/employee/test_employee_reminders.py create mode 100644 erpnext/patches/v13_0/rename_stop_to_send_birthday_reminders.py create mode 100644 erpnext/templates/emails/anniversary_reminder.html create mode 100644 erpnext/templates/emails/holiday_reminder.html diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 74977cd8bca..4854bfd1e16 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -355,7 +355,8 @@ scheduler_events = { "erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity", "erpnext.controllers.accounts_controller.update_invoice_status", "erpnext.accounts.doctype.fiscal_year.fiscal_year.auto_create_fiscal_year", - "erpnext.hr.doctype.employee.employee.send_birthday_reminders", + "erpnext.hr.doctype.employee.employee_reminders.send_work_anniversary_reminders", + "erpnext.hr.doctype.employee.employee_reminders.send_birthday_reminders", "erpnext.projects.doctype.task.task.set_tasks_as_overdue", "erpnext.assets.doctype.asset.depreciation.post_depreciation_entries", "erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.send_summary", @@ -387,6 +388,12 @@ scheduler_events = { "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", "erpnext.crm.doctype.lead.lead.daily_open_lead" ], + "weekly": [ + "erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly" + ], + "monthly": [ + "erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly" + ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_demand_loans" diff --git a/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py index 0d7fded921b..3db81654a65 100644 --- a/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py +++ b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py @@ -8,7 +8,7 @@ from frappe import _ from frappe.utils import date_diff, add_days, getdate, cint, format_date from frappe.model.document import Document from erpnext.hr.utils import validate_dates, validate_overlap, get_leave_period, validate_active_employee, \ - get_holidays_for_employee, create_additional_leave_ledger_entry + create_additional_leave_ledger_entry, get_holiday_dates_for_employee class CompensatoryLeaveRequest(Document): @@ -39,7 +39,7 @@ class CompensatoryLeaveRequest(Document): frappe.throw(_("You are not present all day(s) between compensatory leave request days")) def validate_holidays(self): - holidays = get_holidays_for_employee(self.employee, self.work_from_date, self.work_end_date) + holidays = get_holiday_dates_for_employee(self.employee, self.work_from_date, self.work_end_date) if len(holidays) < date_diff(self.work_end_date, self.work_from_date) + 1: if date_diff(self.work_end_date, self.work_from_date): msg = _("The days between {0} to {1} are not valid holidays.").format(frappe.bold(format_date(self.work_from_date)), frappe.bold(format_date(self.work_end_date))) diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index f4280152c5c..643f3da2ff7 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -1,7 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - -from __future__ import unicode_literals import frappe from frappe.utils import getdate, validate_email_address, today, add_years, cstr @@ -9,7 +7,6 @@ from frappe.model.naming import set_name_by_naming_series from frappe import throw, _, scrub from frappe.permissions import add_user_permission, remove_user_permission, \ set_user_permission_if_allowed, has_permission, get_doc_permissions -from frappe.model.document import Document from erpnext.utilities.transaction_base import delete_events from frappe.utils.nestedset import NestedSet @@ -286,94 +283,8 @@ def update_user_permissions(doc, method): employee = frappe.get_doc("Employee", {"user_id": doc.name}) employee.update_user_permissions() -def send_birthday_reminders(): - """Send Employee birthday reminders if no 'Stop Birthday Reminders' is not set.""" - if int(frappe.db.get_single_value("HR Settings", "stop_birthday_reminders") or 0): - return - - employees_born_today = get_employees_who_are_born_today() - - for company, birthday_persons in employees_born_today.items(): - employee_emails = get_all_employee_emails(company) - birthday_person_emails = [get_employee_email(doc) for doc in birthday_persons] - recipients = list(set(employee_emails) - set(birthday_person_emails)) - - reminder_text, message = get_birthday_reminder_text_and_message(birthday_persons) - send_birthday_reminder(recipients, reminder_text, birthday_persons, message) - - if len(birthday_persons) > 1: - # special email for people sharing birthdays - for person in birthday_persons: - person_email = person["user_id"] or person["personal_email"] or person["company_email"] - others = [d for d in birthday_persons if d != person] - reminder_text, message = get_birthday_reminder_text_and_message(others) - send_birthday_reminder(person_email, reminder_text, others, message) - def get_employee_email(employee_doc): - return employee_doc["user_id"] or employee_doc["personal_email"] or employee_doc["company_email"] - -def get_birthday_reminder_text_and_message(birthday_persons): - if len(birthday_persons) == 1: - birthday_person_text = birthday_persons[0]['name'] - else: - # converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim - person_names = [d['name'] for d in birthday_persons] - last_person = person_names[-1] - birthday_person_text = ", ".join(person_names[:-1]) - birthday_person_text = _("{} & {}").format(birthday_person_text, last_person) - - reminder_text = _("Today is {0}'s birthday πŸŽ‰").format(birthday_person_text) - message = _("A friendly reminder of an important date for our team.") - message += "
" - message += _("Everyone, let’s congratulate {0} on their birthday.").format(birthday_person_text) - - return reminder_text, message - -def send_birthday_reminder(recipients, reminder_text, birthday_persons, message): - frappe.sendmail( - recipients=recipients, - subject=_("Birthday Reminder"), - template="birthday_reminder", - args=dict( - reminder_text=reminder_text, - birthday_persons=birthday_persons, - message=message, - ), - header=_("Birthday Reminder πŸŽ‚") - ) - -def get_employees_who_are_born_today(): - """Get all employee born today & group them based on their company""" - from collections import defaultdict - employees_born_today = frappe.db.multisql({ - "mariadb": """ - SELECT `personal_email`, `company`, `company_email`, `user_id`, `employee_name` AS 'name', `image` - FROM `tabEmployee` - WHERE - DAY(date_of_birth) = DAY(%(today)s) - AND - MONTH(date_of_birth) = MONTH(%(today)s) - AND - `status` = 'Active' - """, - "postgres": """ - SELECT "personal_email", "company", "company_email", "user_id", "employee_name" AS 'name', "image" - FROM "tabEmployee" - WHERE - DATE_PART('day', "date_of_birth") = date_part('day', %(today)s) - AND - DATE_PART('month', "date_of_birth") = date_part('month', %(today)s) - AND - "status" = 'Active' - """, - }, dict(today=today()), as_dict=1) - - grouped_employees = defaultdict(lambda: []) - - for employee_doc in employees_born_today: - grouped_employees[employee_doc.get('company')].append(employee_doc) - - return grouped_employees + return employee_doc.get("user_id") or employee_doc.get("personal_email") or employee_doc.get("company_email") def get_holiday_list_for_employee(employee, raise_exception=True): if employee: @@ -390,17 +301,40 @@ def get_holiday_list_for_employee(employee, raise_exception=True): return holiday_list -def is_holiday(employee, date=None, raise_exception=True): - '''Returns True if given Employee has an holiday on the given date - :param employee: Employee `name` - :param date: Date to check. Will check for today if None''' +def is_holiday(employee, date=None, raise_exception=True, only_non_weekly=False, with_description=False): + ''' + Returns True if given Employee has an holiday on the given date + :param employee: Employee `name` + :param date: Date to check. Will check for today if None + :param raise_exception: Raise an exception if no holiday list found, default is True + :param only_non_weekly: Check only non-weekly holidays, default is False + ''' holiday_list = get_holiday_list_for_employee(employee, raise_exception) if not date: date = today() - if holiday_list: - return frappe.get_all('Holiday List', dict(name=holiday_list, holiday_date=date)) and True or False + if not holiday_list: + return False + + filters = { + 'parent': holiday_list, + 'holiday_date': date + } + if only_non_weekly: + filters['weekly_off'] = False + + holidays = frappe.get_all( + 'Holiday', + fields=['description'], + filters=filters, + pluck='description' + ) + + if with_description: + return len(holidays) > 0, holidays + + return len(holidays) > 0 @frappe.whitelist() def deactivate_sales_person(status = None, employee = None): @@ -503,7 +437,6 @@ def get_children(doctype, parent=None, company=None, is_root=False, is_tree=Fals return employees - def on_doctype_update(): frappe.db.add_index("Employee", ["lft", "rgt"]) diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py new file mode 100644 index 00000000000..2155c027a9b --- /dev/null +++ b/erpnext/hr/doctype/employee/employee_reminders.py @@ -0,0 +1,247 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import comma_sep, getdate, today, add_months, add_days +from erpnext.hr.doctype.employee.employee import get_all_employee_emails, get_employee_email +from erpnext.hr.utils import get_holidays_for_employee + +# ----------------- +# HOLIDAY REMINDERS +# ----------------- +def send_reminders_in_advance_weekly(): + to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders") or 1) + frequency = frappe.db.get_single_value("HR Settings", "frequency") + if not (to_send_in_advance and frequency == "Weekly"): + return + + send_advance_holiday_reminders("Weekly") + +def send_reminders_in_advance_monthly(): + to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders") or 1) + frequency = frappe.db.get_single_value("HR Settings", "frequency") + if not (to_send_in_advance and frequency == "Monthly"): + return + + send_advance_holiday_reminders("Monthly") + +def send_advance_holiday_reminders(frequency): + """Send Holiday Reminders in Advance to Employees + `frequency` (str): 'Weekly' or 'Monthly' + """ + if frequency == "Weekly": + start_date = getdate() + end_date = add_days(getdate(), 7) + elif frequency == "Monthly": + # Sent on 1st of every month + start_date = getdate() + end_date = add_months(getdate(), 1) + else: + return + + employees = frappe.db.get_all('Employee', pluck='name') + for employee in employees: + holidays = get_holidays_for_employee( + employee, + start_date, end_date, + only_non_weekly=True, + raise_exception=False + ) + + if not (holidays is None): + send_holidays_reminder_in_advance(employee, holidays) + +def send_holidays_reminder_in_advance(employee, holidays): + employee_doc = frappe.get_doc('Employee', employee) + employee_email = get_employee_email(employee_doc) + frequency = frappe.db.get_single_value("HR Settings", "frequency") + + email_header = _("Holidays this Month.") if frequency == "Monthly" else _("Holidays this Week.") + frappe.sendmail( + recipients=[employee_email], + subject=_("Upcoming Holidays Reminder"), + template="holiday_reminder", + args=dict( + reminder_text=_("Hey {}! This email is to remind you about the upcoming holidays.").format(employee_doc.get('first_name')), + message=_("Below is the list of upcoming holidays for you:"), + advance_holiday_reminder=True, + holidays=holidays, + frequency=frequency[:-2] + ), + header=email_header + ) + +# ------------------ +# BIRTHDAY REMINDERS +# ------------------ +def send_birthday_reminders(): + """Send Employee birthday reminders if no 'Stop Birthday Reminders' is not set.""" + to_send = int(frappe.db.get_single_value("HR Settings", "send_birthday_reminders") or 1) + if not to_send: + return + + employees_born_today = get_employees_who_are_born_today() + + for company, birthday_persons in employees_born_today.items(): + employee_emails = get_all_employee_emails(company) + birthday_person_emails = [get_employee_email(doc) for doc in birthday_persons] + recipients = list(set(employee_emails) - set(birthday_person_emails)) + + reminder_text, message = get_birthday_reminder_text_and_message(birthday_persons) + send_birthday_reminder(recipients, reminder_text, birthday_persons, message) + + if len(birthday_persons) > 1: + # special email for people sharing birthdays + for person in birthday_persons: + person_email = person["user_id"] or person["personal_email"] or person["company_email"] + others = [d for d in birthday_persons if d != person] + reminder_text, message = get_birthday_reminder_text_and_message(others) + send_birthday_reminder(person_email, reminder_text, others, message) + +def get_birthday_reminder_text_and_message(birthday_persons): + if len(birthday_persons) == 1: + birthday_person_text = birthday_persons[0]['name'] + else: + # converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim + person_names = [d['name'] for d in birthday_persons] + birthday_person_text = comma_sep(person_names, frappe._("{0} & {1}"), False) + + reminder_text = _("Today is {0}'s birthday πŸŽ‰").format(birthday_person_text) + message = _("A friendly reminder of an important date for our team.") + message += "
" + message += _("Everyone, let’s congratulate {0} on their birthday.").format(birthday_person_text) + + return reminder_text, message + +def send_birthday_reminder(recipients, reminder_text, birthday_persons, message): + frappe.sendmail( + recipients=recipients, + subject=_("Birthday Reminder"), + template="birthday_reminder", + args=dict( + reminder_text=reminder_text, + birthday_persons=birthday_persons, + message=message, + ), + header=_("Birthday Reminder πŸŽ‚") + ) + +def get_employees_who_are_born_today(): + """Get all employee born today & group them based on their company""" + return get_employees_having_an_event_today("birthday") + +def get_employees_having_an_event_today(event_type): + """Get all employee who have `event_type` today + & group them based on their company. `event_type` + can be `birthday` or `work_anniversary`""" + + from collections import defaultdict + + # Set column based on event type + if event_type == 'birthday': + condition_column = 'date_of_birth' + elif event_type == 'work_anniversary': + condition_column = 'date_of_joining' + else: + return + + employees_born_today = frappe.db.multisql({ + "mariadb": f""" + SELECT `personal_email`, `company`, `company_email`, `user_id`, `employee_name` AS 'name', `image`, `date_of_joining` + FROM `tabEmployee` + WHERE + DAY({condition_column}) = DAY(%(today)s) + AND + MONTH({condition_column}) = MONTH(%(today)s) + AND + `status` = 'Active' + """, + "postgres": f""" + SELECT "personal_email", "company", "company_email", "user_id", "employee_name" AS 'name', "image" + FROM "tabEmployee" + WHERE + DATE_PART('day', {condition_column}) = date_part('day', %(today)s) + AND + DATE_PART('month', {condition_column}) = date_part('month', %(today)s) + AND + "status" = 'Active' + """, + }, dict(today=today(), condition_column=condition_column), as_dict=1) + + grouped_employees = defaultdict(lambda: []) + + for employee_doc in employees_born_today: + grouped_employees[employee_doc.get('company')].append(employee_doc) + + return grouped_employees + + +# -------------------------- +# WORK ANNIVERSARY REMINDERS +# -------------------------- +def send_work_anniversary_reminders(): + """Send Employee Work Anniversary Reminders if 'Send Work Anniversary Reminders' is checked""" + to_send = int(frappe.db.get_single_value("HR Settings", "send_work_anniversary_reminders") or 1) + if not to_send: + return + + employees_joined_today = get_employees_having_an_event_today("work_anniversary") + + for company, anniversary_persons in employees_joined_today.items(): + employee_emails = get_all_employee_emails(company) + anniversary_person_emails = [get_employee_email(doc) for doc in anniversary_persons] + recipients = list(set(employee_emails) - set(anniversary_person_emails)) + + reminder_text, message = get_work_anniversary_reminder_text_and_message(anniversary_persons) + send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message) + + if len(anniversary_persons) > 1: + # email for people sharing work anniversaries + for person in anniversary_persons: + person_email = person["user_id"] or person["personal_email"] or person["company_email"] + others = [d for d in anniversary_persons if d != person] + reminder_text, message = get_work_anniversary_reminder_text_and_message(others) + send_work_anniversary_reminder(person_email, reminder_text, others, message) + +def get_work_anniversary_reminder_text_and_message(anniversary_persons): + if len(anniversary_persons) == 1: + anniversary_person = anniversary_persons[0]['name'] + persons_name = anniversary_person + # Number of years completed at the company + completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year + anniversary_person += f" completed {completed_years} years" + else: + person_names_with_years = [] + names = [] + for person in anniversary_persons: + person_text = person['name'] + names.append(person_text) + # Number of years completed at the company + completed_years = getdate().year - person['date_of_joining'].year + person_text += f" completed {completed_years} years" + person_names_with_years.append(person_text) + + # converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim + anniversary_person = comma_sep(person_names_with_years, frappe._("{0} & {1}"), False) + persons_name = comma_sep(names, frappe._("{0} & {1}"), False) + + reminder_text = _("Today {0} at our Company! πŸŽ‰").format(anniversary_person) + message = _("A friendly reminder of an important date for our team.") + message += "
" + message += _("Everyone, let’s congratulate {0} on their work anniversary!").format(persons_name) + + return reminder_text, message + +def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message): + frappe.sendmail( + recipients=recipients, + subject=_("Work Anniversary Reminder"), + template="anniversary_reminder", + args=dict( + reminder_text=reminder_text, + anniversary_persons=anniversary_persons, + message=message, + ), + header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️") + ) diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py index 8fc7cf19343..5feb6de8f2b 100644 --- a/erpnext/hr/doctype/employee/test_employee.py +++ b/erpnext/hr/doctype/employee/test_employee.py @@ -1,7 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals - import frappe import erpnext @@ -12,29 +10,6 @@ from erpnext.hr.doctype.employee.employee import InactiveEmployeeStatusError test_records = frappe.get_test_records('Employee') class TestEmployee(unittest.TestCase): - def test_birthday_reminders(self): - employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) - employee.date_of_birth = "1992" + frappe.utils.nowdate()[4:] - employee.company_email = "test@example.com" - employee.company = "_Test Company" - employee.save() - - from erpnext.hr.doctype.employee.employee import get_employees_who_are_born_today, send_birthday_reminders - - employees_born_today = get_employees_who_are_born_today() - self.assertTrue(employees_born_today.get("_Test Company")) - - frappe.db.sql("delete from `tabEmail Queue`") - - hr_settings = frappe.get_doc("HR Settings", "HR Settings") - hr_settings.stop_birthday_reminders = 0 - hr_settings.save() - - send_birthday_reminders() - - email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) - self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message) - def test_employee_status_left(self): employee1 = make_employee("test_employee_1@company.com") employee2 = make_employee("test_employee_2@company.com") diff --git a/erpnext/hr/doctype/employee/test_employee_reminders.py b/erpnext/hr/doctype/employee/test_employee_reminders.py new file mode 100644 index 00000000000..7e560f512d1 --- /dev/null +++ b/erpnext/hr/doctype/employee/test_employee_reminders.py @@ -0,0 +1,173 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +import unittest + +from frappe.utils import getdate +from datetime import timedelta +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change + + +class TestEmployeeReminders(unittest.TestCase): + @classmethod + def setUpClass(cls): + from erpnext.hr.doctype.holiday_list.test_holiday_list import make_holiday_list + + # Create a test holiday list + test_holiday_dates = cls.get_test_holiday_dates() + test_holiday_list = make_holiday_list( + 'TestHolidayRemindersList', + holiday_dates=[ + {'holiday_date': test_holiday_dates[0], 'description': 'test holiday1'}, + {'holiday_date': test_holiday_dates[1], 'description': 'test holiday2'}, + {'holiday_date': test_holiday_dates[2], 'description': 'test holiday3', 'weekly_off': 1}, + {'holiday_date': test_holiday_dates[3], 'description': 'test holiday4'}, + {'holiday_date': test_holiday_dates[4], 'description': 'test holiday5'}, + {'holiday_date': test_holiday_dates[5], 'description': 'test holiday6'}, + ], + from_date=getdate()-timedelta(days=10), + to_date=getdate()+timedelta(weeks=5) + ) + + # Create a test employee + test_employee = frappe.get_doc( + 'Employee', + make_employee('test@gopher.io', company="_Test Company") + ) + + # Attach the holiday list to employee + test_employee.holiday_list = test_holiday_list.name + test_employee.save() + + # Attach to class + cls.test_employee = test_employee + cls.test_holiday_dates = test_holiday_dates + + @classmethod + def get_test_holiday_dates(cls): + today_date = getdate() + return [ + today_date, + today_date-timedelta(days=4), + today_date-timedelta(days=3), + today_date+timedelta(days=1), + today_date+timedelta(days=3), + today_date+timedelta(weeks=3) + ] + + def setUp(self): + # Clear Email Queue + frappe.db.sql("delete from `tabEmail Queue`") + + def test_is_holiday(self): + from erpnext.hr.doctype.employee.employee import is_holiday + + self.assertTrue(is_holiday(self.test_employee.name)) + self.assertTrue(is_holiday(self.test_employee.name, date=self.test_holiday_dates[1])) + self.assertFalse(is_holiday(self.test_employee.name, date=getdate()-timedelta(days=1))) + + # Test weekly_off holidays + self.assertTrue(is_holiday(self.test_employee.name, date=self.test_holiday_dates[2])) + self.assertFalse(is_holiday(self.test_employee.name, date=self.test_holiday_dates[2], only_non_weekly=True)) + + # Test with descriptions + has_holiday, descriptions = is_holiday(self.test_employee.name, with_description=True) + self.assertTrue(has_holiday) + self.assertTrue('test holiday1' in descriptions) + + def test_birthday_reminders(self): + employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + employee.date_of_birth = "1992" + frappe.utils.nowdate()[4:] + employee.company_email = "test@example.com" + employee.company = "_Test Company" + employee.save() + + from erpnext.hr.doctype.employee.employee_reminders import get_employees_who_are_born_today, send_birthday_reminders + + employees_born_today = get_employees_who_are_born_today() + self.assertTrue(employees_born_today.get("_Test Company")) + + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_birthday_reminders = 1 + hr_settings.save() + + send_birthday_reminders() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message) + + def test_work_anniversary_reminders(self): + employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:] + employee.company_email = "test@example.com" + employee.company = "_Test Company" + employee.save() + + from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today, send_work_anniversary_reminders + + employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary') + self.assertTrue(employees_having_work_anniversary.get("_Test Company")) + + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_work_anniversary_reminders = 1 + hr_settings.save() + + send_work_anniversary_reminders() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message) + + def test_send_holidays_reminder_in_advance(self): + from erpnext.hr.utils import get_holidays_for_employee + from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance + + # Get HR settings and enable advance holiday reminders + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_holiday_reminders = 1 + set_proceed_with_frequency_change() + hr_settings.frequency = 'Weekly' + hr_settings.save() + + holidays = get_holidays_for_employee( + self.test_employee.get('name'), + getdate(), getdate() + timedelta(days=3), + only_non_weekly=True, + raise_exception=False + ) + + send_holidays_reminder_in_advance( + self.test_employee.get('name'), + holidays + ) + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertEqual(len(email_queue), 1) + + def test_advance_holiday_reminders_monthly(self): + from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly + # Get HR settings and enable advance holiday reminders + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_holiday_reminders = 1 + set_proceed_with_frequency_change() + hr_settings.frequency = 'Monthly' + hr_settings.save() + + send_reminders_in_advance_monthly() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue(len(email_queue) > 0) + + def test_advance_holiday_reminders_weekly(self): + from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly + # Get HR settings and enable advance holiday reminders + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_holiday_reminders = 1 + hr_settings.frequency = 'Weekly' + hr_settings.save() + + send_reminders_in_advance_weekly() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue(len(email_queue) > 0) diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json index 2396a8eee92..8aa3c0ca9f1 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.json +++ b/erpnext/hr/doctype/hr_settings/hr_settings.json @@ -11,8 +11,14 @@ "emp_created_by", "column_break_4", "standard_working_hours", - "stop_birthday_reminders", "expense_approver_mandatory_in_expense_claim", + "reminders_section", + "send_birthday_reminders", + "column_break_9", + "send_work_anniversary_reminders", + "column_break_11", + "send_holiday_reminders", + "frequency", "leave_settings", "send_leave_notification", "leave_approval_notification_template", @@ -50,13 +56,6 @@ "fieldname": "column_break_4", "fieldtype": "Column Break" }, - { - "default": "0", - "description": "Don't send employee birthday reminders", - "fieldname": "stop_birthday_reminders", - "fieldtype": "Check", - "label": "Stop Birthday Reminders" - }, { "default": "1", "fieldname": "expense_approver_mandatory_in_expense_claim", @@ -142,13 +141,53 @@ "fieldname": "standard_working_hours", "fieldtype": "Int", "label": "Standard Working Hours" + }, + { + "collapsible": 1, + "fieldname": "reminders_section", + "fieldtype": "Section Break", + "label": "Reminders" + }, + { + "default": "1", + "fieldname": "send_holiday_reminders", + "fieldtype": "Check", + "label": "Holidays" + }, + { + "default": "1", + "fieldname": "send_work_anniversary_reminders", + "fieldtype": "Check", + "label": "Work Anniversaries " + }, + { + "default": "Weekly", + "depends_on": "eval:doc.send_holiday_reminders", + "fieldname": "frequency", + "fieldtype": "Select", + "label": "Set the frequency for holiday reminders", + "options": "Weekly\nMonthly" + }, + { + "default": "1", + "fieldname": "send_birthday_reminders", + "fieldtype": "Check", + "label": "Birthdays" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2021-05-11 10:52:56.192773", + "modified": "2021-08-24 14:54:12.834162", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.py b/erpnext/hr/doctype/hr_settings/hr_settings.py index c99df269cc9..a47409363c7 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.py +++ b/erpnext/hr/doctype/hr_settings/hr_settings.py @@ -1,17 +1,79 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt # For license information, please see license.txt -from __future__ import unicode_literals import frappe + from frappe.model.document import Document +from frappe.utils import format_date + +# Wether to proceed with frequency change +PROCEED_WITH_FREQUENCY_CHANGE = False class HRSettings(Document): def validate(self): self.set_naming_series() + # Based on proceed flag + global PROCEED_WITH_FREQUENCY_CHANGE + if not PROCEED_WITH_FREQUENCY_CHANGE: + self.validate_frequency_change() + PROCEED_WITH_FREQUENCY_CHANGE = False + def set_naming_series(self): from erpnext.setup.doctype.naming_series.naming_series import set_by_naming_series set_by_naming_series("Employee", "employee_number", self.get("emp_created_by")=="Naming Series", hide_name_field=True) + + def validate_frequency_change(self): + weekly_job, monthly_job = None, None + + try: + weekly_job = frappe.get_doc( + 'Scheduled Job Type', + 'employee_reminders.send_reminders_in_advance_weekly' + ) + + monthly_job = frappe.get_doc( + 'Scheduled Job Type', + 'employee_reminders.send_reminders_in_advance_monthly' + ) + except frappe.DoesNotExistError: + return + + next_weekly_trigger = weekly_job.get_next_execution() + next_monthly_trigger = monthly_job.get_next_execution() + + if self.freq_changed_from_monthly_to_weekly(): + if next_monthly_trigger < next_weekly_trigger: + self.show_freq_change_warning(next_monthly_trigger, next_weekly_trigger) + + elif self.freq_changed_from_weekly_to_monthly(): + if next_monthly_trigger > next_weekly_trigger: + self.show_freq_change_warning(next_weekly_trigger, next_monthly_trigger) + + def freq_changed_from_weekly_to_monthly(self): + return self.has_value_changed("frequency") and self.frequency == "Monthly" + + def freq_changed_from_monthly_to_weekly(self): + return self.has_value_changed("frequency") and self.frequency == "Weekly" + + def show_freq_change_warning(self, from_date, to_date): + from_date = frappe.bold(format_date(from_date)) + to_date = frappe.bold(format_date(to_date)) + frappe.msgprint( + msg=frappe._('Employees will miss holiday reminders from {} until {}.
Do you want to proceed with this change?').format(from_date, to_date), + title='Confirm change in Frequency', + primary_action={ + 'label': frappe._('Yes, Proceed'), + 'client_action': 'erpnext.proceed_save_with_reminders_frequency_change' + }, + raise_exception=frappe.ValidationError + ) + +@frappe.whitelist() +def set_proceed_with_frequency_change(): + '''Enables proceed with frequency change''' + global PROCEED_WITH_FREQUENCY_CHANGE + PROCEED_WITH_FREQUENCY_CHANGE = True diff --git a/erpnext/hr/doctype/upload_attendance/upload_attendance.py b/erpnext/hr/doctype/upload_attendance/upload_attendance.py index 674c8e3eb45..9c765d73716 100644 --- a/erpnext/hr/doctype/upload_attendance/upload_attendance.py +++ b/erpnext/hr/doctype/upload_attendance/upload_attendance.py @@ -10,7 +10,7 @@ from frappe import _ from frappe.utils.csvutils import UnicodeWriter from frappe.model.document import Document from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee -from erpnext.hr.utils import get_holidays_for_employee +from erpnext.hr.utils import get_holiday_dates_for_employee class UploadAttendance(Document): pass @@ -94,7 +94,7 @@ def get_holidays_for_employees(employees, from_date, to_date): holidays = {} for employee in employees: holiday_list = get_holiday_list_for_employee(employee) - holiday = get_holidays_for_employee(employee, getdate(from_date), getdate(to_date)) + holiday = get_holiday_dates_for_employee(employee, getdate(from_date), getdate(to_date)) if holiday_list not in holidays: holidays[holiday_list] = holiday diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index a1026ce055b..15b237d93cd 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -335,21 +335,44 @@ def get_sal_slip_total_benefit_given(employee, payroll_period, component=False): total_given_benefit_amount = sum_of_given_benefit[0].total_amount return total_given_benefit_amount -def get_holidays_for_employee(employee, start_date, end_date): - holiday_list = get_holiday_list_for_employee(employee) +def get_holiday_dates_for_employee(employee, start_date, end_date): + """return a list of holiday dates for the given employee between start_date and end_date""" + # return only date + holidays = get_holidays_for_employee(employee, start_date, end_date) + + return [cstr(h.holiday_date) for h in holidays] - holidays = frappe.db.sql_list('''select holiday_date from `tabHoliday` - where - parent=%(holiday_list)s - and holiday_date >= %(start_date)s - and holiday_date <= %(end_date)s''', { - "holiday_list": holiday_list, - "start_date": start_date, - "end_date": end_date - }) - holidays = [cstr(i) for i in holidays] +def get_holidays_for_employee(employee, start_date, end_date, raise_exception=True, only_non_weekly=False): + """Get Holidays for a given employee + `employee` (str) + `start_date` (str or datetime) + `end_date` (str or datetime) + `raise_exception` (bool) + `only_non_weekly` (bool) + + return: list of dicts with `holiday_date` and `description` + """ + holiday_list = get_holiday_list_for_employee(employee, raise_exception=raise_exception) + + if not holiday_list: + return [] + + filters = { + 'parent': holiday_list, + 'holiday_date': ('between', [start_date, end_date]) + } + + if only_non_weekly: + filters['weekly_off'] = False + + holidays = frappe.get_all( + 'Holiday', + fields=['description', 'holiday_date'], + filters=filters + ) + return holidays @erpnext.allow_regional diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b86c236a7fb..bf0446bb686 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -275,6 +275,7 @@ erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting erpnext.patches.v13_0.germany_make_custom_fields erpnext.patches.v13_0.germany_fill_debtor_creditor_number erpnext.patches.v13_0.set_pos_closing_as_failed +erpnext.patches.v13_0.rename_stop_to_send_birthday_reminders execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True) erpnext.patches.v13_0.update_timesheet_changes erpnext.patches.v13_0.add_doctype_to_sla #14-06-2021 diff --git a/erpnext/patches/v13_0/rename_stop_to_send_birthday_reminders.py b/erpnext/patches/v13_0/rename_stop_to_send_birthday_reminders.py new file mode 100644 index 00000000000..1787a560254 --- /dev/null +++ b/erpnext/patches/v13_0/rename_stop_to_send_birthday_reminders.py @@ -0,0 +1,23 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + +def execute(): + frappe.reload_doc('hr', 'doctype', 'hr_settings') + + try: + # Rename the field + rename_field('HR Settings', 'stop_birthday_reminders', 'send_birthday_reminders') + + # Reverse the value + old_value = frappe.db.get_single_value('HR Settings', 'send_birthday_reminders') + + frappe.db.set_value( + 'HR Settings', + 'HR Settings', + 'send_birthday_reminders', + 1 if old_value == 0 else 0 + ) + + except Exception as e: + if e.args[0] != 1054: + raise \ No newline at end of file diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py index c7fbb06b100..a1cde08a74c 100644 --- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py +++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py @@ -9,7 +9,7 @@ from frappe.utils import date_diff, getdate, rounded, add_days, cstr, cint, flt from frappe.model.document import Document from erpnext.payroll.doctype.payroll_period.payroll_period import get_payroll_period_days, get_period_factor from erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment import get_assigned_salary_structure -from erpnext.hr.utils import get_sal_slip_total_benefit_given, get_holidays_for_employee, get_previous_claimed_amount, validate_active_employee +from erpnext.hr.utils import get_sal_slip_total_benefit_given, get_holiday_dates_for_employee, get_previous_claimed_amount, validate_active_employee class EmployeeBenefitApplication(Document): def validate(self): @@ -139,7 +139,7 @@ def get_max_benefits_remaining(employee, on_date, payroll_period): # Then the sum multiply with the no of lwp in that period # Include that amount to the prev_sal_slip_flexi_total to get the actual if have_depends_on_payment_days and per_day_amount_total > 0: - holidays = get_holidays_for_employee(employee, payroll_period_obj.start_date, on_date) + holidays = get_holiday_dates_for_employee(employee, payroll_period_obj.start_date, on_date) working_days = date_diff(on_date, payroll_period_obj.start_date) + 1 leave_days = calculate_lwp(employee, payroll_period_obj.start_date, holidays, working_days) leave_days_amount = leave_days * per_day_amount_total diff --git a/erpnext/payroll/doctype/payroll_period/payroll_period.py b/erpnext/payroll/doctype/payroll_period/payroll_period.py index ef3a6cc0061..66dec075d8f 100644 --- a/erpnext/payroll/doctype/payroll_period/payroll_period.py +++ b/erpnext/payroll/doctype/payroll_period/payroll_period.py @@ -7,7 +7,7 @@ import frappe from frappe import _ from frappe.utils import date_diff, getdate, formatdate, cint, month_diff, flt, add_months from frappe.model.document import Document -from erpnext.hr.utils import get_holidays_for_employee +from erpnext.hr.utils import get_holiday_dates_for_employee class PayrollPeriod(Document): def validate(self): @@ -65,7 +65,7 @@ def get_payroll_period_days(start_date, end_date, employee, company=None): actual_no_of_days = date_diff(getdate(payroll_period[0][2]), getdate(payroll_period[0][1])) + 1 working_days = actual_no_of_days if not cint(frappe.db.get_value("Payroll Settings", None, "include_holidays_in_total_working_days")): - holidays = get_holidays_for_employee(employee, getdate(payroll_period[0][1]), getdate(payroll_period[0][2])) + holidays = get_holiday_dates_for_employee(employee, getdate(payroll_period[0][1]), getdate(payroll_period[0][2])) working_days -= len(holidays) return payroll_period[0][0], working_days, actual_no_of_days return False, False, False diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 5f5fdd5c830..6325351debc 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -11,6 +11,7 @@ from frappe.model.naming import make_autoname from frappe import msgprint, _ from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee +from erpnext.hr.utils import get_holiday_dates_for_employee from erpnext.utilities.transaction_base import TransactionBase from frappe.utils.background_jobs import enqueue from erpnext.payroll.doctype.additional_salary.additional_salary import get_additional_salaries @@ -337,20 +338,7 @@ class SalarySlip(TransactionBase): return payment_days def get_holidays_for_employee(self, start_date, end_date): - holiday_list = get_holiday_list_for_employee(self.employee) - holidays = frappe.db.sql_list('''select holiday_date from `tabHoliday` - where - parent=%(holiday_list)s - and holiday_date >= %(start_date)s - and holiday_date <= %(end_date)s''', { - "holiday_list": holiday_list, - "start_date": start_date, - "end_date": end_date - }) - - holidays = [cstr(i) for i in holidays] - - return holidays + return get_holiday_dates_for_employee(self.employee, start_date, end_date) def calculate_lwp_or_ppl_based_on_leave_application(self, holidays, working_days): lwp = 0 diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index f240b8ca91f..9caf1defe97 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -82,6 +82,17 @@ $.extend(erpnext, { }); frappe.set_route('Form','Journal Entry', journal_entry.name); }); + }, + + proceed_save_with_reminders_frequency_change: () => { + frappe.ui.hide_open_dialog(); + + frappe.call({ + method: 'erpnext.hr.doctype.hr_settings.hr_settings.set_proceed_with_frequency_change', + callback: () => { + cur_frm.save(); + } + }); } }); diff --git a/erpnext/templates/emails/anniversary_reminder.html b/erpnext/templates/emails/anniversary_reminder.html new file mode 100644 index 00000000000..ac9f7e4993a --- /dev/null +++ b/erpnext/templates/emails/anniversary_reminder.html @@ -0,0 +1,25 @@ +
+
+ {% for person in anniversary_persons %} + {% if person.image %} + + + {% else %} + + {{ frappe.utils.get_abbr(person.name) }} + + {% endif %} + {% endfor %} +
+
+ {{ reminder_text }} +

{{ message }}

+
+
\ No newline at end of file diff --git a/erpnext/templates/emails/holiday_reminder.html b/erpnext/templates/emails/holiday_reminder.html new file mode 100644 index 00000000000..e38d27bf8bc --- /dev/null +++ b/erpnext/templates/emails/holiday_reminder.html @@ -0,0 +1,16 @@ +
+ {{ reminder_text }} +

{{ message }}

+
+ +{% if advance_holiday_reminder %} + {% if holidays | len > 0 %} +
    + {% for holiday in holidays %} +
  1. {{ frappe.format(holiday.holiday_date, 'Date') }} - {{ holiday.description }}
  2. + {% endfor %} +
+ {% else %} +

You don't have no upcoming holidays this {{ frequency }}.

+ {% endif %} +{% endif %}