From 22318b2eb68cfbfc5b3f18cf906a07a07a014873 Mon Sep 17 00:00:00 2001 From: Anupam K Date: Sat, 18 Apr 2020 00:45:18 +0530 Subject: [PATCH] Linkedin, Twitter Integration --- .../crm/doctype/linkedin_settings/__init__.py | 0 .../linkedin_settings/linkedin_settings.js | 66 ++++++++ .../linkedin_settings/linkedin_settings.json | 111 +++++++++++++ .../linkedin_settings/linkedin_settings.py | 157 ++++++++++++++++++ .../test_linkedin_settings.py | 10 ++ .../crm/doctype/social_media_post/__init__.py | 0 .../social_media_post/social_media_post.js | 54 ++++++ .../social_media_post/social_media_post.json | 157 ++++++++++++++++++ .../social_media_post/social_media_post.py | 49 ++++++ .../social_media_post_list.js | 10 ++ .../test_social_media_post.py | 10 ++ .../crm/doctype/twitter_settings/__init__.py | 0 .../twitter_settings/test_twitter_settings.py | 10 ++ .../twitter_settings/twitter_settings.js | 52 ++++++ .../twitter_settings/twitter_settings.json | 101 +++++++++++ .../twitter_settings/twitter_settings.py | 90 ++++++++++ erpnext/hooks.py | 3 +- .../doctype/campaign/campaign_dashboard.py | 6 +- requirements.txt | 1 + 19 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 erpnext/crm/doctype/linkedin_settings/__init__.py create mode 100644 erpnext/crm/doctype/linkedin_settings/linkedin_settings.js create mode 100644 erpnext/crm/doctype/linkedin_settings/linkedin_settings.json create mode 100644 erpnext/crm/doctype/linkedin_settings/linkedin_settings.py create mode 100644 erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py create mode 100644 erpnext/crm/doctype/social_media_post/__init__.py create mode 100644 erpnext/crm/doctype/social_media_post/social_media_post.js create mode 100644 erpnext/crm/doctype/social_media_post/social_media_post.json create mode 100644 erpnext/crm/doctype/social_media_post/social_media_post.py create mode 100644 erpnext/crm/doctype/social_media_post/social_media_post_list.js create mode 100644 erpnext/crm/doctype/social_media_post/test_social_media_post.py create mode 100644 erpnext/crm/doctype/twitter_settings/__init__.py create mode 100644 erpnext/crm/doctype/twitter_settings/test_twitter_settings.py create mode 100644 erpnext/crm/doctype/twitter_settings/twitter_settings.js create mode 100644 erpnext/crm/doctype/twitter_settings/twitter_settings.json create mode 100644 erpnext/crm/doctype/twitter_settings/twitter_settings.py diff --git a/erpnext/crm/doctype/linkedin_settings/__init__.py b/erpnext/crm/doctype/linkedin_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js new file mode 100644 index 0000000000..fd169f8328 --- /dev/null +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js @@ -0,0 +1,66 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// 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){ + frappe.confirm( + 'Session not valid, Do you want to login?', + function(){ + frm.trigger("login"); + }, + function(){ + window.close(); + } + ) + } + }, + refresh: function(frm){ + if(frm.doc.session_status=="Expired"){ + frm.dashboard.set_headline_alert( + '
' + + '
' + + ' ' + + '
' + + '
' + ); + } + if(frm.doc.session_status=="Active"){ + let d = new Date(frm.doc.modified) + d.setDate(d.getDate()+60); + let dn = new Date() + let days = d.getTime() - dn.getTime(); + days = Math.floor(days/(1000 * 3600 * 24)); + let msg,color; + if(days>0){ + msg = "Your Session will be expire in " + days + " days."; + color = "green"; + } + else{ + msg = "Session is expired. Save doc to login."; + color = "red"; + } + frm.dashboard.set_headline_alert( + '
' + + '
' + + ' ' + + '
' + + '
' + ); + } + }, + login: function(frm){ + if(frm.doc.consumer_key && frm.doc.consumer_secret){ + frappe.call({ + doc: frm.doc, + method: "get_authorization_url", + callback : function(r) { + window.location.href = r.message; + } + }); + } + }, + 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 new file mode 100644 index 0000000000..9eacb0011c --- /dev/null +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json @@ -0,0 +1,111 @@ +{ + "actions": [], + "creation": "2020-01-30 13:36:39.492931", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "account_name", + "column_break_2", + "company_id", + "oauth_details", + "consumer_key", + "column_break_5", + "consumer_secret", + "user_details_section", + "access_token", + "person_urn", + "session_status" + ], + "fields": [ + { + "fieldname": "account_name", + "fieldtype": "Data", + "label": "Account Name", + "read_only": 1 + }, + { + "fieldname": "oauth_details", + "fieldtype": "Section Break", + "label": "OAuth Credentials" + }, + { + "fieldname": "consumer_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Consumer Key", + "reqd": 1 + }, + { + "fieldname": "consumer_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Consumer Secret", + "reqd": 1 + }, + { + "fieldname": "access_token", + "fieldtype": "Data", + "hidden": 1, + "label": "Access Token", + "read_only": 1 + }, + { + "fieldname": "person_urn", + "fieldtype": "Data", + "hidden": 1, + "label": "Person URN", + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "user_details_section", + "fieldtype": "Section Break", + "label": "User Details" + }, + { + "fieldname": "session_status", + "fieldtype": "Select", + "hidden": 1, + "label": "Session Status", + "options": "Expired\nActive", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "company_id", + "fieldtype": "Data", + "label": "Company ID", + "reqd": 1 + } + ], + "issingle": 1, + "links": [], + "modified": "2020-04-16 23:22:51.966397", + "modified_by": "Administrator", + "module": "CRM", + "name": "LinkedIn Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py new file mode 100644 index 0000000000..9326e6d046 --- /dev/null +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe, requests, json +from frappe import _ +from frappe.utils import get_site_url, get_url_to_form, get_link_to_form +from frappe.model.document import Document +from frappe.utils.file_manager import get_file, get_file_path +from six.moves.urllib.parse import urlencode +class LinkedInSettings(Document): + def get_authorization_url(self): + params = urlencode({ + "response_type":"code", + "client_id": self.consumer_key, + "redirect_uri": get_site_url(frappe.local.site) + "/?cmd=erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback", + "scope": "r_emailaddress w_organization_social r_basicprofile r_liteprofile r_organization_social rw_organization_admin w_member_social" + }) + + url = "https://www.linkedin.com/oauth/v2/authorization?{}".format(params) + + return url + + def get_access_token(self, code): + url = "https://www.linkedin.com/oauth/v2/accessToken" + body = { + "grant_type": "authorization_code", + "code": code, + "client_id": self.consumer_key, + "client_secret": self.get_password(fieldname="consumer_secret"), + "redirect_uri": get_site_url(frappe.local.site) + "/?cmd=erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback", + } + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + response = self.http_post(url=url, data=body, headers=headers) + response = frappe.parse_json(response.content.decode()) + 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 = frappe.parse_json(response.content.decode()) + self.db_set("person_urn", response["id"]) + self.db_set("account_name", response["vanityName"]) + self.db_set("session_status", "Active") + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = get_url_to_form("LinkedIn Settings","LinkedIn Settings") + + def post(self, text, media=None): + if not media: + return self.post_text(text) + else: + media_id = self.upload_image(media) + if media_id: + return self.post_text(text, media_id=media_id) + else: + frappe.log_error("Failed to upload media.","LinkedIn Upload Error") + + + def upload_image(self, media): + media = get_file_path(media) + register_url = "https://api.linkedin.com/v2/assets?action=registerUpload" + + body = { + "registerUploadRequest": { + "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"], + "owner": "urn:li:organization:{0}".format(self.company_id), + "serviceRelationships": [{ + "relationshipType": "OWNER", + "identifier": "urn:li:userGeneratedContent" + }] + } + } + headers = { + "Authorization": "Bearer {}".format(self.access_token) + } + response = self.http_post(url=register_url, body=body, headers=headers) + if response.status_code == 200: + response = response.json() + asset = response["value"]["asset"] + upload_url = response["value"]["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"] + headers['Content-Type']='image/jpeg' + response = self.http_post(upload_url, headers=headers, data=open(media,"rb")) + if response.status_code < 200 and response.status_code > 299: + frappe.throw(_("Error While Uploading Image"), title="{0} {1}".format(response.status_code, response.reason)) + return None + return asset + return None + + + def post_text(self, text, media_id=None): + # url = "https://api.linkedin.com/v2/ugcPosts" + 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" + } + body = { + "distribution": { + "linkedInDistributionTarget": {} + }, + "owner":"urn:li:organization:{0}".format(self.company_id), + "subject": "Test Share Subject", + "text": { + "text": text + } + } + if media_id: + body["content"]= { + "contentEntities": [{ + "entity": media_id + }], + "shareMediaCategory": "IMAGE" + } + response = self.http_post(url=url, headers=headers, body=body) + return response + + def http_post(self, url, headers=None, body=None, data=None): + try: + response = requests.post( + url = url, + json = body, + data = data, + headers = headers + ) + if response.status_code not in [201,200]: + 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) + return response + +@frappe.whitelist() +def callback(code=None, error=None, error_description=None): + if not error: + linkedin_settings = frappe.get_doc("LinkedIn Settings") + linkedin_settings.get_access_token(code) + linkedin_settings.get_member_profile() + frappe.db.commit() + else: + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = get_url_to_form("LinkedIn Settings","LinkedIn Settings") diff --git a/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py new file mode 100644 index 0000000000..9c3ef3f32f --- /dev/null +++ b/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestLinkedInSettings(unittest.TestCase): + pass diff --git a/erpnext/crm/doctype/social_media_post/__init__.py b/erpnext/crm/doctype/social_media_post/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.js b/erpnext/crm/doctype/social_media_post/social_media_post.js new file mode 100644 index 0000000000..c858885d2b --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/social_media_post.js @@ -0,0 +1,54 @@ +// 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.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); + } + 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 += '
' + + ' ' + + '
' ; + } + if(frm.doc.linkedin){ + let color = frm.doc.linkedin_post_id ? "green" : "red"; + let status = frm.doc.linkedin_post_id ? "Posted" : "Not Posted"; + html += '
' + + ' ' + + '
' ; + } + html += '
'; + frm.dashboard.set_headline_alert(html); + } + } +}); +var add_post_btn = function(frm){ + frm.add_custom_button(('Post Now'), function(){ + post(frm); + }); +} +var post = function(frm){ + 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(); + } + }) + +} \ No newline at end of file diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.json b/erpnext/crm/doctype/social_media_post/social_media_post.json new file mode 100644 index 0000000000..e34f02b8b5 --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/social_media_post.json @@ -0,0 +1,157 @@ +{ + "actions": [], + "autoname": "format: CRM-SMP-{YYYY}-{MM}-{DD}-{###}", + "creation": "2020-01-30 11:53:13.872864", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "campaign_name", + "sheduled_time", + "post_status", + "column_break_6", + "twitter", + "linkedin", + "twitter_post_id", + "linkedin_post_id", + "content", + "text", + "column_break_14", + "tweet_preview", + "linkedin_section", + "linkedin_post", + "attachments_section", + "image", + "amended_from" + ], + "fields": [ + { + "fieldname": "text", + "fieldtype": "Small Text", + "label": "Tweet", + "reqd": 1 + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "label": "Image" + }, + { + "default": "0", + "fieldname": "twitter", + "fieldtype": "Check", + "label": "Twitter" + }, + { + "default": "0", + "fieldname": "linkedin", + "fieldtype": "Check", + "label": "LinkedIn" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Social Media Post", + "print_hide": 1, + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "sheduled_time", + "fieldtype": "Datetime", + "label": "Scheduled Time" + }, + { + "fieldname": "content", + "fieldtype": "Section Break", + "label": "Twitter" + }, + { + "allow_on_submit": 1, + "fieldname": "post_status", + "fieldtype": "Select", + "label": "Post Status", + "options": "\nScheduled\nPosted\nError", + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "twitter_post_id", + "fieldtype": "Data", + "hidden": 1, + "label": "Twitter Post Id", + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "linkedin_post_id", + "fieldtype": "Data", + "hidden": 1, + "label": "LinkedIn Post Id", + "read_only": 1 + }, + { + "fieldname": "campaign_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Campaign", + "options": "Campaign" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break", + "label": "Share On" + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "fieldname": "tweet_preview", + "fieldtype": "HTML" + }, + { + "collapsible": 1, + "fieldname": "linkedin_section", + "fieldtype": "Section Break", + "label": "LinkedIn" + }, + { + "collapsible": 1, + "fieldname": "attachments_section", + "fieldtype": "Section Break", + "label": "Attachments" + }, + { + "fieldname": "linkedin_post", + "fieldtype": "Text", + "label": "Post" + } + ], + "is_submittable": 1, + "links": [], + "modified": "2020-04-09 22:35:23.821991", + "modified_by": "Administrator", + "module": "CRM", + "name": "Social Media Post", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "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 new file mode 100644 index 0000000000..8b3387ebf2 --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/social_media_post.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document +from frappe import _ + +class SocialMediaPost(Document): + def submit(self): + if self.sheduled_time: + self.post_status = "Scheduled" + super(SocialMediaPost, self).submit() + + def post(self): + try: + if self.twitter and not self.twitter_post_id: + twitter = frappe.get_doc("Twitter Settings") + twitter_post = twitter.post(self.text, self.image) + 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.text, self.image) + self.db_set("linkedin_post_id", linkedin_post.headers['X-RestLi-Id'].split(":")[-1]) + self.db_set("post_status", "Posted") + + except Exception as e: + 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) + +def process_scheduled_social_media_posts(): + import datetime + posts = frappe.get_list("Social Media Post", filters={"status": "Scheduled"}, fields= ["name", "sheduled_time"]) + start = frappe.utils.now_datetime() + end = start + datetime.timedelta(minutes=59) + for post in posts: + post_time = frappe.utils.get_datetime(post.scheduled_time) + if post_time > start and post_time <= end: + post = frappe.get_doc('Social Media Post',post['name']) + post.post() + +@frappe.whitelist() +def publish(doctype, name): + sm_post = frappe.get_doc(doctype, name) + sm_post.post() + frappe.db.commit() 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 new file mode 100644 index 0000000000..c60b91a9a0 --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/social_media_post_list.js @@ -0,0 +1,10 @@ +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]]; + } +} diff --git a/erpnext/crm/doctype/social_media_post/test_social_media_post.py b/erpnext/crm/doctype/social_media_post/test_social_media_post.py new file mode 100644 index 0000000000..ec81ee5871 --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/test_social_media_post.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestSocialMediaPost(unittest.TestCase): + pass diff --git a/erpnext/crm/doctype/twitter_settings/__init__.py b/erpnext/crm/doctype/twitter_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py b/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py new file mode 100644 index 0000000000..3f999c1af4 --- /dev/null +++ b/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestTwitterSettings(unittest.TestCase): + pass diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.js b/erpnext/crm/doctype/twitter_settings/twitter_settings.js new file mode 100644 index 0000000000..359196bcae --- /dev/null +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.js @@ -0,0 +1,52 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Twitter Settings', { + 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(){ + frm.trigger("login"); + }, + function(){ + window.close(); + } + ) + } + }, + refresh: function(frm){ + if(frm.doc.session_status=="Active"){ + frm.dashboard.set_headline_alert( + '
' + + '
' + + ' ' + + '
' + + '
' + ); + } + else if(frm.doc.session_status=="Expired"){ + frm.dashboard.set_headline_alert( + '
' + + '
' + + ' ' + + '
' + + '
' + ); + } + }, + login: function(frm){ + if(frm.doc.consumer_key && frm.doc.consumer_secret){ + frappe.call({ + doc: frm.doc, + method: "get_authorize_url", + callback : function(r) { + window.location.href = r.message; + } + }); + } + }, + 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 new file mode 100644 index 0000000000..fbdfac907e --- /dev/null +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "creation": "2020-01-30 10:29:08.562108", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "account_name", + "profile_pic", + "oauth_details", + "consumer_key", + "column_break_5", + "consumer_secret", + "oauth_token", + "oauth_secret", + "session_status" + ], + "fields": [ + { + "fieldname": "account_name", + "fieldtype": "Data", + "label": "Account Name", + "read_only": 1 + }, + { + "fieldname": "oauth_details", + "fieldtype": "Section Break", + "label": "OAuth Credentials" + }, + { + "fieldname": "consumer_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Consumer Key", + "reqd": 1 + }, + { + "fieldname": "consumer_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Consumer Secret Key", + "reqd": 1 + }, + { + "fieldname": "oauth_token", + "fieldtype": "Data", + "hidden": 1, + "label": "OAuth Token", + "read_only": 1 + }, + { + "fieldname": "oauth_secret", + "fieldtype": "Password", + "hidden": 1, + "label": "OAuth Token Secret", + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "profile_pic", + "fieldtype": "Attach Image", + "hidden": 1, + "read_only": 1 + }, + { + "fieldname": "session_status", + "fieldtype": "Select", + "hidden": 1, + "label": "Session Status", + "options": "Expired\nActive", + "read_only": 1 + } + ], + "image_field": "profile_pic", + "issingle": 1, + "links": [], + "modified": "2020-04-08 23:56:20.621246", + "modified_by": "Administrator", + "module": "CRM", + "name": "Twitter Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.py b/erpnext/crm/doctype/twitter_settings/twitter_settings.py new file mode 100644 index 0000000000..f069a600dd --- /dev/null +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe, os, tweepy, json +from frappe import _ +from frappe.model.document import Document +from frappe.utils.file_manager import get_file_path +from frappe.utils import get_url_to_form, get_link_to_form +from tweepy.error import TweepError + +class TwitterSettings(Document): + def get_authorize_url(self): + callback_url = "{0}/?cmd=erpnext.crm.doctype.twitter_settings.twitter_settings.callback".format(frappe.utils.get_url()) + auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret"), callback_url) + try: + redirect_url = auth.get_authorization_url() + return redirect_url + except: + frappe.msgprint(_("Error! Failed to get request token.")) + frappe.throw(_('Invalid {0} or {1}').format(frappe.bold("Consumer Key"), frappe.bold("Consumer Secret Key"))) + + + def get_access_token(self, oauth_token, oauth_verifier): + auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret")) + auth.request_token = { + 'oauth_token' : oauth_token, + 'oauth_token_secret' : oauth_verifier + } + try: + auth.get_access_token(oauth_verifier) + self.db_set("oauth_token", auth.access_token) + self.db_set("oauth_secret", auth.access_token_secret) + api = self.get_api() + user = api.me() + self.db_set("account_name", user._json["screen_name"]) + profile_pic = (user._json["profile_image_url"]).replace("_normal","") + self.db_set("profile_pic", profile_pic) + self.db_set("session_status", "Active") + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = get_url_to_form("Twitter Settings","Twitter Settings") + except TweepError as e: + frappe.msgprint(_("Error! Failed to get access token.")) + frappe.throw(_('Invalid Consumer Key or Consumer Secret Key')) + + 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.oauth_token, self.get_password(fieldname="oauth_secret")) + + return tweepy.API(auth) + + def post(self, text, media=None): + if not media: + return self.send_tweet(text) + + if media: + media_id = self.upload_image(media) + return self.send_tweet(text, media_id) + + def upload_image(self, media): + media = get_file_path(media) + 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() + try: + if media_id: + response = api.update_status(status = text, media_ids = [media_id]) + else: + response = api.update_status(status = text) + 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)) + +@frappe.whitelist() +def callback(oauth_token, oauth_verifier): + twitter_settings = frappe.get_single("Twitter Settings") + twitter_settings.get_access_token(oauth_token,oauth_verifier) + frappe.db.commit() diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 2f07e15f1e..deaf12edd6 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -270,7 +270,8 @@ auto_cancel_exempted_doctypes= [ scheduler_events = { "all": [ "erpnext.projects.doctype.project.project.project_status_update_reminder", - "erpnext.healthcare_healthcare.doctype.patient_appointment.patient_appointment.send_appointment_reminder" + "erpnext.healthcare_healthcare.doctype.patient_appointment.patient_appointment.send_appointment_reminder", + "erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts" ], "hourly": [ 'erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails', diff --git a/erpnext/selling/doctype/campaign/campaign_dashboard.py b/erpnext/selling/doctype/campaign/campaign_dashboard.py index a9d8eca38c..3cef560c32 100644 --- a/erpnext/selling/doctype/campaign/campaign_dashboard.py +++ b/erpnext/selling/doctype/campaign/campaign_dashboard.py @@ -8,6 +8,10 @@ def get_data(): { 'label': _('Email Campaigns'), 'items': ['Email Campaign'] + }, + { + 'label': _('Social Media Campaigns'), + 'items': ['Social Media Post'] } - ], + ] } diff --git a/requirements.txt b/requirements.txt index c277545fab..9da537e493 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ PyGithub==1.44.1 python-stdnum==1.12 Unidecode==1.1.1 WooCommerce==2.1.1 +tweepy==3.8.0 \ No newline at end of file