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..50b98e9ce1 --- /dev/null +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js @@ -0,0 +1,71 @@ +// 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"){ + let msg = __("Session Not Active. Save doc to login."); + 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.dom.freeze(); + 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..5df35df3dd --- /dev/null +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py @@ -0,0 +1,166 @@ +# -*- 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()) + + frappe.db.set_value(self.doctype, self.name, { + "person_urn": response["id"], + "account_name": response["vanityName"], + "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/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..3a14f2d2e9 --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/social_media_post.js @@ -0,0 +1,67 @@ +// 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); + } + + 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 = `
${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.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(); + } + }) + +} \ 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..2601c14b4d --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/social_media_post.json @@ -0,0 +1,166 @@ +{ + "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", + "scheduled_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", + "column_break_15", + "attachments_section", + "image", + "amended_from" + ], + "fields": [ + { + "fieldname": "text", + "fieldtype": "Small Text", + "label": "Tweet", + "mandatory_depends_on": "eval:doc.twitter ==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 + }, + { + "depends_on": "eval:doc.twitter ==1", + "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, + "depends_on": "eval:doc.linkedin==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", + "mandatory_depends_on": "eval:doc.linkedin ==1" + }, + { + "fieldname": "column_break_15", + "fieldtype": "Column Break" + }, + { + "allow_on_submit": 1, + "fieldname": "scheduled_time", + "fieldtype": "Datetime", + "label": "Scheduled Time", + "read_only_depends_on": "eval:doc.post_status == \"Posted\"" + } + ], + "is_submittable": 1, + "links": [], + "modified": "2020-04-21 15:10:04.953713", + "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..ed1b583944 --- /dev/null +++ b/erpnext/crm/doctype/social_media_post/social_media_post.py @@ -0,0 +1,56 @@ +# -*- 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 _ +import datetime + +class SocialMediaPost(Document): + def validate(self): + 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")) + + def submit(self): + if self.scheduled_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.linkedin_post, self.image) + self.db_set("linkedin_post_id", linkedin_post.headers['X-RestLi-Id'].split(":")[-1]) + 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) + +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"]) + 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() 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..8f9c419062 --- /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){ + let msg,color; + if (frm.doc.session_status == "Active"){ + msg = __("Session Active"); + color = 'green'; + } + else { + msg = __("Session Not Active. Save doc to login."); + color = 'red'; + } + + frm.dashboard.set_headline_alert( + `
+
+ +
+
` + ); + }, + login: function(frm){ + if (frm.doc.consumer_key && frm.doc.consumer_secret){ + frappe.dom.freeze(); + 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..f92e7f0495 --- /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": "API Key", + "reqd": 1 + }, + { + "fieldname": "consumer_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "API 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-21 22:06:43.726798", + "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..64f53b5eb0 --- /dev/null +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.py @@ -0,0 +1,98 @@ +# -*- 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) + api = self.get_api() + user = api.me() + profile_pic = (user._json["profile_image_url"]).replace("_normal","") + + frappe.db.set_value(self.doctype, self.name, { + "oauth_token" : auth.access_token, + "oauth_secret" : auth.access_token_secret, + "account_name" : user._json["screen_name"], + "profile_pic" : profile_pic, + "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 447cc0656f..e6f6c8e47a 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.doctype.patient_appointment.patient_appointment.send_appointment_reminder" + "erpnext.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