From d4817c8685f3b31e72c8d5acca297cbbb6d6877b Mon Sep 17 00:00:00 2001 From: marination Date: Sun, 2 Aug 2020 05:35:45 +0530 Subject: [PATCH 01/30] feat: Track Youtube interactions via Video DocType --- erpnext/utilities/doctype/video/video.js | 34 ++++++++- erpnext/utilities/doctype/video/video.json | 64 +++++++++++++++-- erpnext/utilities/doctype/video/video.py | 20 +++++- .../doctype/video_settings/__init__.py | 0 .../video_settings/test_video_settings.py | 10 +++ .../doctype/video_settings/video_settings.js | 8 +++ .../video_settings/video_settings.json | 49 +++++++++++++ .../doctype/video_settings/video_settings.py | 10 +++ erpnext/utilities/report/__init__.py | 0 .../report/youtube_interactions/__init__.py | 0 .../youtube_interactions.js | 9 +++ .../youtube_interactions.json | 27 +++++++ .../youtube_interactions.py | 72 +++++++++++++++++++ requirements.txt | 1 + 14 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 erpnext/utilities/doctype/video_settings/__init__.py create mode 100644 erpnext/utilities/doctype/video_settings/test_video_settings.py create mode 100644 erpnext/utilities/doctype/video_settings/video_settings.js create mode 100644 erpnext/utilities/doctype/video_settings/video_settings.json create mode 100644 erpnext/utilities/doctype/video_settings/video_settings.py create mode 100644 erpnext/utilities/report/__init__.py create mode 100644 erpnext/utilities/report/youtube_interactions/__init__.py create mode 100644 erpnext/utilities/report/youtube_interactions/youtube_interactions.js create mode 100644 erpnext/utilities/report/youtube_interactions/youtube_interactions.json create mode 100644 erpnext/utilities/report/youtube_interactions/youtube_interactions.py diff --git a/erpnext/utilities/doctype/video/video.js b/erpnext/utilities/doctype/video/video.js index 056bd3ccd66..4dd4e67a7f6 100644 --- a/erpnext/utilities/doctype/video/video.js +++ b/erpnext/utilities/doctype/video/video.js @@ -2,7 +2,37 @@ // For license information, please see license.txt frappe.ui.form.on('Video', { - // refresh: function(frm) { + refresh: function (frm) { + if (frm.doc.provider === "YouTube") { + frappe.db.get_single_value("Video Settings", "enable_youtube_tracking").then(value => { + if (value) { + frm.events.get_video_stats(frm); + } else { + frm.set_df_property('youtube_tracking_section', 'hidden', true); + } + }); + } - // } + frm.add_custom_button("Watch Video", () => frappe.help.show_video(frm.doc.url, frm.doc.title)); + }, + + get_video_stats: (frm) => { + const expression = '(?:youtube.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu.be/)([^"&?\\s]{11})'; + var youtube_id = frm.doc.url.match(expression)[1]; + + frappe.call({ + method: "erpnext.utilities.doctype.video.video.update_video_stats", + args: { + youtube_id: youtube_id + }, + callback: (r) => { + var result = r.message; + var fields = ['like_count', 'view_count', 'dislike_count', 'comment_count']; + fields.forEach((field) => { + frm.doc[field] = result[field]; + }) + frm.refresh_fields(); + } + }); + } }); diff --git a/erpnext/utilities/doctype/video/video.json b/erpnext/utilities/doctype/video/video.json index 5d2cc13348e..a6c0f3f82a9 100644 --- a/erpnext/utilities/doctype/video/video.json +++ b/erpnext/utilities/doctype/video/video.json @@ -15,7 +15,14 @@ "publish_date", "duration", "section_break_7", - "description" + "description", + "image", + "youtube_tracking_section", + "like_count", + "view_count", + "col_break", + "dislike_count", + "comment_count" ], "fields": [ { @@ -37,7 +44,6 @@ { "fieldname": "url", "fieldtype": "Data", - "in_list_view": 1, "label": "URL", "reqd": 1 }, @@ -48,11 +54,12 @@ { "fieldname": "publish_date", "fieldtype": "Date", + "in_list_view": 1, "label": "Publish Date" }, { "fieldname": "duration", - "fieldtype": "Data", + "fieldtype": "Duration", "label": "Duration" }, { @@ -62,13 +69,60 @@ { "fieldname": "description", "fieldtype": "Text Editor", - "in_list_view": 1, "label": "Description", "reqd": 1 + }, + { + "fieldname": "like_count", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Likes", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "view_count", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Views", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "dislike_count", + "fieldtype": "Float", + "label": "Dislikes", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "comment_count", + "fieldtype": "Float", + "label": "Comments", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "hidden": 1, + "label": "Image", + "no_copy": 1 + }, + { + "depends_on": "eval:doc.provider==\"YouTube\"", + "fieldname": "youtube_tracking_section", + "fieldtype": "Section Break", + "label": "Youtube Statistics" } ], + "image_field": "image", "links": [], - "modified": "2020-07-21 19:29:46.603734", + "modified": "2020-08-02 04:26:16.345569", "modified_by": "Administrator", "module": "Utilities", "name": "Video", diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index 3c17b560f33..263884a93d6 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -3,8 +3,26 @@ # For license information, please see license.txt from __future__ import unicode_literals -# import frappe +import frappe from frappe.model.document import Document +from pyyoutube import Api class Video(Document): pass + +@frappe.whitelist() +def update_video_stats(youtube_id): + ''' + :param youtube_id: Unique ID from URL + ''' + api_key = frappe.db.get_single_value("Video Settings", "api_key") + api = Api(api_key=api_key) + + video = api.get_video_by_id(video_id=youtube_id) + video_stats = video.items[0].to_dict().get('statistics') + return { + 'like_count' : video_stats.get('likeCount'), + 'view_count' : video_stats.get('viewCount'), + 'dislike_count' : video_stats.get('dislikeCount'), + 'comment_count' : video_stats.get('commentCount') + } \ No newline at end of file diff --git a/erpnext/utilities/doctype/video_settings/__init__.py b/erpnext/utilities/doctype/video_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/utilities/doctype/video_settings/test_video_settings.py b/erpnext/utilities/doctype/video_settings/test_video_settings.py new file mode 100644 index 00000000000..b217afe3d84 --- /dev/null +++ b/erpnext/utilities/doctype/video_settings/test_video_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 TestVideoSettings(unittest.TestCase): + pass diff --git a/erpnext/utilities/doctype/video_settings/video_settings.js b/erpnext/utilities/doctype/video_settings/video_settings.js new file mode 100644 index 00000000000..9ac8b9ec16b --- /dev/null +++ b/erpnext/utilities/doctype/video_settings/video_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Video Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/utilities/doctype/video_settings/video_settings.json b/erpnext/utilities/doctype/video_settings/video_settings.json new file mode 100644 index 00000000000..0a0efd9a539 --- /dev/null +++ b/erpnext/utilities/doctype/video_settings/video_settings.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "creation": "2020-08-02 03:50:21.339609", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enable_youtube_tracking", + "api_key" + ], + "fields": [ + { + "default": "0", + "fieldname": "enable_youtube_tracking", + "fieldtype": "Check", + "label": "Enable YouTube Tracking" + }, + { + "depends_on": "eval:doc.enable_youtube_tracking", + "fieldname": "api_key", + "fieldtype": "Data", + "label": "API Key", + "mandatory_depends_on": "eval:doc.enable_youtube_tracking" + } + ], + "issingle": 1, + "links": [], + "modified": "2020-08-02 03:56:49.673870", + "modified_by": "Administrator", + "module": "Utilities", + "name": "Video 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/utilities/doctype/video_settings/video_settings.py b/erpnext/utilities/doctype/video_settings/video_settings.py new file mode 100644 index 00000000000..70080669097 --- /dev/null +++ b/erpnext/utilities/doctype/video_settings/video_settings.py @@ -0,0 +1,10 @@ +# -*- 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 + +class VideoSettings(Document): + pass diff --git a/erpnext/utilities/report/__init__.py b/erpnext/utilities/report/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/utilities/report/youtube_interactions/__init__.py b/erpnext/utilities/report/youtube_interactions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/utilities/report/youtube_interactions/youtube_interactions.js b/erpnext/utilities/report/youtube_interactions/youtube_interactions.js new file mode 100644 index 00000000000..f194cca8344 --- /dev/null +++ b/erpnext/utilities/report/youtube_interactions/youtube_interactions.js @@ -0,0 +1,9 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["YouTube Interactions"] = { + "filters": [ + + ] +}; diff --git a/erpnext/utilities/report/youtube_interactions/youtube_interactions.json b/erpnext/utilities/report/youtube_interactions/youtube_interactions.json new file mode 100644 index 00000000000..a40247b6df3 --- /dev/null +++ b/erpnext/utilities/report/youtube_interactions/youtube_interactions.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "creation": "2020-08-02 05:05:00.457093", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "idx": 0, + "is_standard": "Yes", + "modified": "2020-08-02 05:05:00.457093", + "modified_by": "Administrator", + "module": "Utilities", + "name": "YouTube Interactions", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Video", + "report_name": "YouTube Interactions", + "report_type": "Script Report", + "roles": [ + { + "role": "All" + }, + { + "role": "System Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/utilities/report/youtube_interactions/youtube_interactions.py b/erpnext/utilities/report/youtube_interactions/youtube_interactions.py new file mode 100644 index 00000000000..169d0716b09 --- /dev/null +++ b/erpnext/utilities/report/youtube_interactions/youtube_interactions.py @@ -0,0 +1,72 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ + +def execute(filters=None): + columns = get_columns() + data = get_data() + return columns, data + +def get_columns(): + return [ + { + "label": _("Published Date"), + "fieldname": "publish_date", + "fieldtype": "Date", + "width": 100 + }, + { + "label": _("Title"), + "fieldname": "title", + "fieldtype": "Data", + "width": 100 + }, + { + "label": _("Provider"), + "fieldname": "provider", + "fieldtype": "Data", + "width": 100 + }, + { + "label": _("Views"), + "fieldname": "view_count", + "fieldtype": "Float", + "width": 100 + }, + { + "label": _("Likes"), + "fieldname": "like_count", + "fieldtype": "Float", + "width": 100 + }, + { + "label": _("Dislikes"), + "fieldname": "dislike_count", + "fieldtype": "Float", + "width": 100 + }, + { + "label": _("Views"), + "fieldname": "view_count", + "fieldtype": "Float", + "width": 100 + }, + { + "label": _("Like:Dislike Ratio"), + "fieldname": "ratio", + "fieldtype": "Data", + "width": 100 + } + ] + +def get_data(): + return frappe.db.sql(""" + SELECT + publish_date, title, provider, + view_count, like_count, dislike_count, comment_count + FROM `tabVideo` + WHERE view_count is not null + ORDER BY view_count desc""") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 912d61f7a6f..872d78caa39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ plaid-python==3.4.0 pycountry==19.8.18 PyGithub==1.44.1 python-stdnum==1.12 +python-youtube==0.6.0 taxjar==1.9.0 tweepy==3.8.0 Unidecode==1.1.1 From ccf4ab9f852f7af8cf669abd1bd664d9e15dfc58 Mon Sep 17 00:00:00 2001 From: marination Date: Sun, 2 Aug 2020 16:26:36 +0530 Subject: [PATCH 02/30] chore: Added Interactions Report and behaviour fixes - Youtube Interactions Report with Chart and Summary - Statistics change in doc on refresh and get updated in db as well --- erpnext/utilities/doctype/video/video.js | 11 +-- erpnext/utilities/doctype/video/video.py | 27 ++++++- .../youtube_interactions.js | 13 ++- .../youtube_interactions.py | 79 ++++++++++++++----- 4 files changed, 97 insertions(+), 33 deletions(-) diff --git a/erpnext/utilities/doctype/video/video.js b/erpnext/utilities/doctype/video/video.js index 4dd4e67a7f6..c2994ecc962 100644 --- a/erpnext/utilities/doctype/video/video.js +++ b/erpnext/utilities/doctype/video/video.js @@ -21,17 +21,10 @@ frappe.ui.form.on('Video', { var youtube_id = frm.doc.url.match(expression)[1]; frappe.call({ - method: "erpnext.utilities.doctype.video.video.update_video_stats", + method: "erpnext.utilities.doctype.video.video.get_video_stats", args: { + docname: frm.doc.name, youtube_id: youtube_id - }, - callback: (r) => { - var result = r.message; - var fields = ['like_count', 'view_count', 'dislike_count', 'comment_count']; - fields.forEach((field) => { - frm.doc[field] = result[field]; - }) - frm.refresh_fields(); } }); } diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index 263884a93d6..bea7904609d 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -5,24 +5,43 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document +from six import string_types from pyyoutube import Api class Video(Document): pass @frappe.whitelist() -def update_video_stats(youtube_id): - ''' +def get_video_stats(docname, youtube_id, update=True): + '''Returns/Sets video statistics + :param docname: Name of Video :param youtube_id: Unique ID from URL + :param update: Updates db stats value if True, else returns statistics ''' + if isinstance(update, string_types): + update = json.loads(update) + api_key = frappe.db.get_single_value("Video Settings", "api_key") api = Api(api_key=api_key) video = api.get_video_by_id(video_id=youtube_id) video_stats = video.items[0].to_dict().get('statistics') - return { + stats = { 'like_count' : video_stats.get('likeCount'), 'view_count' : video_stats.get('viewCount'), 'dislike_count' : video_stats.get('dislikeCount'), 'comment_count' : video_stats.get('commentCount') - } \ No newline at end of file + } + + if not update: + return stats + + frappe.db.sql(""" + UPDATE `tabVideo` + SET + like_count = %(like_count)s, + view_count = %(view_count)s, + dislike_count = %(dislike_count)s, + comment_count = %(comment_count)s + WHERE name = {0}""".format(frappe.db.escape(docname)), stats) + frappe.db.commit() \ No newline at end of file diff --git a/erpnext/utilities/report/youtube_interactions/youtube_interactions.js b/erpnext/utilities/report/youtube_interactions/youtube_interactions.js index f194cca8344..6e3e4e69800 100644 --- a/erpnext/utilities/report/youtube_interactions/youtube_interactions.js +++ b/erpnext/utilities/report/youtube_interactions/youtube_interactions.js @@ -4,6 +4,17 @@ frappe.query_reports["YouTube Interactions"] = { "filters": [ - + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_months(frappe.datetime.now_date(), -12), + }, + { + fieldname:"to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.now_date(), + } ] }; diff --git a/erpnext/utilities/report/youtube_interactions/youtube_interactions.py b/erpnext/utilities/report/youtube_interactions/youtube_interactions.py index 169d0716b09..3516a35097a 100644 --- a/erpnext/utilities/report/youtube_interactions/youtube_interactions.py +++ b/erpnext/utilities/report/youtube_interactions/youtube_interactions.py @@ -4,11 +4,16 @@ from __future__ import unicode_literals import frappe from frappe import _ +from frappe.utils import flt def execute(filters=None): + if not frappe.db.get_single_value("Video Settings", "enable_youtube_tracking") or not filters: + return [], [] + columns = get_columns() - data = get_data() - return columns, data + data = get_data(filters) + chart_data, summary = get_chart_summary_data(data) + return columns, data, None, chart_data, summary def get_columns(): return [ @@ -22,25 +27,25 @@ def get_columns(): "label": _("Title"), "fieldname": "title", "fieldtype": "Data", - "width": 100 + "width": 200 }, { - "label": _("Provider"), - "fieldname": "provider", - "fieldtype": "Data", + "label": _("Duration"), + "fieldname": "duration", + "fieldtype": "Duration", "width": 100 }, { "label": _("Views"), "fieldname": "view_count", "fieldtype": "Float", - "width": 100 + "width": 200 }, { "label": _("Likes"), "fieldname": "like_count", "fieldtype": "Float", - "width": 100 + "width": 200 }, { "label": _("Dislikes"), @@ -49,24 +54,60 @@ def get_columns(): "width": 100 }, { - "label": _("Views"), - "fieldname": "view_count", + "label": _("Comments"), + "fieldname": "comment_count", "fieldtype": "Float", "width": 100 - }, - { - "label": _("Like:Dislike Ratio"), - "fieldname": "ratio", - "fieldtype": "Data", - "width": 100 } ] -def get_data(): +def get_data(filters): return frappe.db.sql(""" SELECT - publish_date, title, provider, + publish_date, title, provider, duration, view_count, like_count, dislike_count, comment_count FROM `tabVideo` WHERE view_count is not null - ORDER BY view_count desc""") \ No newline at end of file + and publish_date between %(from_date)s and %(to_date)s + ORDER BY view_count desc""", filters, as_dict=1) + +def get_chart_summary_data(data): + labels, likes, views = [], [], [] + total_views = 0 + + for row in data: + labels.append(row.get('title')) + likes.append(row.get('like_count')) + views.append(row.get('view_count')) + total_views += flt(row.get('view_count')) + + + chart_data = { + "data" : { + "labels" : labels, + "datasets" : [ + { + "name" : "Likes", + "values" : likes + }, + { + "name" : "Views", + "values" : views + } + ] + }, + "type": "bar", + "barOptions": { + "stacked": 1 + }, + } + + summary = [ + { + "value": total_views, + "indicator": "Blue", + "label": "Total Views", + "datatype": "Float", + } + ] + return chart_data, summary \ No newline at end of file From c16ace6732ba27061da55d9f20171fe4693972d7 Mon Sep 17 00:00:00 2001 From: marination Date: Sun, 2 Aug 2020 16:43:15 +0530 Subject: [PATCH 03/30] fix: Codacy --- erpnext/utilities/doctype/video/video.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index bea7904609d..002ee681edc 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import frappe +import json from frappe.model.document import Document from six import string_types from pyyoutube import Api @@ -14,6 +15,7 @@ class Video(Document): @frappe.whitelist() def get_video_stats(docname, youtube_id, update=True): '''Returns/Sets video statistics + :param docname: Name of Video :param youtube_id: Unique ID from URL :param update: Updates db stats value if True, else returns statistics @@ -43,5 +45,5 @@ def get_video_stats(docname, youtube_id, update=True): view_count = %(view_count)s, dislike_count = %(dislike_count)s, comment_count = %(comment_count)s - WHERE name = {0}""".format(frappe.db.escape(docname)), stats) + WHERE name = {0}""".format(frappe.db.escape(docname)), stats) #nosec frappe.db.commit() \ No newline at end of file From e3495116dd959bccbfaa39157e62b151c688c47e Mon Sep 17 00:00:00 2001 From: marination Date: Sun, 2 Aug 2020 17:13:34 +0530 Subject: [PATCH 04/30] chore: Error Logging and exception hnadling on connection failure --- erpnext/utilities/doctype/video/video.py | 42 +++++++++++++----------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index 002ee681edc..a2a4a7b745b 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -26,24 +26,28 @@ def get_video_stats(docname, youtube_id, update=True): api_key = frappe.db.get_single_value("Video Settings", "api_key") api = Api(api_key=api_key) - video = api.get_video_by_id(video_id=youtube_id) - video_stats = video.items[0].to_dict().get('statistics') - stats = { - 'like_count' : video_stats.get('likeCount'), - 'view_count' : video_stats.get('viewCount'), - 'dislike_count' : video_stats.get('dislikeCount'), - 'comment_count' : video_stats.get('commentCount') - } + try: + video = api.get_video_by_id(video_id=youtube_id) + video_stats = video.items[0].to_dict().get('statistics') + stats = { + 'like_count' : video_stats.get('likeCount'), + 'view_count' : video_stats.get('viewCount'), + 'dislike_count' : video_stats.get('dislikeCount'), + 'comment_count' : video_stats.get('commentCount') + } - if not update: - return stats + if not update: + return stats - frappe.db.sql(""" - UPDATE `tabVideo` - SET - like_count = %(like_count)s, - view_count = %(view_count)s, - dislike_count = %(dislike_count)s, - comment_count = %(comment_count)s - WHERE name = {0}""".format(frappe.db.escape(docname)), stats) #nosec - frappe.db.commit() \ No newline at end of file + frappe.db.sql(""" + UPDATE `tabVideo` + SET + like_count = %(like_count)s, + view_count = %(view_count)s, + dislike_count = %(dislike_count)s, + comment_count = %(comment_count)s + WHERE name = {0}""".format(frappe.db.escape(docname)), stats) #nosec + frappe.db.commit() + except: + message = "Please make sure you are connected to the Internet" + frappe.log_error(message + "\n\n" + frappe.get_traceback(), "Failed to Update YouTube Statistics for Video: {0}".format(docname)) \ No newline at end of file From 9ce38de439ebd2a240539542a8de40f8ae2d2417 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 7 Sep 2020 13:27:17 +0530 Subject: [PATCH 05/30] chore: Key Validation and restructure - Added API Key validation in Video Settings - Moved Statistics section above description in Video - Moved id retrieving from URL to py side - Removed js side calls, added section toggling in js - Restructured Code --- erpnext/utilities/doctype/video/video.js | 28 ++------ erpnext/utilities/doctype/video/video.json | 10 +-- erpnext/utilities/doctype/video/video.py | 71 +++++++++---------- .../doctype/video_settings/video_settings.py | 16 ++++- 4 files changed, 61 insertions(+), 64 deletions(-) diff --git a/erpnext/utilities/doctype/video/video.js b/erpnext/utilities/doctype/video/video.js index c2994ecc962..9cb5a155ade 100644 --- a/erpnext/utilities/doctype/video/video.js +++ b/erpnext/utilities/doctype/video/video.js @@ -3,29 +3,15 @@ frappe.ui.form.on('Video', { refresh: function (frm) { - if (frm.doc.provider === "YouTube") { - frappe.db.get_single_value("Video Settings", "enable_youtube_tracking").then(value => { - if (value) { - frm.events.get_video_stats(frm); - } else { - frm.set_df_property('youtube_tracking_section', 'hidden', true); - } - }); - } - + frm.events.toggle_youtube_statistics_section(frm); frm.add_custom_button("Watch Video", () => frappe.help.show_video(frm.doc.url, frm.doc.title)); }, - get_video_stats: (frm) => { - const expression = '(?:youtube.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu.be/)([^"&?\\s]{11})'; - var youtube_id = frm.doc.url.match(expression)[1]; - - frappe.call({ - method: "erpnext.utilities.doctype.video.video.get_video_stats", - args: { - docname: frm.doc.name, - youtube_id: youtube_id - } - }); + toggle_youtube_statistics_section: (frm) => { + if (frm.doc.provider === "YouTube") { + frappe.db.get_single_value("Video Settings", "enable_youtube_tracking").then( val => { + frm.toggle_display("youtube_tracking_section", val); + }); + } } }); diff --git a/erpnext/utilities/doctype/video/video.json b/erpnext/utilities/doctype/video/video.json index a6c0f3f82a9..11df56c77da 100644 --- a/erpnext/utilities/doctype/video/video.json +++ b/erpnext/utilities/doctype/video/video.json @@ -14,15 +14,15 @@ "column_break_4", "publish_date", "duration", - "section_break_7", - "description", - "image", "youtube_tracking_section", "like_count", "view_count", "col_break", "dislike_count", - "comment_count" + "comment_count", + "section_break_7", + "description", + "image" ], "fields": [ { @@ -122,7 +122,7 @@ ], "image_field": "image", "links": [], - "modified": "2020-08-02 04:26:16.345569", + "modified": "2020-09-04 12:59:28.283622", "modified_by": "Administrator", "module": "Utilities", "name": "Video", diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index a2a4a7b745b..2299f95f76b 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -5,49 +5,48 @@ from __future__ import unicode_literals import frappe import json +import re from frappe.model.document import Document +from frappe import _ from six import string_types from pyyoutube import Api class Video(Document): - pass + def validate(self): + self.set_youtube_statistics() + + def set_youtube_statistics(self): + tracking_enabled = frappe.db.get_single_value("Video Settings", "enable_youtube_tracking") + if self.provider == "YouTube" and not tracking_enabled: + return + + api_key = frappe.db.get_single_value("Video Settings", "api_key") + youtube_id = get_id_from_url(self.url) + api = Api(api_key=api_key) + + try: + video = api.get_video_by_id(video_id=youtube_id) + video_stats = video.items[0].to_dict().get('statistics') + + self.like_count = video_stats.get('likeCount') + self.view_count = video_stats.get('viewCount') + self.dislike_count = video_stats.get('dislikeCount') + self.comment_count = video_stats.get('commentCount') + + except Exception: + title = "Failed to Update YouTube Statistics for Video: {0}".format(self.name) + frappe.log_error(title + "\n\n" + frappe.get_traceback(), title=title) @frappe.whitelist() -def get_video_stats(docname, youtube_id, update=True): - '''Returns/Sets video statistics - - :param docname: Name of Video - :param youtube_id: Unique ID from URL - :param update: Updates db stats value if True, else returns statistics +def get_id_from_url(url): ''' - if isinstance(update, string_types): - update = json.loads(update) + Returns video id from url - api_key = frappe.db.get_single_value("Video Settings", "api_key") - api = Api(api_key=api_key) + :param youtube url: String URL + ''' + if not isinstance(url, string_types): + frappe.throw(_("URL can only be a string"), title=_("Invalid URL")) - try: - video = api.get_video_by_id(video_id=youtube_id) - video_stats = video.items[0].to_dict().get('statistics') - stats = { - 'like_count' : video_stats.get('likeCount'), - 'view_count' : video_stats.get('viewCount'), - 'dislike_count' : video_stats.get('dislikeCount'), - 'comment_count' : video_stats.get('commentCount') - } - - if not update: - return stats - - frappe.db.sql(""" - UPDATE `tabVideo` - SET - like_count = %(like_count)s, - view_count = %(view_count)s, - dislike_count = %(dislike_count)s, - comment_count = %(comment_count)s - WHERE name = {0}""".format(frappe.db.escape(docname)), stats) #nosec - frappe.db.commit() - except: - message = "Please make sure you are connected to the Internet" - frappe.log_error(message + "\n\n" + frappe.get_traceback(), "Failed to Update YouTube Statistics for Video: {0}".format(docname)) \ No newline at end of file + pattern = re.compile(r'[a-z\:\//\.]+(youtube|youtu)\.(com|be)/(watch\?v=|embed/|.+\?v=)?([^"&?\s]{11})?') + id = pattern.match(url) + return id.groups()[-1] \ No newline at end of file diff --git a/erpnext/utilities/doctype/video_settings/video_settings.py b/erpnext/utilities/doctype/video_settings/video_settings.py index 70080669097..36fb54f0150 100644 --- a/erpnext/utilities/doctype/video_settings/video_settings.py +++ b/erpnext/utilities/doctype/video_settings/video_settings.py @@ -3,8 +3,20 @@ # For license information, please see license.txt from __future__ import unicode_literals -# import frappe +import frappe +from frappe import _ from frappe.model.document import Document +from apiclient.discovery import build class VideoSettings(Document): - pass + def validate(self): + self.validate_youtube_api_key() + + def validate_youtube_api_key(self): + if self.enable_youtube_tracking and self.api_key: + try: + build("youtube", "v3", developerKey=self.api_key) + except Exception: + title = _("Failed to Authenticate the API key.") + frappe.log_error(title + "\n\n" + frappe.get_traceback(), title=title) + frappe.throw(title + " Please check the error logs.", title=_("Invalid Credentials")) \ No newline at end of file From 06ee0ea00bab3793ef7fcb48348ba4a3ef501c0b Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 7 Sep 2020 18:49:06 +0530 Subject: [PATCH 06/30] feat: Added Scheduler Job to auto update statistics - Added frequency in Video Settings - Cron job to check half hourly job - Hourly job to run job based on frequency - Patch to set youtube id in video for ease in computing --- erpnext/hooks.py | 6 + erpnext/patches.txt | 1 + erpnext/patches/v13_0/set_youtube_video_id.py | 8 ++ erpnext/utilities/doctype/video/video.json | 10 +- erpnext/utilities/doctype/video/video.py | 124 ++++++++++++++++-- .../video_settings/video_settings.json | 15 ++- 6 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 erpnext/patches/v13_0/set_youtube_video_id.py diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 95a836fe652..db1fd2f3fcd 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -282,6 +282,11 @@ auto_cancel_exempted_doctypes= [ ] scheduler_events = { + "cron": { + "0/30 * * * *": [ + "erpnext.utilities.doctype.video.video.update_youtube_data_half_hourly", + ] + }, "all": [ "erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.healthcare.doctype.patient_appointment.patient_appointment.send_appointment_reminder", @@ -297,6 +302,7 @@ scheduler_events = { "erpnext.projects.doctype.project.project.collect_project_status", "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts", "erpnext.support.doctype.issue.issue.set_service_level_agreement_variance", + "erpnext.utilities.doctype.video.video.update_youtube_data" ], "daily": [ "erpnext.stock.reorder_item.reorder_item", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3bd416952f8..2a52ff67e59 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -718,3 +718,4 @@ erpnext.patches.v13_0.delete_report_requested_items_to_order erpnext.patches.v12_0.update_item_tax_template_company erpnext.patches.v13_0.move_branch_code_to_bank_account erpnext.patches.v13_0.healthcare_lab_module_rename_doctypes +erpnext.patches.v13_0.set_youtube_video_id diff --git a/erpnext/patches/v13_0/set_youtube_video_id.py b/erpnext/patches/v13_0/set_youtube_video_id.py new file mode 100644 index 00000000000..8e5dd306a92 --- /dev/null +++ b/erpnext/patches/v13_0/set_youtube_video_id.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals +import frappe +from erpnext.utilities.doctype.video.video import get_id_from_url + +def execute(): + for video in frappe.get_all("Video", fields=["name", "url", "youtube_video_id"]): + if video.url and not video.youtube_video_id: + frappe.db.set_value("Video", video.name, "youtube_video_id", get_id_from_url(video.url)) \ No newline at end of file diff --git a/erpnext/utilities/doctype/video/video.json b/erpnext/utilities/doctype/video/video.json index 11df56c77da..2a82db25145 100644 --- a/erpnext/utilities/doctype/video/video.json +++ b/erpnext/utilities/doctype/video/video.json @@ -11,6 +11,7 @@ "title", "provider", "url", + "youtube_video_id", "column_break_4", "publish_date", "duration", @@ -118,11 +119,18 @@ "fieldname": "youtube_tracking_section", "fieldtype": "Section Break", "label": "Youtube Statistics" + }, + { + "fieldname": "youtube_video_id", + "fieldtype": "Data", + "hidden": 1, + "label": "Youtube ID", + "read_only": 1 } ], "image_field": "image", "links": [], - "modified": "2020-09-04 12:59:28.283622", + "modified": "2020-09-07 17:02:20.185794", "modified_by": "Administrator", "module": "Utilities", "name": "Video", diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index 2299f95f76b..d8653f64467 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -4,8 +4,8 @@ from __future__ import unicode_literals import frappe -import json import re +import pytz from frappe.model.document import Document from frappe import _ from six import string_types @@ -13,20 +13,32 @@ from pyyoutube import Api class Video(Document): def validate(self): + self.set_video_id() self.set_youtube_statistics() - def set_youtube_statistics(self): - tracking_enabled = frappe.db.get_single_value("Video Settings", "enable_youtube_tracking") - if self.provider == "YouTube" and not tracking_enabled: + def set_video_id(self): + if self.provider == "YouTube" and self.url and not self.get("youtube_video_id"): + self.youtube_video_id = get_id_from_url(self.url) + + @classmethod + def set_youtube_statistics(self, video_ids=None, update=True): + if self.provider == "YouTube" and not is_tracking_enabled(): return api_key = frappe.db.get_single_value("Video Settings", "api_key") - youtube_id = get_id_from_url(self.url) api = Api(api_key=api_key) try: - video = api.get_video_by_id(video_id=youtube_id) - video_stats = video.items[0].to_dict().get('statistics') + video_id = video_ids or self.youtube_video_id + video = api.get_video_by_id(video_id=video_id) + + if video_ids: + video_stats = video.items + else: + video_stats = video.items[0].to_dict().get('statistics') + + if not update: + return video_stats self.like_count = video_stats.get('likeCount') self.view_count = video_stats.get('viewCount') @@ -37,16 +49,106 @@ class Video(Document): title = "Failed to Update YouTube Statistics for Video: {0}".format(self.name) frappe.log_error(title + "\n\n" + frappe.get_traceback(), title=title) +def is_tracking_enabled(): + return frappe.db.get_single_value("Video Settings", "enable_youtube_tracking") + +def get_frequency(value): + if not value: + return None + + # Return frequency in hours + if value != "Daily": + return frappe.utils.cint(value[:2].strip()) + else: + # 24 hours for Daily + return 24 + + +def update_youtube_data_half_hourly(): + # Called every 30 mins via hooks + frequency = get_frequency(frappe.db.get_single_value("Video Settings", "frequency")) + if not is_tracking_enabled() or not frequency: + return + + if frequency == 30: + batch_update_data() + + +def update_youtube_data(): + # Called every hour via hooks + frequency = get_frequency(frappe.db.get_single_value("Video Settings", "frequency")) + + # if frequency is 30 mins dont proceed, as its handled in another method + if not is_tracking_enabled() or not frequency or frequency == 30: + return + + time = datetime.now() + timezone = pytz.timezone(frappe.utils.get_time_zone()) + site_time = time.astimezone(timezone) + + if site_time.hour % frequency == 0: + batch_update_youtube_data() + +def get_formatted_ids(video_list): + # format ids to comma separated string for bulk request + ids = [] + for video in video_list: + ids.append(video.youtube_video_id) + + return ','.join(ids) + @frappe.whitelist() def get_id_from_url(url): - ''' + """ Returns video id from url - :param youtube url: String URL - ''' + """ if not isinstance(url, string_types): frappe.throw(_("URL can only be a string"), title=_("Invalid URL")) pattern = re.compile(r'[a-z\:\//\.]+(youtube|youtu)\.(com|be)/(watch\?v=|embed/|.+\?v=)?([^"&?\s]{11})?') id = pattern.match(url) - return id.groups()[-1] \ No newline at end of file + return id.groups()[-1] + + +@frappe.whitelist() +def batch_update_youtube_data(): + def prepare_and_set_data(video_list): + video_ids = get_formatted_ids(video_list) + Video.provider = "YouTube" + stats = Video.set_youtube_statistics(video_ids=video_ids, update=False) + set_youtube_data(stats) + + def set_youtube_data(entries): + for entry in entries: + video_stats = entry.to_dict().get('statistics') + video_id = entry.to_dict().get('id') + stats = { + 'like_count' : video_stats.get('likeCount'), + 'view_count' : video_stats.get('viewCount'), + 'dislike_count' : video_stats.get('dislikeCount'), + 'comment_count' : video_stats.get('commentCount') + } + + frappe.db.sql(""" + UPDATE `tabVideo` + SET + like_count = %(like_count)s, + view_count = %(view_count)s, + dislike_count = %(dislike_count)s, + comment_count = %(comment_count)s + WHERE youtube_video_id = '{0}'""".format(video_id), stats) + + frappe.log_error("yooooooooo") + + video_list = frappe.get_all("Video", fields=["youtube_video_id"]) + if len(video_list) > 50: + # Update in batches of 50 + start, end = 0, 50 + while start < len(video_list): + batch = video_list[start:end] + prepare_and_set_data(batch) + start += 50 + end += 50 + else: + prepare_and_set_data(video_list) \ No newline at end of file diff --git a/erpnext/utilities/doctype/video_settings/video_settings.json b/erpnext/utilities/doctype/video_settings/video_settings.json index 0a0efd9a539..fb3274decdd 100644 --- a/erpnext/utilities/doctype/video_settings/video_settings.json +++ b/erpnext/utilities/doctype/video_settings/video_settings.json @@ -6,7 +6,8 @@ "engine": "InnoDB", "field_order": [ "enable_youtube_tracking", - "api_key" + "api_key", + "frequency" ], "fields": [ { @@ -21,11 +22,21 @@ "fieldtype": "Data", "label": "API Key", "mandatory_depends_on": "eval:doc.enable_youtube_tracking" + }, + { + "default": "1 hr", + "depends_on": "eval:doc.enable_youtube_tracking", + "fieldname": "frequency", + "fieldtype": "Select", + "label": "Frequency", + "mandatory_depends_on": "eval:doc.enable_youtube_tracking", + "options": "30 mins\n1 hr\n6 hrs\nDaily" } ], + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-08-02 03:56:49.673870", + "modified": "2020-09-07 16:09:00.360668", "modified_by": "Administrator", "module": "Utilities", "name": "Video Settings", From f18dd05c00c92033cd0f1e1db49f6e6b60d22e5f Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 7 Sep 2020 18:59:57 +0530 Subject: [PATCH 07/30] fix: Missing import and typo --- erpnext/utilities/doctype/video/video.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index d8653f64467..3b6bd7e0df4 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -8,6 +8,7 @@ import re import pytz from frappe.model.document import Document from frappe import _ +from datetime import datetime from six import string_types from pyyoutube import Api @@ -71,7 +72,7 @@ def update_youtube_data_half_hourly(): return if frequency == 30: - batch_update_data() + batch_update_youtube_data() def update_youtube_data(): From 53cd60c217fc2442dc8d1db23c2595446efc08bb Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 7 Sep 2020 20:45:50 +0530 Subject: [PATCH 08/30] fix: Patch reload doctype --- erpnext/patches/v13_0/set_youtube_video_id.py | 2 ++ erpnext/utilities/doctype/video/video.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/patches/v13_0/set_youtube_video_id.py b/erpnext/patches/v13_0/set_youtube_video_id.py index 8e5dd306a92..c3b49eb4fe5 100644 --- a/erpnext/patches/v13_0/set_youtube_video_id.py +++ b/erpnext/patches/v13_0/set_youtube_video_id.py @@ -3,6 +3,8 @@ import frappe from erpnext.utilities.doctype.video.video import get_id_from_url def execute(): + frappe.reload_doc("utilities", "doctype","video") + for video in frappe.get_all("Video", fields=["name", "url", "youtube_video_id"]): if video.url and not video.youtube_video_id: frappe.db.set_value("Video", video.name, "youtube_video_id", get_id_from_url(video.url)) \ No newline at end of file diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index 3b6bd7e0df4..6971beb5d7b 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -140,8 +140,6 @@ def batch_update_youtube_data(): comment_count = %(comment_count)s WHERE youtube_video_id = '{0}'""".format(video_id), stats) - frappe.log_error("yooooooooo") - video_list = frappe.get_all("Video", fields=["youtube_video_id"]) if len(video_list) > 50: # Update in batches of 50 From 0a0e258d8b43f88dc51fc1ed1aac48b55d4ca22a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 10 Sep 2020 13:12:35 +0530 Subject: [PATCH 09/30] feat: Add Desk Page for Utilities for Video group --- .../desk_page/utilities/utilities.json | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 erpnext/utilities/desk_page/utilities/utilities.json diff --git a/erpnext/utilities/desk_page/utilities/utilities.json b/erpnext/utilities/desk_page/utilities/utilities.json new file mode 100644 index 00000000000..591eab5ed40 --- /dev/null +++ b/erpnext/utilities/desk_page/utilities/utilities.json @@ -0,0 +1,29 @@ +{ + "cards": [ + { + "hidden": 0, + "label": "Video", + "links": "[\n {\n \"description\": \"Video\",\n \"label\": \"Video\",\n \"name\": \"Video\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Video settings\",\n \"label\": \"Video Settings\",\n \"name\": \"Video Settings\",\n \"type\": \"doctype\"\n }\n]" + } + ], + "category": "Modules", + "charts": [], + "creation": "2020-09-10 12:21:22.335307", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Desk Page", + "extends_another_page": 0, + "hide_custom": 0, + "idx": 0, + "is_standard": 1, + "label": "Utilities", + "modified": "2020-09-10 12:33:30.089853", + "modified_by": "user@erpnext.com", + "module": "Utilities", + "name": "Utilities", + "owner": "user@erpnext.com", + "pin_to_bottom": 1, + "pin_to_top": 0, + "shortcuts": [] +} \ No newline at end of file From 71ac39936977e6408d815a5ecb100fd51f8dadb8 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 10 Sep 2020 13:57:44 +0530 Subject: [PATCH 10/30] fix: Commonified function and updated scheduler events Co-authored-by: marination --- erpnext/hooks.py | 3 +- erpnext/utilities/doctype/video/video.py | 42 ++++++++++-------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 3beae3b6c81..f8b6be70ca0 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -284,7 +284,7 @@ auto_cancel_exempted_doctypes= [ scheduler_events = { "cron": { "0/30 * * * *": [ - "erpnext.utilities.doctype.video.video.update_youtube_data_half_hourly", + "erpnext.utilities.doctype.video.video.update_youtube_data", ] }, "all": [ @@ -302,7 +302,6 @@ scheduler_events = { "erpnext.projects.doctype.project.project.collect_project_status", "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts", "erpnext.support.doctype.issue.issue.set_service_level_agreement_variance", - "erpnext.utilities.doctype.video.video.update_youtube_data" ], "daily": [ "erpnext.stock.reorder_item.reorder_item", diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index 6971beb5d7b..7918b828aad 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -50,45 +50,37 @@ class Video(Document): title = "Failed to Update YouTube Statistics for Video: {0}".format(self.name) frappe.log_error(title + "\n\n" + frappe.get_traceback(), title=title) + def is_tracking_enabled(): return frappe.db.get_single_value("Video Settings", "enable_youtube_tracking") -def get_frequency(value): - if not value: - return None - # Return frequency in hours +def get_frequency(value): + # Return numeric value from frequency field, return 1 as fallback default value: 1 hour if value != "Daily": return frappe.utils.cint(value[:2].strip()) - else: - # 24 hours for Daily + elif value: return 24 - - -def update_youtube_data_half_hourly(): - # Called every 30 mins via hooks - frequency = get_frequency(frappe.db.get_single_value("Video Settings", "frequency")) - if not is_tracking_enabled() or not frequency: - return - - if frequency == 30: - batch_update_youtube_data() + return 1 def update_youtube_data(): - # Called every hour via hooks - frequency = get_frequency(frappe.db.get_single_value("Video Settings", "frequency")) + # Called every 30 minutes via hooks + enable_youtube_tracking, frequency = frappe.db.get_value("Video Settings", "Video Settings", ["enable_youtube_tracking", "frequency"]) - # if frequency is 30 mins dont proceed, as its handled in another method - if not is_tracking_enabled() or not frequency or frequency == 30: + if not enable_youtube_tracking: return + frequency = get_frequency(frequency) time = datetime.now() timezone = pytz.timezone(frappe.utils.get_time_zone()) site_time = time.astimezone(timezone) - if site_time.hour % frequency == 0: + if frequency == 30: batch_update_youtube_data() + elif site_time.hour % frequency == 0: + batch_update_youtube_data() + def get_formatted_ids(video_list): # format ids to comma separated string for bulk request @@ -98,6 +90,7 @@ def get_formatted_ids(video_list): return ','.join(ids) + @frappe.whitelist() def get_id_from_url(url): """ @@ -128,7 +121,8 @@ def batch_update_youtube_data(): 'like_count' : video_stats.get('likeCount'), 'view_count' : video_stats.get('viewCount'), 'dislike_count' : video_stats.get('dislikeCount'), - 'comment_count' : video_stats.get('commentCount') + 'comment_count' : video_stats.get('commentCount'), + 'video_id': video_id } frappe.db.sql(""" @@ -138,7 +132,7 @@ def batch_update_youtube_data(): view_count = %(view_count)s, dislike_count = %(dislike_count)s, comment_count = %(comment_count)s - WHERE youtube_video_id = '{0}'""".format(video_id), stats) + WHERE youtube_video_id = %(video_id)s""", stats) video_list = frappe.get_all("Video", fields=["youtube_video_id"]) if len(video_list) > 50: @@ -150,4 +144,4 @@ def batch_update_youtube_data(): start += 50 end += 50 else: - prepare_and_set_data(video_list) \ No newline at end of file + prepare_and_set_data(video_list) From 799d663398781f55af8e3e6b2f239b23f0e3eecf Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 10 Sep 2020 16:42:20 +0530 Subject: [PATCH 11/30] fix: Make set_youtube_statistics reusable and job logic fix - Make sure job runs only once every hour - set_youtube_statistics should be usable in and outside class --- erpnext/utilities/doctype/video/video.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index 7918b828aad..f519146e6bc 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -21,7 +21,6 @@ class Video(Document): if self.provider == "YouTube" and self.url and not self.get("youtube_video_id"): self.youtube_video_id = get_id_from_url(self.url) - @classmethod def set_youtube_statistics(self, video_ids=None, update=True): if self.provider == "YouTube" and not is_tracking_enabled(): return @@ -78,7 +77,8 @@ def update_youtube_data(): if frequency == 30: batch_update_youtube_data() - elif site_time.hour % frequency == 0: + elif site_time.hour % frequency == 0 and site_time.minute < 15: + # make sure it runs within the first 15 mins of the hour batch_update_youtube_data() @@ -109,8 +109,8 @@ def get_id_from_url(url): def batch_update_youtube_data(): def prepare_and_set_data(video_list): video_ids = get_formatted_ids(video_list) - Video.provider = "YouTube" - stats = Video.set_youtube_statistics(video_ids=video_ids, update=False) + video_doc = frappe.new_doc("Video") + stats = video_doc.set_youtube_statistics(video_ids=video_ids, update=False) set_youtube_data(stats) def set_youtube_data(entries): From 37b99d202d6f4d7c5ffdffb1739d91aa74965757 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 10 Sep 2020 18:27:20 +0530 Subject: [PATCH 12/30] fix: Separate function to get bulk and single youtube stats --- erpnext/utilities/doctype/video/video.py | 39 ++++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index f519146e6bc..c2e414eef8c 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -14,31 +14,21 @@ from pyyoutube import Api class Video(Document): def validate(self): - self.set_video_id() - self.set_youtube_statistics() + if self.provider == "YouTube" and is_tracking_enabled(): + self.set_video_id() + self.set_youtube_statistics() def set_video_id(self): - if self.provider == "YouTube" and self.url and not self.get("youtube_video_id"): + if self.url and not self.get("youtube_video_id"): self.youtube_video_id = get_id_from_url(self.url) - def set_youtube_statistics(self, video_ids=None, update=True): - if self.provider == "YouTube" and not is_tracking_enabled(): - return - + def set_youtube_statistics(self): api_key = frappe.db.get_single_value("Video Settings", "api_key") api = Api(api_key=api_key) try: - video_id = video_ids or self.youtube_video_id - video = api.get_video_by_id(video_id=video_id) - - if video_ids: - video_stats = video.items - else: - video_stats = video.items[0].to_dict().get('statistics') - - if not update: - return video_stats + video = api.get_video_by_id(video_id=self.youtube_video_id) + video_stats = video.items[0].to_dict().get('statistics') self.like_count = video_stats.get('likeCount') self.view_count = video_stats.get('viewCount') @@ -49,7 +39,6 @@ class Video(Document): title = "Failed to Update YouTube Statistics for Video: {0}".format(self.name) frappe.log_error(title + "\n\n" + frappe.get_traceback(), title=title) - def is_tracking_enabled(): return frappe.db.get_single_value("Video Settings", "enable_youtube_tracking") @@ -107,10 +96,20 @@ def get_id_from_url(url): @frappe.whitelist() def batch_update_youtube_data(): + def get_youtube_statistics(video_ids): + api_key = frappe.db.get_single_value("Video Settings", "api_key") + api = Api(api_key=api_key) + try: + video = api.get_video_by_id(video_id=video_ids) + video_stats = video.items + return video_stats + except Exception: + title = "Failed to Update YouTube Statistics" + frappe.log_error(title + "\n\n" + frappe.get_traceback(), title=title) + def prepare_and_set_data(video_list): video_ids = get_formatted_ids(video_list) - video_doc = frappe.new_doc("Video") - stats = video_doc.set_youtube_statistics(video_ids=video_ids, update=False) + stats = get_youtube_statistics(video_ids) set_youtube_data(stats) def set_youtube_data(entries): From 123eaea1a60f14fc6d464c8d816dcc9782db3773 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Fri, 18 Sep 2020 16:30:50 +0530 Subject: [PATCH 13/30] fix: added new doctypes in get_level --- erpnext/utilities/activation.py | 35 +++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/erpnext/utilities/activation.py b/erpnext/utilities/activation.py index 63c36b35d1f..7b17c8c464b 100644 --- a/erpnext/utilities/activation.py +++ b/erpnext/utilities/activation.py @@ -11,8 +11,39 @@ def get_level(): activation_level = 0 sales_data = [] min_count = 0 - doctypes = {"Item": 5, "Customer": 5, "Sales Order": 2, "Sales Invoice": 2, "Purchase Order": 2, "Employee": 3, "Lead": 3, "Quotation": 3, - "Payment Entry": 2, "User": 5, "Student": 5, "Instructor": 5, "BOM": 3, "Journal Entry": 3, "Stock Entry": 3} + doctypes = { + "Asset": 5, + "BOM": 3, + "Customer": 5, + "Delivery Note": 5, + "Employee": 3, + "Instructor": 5, + "Instructor": 5, + "Issue": 5, + "Item": 5, + "Journal Entry": 3, + "Lead": 3, + "Leave Application": 5, + "Material Request": 5, + "Opportunity": 5, + "Payment Entry": 2, + "Project": 5, + "Purchase Order": 2, + "Purchase Invoice": 5, + "Purchase Receipt": 5, + "Quotation": 3, + "Salary Slip": 5, + "Salary Structure": 5, + "Sales Order": 2, + "Sales Invoice": 2, + "Stock Entry": 3, + "Student": 5, + "Supplier": 5, + "Task": 5, + "User": 5, + "Work Order": 5 + } + for doctype, min_count in iteritems(doctypes): count = frappe.db.count(doctype) if count > min_count: From bf166736b098574aecbbaa2329e58a5c75a938dd Mon Sep 17 00:00:00 2001 From: Anupam Date: Fri, 18 Sep 2020 17:15:49 +0530 Subject: [PATCH 14/30] fix: Batch-Wise Balance History filter validation --- .../batch_wise_balance_history/batch_wise_balance_history.js | 4 +++- .../batch_wise_balance_history/batch_wise_balance_history.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js index 23700c94ee5..2499c801d2d 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js @@ -9,13 +9,15 @@ frappe.query_reports["Batch-Wise Balance History"] = { "fieldtype": "Date", "width": "80", "default": frappe.sys_defaults.year_start_date, + "reqd": 1 }, { "fieldname":"to_date", "label": __("To Date"), "fieldtype": "Date", "width": "80", - "default": frappe.datetime.get_today() + "default": frappe.datetime.get_today(), + "reqd": 1 }, { "fieldname": "item", diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index 2c95084b813..8f3e246e7f3 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -11,6 +11,9 @@ from frappe.utils import cint, flt, getdate def execute(filters=None): if not filters: filters = {} + if filters.from_date > filters.to_date: + frappe.throw(_("From Date must be before To Date")) + float_precision = cint(frappe.db.get_default("float_precision")) or 3 columns = get_columns(filters) From 97f61d233e75a8b0eeb757a7c6085976cf9031ed Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 21 Sep 2020 13:00:43 +0530 Subject: [PATCH 15/30] fix: breadcrumbs for maintenance visit and schedule under support --- erpnext/public/js/conf.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/conf.js b/erpnext/public/js/conf.js index 2af9140f9e2..eb709e5e85e 100644 --- a/erpnext/public/js/conf.js +++ b/erpnext/public/js/conf.js @@ -11,7 +11,9 @@ $.extend(frappe.breadcrumbs.preferred, { "Territory": "Selling", "Sales Person": "Selling", "Sales Partner": "Selling", - "Brand": "Stock" + "Brand": "Stock", + "Maintenance Schedule": "Support", + "Maintenance Visit": "Support" }); $.extend(frappe.breadcrumbs.module_map, { From e42f08085b9f165422210e056e8c04820a619e09 Mon Sep 17 00:00:00 2001 From: Rohan Date: Mon, 21 Sep 2020 13:14:07 +0530 Subject: [PATCH 16/30] fix: use Plaid's new API (develop) (#23318) Co-authored-by: Mangesh-Khairnar --- .../bank_reconciliation.js | 38 ++++---- .../doctype/plaid_settings/plaid_connector.py | 97 ++++++++++--------- .../doctype/plaid_settings/plaid_settings.js | 57 +++++------ .../plaid_settings/plaid_settings.json | 11 +-- .../doctype/plaid_settings/plaid_settings.py | 50 ++++++---- .../plaid_settings/test_plaid_settings.py | 21 ++-- .../v12_0/move_plaid_settings_to_doctype.py | 5 +- requirements.txt | 2 +- 8 files changed, 149 insertions(+), 132 deletions(-) diff --git a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.js b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.js index efc76f9158b..97035278754 100644 --- a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.js +++ b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.js @@ -72,7 +72,7 @@ erpnext.accounts.bankReconciliation = class BankReconciliation { check_plaid_status() { const me = this; frappe.db.get_value("Plaid Settings", "Plaid Settings", "enabled", (r) => { - if (r && r.enabled == "1") { + if (r && r.enabled === "1") { me.plaid_status = "active" } else { me.plaid_status = "inactive" @@ -139,7 +139,7 @@ erpnext.accounts.bankTransactionUpload = class bankTransactionUpload { } make() { - const me = this; + const me = this; new frappe.ui.FileUploader({ method: 'erpnext.accounts.doctype.bank_transaction.bank_transaction_upload.upload_bank_statement', allow_multiple: 0, @@ -214,31 +214,35 @@ erpnext.accounts.bankTransactionSync = class bankTransactionSync { init_config() { const me = this; - frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.plaid_configuration') - .then(result => { - me.plaid_env = result.plaid_env; - me.plaid_public_key = result.plaid_public_key; - me.client_name = result.client_name; - me.sync_transactions() - }) + frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.get_plaid_configuration') + .then(result => { + me.plaid_env = result.plaid_env; + me.client_name = result.client_name; + me.link_token = result.link_token; + me.sync_transactions(); + }) } sync_transactions() { const me = this; - frappe.db.get_value("Bank Account", me.parent.bank_account, "bank", (v) => { + frappe.db.get_value("Bank Account", me.parent.bank_account, "bank", (r) => { frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.sync_transactions', { - bank: v['bank'], + bank: r.bank, bank_account: me.parent.bank_account, freeze: true }) .then((result) => { - let result_title = (result.length > 0) ? __("{0} bank transaction(s) created", [result.length]) : __("This bank account is already synchronized") + let result_title = (result && result.length > 0) + ? __("{0} bank transaction(s) created", [result.length]) + : __("This bank account is already synchronized"); + let result_msg = ` -
-
${result_title}
-
` +
+
${result_title}
+
` + this.parent.$main_section.append(result_msg) - frappe.show_alert({message:__("Bank account '{0}' has been synchronized", [me.parent.bank_account]), indicator:'green'}); + frappe.show_alert({ message: __("Bank account '{0}' has been synchronized", [me.parent.bank_account]), indicator: 'green' }); }) }) } @@ -384,7 +388,7 @@ erpnext.accounts.ReconciliationRow = class ReconciliationRow { }) frappe.xcall('erpnext.accounts.page.bank_reconciliation.bank_reconciliation.get_linked_payments', - {bank_transaction: data, freeze:true, freeze_message:__("Finding linked payments")} + { bank_transaction: data, freeze: true, freeze_message: __("Finding linked payments") } ).then((result) => { me.make_dialog(result) }) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index 532e19cffd9..f8154f2edd8 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -2,81 +2,84 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from __future__ import unicode_literals -from frappe import _ -from frappe.utils.password import get_decrypted_password -from plaid import Client -from plaid.errors import APIError, ItemError +import plaid +import requests +from plaid.errors import APIError, ItemError, InvalidRequestError import frappe -import requests +from frappe import _ + class PlaidConnector(): def __init__(self, access_token=None): - - plaid_settings = frappe.get_single("Plaid Settings") - - self.config = { - "plaid_client_id": plaid_settings.plaid_client_id, - "plaid_secret": get_decrypted_password("Plaid Settings", "Plaid Settings", 'plaid_secret'), - "plaid_public_key": plaid_settings.plaid_public_key, - "plaid_env": plaid_settings.plaid_env - } - - self.client = Client(client_id=self.config.get("plaid_client_id"), - secret=self.config.get("plaid_secret"), - public_key=self.config.get("plaid_public_key"), - environment=self.config.get("plaid_env") - ) - self.access_token = access_token + self.settings = frappe.get_single("Plaid Settings") + self.products = ["auth", "transactions"] + self.client_name = frappe.local.site + self.client = plaid.Client( + client_id=self.settings.plaid_client_id, + secret=self.settings.get_password("plaid_secret"), + environment=self.settings.plaid_env, + api_version="2019-05-29" + ) def get_access_token(self, public_token): if public_token is None: frappe.log_error(_("Public token is missing for this bank"), _("Plaid public token error")) - response = self.client.Item.public_token.exchange(public_token) - access_token = response['access_token'] - + access_token = response["access_token"] return access_token + def get_link_token(self): + token_request = { + "client_name": self.client_name, + "client_id": self.settings.plaid_client_id, + "secret": self.settings.plaid_secret, + "products": self.products, + # only allow Plaid-supported languages and countries (LAST: Sep-19-2020) + "language": frappe.local.lang if frappe.local.lang in ["en", "fr", "es", "nl"] else "en", + "country_codes": ["US", "CA", "FR", "IE", "NL", "ES", "GB"], + "user": { + "client_user_id": frappe.generate_hash(frappe.session.user, length=32) + } + } + + try: + response = self.client.LinkToken.create(token_request) + except InvalidRequestError: + frappe.log_error(frappe.get_traceback(), _("Plaid invalid request error")) + frappe.msgprint(_("Please check your Plaid client ID and secret values")) + except APIError as e: + frappe.log_error(frappe.get_traceback(), _("Plaid authentication error")) + frappe.throw(_(str(e)), title=_("Authentication Failed")) + else: + return response["link_token"] + def auth(self): try: self.client.Auth.get(self.access_token) - print("Authentication successful.....") except ItemError as e: - if e.code == 'ITEM_LOGIN_REQUIRED': - pass - else: + if e.code == "ITEM_LOGIN_REQUIRED": pass except APIError as e: - if e.code == 'PLANNED_MAINTENANCE': - pass - else: + if e.code == "PLANNED_MAINTENANCE": pass except requests.Timeout: pass except Exception as e: - print(e) frappe.log_error(frappe.get_traceback(), _("Plaid authentication error")) - frappe.msgprint({"title": _("Authentication Failed"), "message":e, "raise_exception":1, "indicator":'red'}) + frappe.throw(_(str(e)), title=_("Authentication Failed")) def get_transactions(self, start_date, end_date, account_id=None): + self.auth() + account_ids = list(account_id) if account_id else None + try: - self.auth() - if account_id: - account_ids = [account_id] - - response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date, account_ids=account_ids) - - else: - response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date) - - transactions = response['transactions'] - - while len(transactions) < response['total_transactions']: + response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date, account_ids=account_ids) + transactions = response["transactions"] + while len(transactions) < response["total_transactions"]: response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date, offset=len(transactions)) - transactions.extend(response['transactions']) + transactions.extend(response["transactions"]) return transactions except Exception: frappe.log_error(frappe.get_traceback(), _("Plaid transactions sync error")) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index 0ffbb877ea7..22a4004955f 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -4,14 +4,14 @@ frappe.provide("erpnext.integrations"); frappe.ui.form.on('Plaid Settings', { - enabled: function(frm) { + enabled: function (frm) { frm.toggle_reqd('plaid_client_id', frm.doc.enabled); frm.toggle_reqd('plaid_secret', frm.doc.enabled); - frm.toggle_reqd('plaid_public_key', frm.doc.enabled); frm.toggle_reqd('plaid_env', frm.doc.enabled); }, - refresh: function(frm) { - if(frm.doc.enabled) { + + refresh: function (frm) { + if (frm.doc.enabled) { frm.add_custom_button('Link a new bank account', () => { new erpnext.integrations.plaidLink(frm); }); @@ -22,17 +22,16 @@ frappe.ui.form.on('Plaid Settings', { erpnext.integrations.plaidLink = class plaidLink { constructor(parent) { this.frm = parent; - this.product = ["transactions", "auth"]; this.plaidUrl = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'; this.init_config(); } - init_config() { - const me = this; - me.plaid_env = me.frm.doc.plaid_env; - me.plaid_public_key = me.frm.doc.plaid_public_key; - me.client_name = frappe.boot.sitename; - me.init_plaid(); + async init_config() { + this.product = ["auth", "transactions"]; + this.plaid_env = this.frm.doc.plaid_env; + this.client_name = frappe.boot.sitename; + this.token = await this.frm.call("get_link_token").then(resp => resp.message); + this.init_plaid(); } init_plaid() { @@ -69,17 +68,17 @@ erpnext.integrations.plaidLink = class plaidLink { } onScriptLoaded(me) { - me.linkHandler = window.Plaid.create({ + me.linkHandler = Plaid.create({ clientName: me.client_name, + product: me.product, env: me.plaid_env, - key: me.plaid_public_key, - onSuccess: me.plaid_success, - product: me.product + token: me.token, + onSuccess: me.plaid_success }); } onScriptError(error) { - frappe.msgprint('There was an issue loading the link-initialize.js script'); + frappe.msgprint("There was an issue connecting to Plaid's authentication server"); frappe.msgprint(error); } @@ -87,21 +86,25 @@ erpnext.integrations.plaidLink = class plaidLink { const me = this; frappe.prompt({ - fieldtype:"Link", + fieldtype: "Link", options: "Company", - label:__("Company"), - fieldname:"company", - reqd:1 + label: __("Company"), + fieldname: "company", + reqd: 1 }, (data) => { me.company = data.company; - frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.add_institution', {token: token, response: response}) - .then((result) => { - frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.add_bank_accounts', {response: response, - bank: result, company: me.company}); - }) - .then(() => { - frappe.show_alert({message:__("Bank accounts added"), indicator:'green'}); + frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.add_institution', { + token: token, + response: response + }).then((result) => { + frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.add_bank_accounts', { + response: response, + bank: result, + company: me.company }); + }).then(() => { + frappe.show_alert({ message: __("Bank accounts added"), indicator: 'green' }); + }); }, __("Select a company"), __("Continue")); } }; diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json index d8203d7390f..27062172239 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json @@ -1,5 +1,4 @@ { - "actions": [], "creation": "2018-10-25 10:02:48.656165", "doctype": "DocType", "editable_grid": 1, @@ -12,7 +11,6 @@ "plaid_client_id", "plaid_secret", "column_break_7", - "plaid_public_key", "plaid_env" ], "fields": [ @@ -41,12 +39,6 @@ "in_list_view": 1, "label": "Plaid Secret" }, - { - "fieldname": "plaid_public_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Plaid Public Key" - }, { "fieldname": "plaid_env", "fieldtype": "Select", @@ -69,8 +61,7 @@ } ], "issingle": 1, - "links": [], - "modified": "2020-02-07 15:21:11.616231", + "modified": "2020-09-12 02:31:44.542385", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "Plaid Settings", diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index c3371ed5dfb..e535e81bdef 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -2,30 +2,36 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from __future__ import unicode_literals -import frappe import json -from frappe import _ -from frappe.model.document import Document + +import frappe from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_connector import PlaidConnector -from frappe.utils import getdate, formatdate, today, add_months +from frappe import _ from frappe.desk.doctype.tag.tag import add_tag +from frappe.model.document import Document +from frappe.utils import add_months, formatdate, getdate, today + class PlaidSettings(Document): - pass + @staticmethod + def get_link_token(): + plaid = PlaidConnector() + return plaid.get_link_token() + @frappe.whitelist() -def plaid_configuration(): +def get_plaid_configuration(): if frappe.db.get_single_value("Plaid Settings", "enabled"): plaid_settings = frappe.get_single("Plaid Settings") return { - "plaid_public_key": plaid_settings.plaid_public_key, "plaid_env": plaid_settings.plaid_env, + "link_token": plaid_settings.get_link_token(), "client_name": frappe.local.site } - else: - return "disabled" + + return "disabled" + @frappe.whitelist() def add_institution(token, response): @@ -33,6 +39,7 @@ def add_institution(token, response): plaid = PlaidConnector() access_token = plaid.get_access_token(token) + bank = None if not frappe.db.exists("Bank", response["institution"]["name"]): try: @@ -44,7 +51,6 @@ def add_institution(token, response): bank.insert() except Exception: frappe.throw(frappe.get_traceback()) - else: bank = frappe.get_doc("Bank", response["institution"]["name"]) bank.plaid_access_token = access_token @@ -52,6 +58,7 @@ def add_institution(token, response): return bank + @frappe.whitelist() def add_bank_accounts(response, bank, company): try: @@ -92,9 +99,8 @@ def add_bank_accounts(response, bank, company): new_account.insert() result.append(new_account.name) - except frappe.UniqueValidationError: - frappe.msgprint(_("Bank account {0} already exists and could not be created again").format(new_account.account_name)) + frappe.msgprint(_("Bank account {0} already exists and could not be created again").format(account["name"])) except Exception: frappe.throw(frappe.get_traceback()) @@ -103,6 +109,7 @@ def add_bank_accounts(response, bank, company): return result + def add_account_type(account_type): try: frappe.get_doc({ @@ -122,10 +129,11 @@ def add_account_subtype(account_subtype): except Exception: frappe.throw(frappe.get_traceback()) + @frappe.whitelist() def sync_transactions(bank, bank_account): - '''Sync transactions based on the last integration date as the start date, after the sync is completed - add the transaction date of the oldest transaction as the last integration date''' + """Sync transactions based on the last integration date as the start date, after sync is completed + add the transaction date of the oldest transaction as the last integration date.""" last_transaction_date = frappe.db.get_value("Bank Account", bank_account, "last_integration_date") if last_transaction_date: start_date = formatdate(last_transaction_date, "YYYY-MM-dd") @@ -147,10 +155,10 @@ def sync_transactions(bank, bank_account): len(result), bank_account, start_date, end_date)) frappe.db.set_value("Bank Account", bank_account, "last_integration_date", last_transaction_date) - except Exception: frappe.log_error(frappe.get_traceback(), _("Plaid transactions sync error")) + def get_transactions(bank, bank_account=None, start_date=None, end_date=None): access_token = None @@ -168,6 +176,7 @@ def get_transactions(bank, bank_account=None, start_date=None, end_date=None): return transactions + def new_bank_transaction(transaction): result = [] @@ -182,8 +191,8 @@ def new_bank_transaction(transaction): status = "Pending" if transaction["pending"] == "True" else "Settled" + tags = [] try: - tags = [] tags += transaction["category"] tags += ["Plaid Cat. {}".format(transaction["category_id"])] except KeyError: @@ -216,6 +225,7 @@ def new_bank_transaction(transaction): return result + def automatic_synchronization(): settings = frappe.get_doc("Plaid Settings", "Plaid Settings") @@ -223,4 +233,8 @@ def automatic_synchronization(): plaid_accounts = frappe.get_all("Bank Account", filters={"integration_id": ["!=", ""]}, fields=["name", "bank"]) for plaid_account in plaid_accounts: - frappe.enqueue("erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.sync_transactions", bank=plaid_account.bank, bank_account=plaid_account.name) + frappe.enqueue( + "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.sync_transactions", + bank=plaid_account.bank, + bank_account=plaid_account.name + ) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py index 1a063d6b6f8..3c906374c42 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py @@ -1,14 +1,17 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -from __future__ import unicode_literals -import unittest -import frappe -from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings import plaid_configuration, add_account_type, add_account_subtype, new_bank_transaction, add_bank_accounts import json -from frappe.utils.response import json_handler +import unittest + +import frappe from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account +from erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings import ( + add_account_subtype, add_account_type, add_bank_accounts, + new_bank_transaction, get_plaid_configuration) +from frappe.utils.response import json_handler + class TestPlaidSettings(unittest.TestCase): def setUp(self): @@ -31,7 +34,7 @@ class TestPlaidSettings(unittest.TestCase): def test_plaid_disabled(self): frappe.db.set_value("Plaid Settings", None, "enabled", 0) - self.assertTrue(plaid_configuration() == "disabled") + self.assertTrue(get_plaid_configuration() == "disabled") def test_add_account_type(self): add_account_type("brokerage") @@ -64,7 +67,7 @@ class TestPlaidSettings(unittest.TestCase): 'mask': '0000', 'id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK', 'name': 'Plaid Checking' - }], + }], 'institution': { 'institution_id': 'ins_6', 'name': 'Citi' @@ -100,7 +103,7 @@ class TestPlaidSettings(unittest.TestCase): 'mask': '0000', 'id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK', 'name': 'Plaid Checking' - }], + }], 'institution': { 'institution_id': 'ins_6', 'name': 'Citi' @@ -152,4 +155,4 @@ class TestPlaidSettings(unittest.TestCase): new_bank_transaction(transactions) - self.assertTrue(len(frappe.get_all("Bank Transaction")) == 1) \ No newline at end of file + self.assertTrue(len(frappe.get_all("Bank Transaction")) == 1) diff --git a/erpnext/patches/v12_0/move_plaid_settings_to_doctype.py b/erpnext/patches/v12_0/move_plaid_settings_to_doctype.py index 8e60d33f850..d2bcb12070c 100644 --- a/erpnext/patches/v12_0/move_plaid_settings_to_doctype.py +++ b/erpnext/patches/v12_0/move_plaid_settings_to_doctype.py @@ -4,17 +4,16 @@ from __future__ import unicode_literals import frappe + def execute(): frappe.reload_doc("erpnext_integrations", "doctype", "plaid_settings") plaid_settings = frappe.get_single("Plaid Settings") if plaid_settings.enabled: - if not (frappe.conf.plaid_client_id and frappe.conf.plaid_env \ - and frappe.conf.plaid_public_key and frappe.conf.plaid_secret): + if not (frappe.conf.plaid_client_id and frappe.conf.plaid_env and frappe.conf.plaid_secret): plaid_settings.enabled = 0 else: plaid_settings.update({ "plaid_client_id": frappe.conf.plaid_client_id, - "plaid_public_key": frappe.conf.plaid_public_key, "plaid_env": frappe.conf.plaid_env, "plaid_secret": frappe.conf.plaid_secret }) diff --git a/requirements.txt b/requirements.txt index 912d61f7a6f..b7ba4128938 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ frappe gocardless-pro==1.11.0 googlemaps==3.1.1 pandas==1.0.5 -plaid-python==3.4.0 +plaid-python==6.0.0 pycountry==19.8.18 PyGithub==1.44.1 python-stdnum==1.12 From de7a2bc4be092133709133cb7c1fcb3ffd608201 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 21 Sep 2020 13:57:04 +0530 Subject: [PATCH 17/30] fix: apply user permissions in tax accounts query --- erpnext/controllers/queries.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index c88bf66411f..10ff6ad8d92 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -165,9 +165,14 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters): AND company = %(company)s AND account_currency = %(currency)s AND `{searchfield}` LIKE %(txt)s + {mcond} ORDER BY idx DESC, name LIMIT %(offset)s, %(limit)s - """.format(account_type_condition=account_type_condition, searchfield=searchfield), + """.format( + account_type_condition=account_type_condition, + searchfield=searchfield, + mcond=get_match_cond(doctype) + ), dict( account_types=filters.get("account_type"), company=filters.get("company"), From e3c61286385c80f0bde88c03271702841fefc30f Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 21 Sep 2020 18:50:22 +0530 Subject: [PATCH 18/30] fix(plaid): do not send null list in account ids (#23376) --- .../doctype/plaid_settings/plaid_connector.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index f8154f2edd8..a033a2a722d 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -72,10 +72,16 @@ class PlaidConnector(): def get_transactions(self, start_date, end_date, account_id=None): self.auth() - account_ids = list(account_id) if account_id else None + kwargs = dict( + access_token=self.access_token, + start_date=start_date, + end_date=end_date + ) + if account_id: + kwargs.update(dict(account_ids=[account_id])) try: - response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date, account_ids=account_ids) + response = self.client.Transactions.get(**kwargs) transactions = response["transactions"] while len(transactions) < response["total_transactions"]: response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date, offset=len(transactions)) From 2b53b98c1d438c6e43e1d5cdf1086001e7b1faec Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 22 Sep 2020 12:18:12 +0530 Subject: [PATCH 19/30] fix: reload dashboard (#23380) --- erpnext/patches.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9ce570e6d0a..a919b670cbf 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -690,6 +690,7 @@ erpnext.patches.v12_0.set_valid_till_date_in_supplier_quotation erpnext.patches.v13_0.update_old_loans erpnext.patches.v12_0.set_serial_no_status #2020-05-21 erpnext.patches.v12_0.update_price_list_currency_in_bom +execute:frappe.reload_doctype('Dashboard') execute:frappe.delete_doc_if_exists('Dashboard', 'Accounts') erpnext.patches.v13_0.update_actual_start_and_end_date_in_wo erpnext.patches.v13_0.set_company_field_in_healthcare_doctypes #2020-05-25 From a0d192eae4c00b6aeaadd5e621d55a2227207674 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 22 Sep 2020 13:54:07 +0530 Subject: [PATCH 20/30] feat: Show searchfields in batch query --- erpnext/controllers/queries.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index c88bf66411f..e8fe62fd51a 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -359,9 +359,21 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): if filters.get("is_return"): having_clause = "" + meta = frappe.get_meta("Batch", cached=True) + searchfields = meta.get_search_fields() + + search_columns = '' + if searchfields: + search_columns = ", " + ", ".join(searchfields) + if args.get('warehouse'): + searchfields = ['batch.' + field for field in searchfields] + if searchfields: + search_columns = ", " + ", ".join(searchfields) + batch_nos = frappe.db.sql("""select sle.batch_no, round(sum(sle.actual_qty),2), sle.stock_uom, concat('MFG-',batch.manufacturing_date), concat('EXP-',batch.expiry_date) + {search_columns} from `tabStock Ledger Entry` sle INNER JOIN `tabBatch` batch on sle.batch_no = batch.name where @@ -377,6 +389,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): group by batch_no {having_clause} order by batch.expiry_date, sle.batch_no desc limit %(start)s, %(page_len)s""".format( + search_columns = search_columns, cond=cond, match_conditions=get_match_cond(doctype), having_clause = having_clause @@ -384,7 +397,9 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): return batch_nos else: - return frappe.db.sql("""select name, concat('MFG-', manufacturing_date), concat('EXP-',expiry_date) from `tabBatch` batch + return frappe.db.sql("""select name, concat('MFG-', manufacturing_date), concat('EXP-',expiry_date) + {search_columns} + from `tabBatch` batch where batch.disabled = 0 and item = %(item_code)s and (name like %(txt)s @@ -394,7 +409,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): {0} {match_conditions} order by expiry_date, name desc - limit %(start)s, %(page_len)s""".format(cond, match_conditions=get_match_cond(doctype)), args) + limit %(start)s, %(page_len)s""".format(cond, search_columns = search_columns, match_conditions=get_match_cond(doctype)), args) @frappe.whitelist() From 6087fcaa9baeb4431b3ad22b073b4b6c351aaef1 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 22 Sep 2020 17:48:04 +0530 Subject: [PATCH 21/30] chore: Added video settings to List View Menu --- erpnext/utilities/doctype/video/video_list.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 erpnext/utilities/doctype/video/video_list.js diff --git a/erpnext/utilities/doctype/video/video_list.js b/erpnext/utilities/doctype/video/video_list.js new file mode 100644 index 00000000000..8273a4a781f --- /dev/null +++ b/erpnext/utilities/doctype/video/video_list.js @@ -0,0 +1,7 @@ +frappe.listview_settings["Video"] = { + onload: (listview) => { + listview.page.add_menu_item(__("Video Settings"), function() { + frappe.set_route("Form","Video Settings", "Video Settings"); + }); + } +} \ No newline at end of file From 1831893b774f0bbc9de09329a0b12fa6f58dc5e7 Mon Sep 17 00:00:00 2001 From: Saqib Date: Wed, 23 Sep 2020 13:01:49 +0530 Subject: [PATCH 22/30] fix: failed workflow condition error message in update items (#23393) --- erpnext/controllers/accounts_controller.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 046fb2ca685..90c466b6316 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1264,7 +1264,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil transitions.append(transition.as_dict()) if not transitions: - frappe.throw(_("You do not have workflow access to update this document."), title=_("Insufficient Workflow Permissions")) + frappe.throw( + _("You are not allowed to update as per the conditions set in {} Workflow.").format(get_link_to_form("Workflow", workflow)), + title=_("Insufficient Permissions") + ) def get_new_child_item(item_row): new_child_function = set_sales_order_defaults if parent_doctype == "Sales Order" else set_purchase_order_defaults From 8be51e22c48c3ceaf09196e70f609db6e44a8c68 Mon Sep 17 00:00:00 2001 From: Afshan Date: Wed, 23 Sep 2020 14:52:36 +0530 Subject: [PATCH 23/30] fix: escape apostrophe in cost centre and project if exist --- erpnext/accounts/report/gross_profit/gross_profit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 2563b66d1cf..84c74543dae 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -268,9 +268,9 @@ class GrossProfitGenerator(object): def get_last_purchase_rate(self, item_code, row): condition = '' if row.project: - condition += " AND a.project='%s'" % (row.project) + condition += " AND a.project=%s" % (frappe.db.escape(row.project)) elif row.cost_center: - condition += " AND a.cost_center='%s'" % (row.cost_center) + condition += " AND a.cost_center=%s" % (frappe.db.escape(row.cost_center)) if self.filters.to_date: condition += " AND modified='%s'" % (self.filters.to_date) From 879a8ef26bdbea3d97812418e080c69638de02eb Mon Sep 17 00:00:00 2001 From: Michelle Alva <50285544+michellealva@users.noreply.github.com> Date: Wed, 23 Sep 2020 15:36:07 +0530 Subject: [PATCH 24/30] fix: Change button label in Item (#23410) --- erpnext/stock/doctype/item/item.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 38e5fe53a7c..faeeb578fe3 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -26,19 +26,19 @@ frappe.ui.form.on("Item", { refresh: function(frm) { if (frm.doc.is_stock_item) { - frm.add_custom_button(__("Balance"), function() { + frm.add_custom_button(__("Stock Balance"), function() { frappe.route_options = { "item_code": frm.doc.name } frappe.set_route("query-report", "Stock Balance"); }, __("View")); - frm.add_custom_button(__("Ledger"), function() { + frm.add_custom_button(__("Stock Ledger"), function() { frappe.route_options = { "item_code": frm.doc.name } frappe.set_route("query-report", "Stock Ledger"); }, __("View")); - frm.add_custom_button(__("Projected"), function() { + frm.add_custom_button(__("Stock Projected Qty"), function() { frappe.route_options = { "item_code": frm.doc.name } From 46d418038186e7b79e523616b41a80d50b341bec Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 23 Sep 2020 16:53:16 +0530 Subject: [PATCH 25/30] refactor: Issue form cleaned up and renamed Minutes to First Response field (#23066) * refactor: re-order fields in Issue DocType * refactor: rename Mins to First Response to First Response Time * refactor: First Response Time Reports for Issue and Opportunity * fix: added patch for renamed fields and setting durations * chore: update CRM and Support Desk Pages for Response Time reports * fix: first response time for opportunity report * fix: patch Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- erpnext/crm/desk_page/crm/crm.json | 4 +- .../crm/doctype/opportunity/opportunity.json | 18 ++--- .../__init__.py | 0 .../first_response_time_for_opportunity.js} | 25 +++++-- .../first_response_time_for_opportunity.json | 28 +++++++ .../first_response_time_for_opportunity.py | 35 +++++++++ ...tes_to_first_response_for_opportunity.json | 26 ------- ...nutes_to_first_response_for_opportunity.py | 28 ------- erpnext/patches.txt | 1 + .../v13_0/rename_issue_doctype_fields.py | 65 +++++++++++++++++ .../support/desk_page/support/support.json | 4 +- erpnext/support/doctype/issue/issue.js | 30 ++++---- erpnext/support/doctype/issue/issue.json | 73 +++++++++---------- erpnext/support/doctype/issue/issue.py | 42 +++++------ erpnext/support/doctype/issue/test_issue.py | 2 +- .../__init__.py | 0 .../first_response_time_for_issues.js | 44 +++++++++++ .../first_response_time_for_issues.json | 26 +++++++ .../first_response_time_for_issues.py | 35 +++++++++ .../minutes_to_first_response_for_issues.js | 30 -------- .../minutes_to_first_response_for_issues.json | 24 ------ .../minutes_to_first_response_for_issues.py | 28 ------- 22 files changed, 340 insertions(+), 228 deletions(-) rename erpnext/crm/report/{minutes_to_first_response_for_opportunity => first_response_time_for_opportunity}/__init__.py (100%) rename erpnext/crm/report/{minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.js => first_response_time_for_opportunity/first_response_time_for_opportunity.js} (55%) create mode 100644 erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.json create mode 100644 erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py delete mode 100644 erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.json delete mode 100644 erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.py create mode 100644 erpnext/patches/v13_0/rename_issue_doctype_fields.py rename erpnext/support/report/{minutes_to_first_response_for_issues => first_response_time_for_issues}/__init__.py (100%) create mode 100644 erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js create mode 100644 erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.json create mode 100644 erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py delete mode 100644 erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.js delete mode 100644 erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.json delete mode 100644 erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.py diff --git a/erpnext/crm/desk_page/crm/crm.json b/erpnext/crm/desk_page/crm/crm.json index eb69dc06b65..d974beb2de8 100644 --- a/erpnext/crm/desk_page/crm/crm.json +++ b/erpnext/crm/desk_page/crm/crm.json @@ -8,7 +8,7 @@ { "hidden": 0, "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Lead Details\",\n \"name\": \"Lead Details\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"icon\": \"fa fa-bar-chart\",\n \"label\": \"Sales Funnel\",\n \"name\": \"sales-funnel\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Prospects Engaged But Not Converted\",\n \"name\": \"Prospects Engaged But Not Converted\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Opportunity\"\n ],\n \"doctype\": \"Opportunity\",\n \"is_query_report\": true,\n \"label\": \"Minutes to First Response for Opportunity\",\n \"name\": \"Minutes to First Response for Opportunity\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Inactive Customers\",\n \"name\": \"Inactive Customers\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Campaign Efficiency\",\n \"name\": \"Campaign Efficiency\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Lead Owner Efficiency\",\n \"name\": \"Lead Owner Efficiency\",\n \"type\": \"report\"\n }\n]" + "links": "[\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Lead Details\",\n \"name\": \"Lead Details\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"icon\": \"fa fa-bar-chart\",\n \"label\": \"Sales Funnel\",\n \"name\": \"sales-funnel\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Prospects Engaged But Not Converted\",\n \"name\": \"Prospects Engaged But Not Converted\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Opportunity\"\n ],\n \"doctype\": \"Opportunity\",\n \"is_query_report\": true,\n \"label\": \"First Response Time for Opportunity\",\n \"name\": \"First Response Time for Opportunity\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Inactive Customers\",\n \"name\": \"Inactive Customers\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Campaign Efficiency\",\n \"name\": \"Campaign Efficiency\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Lead Owner Efficiency\",\n \"name\": \"Lead Owner Efficiency\",\n \"type\": \"report\"\n }\n]" }, { "hidden": 0, @@ -42,7 +42,7 @@ "idx": 0, "is_standard": 1, "label": "CRM", - "modified": "2020-05-28 13:33:52.906750", + "modified": "2020-08-11 18:55:18.238900", "modified_by": "Administrator", "module": "CRM", "name": "CRM", diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index b61cad36209..eee13f7e799 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -24,7 +24,7 @@ "converted_by", "sales_stage", "order_lost_reason", - "mins_to_first_response", + "first_response_time", "expected_closing", "next_contact", "contact_by", @@ -152,13 +152,6 @@ "no_copy": 1, "read_only": 1 }, - { - "bold": 1, - "fieldname": "mins_to_first_response", - "fieldtype": "Float", - "label": "Mins to first response", - "read_only": 1 - }, { "fieldname": "expected_closing", "fieldtype": "Date", @@ -419,12 +412,19 @@ "fieldtype": "Link", "label": "Converted By", "options": "User" + }, + { + "bold": 1, + "fieldname": "first_response_time", + "fieldtype": "Duration", + "label": "First Response Time", + "read_only": 1 } ], "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2020-08-11 17:34:35.066961", + "modified": "2020-08-12 17:34:35.066961", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/report/minutes_to_first_response_for_opportunity/__init__.py b/erpnext/crm/report/first_response_time_for_opportunity/__init__.py similarity index 100% rename from erpnext/crm/report/minutes_to_first_response_for_opportunity/__init__.py rename to erpnext/crm/report/first_response_time_for_opportunity/__init__.py diff --git a/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.js b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js similarity index 55% rename from erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.js rename to erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js index 92d026a79c5..3f5c95ab0aa 100644 --- a/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.js +++ b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js @@ -1,33 +1,44 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +/* eslint-disable */ -frappe.query_reports["Minutes to First Response for Opportunity"] = { +frappe.query_reports["First Response Time for Opportunity"] = { "filters": [ { "fieldname": "from_date", "label": __("From Date"), "fieldtype": "Date", - 'reqd': 1, + "reqd": 1, "default": frappe.datetime.add_days(frappe.datetime.nowdate(), -30) }, { "fieldname": "to_date", "label": __("To Date"), "fieldtype": "Date", - 'reqd': 1, + "reqd": 1, "default": frappe.datetime.nowdate() }, ], - get_chart_data: function (columns, result) { + get_chart_data: function (_columns, result) { return { data: { labels: result.map(d => d[0]), datasets: [{ - name: 'Mins to first response', + name: "First Response Time", values: result.map(d => d[1]) }] }, - type: 'line', + type: "line", + tooltipOptions: { + formatTooltipY: d => { + let duration_options = { + hide_days: 0, + hide_seconds: 0 + }; + value = frappe.utils.get_formatted_duration(d, duration_options); + return value; + } + } } } -} +}; diff --git a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.json b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.json new file mode 100644 index 00000000000..1b3184fe0a2 --- /dev/null +++ b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.json @@ -0,0 +1,28 @@ +{ + "add_total_row": 0, + "creation": "2020-08-10 18:34:19.083872", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "idx": 0, + "is_standard": "Yes", + "letter_head": "Test 2", + "modified": "2020-08-10 18:34:19.083872", + "modified_by": "Administrator", + "module": "CRM", + "name": "First Response Time for Opportunity", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Opportunity", + "report_name": "First Response Time for Opportunity", + "report_type": "Script Report", + "roles": [ + { + "role": "Sales User" + }, + { + "role": "Sales Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py new file mode 100644 index 00000000000..2ffbc3e62ac --- /dev/null +++ b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py @@ -0,0 +1,35 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe + +def execute(filters=None): + columns = [ + { + 'fieldname': 'creation_date', + 'label': 'Date', + 'fieldtype': 'Date', + 'width': 300 + }, + { + 'fieldname': 'first_response_time', + 'fieldtype': 'Duration', + 'label': 'First Response Time', + 'width': 300 + }, + ] + + data = frappe.db.sql(''' + SELECT + date(creation) as creation_date, + avg(first_response_time) as avg_response_time + FROM tabOpportunity + WHERE + date(creation) between %s and %s + and first_response_time > 0 + GROUP BY creation_date + ORDER BY creation_date desc + ''', (filters.from_date, filters.to_date)) + + return columns, data diff --git a/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.json b/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.json deleted file mode 100644 index bcd092ba970..00000000000 --- a/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "add_total_row": 0, - "apply_user_permissions": 0, - "creation": "2016-06-17 11:28:25.867258", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 2, - "is_standard": "Yes", - "modified": "2017-02-24 20:06:08.801109", - "modified_by": "Administrator", - "module": "CRM", - "name": "Minutes to First Response for Opportunity", - "owner": "Administrator", - "ref_doctype": "Opportunity", - "report_name": "Minutes to First Response for Opportunity", - "report_type": "Script Report", - "roles": [ - { - "role": "Sales User" - }, - { - "role": "Sales Manager" - } - ] -} \ No newline at end of file diff --git a/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.py b/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.py deleted file mode 100644 index 54e3a60308e..00000000000 --- a/erpnext/crm/report/minutes_to_first_response_for_opportunity/minutes_to_first_response_for_opportunity.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe - -def execute(filters=None): - columns = [ - { - 'fieldname': 'creation_date', - 'label': 'Date', - 'fieldtype': 'Date' - }, - { - 'fieldname': 'mins', - 'fieldtype': 'Float', - 'label': 'Mins to First Response' - }, - ] - - data = frappe.db.sql('''select date(creation) as creation_date, - avg(mins_to_first_response) as mins - from tabOpportunity - where date(creation) between %s and %s - and mins_to_first_response > 0 - group by creation_date order by creation_date desc''', (filters.from_date, filters.to_date)) - - return columns, data diff --git a/erpnext/patches.txt b/erpnext/patches.txt index c1aa4a632e0..6087ce29aa5 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -726,5 +726,6 @@ erpnext.patches.v12_0.rename_lost_reason_detail erpnext.patches.v13_0.drop_razorpay_payload_column erpnext.patches.v13_0.update_start_end_date_for_old_shift_assignment erpnext.patches.v13_0.setting_custom_roles_for_some_regional_reports +erpnext.patches.v13_0.rename_issue_doctype_fields erpnext.patches.v13_0.change_default_pos_print_format erpnext.patches.v13_0.set_youtube_video_id diff --git a/erpnext/patches/v13_0/rename_issue_doctype_fields.py b/erpnext/patches/v13_0/rename_issue_doctype_fields.py new file mode 100644 index 00000000000..5bd65965790 --- /dev/null +++ b/erpnext/patches/v13_0/rename_issue_doctype_fields.py @@ -0,0 +1,65 @@ +# Copyright (c) 2020, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.utils.rename_field import rename_field + +def execute(): + if frappe.db.exists('DocType', 'Issue'): + issues = frappe.db.get_all('Issue', fields=['name', 'response_by_variance', 'resolution_by_variance', 'mins_to_first_response'], + order_by='creation desc') + frappe.reload_doc('support', 'doctype', 'issue') + + # rename fields + rename_map = { + 'agreement_fulfilled': 'agreement_status', + 'mins_to_first_response': 'first_response_time' + } + for old, new in rename_map.items(): + rename_field('Issue', old, new) + + # change fieldtype to duration + count = 0 + for entry in issues: + response_by_variance = convert_to_seconds(entry.response_by_variance, 'Hours') + resolution_by_variance = convert_to_seconds(entry.resolution_by_variance, 'Hours') + mins_to_first_response = convert_to_seconds(entry.mins_to_first_response, 'Minutes') + frappe.db.set_value('Issue', entry.name, { + 'response_by_variance': response_by_variance, + 'resolution_by_variance': resolution_by_variance, + 'first_response_time': mins_to_first_response + }) + # commit after every 100 updates + count += 1 + if count%100 == 0: + frappe.db.commit() + + if frappe.db.exists('DocType', 'Opportunity'): + opportunities = frappe.db.get_all('Opportunity', fields=['name', 'mins_to_first_response'], order_by='creation desc') + frappe.reload_doc('crm', 'doctype', 'opportunity') + rename_field('Opportunity', 'mins_to_first_response', 'first_response_time') + + # change fieldtype to duration + count = 0 + for entry in opportunities: + mins_to_first_response = convert_to_seconds(entry.mins_to_first_response, 'Minutes') + frappe.db.set_value('Opportunity', entry.name, 'first_response_time', mins_to_first_response) + # commit after every 100 updates + count += 1 + if count%100 == 0: + frappe.db.commit() + + # renamed reports from "Minutes to First Response for Issues" to "First Response Time for Issues". Same for Opportunity + for report in ['Minutes to First Response for Issues', 'Minutes to First Response for Opportunity']: + if frappe.db.exists('Report', report): + frappe.delete_doc('Report', report) + + +def convert_to_seconds(value, unit): + seconds = 0 + if unit == 'Hours': + seconds = value * 3600 + if unit == 'Minutes': + seconds = value * 60 + return seconds diff --git a/erpnext/support/desk_page/support/support.json b/erpnext/support/desk_page/support/support.json index b1ad7c8aa0a..28410f3a71a 100644 --- a/erpnext/support/desk_page/support/support.json +++ b/erpnext/support/desk_page/support/support.json @@ -28,7 +28,7 @@ { "hidden": 0, "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Issue\"\n ],\n \"doctype\": \"Issue\",\n \"is_query_report\": true,\n \"label\": \"Minutes to First Response for Issues\",\n \"name\": \"Minutes to First Response for Issues\",\n \"type\": \"report\"\n }\n]" + "links": "[\n {\n \"dependencies\": [\n \"Issue\"\n ],\n \"doctype\": \"Issue\",\n \"is_query_report\": true,\n \"label\": \"First Response Time for Issues\",\n \"name\": \"First Response Time for Issues\",\n \"type\": \"report\"\n }\n]" } ], "category": "Modules", @@ -43,7 +43,7 @@ "idx": 0, "is_standard": 1, "label": "Support", - "modified": "2020-06-04 11:54:56.124219", + "modified": "2020-08-11 15:49:34.307341", "modified_by": "Administrator", "module": "Support", "name": "Support", diff --git a/erpnext/support/doctype/issue/issue.js b/erpnext/support/doctype/issue/issue.js index 858564a5270..fe01d4b983c 100644 --- a/erpnext/support/doctype/issue/issue.js +++ b/erpnext/support/doctype/issue/issue.js @@ -2,10 +2,14 @@ frappe.ui.form.on("Issue", { onload: function(frm) { frm.email_field = "raised_by"; - frappe.db.get_value("Support Settings", {name: "Support Settings"}, "allow_resetting_service_level_agreement", (r) => { - if (!r.allow_resetting_service_level_agreement) { - frm.set_df_property("reset_service_level_agreement", "hidden", 1) ; - } + frappe.db.get_value("Support Settings", {name: "Support Settings"}, + ["allow_resetting_service_level_agreement", "track_service_level_agreement"], (r) => { + if (r && r.track_service_level_agreement == "0") { + frm.set_df_property("service_level_section", "hidden", 1); + } + if (r && r.allow_resetting_service_level_agreement == "0") { + frm.set_df_property("reset_service_level_agreement", "hidden", 1); + } }); if (frm.doc.service_level_agreement) { @@ -38,7 +42,7 @@ frappe.ui.form.on("Issue", { }, refresh: function (frm) { - if (frm.doc.status !== "Closed" && frm.doc.agreement_fulfilled === "Ongoing") { + if (frm.doc.status !== "Closed" && frm.doc.agreement_status === "Ongoing") { if (frm.doc.service_level_agreement) { frappe.call({ 'method': 'frappe.client.get', @@ -85,14 +89,14 @@ frappe.ui.form.on("Issue", { if (frm.doc.service_level_agreement) { frm.dashboard.clear_headline(); - let agreement_fulfilled = (frm.doc.agreement_fulfilled == "Fulfilled") ? + let agreement_status = (frm.doc.agreement_status == "Fulfilled") ? {"indicator": "green", "msg": "Service Level Agreement has been fulfilled"} : {"indicator": "red", "msg": "Service Level Agreement Failed"}; frm.dashboard.set_headline_alert( '
' + '
' + - ' ' + + ' ' + '
' + '
' ); @@ -198,13 +202,13 @@ function set_time_to_resolve_and_response(frm) { frm.dashboard.clear_headline(); var time_to_respond = get_status(frm.doc.response_by_variance); - if (!frm.doc.first_responded_on && frm.doc.agreement_fulfilled === "Ongoing") { - time_to_respond = get_time_left(frm.doc.response_by, frm.doc.agreement_fulfilled); + if (!frm.doc.first_responded_on && frm.doc.agreement_status === "Ongoing") { + time_to_respond = get_time_left(frm.doc.response_by, frm.doc.agreement_status); } var time_to_resolve = get_status(frm.doc.resolution_by_variance); - if (!frm.doc.resolution_date && frm.doc.agreement_fulfilled === "Ongoing") { - time_to_resolve = get_time_left(frm.doc.resolution_by, frm.doc.agreement_fulfilled); + if (!frm.doc.resolution_date && frm.doc.agreement_status === "Ongoing") { + time_to_resolve = get_time_left(frm.doc.resolution_by, frm.doc.agreement_status); } frm.dashboard.set_headline_alert( @@ -219,10 +223,10 @@ function set_time_to_resolve_and_response(frm) { ); } -function get_time_left(timestamp, agreement_fulfilled) { +function get_time_left(timestamp, agreement_status) { const diff = moment(timestamp).diff(moment()); const diff_display = diff >= 44500 ? moment.duration(diff).humanize() : "Failed"; - let indicator = (diff_display == 'Failed' && agreement_fulfilled != "Fulfilled") ? "red" : "green"; + let indicator = (diff_display == 'Failed' && agreement_status != "Fulfilled") ? "red" : "green"; return {"diff_display": diff_display, "indicator": indicator}; } diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json index 6525ab27d30..a43381c5c6b 100644 --- a/erpnext/support/doctype/issue/issue.json +++ b/erpnext/support/doctype/issue/issue.json @@ -27,17 +27,25 @@ "response_by_variance", "reset_service_level_agreement", "cb", - "agreement_fulfilled", + "agreement_status", "resolution_by", "resolution_by_variance", "service_level_agreement_creation", "on_hold_since", "total_hold_time", "response", - "mins_to_first_response", + "first_response_time", "first_responded_on", "column_break_26", "avg_response_time", + "section_break_19", + "resolution_details", + "column_break1", + "opening_date", + "opening_time", + "resolution_date", + "resolution_time", + "user_resolution_time", "additional_info", "lead", "contact", @@ -46,23 +54,14 @@ "customer_name", "project", "company", - "section_break_19", - "resolution_details", - "column_break1", - "opening_date", - "opening_time", - "resolution_date", - "content_type", - "attachment", "via_customer_portal", - "resolution_time", - "user_resolution_time" + "attachment", + "content_type" ], "fields": [ { "fieldname": "subject_section", "fieldtype": "Section Break", - "label": "Subject", "options": "fa fa-flag" }, { @@ -158,7 +157,7 @@ "collapsible": 1, "fieldname": "service_level_section", "fieldtype": "Section Break", - "label": "Service Level" + "label": "Service Level Agreement Details" }, { "fieldname": "service_level_agreement", @@ -191,14 +190,7 @@ "collapsible": 1, "fieldname": "response", "fieldtype": "Section Break", - "label": "Response" - }, - { - "bold": 1, - "fieldname": "mins_to_first_response", - "fieldtype": "Float", - "label": "Mins to First Response", - "read_only": 1 + "label": "Response Details" }, { "fieldname": "first_responded_on", @@ -261,7 +253,7 @@ "collapsible": 1, "fieldname": "section_break_19", "fieldtype": "Section Break", - "label": "Resolution" + "label": "Resolution Details" }, { "depends_on": "eval:!doc.__islocal", @@ -326,28 +318,19 @@ "fieldtype": "Check", "label": "Via Customer Portal" }, - { - "default": "Ongoing", - "depends_on": "eval: doc.service_level_agreement", - "fieldname": "agreement_fulfilled", - "fieldtype": "Select", - "label": "Service Level Agreement Fulfilled", - "options": "Ongoing\nFulfilled\nFailed", - "read_only": 1 - }, { "depends_on": "eval: doc.service_level_agreement && doc.status != 'Replied';", - "description": "in hours", "fieldname": "response_by_variance", - "fieldtype": "Float", + "fieldtype": "Duration", + "hide_seconds": 1, "label": "Response By Variance", "read_only": 1 }, { "depends_on": "eval: doc.service_level_agreement && doc.status != 'Replied';", - "description": "in hours", "fieldname": "resolution_by_variance", - "fieldtype": "Float", + "fieldtype": "Duration", + "hide_seconds": 1, "label": "Resolution By Variance", "read_only": 1 }, @@ -406,12 +389,28 @@ "fieldtype": "Duration", "label": "Total Hold Time", "read_only": 1 + }, + { + "default": "Ongoing", + "depends_on": "eval: doc.service_level_agreement", + "fieldname": "agreement_status", + "fieldtype": "Select", + "label": "Service Level Agreement Status", + "options": "Ongoing\nFulfilled\nFailed", + "read_only": 1 + }, + { + "bold": 1, + "fieldname": "first_response_time", + "fieldtype": "Duration", + "label": "First Response Time", + "read_only": 1 } ], "icon": "fa fa-ticket", "idx": 7, "links": [], - "modified": "2020-06-10 12:47:37.146914", + "modified": "2020-08-11 18:49:07.574769", "modified_by": "Administrator", "module": "Support", "name": "Issue", diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index 87168e151e6..920c13c38d6 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -61,7 +61,7 @@ class Issue(Document): if self.status in ["Closed", "Resolved"] and status not in ["Resolved", "Closed"]: self.resolution_date = frappe.flags.current_time or now_datetime() - if frappe.db.get_value("Issue", self.name, "agreement_fulfilled") == "Ongoing": + if frappe.db.get_value("Issue", self.name, "agreement_status") == "Ongoing": set_service_level_agreement_variance(issue=self.name) self.update_agreement_status() set_resolution_time(issue=self) @@ -72,7 +72,7 @@ class Issue(Document): self.resolution_date = None self.reset_issue_metrics() # enable SLA and variance on Reopen - self.agreement_fulfilled = "Ongoing" + self.agreement_status = "Ongoing" set_service_level_agreement_variance(issue=self.name) self.handle_hold_time(status) @@ -113,39 +113,39 @@ class Issue(Document): if not self.first_responded_on: response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) response_by = add_to_date(response_by, seconds=round(last_hold_time)) - response_by_variance = round(time_diff_in_hours(response_by, now_time)) + response_by_variance = round(time_diff_in_seconds(response_by, now_time)) update_values['response_by'] = response_by - update_values['response_by_variance'] = response_by_variance + (last_hold_time // 3600) + update_values['response_by_variance'] = response_by_variance + last_hold_time resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) resolution_by = add_to_date(resolution_by, seconds=round(last_hold_time)) - resolution_by_variance = round(time_diff_in_hours(resolution_by, now_time)) + resolution_by_variance = round(time_diff_in_seconds(resolution_by, now_time)) update_values['resolution_by'] = resolution_by - update_values['resolution_by_variance'] = resolution_by_variance + (last_hold_time // 3600) + update_values['resolution_by_variance'] = resolution_by_variance + last_hold_time update_values['on_hold_since'] = None self.db_set(update_values) def update_agreement_status(self): - if self.service_level_agreement and self.agreement_fulfilled == "Ongoing": + if self.service_level_agreement and self.agreement_status == "Ongoing": if frappe.db.get_value("Issue", self.name, "response_by_variance") < 0 or \ frappe.db.get_value("Issue", self.name, "resolution_by_variance") < 0: - self.agreement_fulfilled = "Failed" + self.agreement_status = "Failed" else: - self.agreement_fulfilled = "Fulfilled" + self.agreement_status = "Fulfilled" - def update_agreement_fulfilled_on_custom_status(self): + def update_agreement_status_on_custom_status(self): """ Update Agreement Fulfilled status using Custom Scripts for Custom Issue Status """ if not self.first_responded_on: # first_responded_on set when first reply is sent to customer - self.response_by_variance = round(time_diff_in_hours(self.response_by, now_datetime()), 2) + self.response_by_variance = round(time_diff_in_seconds(self.response_by, now_datetime()), 2) if not self.resolution_date: # resolution_date set when issue has been closed - self.resolution_by_variance = round(time_diff_in_hours(self.resolution_by, now_datetime()), 2) + self.resolution_by_variance = round(time_diff_in_seconds(self.resolution_by, now_datetime()), 2) - self.agreement_fulfilled = "Fulfilled" if self.response_by_variance > 0 and self.resolution_by_variance > 0 else "Failed" + self.agreement_status = "Fulfilled" if self.response_by_variance > 0 and self.resolution_by_variance > 0 else "Failed" def create_communication(self): communication = frappe.new_doc("Communication") @@ -172,7 +172,7 @@ class Issue(Document): replicated_issue = deepcopy(self) replicated_issue.subject = subject replicated_issue.issue_split_from = self.name - replicated_issue.mins_to_first_response = 0 + replicated_issue.first_response_time = 0 replicated_issue.first_responded_on = None replicated_issue.creation = now_datetime() @@ -180,7 +180,7 @@ class Issue(Document): if replicated_issue.service_level_agreement: replicated_issue.service_level_agreement_creation = now_datetime() replicated_issue.service_level_agreement = None - replicated_issue.agreement_fulfilled = "Ongoing" + replicated_issue.agreement_status = "Ongoing" replicated_issue.response_by = None replicated_issue.response_by_variance = None replicated_issue.resolution_by = None @@ -241,8 +241,8 @@ class Issue(Document): self.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) self.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) - self.response_by_variance = round(time_diff_in_hours(self.response_by, now_datetime())) - self.resolution_by_variance = round(time_diff_in_hours(self.resolution_by, now_datetime())) + self.response_by_variance = round(time_diff_in_seconds(self.response_by, now_datetime())) + self.resolution_by_variance = round(time_diff_in_seconds(self.resolution_by, now_datetime())) def change_service_level_agreement_and_priority(self): if self.service_level_agreement and frappe.db.exists("Issue", self.name) and \ @@ -271,7 +271,7 @@ class Issue(Document): self.service_level_agreement_creation = now_datetime() self.set_response_and_resolution_time(priority=self.priority, service_level_agreement=self.service_level_agreement) - self.agreement_fulfilled = "Ongoing" + self.agreement_status = "Ongoing" self.save() def reset_issue_metrics(self): @@ -347,7 +347,7 @@ def get_expected_time_for(parameter, service_level, start_date_time): def set_service_level_agreement_variance(issue=None): current_time = frappe.flags.current_time or now_datetime() - filters = {"status": "Open", "agreement_fulfilled": "Ongoing"} + filters = {"status": "Open", "agreement_status": "Ongoing"} if issue: filters = {"name": issue} @@ -358,13 +358,13 @@ def set_service_level_agreement_variance(issue=None): variance = round(time_diff_in_hours(doc.response_by, current_time), 2) frappe.db.set_value(dt="Issue", dn=doc.name, field="response_by_variance", val=variance, update_modified=False) if variance < 0: - frappe.db.set_value(dt="Issue", dn=doc.name, field="agreement_fulfilled", val="Failed", update_modified=False) + frappe.db.set_value(dt="Issue", dn=doc.name, field="agreement_status", val="Failed", update_modified=False) if not doc.resolution_date: # resolution_date set when issue has been closed variance = round(time_diff_in_hours(doc.resolution_by, current_time), 2) frappe.db.set_value(dt="Issue", dn=doc.name, field="resolution_by_variance", val=variance, update_modified=False) if variance < 0: - frappe.db.set_value(dt="Issue", dn=doc.name, field="agreement_fulfilled", val="Failed", update_modified=False) + frappe.db.set_value(dt="Issue", dn=doc.name, field="agreement_status", val="Failed", update_modified=False) def set_resolution_time(issue): diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index fb8ceb53b21..c962dc6b317 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -73,7 +73,7 @@ class TestIssue(unittest.TestCase): issue.status = 'Closed' issue.save() - self.assertEqual(issue.agreement_fulfilled, 'Fulfilled') + self.assertEqual(issue.agreement_status, 'Fulfilled') def test_issue_metrics(self): creation = datetime.datetime(2020, 3, 4, 4, 0) diff --git a/erpnext/support/report/minutes_to_first_response_for_issues/__init__.py b/erpnext/support/report/first_response_time_for_issues/__init__.py similarity index 100% rename from erpnext/support/report/minutes_to_first_response_for_issues/__init__.py rename to erpnext/support/report/first_response_time_for_issues/__init__.py diff --git a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js new file mode 100644 index 00000000000..576e0b76dad --- /dev/null +++ b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js @@ -0,0 +1,44 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["First Response Time for Issues"] = { + "filters": [ + { + "fieldname": "from_date", + "label": __("From Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_days(frappe.datetime.nowdate(), -30) + }, + { + "fieldname": "to_date", + "label": __("To Date"), + "fieldtype": "Date", + "reqd": 1, + "default":frappe.datetime.nowdate() + } + ], + get_chart_data: function(_columns, result) { + return { + data: { + labels: result.map(d => d[0]), + datasets: [{ + name: 'First Response Time', + values: result.map(d => d[1]) + }] + }, + type: "line", + tooltipOptions: { + formatTooltipY: d => { + let duration_options = { + hide_days: 0, + hide_seconds: 0 + }; + value = frappe.utils.get_formatted_duration(d, duration_options); + return value; + } + } + } + } +}; diff --git a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.json b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.json new file mode 100644 index 00000000000..c4fe6f51931 --- /dev/null +++ b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.json @@ -0,0 +1,26 @@ +{ + "add_total_row": 0, + "creation": "2020-08-10 18:12:42.391224", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "idx": 0, + "is_standard": "Yes", + "letter_head": "Test 2", + "modified": "2020-08-10 18:12:42.391224", + "modified_by": "Administrator", + "module": "Support", + "name": "First Response Time for Issues", + "owner": "Administrator", + "prepared_report": 0, + "query": "select date(creation) as creation_date, avg(mins_to_first_response) from tabIssue where creation > '2016-05-01' group by date(creation) order by creation_date;", + "ref_doctype": "Issue", + "report_name": "First Response Time for Issues", + "report_type": "Script Report", + "roles": [ + { + "role": "Support Team" + } + ] +} \ No newline at end of file diff --git a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py new file mode 100644 index 00000000000..922da2b33de --- /dev/null +++ b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py @@ -0,0 +1,35 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe + +def execute(filters=None): + columns = [ + { + 'fieldname': 'creation_date', + 'label': 'Date', + 'fieldtype': 'Date', + 'width': 300 + }, + { + 'fieldname': 'first_response_time', + 'fieldtype': 'Duration', + 'label': 'First Response Time', + 'width': 300 + }, + ] + + data = frappe.db.sql(''' + SELECT + date(creation) as creation_date, + avg(first_response_time) as avg_response_time + FROM tabIssue + WHERE + date(creation) between %s and %s + and first_response_time > 0 + GROUP BY creation_date + ORDER BY creation_date desc + ''', (filters.from_date, filters.to_date)) + + return columns, data \ No newline at end of file diff --git a/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.js b/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.js deleted file mode 100644 index 034e7779a6a..00000000000 --- a/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.js +++ /dev/null @@ -1,30 +0,0 @@ -frappe.query_reports["Minutes to First Response for Issues"] = { - "filters": [ - { - "fieldname":"from_date", - "label": __("From Date"), - "fieldtype": "Date", - 'reqd': 1, - "default": frappe.datetime.add_days(frappe.datetime.nowdate(), -30) - }, - { - "fieldname":"to_date", - "label": __("To Date"), - "fieldtype": "Date", - 'reqd': 1, - "default":frappe.datetime.nowdate() - }, - ], - get_chart_data: function(columns, result) { - return { - data: { - labels: result.map(d => d[0]), - datasets: [{ - name: 'Mins to first response', - values: result.map(d => d[1]) - }] - }, - type: 'line', - } - } -} diff --git a/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.json b/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.json deleted file mode 100644 index 539d3d941f7..00000000000 --- a/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2016-06-14 17:44:26.034112", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 2, - "is_standard": "Yes", - "modified": "2017-02-24 20:06:18.391100", - "modified_by": "Administrator", - "module": "Support", - "name": "Minutes to First Response for Issues", - "owner": "Administrator", - "query": "select date(creation) as creation_date, avg(mins_to_first_response) from tabIssue where creation > '2016-05-01' group by date(creation) order by creation_date;", - "ref_doctype": "Issue", - "report_name": "Minutes to First Response for Issues", - "report_type": "Script Report", - "roles": [ - { - "role": "Support Team" - } - ] -} \ No newline at end of file diff --git a/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.py b/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.py deleted file mode 100644 index 57c2d442b21..00000000000 --- a/erpnext/support/report/minutes_to_first_response_for_issues/minutes_to_first_response_for_issues.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe - -def execute(filters=None): - columns = [ - { - 'fieldname': 'creation_date', - 'label': 'Date', - 'fieldtype': 'Date' - }, - { - 'fieldname': 'mins', - 'fieldtype': 'Float', - 'label': 'Mins to First Response' - }, - ] - - data = frappe.db.sql('''select date(creation) as creation_date, - avg(mins_to_first_response) as mins - from tabIssue - where date(creation) between %s and %s - and mins_to_first_response > 0 - group by creation_date order by creation_date desc''', (filters.from_date, filters.to_date)) - - return columns, data From fd42af5b917e101ef99b8dc8448840d61fc54a3c Mon Sep 17 00:00:00 2001 From: Prssanna Desai Date: Wed, 23 Sep 2020 17:14:37 +0530 Subject: [PATCH 26/30] fix: ignore permission while creating supplier scorecard period in supplier scorecard (#23406) --- erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py | 1 + .../supplier_scorecard_period/supplier_scorecard_period.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py index af109ba2848..e956afdf749 100644 --- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py @@ -178,6 +178,7 @@ def make_all_scorecards(docname): period_card = make_supplier_scorecard(docname, None) period_card.start_date = start_date period_card.end_date = end_date + period_card.insert(ignore_permissions=True) period_card.submit() scp_count = scp_count + 1 if start_date < first_start_date: diff --git a/erpnext/buying/doctype/supplier_scorecard_period/supplier_scorecard_period.py b/erpnext/buying/doctype/supplier_scorecard_period/supplier_scorecard_period.py index 87f10336f4d..9938710e6e6 100644 --- a/erpnext/buying/doctype/supplier_scorecard_period/supplier_scorecard_period.py +++ b/erpnext/buying/doctype/supplier_scorecard_period/supplier_scorecard_period.py @@ -106,7 +106,7 @@ def make_supplier_scorecard(source_name, target_doc=None): "doctype": "Supplier Scorecard Scoring Criteria", "postprocess": update_criteria_fields, } - }, target_doc, post_process) + }, target_doc, post_process, ignore_permissions=True) return doc From cfc5e291274ef62f452cc9edfab686667d2da027 Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Wed, 23 Sep 2020 17:19:11 +0530 Subject: [PATCH 27/30] fix: Post cancellation accounting entry on posting date instaed of current (#23361) --- erpnext/accounts/general_ledger.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 01d3903d288..c12e006d2b2 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -320,7 +320,6 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, entry['remarks'] = "On cancellation of " + entry['voucher_no'] entry['is_cancelled'] = 1 - entry['posting_date'] = today() if entry['debit'] or entry['credit']: make_entry(entry, adv_adj, "Yes") From 8370d9b997c4fb9368b29102a6fd2fa9ceb73a96 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 24 Sep 2020 11:51:03 +0530 Subject: [PATCH 28/30] fix: Check Company in Payment Entry before selecting values --- .../doctype/payment_entry/payment_entry.js | 18 ++++++++++++++++-- erpnext/public/js/controllers/accounts.js | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 9fc44bc1f07..e1174717382 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -12,9 +12,10 @@ frappe.ui.form.on('Payment Entry', { setup: function(frm) { frm.set_query("paid_from", function() { + frm.events.validate_company(frm); + var account_types = in_list(["Pay", "Internal Transfer"], frm.doc.payment_type) ? ["Bank", "Cash"] : [frappe.boot.party_account_types[frm.doc.party_type]]; - return { filters: { "account_type": ["in", account_types], @@ -23,13 +24,16 @@ frappe.ui.form.on('Payment Entry', { } } }); + frm.set_query("party_type", function() { + frm.events.validate_company(frm); return{ filters: { "name": ["in", Object.keys(frappe.boot.party_account_types)], } } }); + frm.set_query("party_bank_account", function() { return { filters: { @@ -39,6 +43,7 @@ frappe.ui.form.on('Payment Entry', { } } }); + frm.set_query("bank_account", function() { return { filters: { @@ -47,6 +52,7 @@ frappe.ui.form.on('Payment Entry', { } } }); + frm.set_query("contact_person", function() { if (frm.doc.party) { return { @@ -58,10 +64,12 @@ frappe.ui.form.on('Payment Entry', { }; } }); + frm.set_query("paid_to", function() { + frm.events.validate_company(frm); + var account_types = in_list(["Receive", "Internal Transfer"], frm.doc.payment_type) ? ["Bank", "Cash"] : [frappe.boot.party_account_types[frm.doc.party_type]]; - return { filters: { "account_type": ["in", account_types], @@ -150,6 +158,12 @@ frappe.ui.form.on('Payment Entry', { frm.events.show_general_ledger(frm); }, + validate_company: (frm) => { + if (!frm.doc.company){ + frappe.throw({message:__("Please select a Company first."), title: __("Mandatory")}); + } + }, + company: function(frm) { frm.events.hide_unhide_fields(frm); frm.events.set_dynamic_labels(frm); diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index f4eaad58dae..6e97d811fc1 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -120,7 +120,7 @@ frappe.ui.form.on('Salary Structure', { var get_payment_mode_account = function(frm, mode_of_payment, callback) { if(!frm.doc.company) { - frappe.throw(__("Please select the Company first")); + frappe.throw({message:__("Please select a Company first."), title: __("Mandatory")}); } if(!mode_of_payment) { From 2fa2a40b3f37a83380dc52a493c8a3a628fcb8c4 Mon Sep 17 00:00:00 2001 From: Saqib Date: Thu, 24 Sep 2020 15:07:29 +0530 Subject: [PATCH 29/30] chore: make asset movement transaction date match with purchase date & time (#23423) --- erpnext/assets/doctype/asset/asset.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 72debb7ebaa..efdbdb1fbfc 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe, erpnext, math, json from frappe import _ from six import string_types -from frappe.utils import flt, add_months, cint, nowdate, getdate, today, date_diff, month_diff, add_days, get_last_day +from frappe.utils import flt, add_months, cint, nowdate, getdate, today, date_diff, month_diff, add_days, get_last_day, get_datetime from frappe.model.document import Document from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.assets.doctype.asset.depreciation \ @@ -140,6 +140,10 @@ class Asset(AccountsController): def make_asset_movement(self): reference_doctype = 'Purchase Receipt' if self.purchase_receipt else 'Purchase Invoice' reference_docname = self.purchase_receipt or self.purchase_invoice + transaction_date = getdate(self.purchase_date) + if reference_docname: + posting_date, posting_time = frappe.db.get_value(reference_doctype, reference_docname, ["posting_date", "posting_time"]) + transaction_date = get_datetime("{} {}".format(posting_date, posting_time)) assets = [{ 'asset': self.name, 'asset_name': self.asset_name, @@ -151,7 +155,7 @@ class Asset(AccountsController): 'assets': assets, 'purpose': 'Receipt', 'company': self.company, - 'transaction_date': getdate(self.purchase_date), + 'transaction_date': transaction_date, 'reference_doctype': reference_doctype, 'reference_name': reference_docname }).insert() From 751256518483252771bc47a4452800a0b2c5fb03 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 25 Sep 2020 12:45:47 +0530 Subject: [PATCH 30/30] fix: Patient Appointment not able to save (#23434) Co-authored-by: Rucha Mahabal --- .../doctype/patient_appointment/patient_appointment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py index 512fb48360f..e685b20a8c8 100755 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document import json -from frappe.utils import getdate, get_time +from frappe.utils import getdate, get_time, flt from frappe.model.mapper import get_mapped_doc from frappe import _ import datetime @@ -45,7 +45,7 @@ class PatientAppointment(Document): def validate_overlaps(self): end_time = datetime.datetime.combine(getdate(self.appointment_date), get_time(self.appointment_time)) \ - + datetime.timedelta(minutes=float(self.duration)) + + datetime.timedelta(minutes=flt(self.duration)) overlaps = frappe.db.sql(""" select