diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index e1f16970fe..5be3a87884 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -15,7 +15,7 @@ If your issue is not clear or does not meet the guidelines, then it will be clos
### General Issue Guidelines
1. **Search existing Issues:** Before raising a Issue, search if it has been raised before. Maybe add a 👍 or give additional help by creating a mockup if it is not already created.
-2. **Report each issue separately:** Don't club multiple, unreleated issues in one note.
+2. **Report each issue separately:** Don't club multiple, unrelated issues in one note.
3. **Brief:** Please don't include long explanations. Use screenshots and bullet points instead of descriptive paragraphs.
### Bug Report Guidelines
diff --git a/.travis.yml b/.travis.yml
index 2331217363..23fb525138 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -104,11 +104,11 @@ install:
- cd ./frappe-bench
- - sed -i 's/watch:/# watch:/g' Procfile
- - sed -i 's/schedule:/# schedule:/g' Procfile
+ - sed -i 's/^watch:/# watch:/g' Procfile
+ - sed -i 's/^schedule:/# schedule:/g' Procfile
- - if [ $TYPE == "server" ]; then sed -i 's/socketio:/# socketio:/g' Procfile; fi
- - if [ $TYPE == "server" ]; then sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile; fi
+ - if [ $TYPE == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; fi
+ - if [ $TYPE == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi
- if [ $TYPE == "ui" ]; then bench setup requirements --node; fi
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.json b/frappe/automation/doctype/auto_repeat/auto_repeat.json
index 5ff4cbeead..74965346fd 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.json
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.json
@@ -23,7 +23,7 @@
"repeat_on_last_day",
"column_break_12",
"next_schedule_date",
- "section_break_12",
+ "section_break_16",
"repeat_on_days",
"notification",
"notify_by_email",
@@ -198,20 +198,20 @@
"label": "Repeat on Days",
"options": "Auto Repeat Day"
},
- {
- "depends_on": "eval:doc.frequency==='Weekly';",
- "fieldname": "section_break_12",
- "fieldtype": "Section Break"
- },
{
"default": "0",
"fieldname": "submit_on_creation",
"fieldtype": "Check",
"label": "Submit on Creation"
+ },
+ {
+ "depends_on": "eval:doc.frequency==='Weekly';",
+ "fieldname": "section_break_16",
+ "fieldtype": "Section Break"
}
],
"links": [],
- "modified": "2020-12-10 10:43:13.449172",
+ "modified": "2021-01-12 09:24:49.719611",
"modified_by": "Administrator",
"module": "Automation",
"name": "Auto Repeat",
diff --git a/frappe/config/integrations.py b/frappe/config/integrations.py
index a7ac20065f..672c0c4acc 100644
--- a/frappe/config/integrations.py
+++ b/frappe/config/integrations.py
@@ -77,6 +77,11 @@ def get_data():
"name": "OAuth Provider Settings",
"description": _("Settings for OAuth Provider"),
},
+ {
+ "type": "doctype",
+ "name": "Connected App",
+ "description": _("Connect to any OAuth Provider"),
+ },
]
},
{
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index 7880648b6f..dde3dfaee9 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -751,7 +751,7 @@ class Row:
self.warnings.append(
{
"row": self.row_number,
- "message": _("{0} is a mandatory field asdadsf").format(id_field.label),
+ "message": _("{0} is a mandatory field").format(id_field.label),
}
)
return
diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json
index 2073f41fdd..7d91e8cfe0 100644
--- a/frappe/core/doctype/user/user.json
+++ b/frappe/core/doctype/user/user.json
@@ -642,10 +642,15 @@
"group": "Activity",
"link_doctype": "ToDo",
"link_fieldname": "owner"
+ },
+ {
+ "group": "Integrations",
+ "link_doctype": "Token Cache",
+ "link_fieldname": "user"
}
],
"max_attachments": 5,
- "modified": "2020-08-26 19:48:49.677800",
+ "modified": "2020-10-18 15:18:53.126800",
"modified_by": "Administrator",
"module": "Core",
"name": "User",
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
index 2fa36b5514..b19f6cf9f0 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
@@ -73,7 +73,7 @@ def has_permission(doc, ptype, user):
if doc.report_name in allowed_reports:
return True
else:
- allowed_doctypes = [frappe.permissions.get_doctypes_with_read()]
+ allowed_doctypes = frappe.permissions.get_doctypes_with_read()
if doc.document_type in allowed_doctypes:
return True
diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py
index 5219a98cbd..da43b14fce 100644
--- a/frappe/desk/form/save.py
+++ b/frappe/desk/form/save.py
@@ -42,7 +42,6 @@ def cancel(doctype=None, name=None, workflow_state_fieldname=None, workflow_stat
except Exception:
frappe.errprint(frappe.utils.get_traceback())
- frappe.msgprint(frappe._("Did not cancel"))
raise
def send_updated_docs(doc):
diff --git a/frappe/desk/page/setup_wizard/install_fixtures.py b/frappe/desk/page/setup_wizard/install_fixtures.py
index 60e1f3242a..6d3aaee22b 100644
--- a/frappe/desk/page/setup_wizard/install_fixtures.py
+++ b/frappe/desk/page/setup_wizard/install_fixtures.py
@@ -18,14 +18,14 @@ def install():
@frappe.whitelist()
def update_genders():
- default_genders = [_("Male"), _("Female"), _("Other"),_("Transgender"), _("Genderqueer"), _("Non-Conforming"),_("Prefer not to say")]
+ default_genders = ["Male", "Female", "Other","Transgender", "Genderqueer", "Non-Conforming","Prefer not to say"]
records = [{'doctype': 'Gender', 'gender': d} for d in default_genders]
for record in records:
frappe.get_doc(record).insert(ignore_permissions=True, ignore_if_duplicate=True)
@frappe.whitelist()
def update_salutations():
- default_salutations = [_("Mr"), _("Ms"), _('Mx'), _("Dr"), _("Mrs"), _("Madam"), _("Miss"), _("Master"), _("Prof")]
+ default_salutations = ["Mr", "Ms", 'Mx', "Dr", "Mrs", "Madam", "Miss", "Master", "Prof"]
records = [{'doctype': 'Salutation', 'salutation': d} for d in default_salutations]
for record in records:
doc = frappe.new_doc(record.get("doctype"))
diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py
index 539f6c9db8..de27fafee3 100644
--- a/frappe/email/doctype/auto_email_report/auto_email_report.py
+++ b/frappe/email/doctype/auto_email_report/auto_email_report.py
@@ -81,7 +81,7 @@ class AutoEmailReport(Document):
if self.format == 'HTML':
columns, data = make_links(columns, data)
-
+ columns = update_field_types(columns)
return self.get_html_table(columns, data)
elif self.format == 'XLSX':
@@ -236,5 +236,14 @@ def make_links(columns, data):
elif col.fieldtype == "Dynamic Link":
if col.options and row.get(col.fieldname) and row.get(col.options):
row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname])
+ elif col.fieldtype == "Currency":
+ row[col.fieldname] = frappe.format_value(row[col.fieldname], col)
return columns, data
+
+def update_field_types(columns):
+ for col in columns:
+ if col.fieldtype in ("Link", "Dynamic Link", "Currency") and col.options != "Currency":
+ col.fieldtype = "Data"
+ col.options = ""
+ return columns
\ No newline at end of file
diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py
index 343141c66d..ca4dbb83e2 100755
--- a/frappe/email/doctype/email_account/email_account.py
+++ b/frappe/email/doctype/email_account/email_account.py
@@ -210,7 +210,7 @@ class EmailAccount(Document):
elif not in_receive and any(map(lambda t: t in message, auth_error_codes)):
self.throw_invalid_credentials_exception()
else:
- frappe.throw(e)
+ frappe.throw(cstr(e))
except socket.error:
if in_receive:
diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py
index ee7f123b7e..bd8fadc29c 100644
--- a/frappe/email/doctype/newsletter/test_newsletter.py
+++ b/frappe/email/doctype/newsletter/test_newsletter.py
@@ -2,58 +2,66 @@
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals
-import frappe, unittest
-from frappe.utils import getdate, add_days
+import unittest
+from random import choice
-from frappe.email.doctype.newsletter.newsletter import confirmed_unsubscribe, send_scheduled_email
-from six.moves.urllib.parse import unquote
+import frappe
+from frappe.email.doctype.newsletter.newsletter import (
+ confirmed_unsubscribe,
+ send_scheduled_email,
+)
+from frappe.email.doctype.newsletter.newsletter import get_newsletter_list
+from frappe.email.queue import flush
+from frappe.utils import add_days, getdate
test_dependencies = ["Email Group"]
+emails = [
+ "test_subscriber1@example.com",
+ "test_subscriber2@example.com",
+ "test_subscriber3@example.com",
+ "test1@example.com",
+]
-emails = ["test_subscriber1@example.com", "test_subscriber2@example.com",
- "test_subscriber3@example.com", "test1@example.com"]
class TestNewsletter(unittest.TestCase):
def setUp(self):
frappe.set_user("Administrator")
- frappe.db.sql('delete from `tabEmail Group Member`')
+ frappe.db.sql("delete from `tabEmail Group Member`")
+
+ if not frappe.db.exists("Email Group", "_Test Email Group"):
+ frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert()
- group_exist=frappe.db.exists("Email Group", "_Test Email Group")
- if len(group_exist) == 0:
- frappe.get_doc({
- "doctype": "Email Group",
- "title": "_Test Email Group"
- }).insert()
for email in emails:
- frappe.get_doc({
- "doctype": "Email Group Member",
- "email": email,
- "email_group": "_Test Email Group"
- }).insert()
+ frappe.get_doc({
+ "doctype": "Email Group Member",
+ "email": email,
+ "email_group": "_Test Email Group"
+ }).insert()
def test_send(self):
- name = self.send_newsletter()
+ self.send_newsletter()
- email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")]
+ email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 4)
- recipients = [e.recipients[0].recipient for e in email_queue_list]
- for email in emails:
- self.assertTrue(email in recipients)
+
+ recipients = set([e.recipients[0].recipient for e in email_queue_list])
+ self.assertTrue(set(emails).issubset(recipients))
def test_unsubscribe(self):
- # test unsubscribe
name = self.send_newsletter()
- from frappe.email.queue import flush
+ to_unsubscribe = choice(emails)
+ group = frappe.get_all("Newsletter Email Group", filters={"parent": name}, fields=["email_group"])
+
flush(from_test=True)
- to_unsubscribe = unquote(frappe.local.flags.signed_query_string.split("email=")[1].split("&")[0])
- group = frappe.get_all("Newsletter Email Group", filters={"parent" : name}, fields=["email_group"])
confirmed_unsubscribe(to_unsubscribe, group[0].email_group)
name = self.send_newsletter()
-
- email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")]
+ email_queue_list = [
+ frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")
+ ]
self.assertEqual(len(email_queue_list), 3)
recipients = [e.recipients[0].recipient for e in email_queue_list]
+
for email in emails:
if email != to_unsubscribe:
self.assertTrue(email in recipients)
@@ -86,7 +94,6 @@ class TestNewsletter(unittest.TestCase):
def test_portal(self):
self.send_newsletter(1)
frappe.set_user("test1@example.com")
- from frappe.email.doctype.newsletter.newsletter import get_newsletter_list
newsletters = get_newsletter_list("Newsletter", None, None, 0)
self.assertEqual(len(newsletters), 1)
@@ -106,4 +113,4 @@ class TestNewsletter(unittest.TestCase):
self.assertEqual(len(email_queue_list), 4)
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
- self.assertTrue(email in recipients)
\ No newline at end of file
+ self.assertTrue(email in recipients)
diff --git a/frappe/event_streaming/doctype/event_producer/event_producer.py b/frappe/event_streaming/doctype/event_producer/event_producer.py
index d8a6a55510..e43b4d131c 100644
--- a/frappe/event_streaming/doctype/event_producer/event_producer.py
+++ b/frappe/event_streaming/doctype/event_producer/event_producer.py
@@ -295,7 +295,7 @@ def set_update(update, producer_site):
if data.changed:
local_doc.update(data.changed)
if data.removed:
- update_row_removed(local_doc, data.removed)
+ local_doc = update_row_removed(local_doc, data.removed)
if data.row_changed:
update_row_changed(local_doc, data.row_changed)
if data.added:
@@ -318,7 +318,17 @@ def update_row_removed(local_doc, removed):
for tablename, rownames in iteritems(removed):
table = local_doc.get_table_field_doctype(tablename)
for row in rownames:
- frappe.db.delete(table, row)
+ table_rows = local_doc.get(tablename)
+ child_table_row = get_child_table_row(table_rows, row)
+ table_rows.remove(child_table_row)
+ local_doc.set(tablename, table_rows)
+ return local_doc
+
+
+def get_child_table_row(table_rows, row):
+ for entry in table_rows:
+ if entry.get('name') == row:
+ return entry
def update_row_changed(local_doc, changed):
diff --git a/frappe/integrations/desk_page/integrations/integrations.json b/frappe/integrations/desk_page/integrations/integrations.json
index 1acf4e6c4a..97e2b29d1a 100644
--- a/frappe/integrations/desk_page/integrations/integrations.json
+++ b/frappe/integrations/desk_page/integrations/integrations.json
@@ -13,7 +13,7 @@
{
"hidden": 0,
"label": "Authentication",
- "links": "[\n {\n \"description\": \"Enter keys to enable login via Facebook, Google, GitHub.\",\n \"label\": \"Social Login Key\",\n \"name\": \"Social Login Key\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Ldap settings\",\n \"label\": \"LDAP Settings\",\n \"name\": \"LDAP Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Register OAuth Client App\",\n \"label\": \"OAuth Client\",\n \"name\": \"OAuth Client\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for OAuth Provider\",\n \"label\": \"OAuth Provider Settings\",\n \"name\": \"OAuth Provider Settings\",\n \"type\": \"doctype\"\n }\n]"
+ "links": "[\n {\n \"description\": \"Enter keys to enable login via Facebook, Google, GitHub.\",\n \"label\": \"Social Login Key\",\n \"name\": \"Social Login Key\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Ldap settings\",\n \"label\": \"LDAP Settings\",\n \"name\": \"LDAP Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Register OAuth Client App\",\n \"label\": \"OAuth Client\",\n \"name\": \"OAuth Client\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for OAuth Provider\",\n \"label\": \"OAuth Provider Settings\",\n \"name\": \"OAuth Provider Settings\",\n \"type\": \"doctype\"\n }\n ,\n {\n \"description\": \"Connect to any OAuth Provider\",\n \"label\": \"Connected App\",\n \"name\": \"Connected App\",\n \"type\": \"doctype\"\n }\n]"
},
{
"hidden": 0,
diff --git a/frappe/integrations/doctype/connected_app/__init__.py b/frappe/integrations/doctype/connected_app/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/integrations/doctype/connected_app/connected_app.js b/frappe/integrations/doctype/connected_app/connected_app.js
new file mode 100644
index 0000000000..4d20f65559
--- /dev/null
+++ b/frappe/integrations/doctype/connected_app/connected_app.js
@@ -0,0 +1,38 @@
+// Copyright (c) 2019, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Connected App', {
+ refresh: frm => {
+ frm.add_custom_button(__('Get OpenID Configuration'), async () => {
+ if (!frm.doc.openid_configuration) {
+ frappe.msgprint(__('Please enter OpenID Configuration URL'));
+ } else {
+ try {
+ const response = await fetch(frm.doc.openid_configuration);
+ const oidc = await response.json();
+ frm.set_value('authorization_uri', oidc.authorization_endpoint);
+ frm.set_value('token_uri', oidc.token_endpoint);
+ frm.set_value('userinfo_uri', oidc.userinfo_endpoint);
+ frm.set_value('introspection_uri', oidc.introspection_endpoint);
+ frm.set_value('revocation_uri', oidc.revocation_endpoint);
+ } catch (error) {
+ frappe.msgprint(__('Please check OpenID Configuration URL'));
+ }
+ }
+ });
+
+ if (!frm.is_new()) {
+ frm.add_custom_button(__('Connect to {}', [frm.doc.provider_name]), async () => {
+ frappe.call({
+ method: 'initiate_web_application_flow',
+ doc: frm.doc,
+ callback: function(r) {
+ window.open(r.message, '_blank');
+ }
+ });
+ });
+ }
+
+ frm.toggle_display('sb_client_credentials_section', !frm.is_new());
+ }
+});
diff --git a/frappe/integrations/doctype/connected_app/connected_app.json b/frappe/integrations/doctype/connected_app/connected_app.json
new file mode 100644
index 0000000000..e5dbb0472a
--- /dev/null
+++ b/frappe/integrations/doctype/connected_app/connected_app.json
@@ -0,0 +1,166 @@
+{
+ "actions": [],
+ "beta": 1,
+ "creation": "2019-01-24 15:51:06.362222",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "provider_name",
+ "cb_00",
+ "openid_configuration",
+ "sb_client_credentials_section",
+ "client_id",
+ "redirect_uri",
+ "cb_01",
+ "client_secret",
+ "sb_scope_section",
+ "scopes",
+ "sb_endpoints_section",
+ "authorization_uri",
+ "token_uri",
+ "revocation_uri",
+ "cb_02",
+ "userinfo_uri",
+ "introspection_uri",
+ "section_break_18",
+ "query_parameters"
+ ],
+ "fields": [
+ {
+ "fieldname": "provider_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Provider Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "cb_00",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "openid_configuration",
+ "fieldtype": "Data",
+ "label": "OpenID Configuration"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "sb_client_credentials_section",
+ "fieldtype": "Section Break",
+ "label": "Client Credentials"
+ },
+ {
+ "fieldname": "client_id",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Client Id"
+ },
+ {
+ "fieldname": "redirect_uri",
+ "fieldtype": "Data",
+ "label": "Redirect URI",
+ "read_only": 1
+ },
+ {
+ "fieldname": "cb_01",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "client_secret",
+ "fieldtype": "Password",
+ "label": "Client Secret"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "sb_scope_section",
+ "fieldtype": "Section Break",
+ "label": "Scopes"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "sb_endpoints_section",
+ "fieldtype": "Section Break",
+ "label": "Endpoints"
+ },
+ {
+ "fieldname": "cb_02",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "scopes",
+ "fieldtype": "Table",
+ "label": "Scopes",
+ "options": "OAuth Scope"
+ },
+ {
+ "fieldname": "authorization_uri",
+ "fieldtype": "Data",
+ "label": "Authorization URI"
+ },
+ {
+ "fieldname": "token_uri",
+ "fieldtype": "Data",
+ "label": "Token URI"
+ },
+ {
+ "fieldname": "revocation_uri",
+ "fieldtype": "Data",
+ "label": "Revocation URI"
+ },
+ {
+ "fieldname": "userinfo_uri",
+ "fieldtype": "Data",
+ "label": "Userinfo URI"
+ },
+ {
+ "fieldname": "introspection_uri",
+ "fieldtype": "Data",
+ "label": "Introspection URI"
+ },
+ {
+ "fieldname": "section_break_18",
+ "fieldtype": "Section Break",
+ "label": "Extra Parameters"
+ },
+ {
+ "fieldname": "query_parameters",
+ "fieldtype": "Table",
+ "label": "Query Parameters",
+ "options": "Query Parameters"
+ }
+ ],
+ "links": [
+ {
+ "link_doctype": "Token Cache",
+ "link_fieldname": "connected_app"
+ }
+ ],
+ "modified": "2020-11-16 16:29:50.277405",
+ "modified_by": "Administrator",
+ "module": "Integrations",
+ "name": "Connected App",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "read": 1,
+ "role": "All"
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "title_field": "provider_name",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py
new file mode 100644
index 0000000000..ec08f8e4be
--- /dev/null
+++ b/frappe/integrations/doctype/connected_app/connected_app.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2019, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+import os
+from urllib.parse import urljoin
+from urllib.parse import urlencode
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from requests_oauthlib import OAuth2Session
+
+if any((os.getenv('CI'), frappe.conf.developer_mode, frappe.conf.allow_tests)):
+ # Disable mandatory TLS in developer mode and tests
+ os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
+
+class ConnectedApp(Document):
+ """Connect to a remote oAuth Server. Retrieve and store user's access token
+ in a Token Cache.
+ """
+
+ def validate(self):
+ base_url = frappe.utils.get_url()
+ callback_path = '/api/method/frappe.integrations.doctype.connected_app.connected_app.callback/' + self.name
+ self.redirect_uri = urljoin(base_url, callback_path)
+
+ def get_oauth2_session(self, user=None, init=False):
+ token = None
+ token_updater = None
+
+ if not init:
+ user = user or frappe.session.user
+ token_cache = self.get_user_token(user)
+ token = token_cache.get_json()
+ token_updater = token_cache.update_data
+
+ return OAuth2Session(
+ client_id=self.client_id,
+ token=token,
+ token_updater=token_updater,
+ auto_refresh_url=self.token_uri,
+ redirect_uri=self.redirect_uri,
+ scope=self.get_scopes()
+ )
+
+ def initiate_web_application_flow(self, user=None, success_uri=None):
+ """Return an authorization URL for the user. Save state in Token Cache."""
+ user = user or frappe.session.user
+ oauth = self.get_oauth2_session(init=True)
+ query_params = self.get_query_params()
+ authorization_url, state = oauth.authorization_url(self.authorization_uri, **query_params)
+ token_cache = self.get_token_cache(user)
+
+ if not token_cache:
+ token_cache = frappe.new_doc('Token Cache')
+ token_cache.user = user
+ token_cache.connected_app = self.name
+
+ token_cache.success_uri = success_uri
+ token_cache.state = state
+ token_cache.save(ignore_permissions=True)
+ frappe.db.commit()
+
+ return authorization_url
+
+ def get_user_token(self, user=None, success_uri=None):
+ """Return an existing user token or initiate a Web Application Flow."""
+ user = user or frappe.session.user
+ token_cache = self.get_token_cache(user)
+
+ if token_cache:
+ return token_cache
+
+ redirect = self.initiate_web_application_flow(user, success_uri)
+ frappe.local.response['type'] = 'redirect'
+ frappe.local.response['location'] = redirect
+ return redirect
+
+ def get_token_cache(self, user):
+ token_cache = None
+ token_cache_name = self.name + '-' + user
+
+ if frappe.db.exists('Token Cache', token_cache_name):
+ token_cache = frappe.get_doc('Token Cache', token_cache_name)
+
+ return token_cache
+
+ def get_scopes(self):
+ return [row.scope for row in self.scopes]
+
+ def get_query_params(self):
+ return {param.key: param.value for param in self.query_parameters}
+
+
+@frappe.whitelist(allow_guest=True)
+def callback(code=None, state=None):
+ """Handle client's code.
+
+ Called during the oauthorization flow by the remote oAuth2 server to
+ transmit a code that can be used by the local server to obtain an access
+ token.
+ """
+ if frappe.request.method != 'GET':
+ frappe.throw(_('Invalid request method: {}').format(frappe.request.method))
+
+ if frappe.session.user == 'Guest':
+ frappe.local.response['type'] = 'redirect'
+ frappe.local.response['location'] = '/login?' + urlencode({'redirect-to': frappe.request.url})
+ return
+
+ path = frappe.request.path[1:].split('/')
+ if len(path) != 4 or not path[3]:
+ frappe.throw(_('Invalid Parameters.'))
+
+ connected_app = frappe.get_doc('Connected App', path[3])
+ token_cache = frappe.get_doc('Token Cache', connected_app.name + '-' + frappe.session.user)
+
+ if state != token_cache.state:
+ frappe.throw(_('Invalid state.'))
+
+ oauth_session = connected_app.get_oauth2_session(init=True)
+ query_params = connected_app.get_query_params()
+ token = oauth_session.fetch_token(connected_app.token_uri,
+ code=code,
+ client_secret=connected_app.get_password('client_secret'),
+ include_client_id=True,
+ **query_params
+ )
+ token_cache.update_data(token)
+
+ frappe.local.response['type'] = 'redirect'
+ frappe.local.response['location'] = token_cache.get('success_uri') or connected_app.get_url()
diff --git a/frappe/integrations/doctype/connected_app/test_connected_app.py b/frappe/integrations/doctype/connected_app/test_connected_app.py
new file mode 100644
index 0000000000..6faa542a60
--- /dev/null
+++ b/frappe/integrations/doctype/connected_app/test_connected_app.py
@@ -0,0 +1,162 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2019, Frappe Technologies and contributors
+# See license.txt
+from __future__ import unicode_literals
+
+import unittest
+import requests
+from urllib.parse import urljoin
+
+import frappe
+from frappe.integrations.doctype.social_login_key.test_social_login_key import create_or_update_social_login_key
+
+
+def get_user(usr, pwd):
+ user = frappe.new_doc('User')
+ user.email = usr
+ user.enabled = 1
+ user.first_name = "_Test"
+ user.new_password = pwd
+ user.roles = []
+ user.append('roles', {
+ 'doctype': 'Has Role',
+ 'parentfield': 'roles',
+ 'role': 'System Manager'
+ })
+ user.insert()
+
+ return user
+
+
+def get_connected_app():
+ doctype = 'Connected App'
+ connected_app = frappe.new_doc(doctype)
+ connected_app.provider_name = 'frappe'
+ connected_app.scopes = []
+ connected_app.append('scopes', {'scope': 'all'})
+ connected_app.insert()
+
+ return connected_app
+
+
+def get_oauth_client():
+ oauth_client = frappe.new_doc('OAuth Client')
+ oauth_client.app_name = '_Test Connected App'
+ oauth_client.redirect_uris = 'to be replaced'
+ oauth_client.default_redirect_uri = 'to be replaced'
+ oauth_client.grant_type = 'Authorization Code'
+ oauth_client.response_type = 'Code'
+ oauth_client.skip_authorization = 1
+ oauth_client.insert()
+
+ return oauth_client
+
+
+class TestConnectedApp(unittest.TestCase):
+
+ def setUp(self):
+ """Set up a Connected App that connects to our own oAuth provider.
+
+ Frappe comes with it's own oAuth2 provider that we can test against. The
+ client credentials can be obtained from an "OAuth Client". All depends
+ on "Social Login Key" so we create one as well.
+
+ The redirect URIs from "Connected App" and "OAuth Client" have to match.
+ Frappe's "Authorization URL" and "Access Token URL" (actually they're
+ just endpoints) are stored in "Social Login Key" so we get them from
+ there.
+ """
+ self.user_name = 'test-connected-app@example.com'
+ self.user_password = 'Eastern_43A1W'
+
+ self.user = get_user(self.user_name, self.user_password)
+ self.connected_app = get_connected_app()
+ self.oauth_client = get_oauth_client()
+ social_login_key = create_or_update_social_login_key()
+ self.base_url = social_login_key.get('base_url')
+
+ frappe.db.commit()
+ self.connected_app.reload()
+ self.oauth_client.reload()
+
+ redirect_uri = self.connected_app.get('redirect_uri')
+ self.oauth_client.update({
+ 'redirect_uris': redirect_uri,
+ 'default_redirect_uri': redirect_uri
+ })
+ self.oauth_client.save()
+
+ self.connected_app.update({
+ 'authorization_uri': urljoin(self.base_url, social_login_key.get('authorize_url')),
+ 'client_id': self.oauth_client.get('client_id'),
+ 'client_secret': self.oauth_client.get('client_secret'),
+ 'token_uri': urljoin(self.base_url, social_login_key.get('access_token_url'))
+ })
+ self.connected_app.save()
+
+ frappe.db.commit()
+ self.connected_app.reload()
+ self.oauth_client.reload()
+
+ def test_web_application_flow(self):
+ """Simulate a logged in user who opens the authorization URL."""
+ def login():
+ return session.get(urljoin(self.base_url, '/api/method/login'), params={
+ 'usr': self.user_name,
+ 'pwd': self.user_password
+ })
+
+ session = requests.Session()
+
+ # first login of a new user on a new site fails with "401 UNAUTHORIZED"
+ # when anybody fixes that, the two lines below can be removed
+ first_login = login()
+ self.assertEqual(first_login.status_code, 401)
+
+ second_login = login()
+ self.assertEqual(second_login.status_code, 200)
+
+ authorization_url = self.connected_app.initiate_web_application_flow(user=self.user_name)
+
+ auth_response = session.get(authorization_url)
+ self.assertEqual(auth_response.status_code, 200)
+
+ callback_response = session.get(auth_response.url)
+ self.assertEqual(callback_response.status_code, 200)
+
+ self.token_cache = self.connected_app.get_token_cache(self.user_name)
+ token = self.token_cache.get_password('access_token')
+ self.assertNotEqual(token, None)
+
+ oauth2_session = self.connected_app.get_oauth2_session(self.user_name)
+ resp = oauth2_session.get(urljoin(self.base_url, '/api/method/frappe.auth.get_logged_user'))
+ self.assertEqual(resp.json().get('message'), self.user_name)
+
+ def tearDown(self):
+ def delete_if_exists(attribute):
+ doc = getattr(self, attribute, None)
+ if doc:
+ doc.delete()
+
+ delete_if_exists('token_cache')
+ delete_if_exists('connected_app')
+
+ if getattr(self, 'oauth_client', None):
+ tokens = frappe.get_all('OAuth Bearer Token', filters={
+ 'client': self.oauth_client.name
+ })
+ for token in tokens:
+ doc = frappe.get_doc('OAuth Bearer Token', token.name)
+ doc.delete()
+
+ codes = frappe.get_all('OAuth Authorization Code', filters={
+ 'client': self.oauth_client.name
+ })
+ for code in codes:
+ doc = frappe.get_doc('OAuth Authorization Code', code.name)
+ doc.delete()
+
+ delete_if_exists('user')
+ delete_if_exists('oauth_client')
+
+ frappe.db.commit()
diff --git a/frappe/integrations/doctype/connected_app/test_records.json b/frappe/integrations/doctype/connected_app/test_records.json
new file mode 100644
index 0000000000..4d19369248
--- /dev/null
+++ b/frappe/integrations/doctype/connected_app/test_records.json
@@ -0,0 +1,13 @@
+[
+ {
+ "doctype": "Connected App",
+ "provider_name": "frappe",
+ "client_id": "test_client_id",
+ "client_secret": "test_client_secret",
+ "scopes": [
+ {
+ "scope": "all"
+ }
+ ]
+ }
+]
diff --git a/frappe/integrations/doctype/oauth_client/test_records.json b/frappe/integrations/doctype/oauth_client/test_records.json
index cff06457c5..11e6338a87 100644
--- a/frappe/integrations/doctype/oauth_client/test_records.json
+++ b/frappe/integrations/doctype/oauth_client/test_records.json
@@ -1,7 +1,6 @@
[
{
- "app_name": "_Test OAuth Client",
- "client_id": "test_client_id",
+ "app_name": "_Test OAuth Client",
"client_secret": "test_client_secret",
"default_redirect_uri": "http://localhost",
"docstatus": 0,
diff --git a/frappe/integrations/doctype/oauth_scope/__init__.py b/frappe/integrations/doctype/oauth_scope/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/integrations/doctype/oauth_scope/oauth_scope.json b/frappe/integrations/doctype/oauth_scope/oauth_scope.json
new file mode 100644
index 0000000000..3a6e528999
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_scope/oauth_scope.json
@@ -0,0 +1,30 @@
+{
+ "actions": [],
+ "creation": "2020-07-15 22:08:14.616585",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "scope"
+ ],
+ "fields": [
+ {
+ "fieldname": "scope",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Scope"
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2020-07-15 22:15:18.930632",
+ "modified_by": "Administrator",
+ "module": "Integrations",
+ "name": "OAuth Scope",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/oauth_scope/oauth_scope.py b/frappe/integrations/doctype/oauth_scope/oauth_scope.py
new file mode 100644
index 0000000000..a5dfe7e1ce
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_scope/oauth_scope.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+# import frappe
+from frappe.model.document import Document
+
+class OAuthScope(Document):
+ pass
diff --git a/frappe/integrations/doctype/query_parameters/__init__.py b/frappe/integrations/doctype/query_parameters/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/integrations/doctype/query_parameters/query_parameters.json b/frappe/integrations/doctype/query_parameters/query_parameters.json
new file mode 100644
index 0000000000..de31c28df7
--- /dev/null
+++ b/frappe/integrations/doctype/query_parameters/query_parameters.json
@@ -0,0 +1,37 @@
+{
+ "actions": [],
+ "creation": "2020-11-16 14:54:37.226914",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "key",
+ "value"
+ ],
+ "fields": [
+ {
+ "fieldname": "key",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Key",
+ "reqd": 1
+ },
+ {
+ "fieldname": "value",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Value",
+ "reqd": 1
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2020-11-16 15:18:35.887149",
+ "modified_by": "Administrator",
+ "module": "Integrations",
+ "name": "Query Parameters",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC"
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/query_parameters/query_parameters.py b/frappe/integrations/doctype/query_parameters/query_parameters.py
new file mode 100644
index 0000000000..bfb8eae0b6
--- /dev/null
+++ b/frappe/integrations/doctype/query_parameters/query_parameters.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+# import frappe
+from frappe.model.document import Document
+
+class QueryParameters(Document):
+ pass
diff --git a/frappe/integrations/doctype/social_login_key/test_social_login_key.py b/frappe/integrations/doctype/social_login_key/test_social_login_key.py
index 58bd48d64a..e0b99ad391 100644
--- a/frappe/integrations/doctype/social_login_key/test_social_login_key.py
+++ b/frappe/integrations/doctype/social_login_key/test_social_login_key.py
@@ -22,3 +22,17 @@ def make_social_login_key(**kwargs):
kwargs["provider_name"] = "Test OAuth2 Provider"
doc = frappe.get_doc(kwargs)
return doc
+
+def create_or_update_social_login_key():
+ # used in other tests (connected app, oauth20)
+ try:
+ social_login_key = frappe.get_doc("Social Login Key", "frappe")
+ except frappe.DoesNotExistError:
+ social_login_key = frappe.new_doc("Social Login Key")
+ social_login_key.get_social_login_provider("Frappe", initialize=True)
+ social_login_key.base_url = frappe.utils.get_url()
+ social_login_key.enable_social_login = 0
+ social_login_key.save()
+ frappe.db.commit()
+
+ return social_login_key
diff --git a/frappe/integrations/doctype/token_cache/__init__.py b/frappe/integrations/doctype/token_cache/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/integrations/doctype/token_cache/test_records.json b/frappe/integrations/doctype/token_cache/test_records.json
new file mode 100644
index 0000000000..05840221a6
--- /dev/null
+++ b/frappe/integrations/doctype/token_cache/test_records.json
@@ -0,0 +1,18 @@
+[
+ {
+ "doctype": "Token Cache",
+ "user": "test@example.com",
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token",
+ "token_type": "Bearer",
+ "expires_in": 1000,
+ "scopes": [
+ {
+ "scope": "all"
+ },
+ {
+ "scope": "openid"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/frappe/integrations/doctype/token_cache/test_token_cache.py b/frappe/integrations/doctype/token_cache/test_token_cache.py
new file mode 100644
index 0000000000..73c9f38fce
--- /dev/null
+++ b/frappe/integrations/doctype/token_cache/test_token_cache.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2019, Frappe Technologies and contributors
+# See license.txt
+from __future__ import unicode_literals
+
+import unittest
+import frappe
+
+test_dependencies = ['User', 'Connected App', 'Token Cache']
+
+class TestTokenCache(unittest.TestCase):
+
+ def setUp(self):
+ self.token_cache = frappe.get_last_doc('Token Cache')
+ self.token_cache.update({'connected_app': frappe.get_last_doc('Connected App').name})
+ self.token_cache.save()
+
+ def test_get_auth_header(self):
+ self.token_cache.get_auth_header()
+
+ def test_update_data(self):
+ self.token_cache.update_data({
+ 'access_token': 'new-access-token',
+ 'refresh_token': 'new-refresh-token',
+ 'token_type': 'bearer',
+ 'expires_in': 2000,
+ 'scope': 'new scope'
+ })
+
+ def test_get_expires_in(self):
+ self.token_cache.get_expires_in()
+
+ def test_is_expired(self):
+ self.token_cache.is_expired()
+
+ def get_json(self):
+ self.token_cache.get_json()
diff --git a/frappe/integrations/doctype/token_cache/token_cache.js b/frappe/integrations/doctype/token_cache/token_cache.js
new file mode 100644
index 0000000000..b7cac9b804
--- /dev/null
+++ b/frappe/integrations/doctype/token_cache/token_cache.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2019, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Token Cache', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/integrations/doctype/token_cache/token_cache.json b/frappe/integrations/doctype/token_cache/token_cache.json
new file mode 100644
index 0000000000..c016405031
--- /dev/null
+++ b/frappe/integrations/doctype/token_cache/token_cache.json
@@ -0,0 +1,110 @@
+{
+ "actions": [],
+ "autoname": "format:{connected_app}-{user}",
+ "beta": 1,
+ "creation": "2019-01-24 16:56:55.631096",
+ "doctype": "DocType",
+ "document_type": "System",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "connected_app",
+ "provider_name",
+ "access_token",
+ "refresh_token",
+ "expires_in",
+ "state",
+ "scopes",
+ "success_uri",
+ "token_type"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "label": "User",
+ "options": "User",
+ "read_only": 1
+ },
+ {
+ "fieldname": "connected_app",
+ "fieldtype": "Link",
+ "label": "Connected App",
+ "options": "Connected App",
+ "read_only": 1
+ },
+ {
+ "fieldname": "access_token",
+ "fieldtype": "Password",
+ "label": "Access Token",
+ "read_only": 1
+ },
+ {
+ "fieldname": "refresh_token",
+ "fieldtype": "Password",
+ "label": "Refresh Token",
+ "read_only": 1
+ },
+ {
+ "fieldname": "expires_in",
+ "fieldtype": "Int",
+ "label": "Expires In",
+ "read_only": 1
+ },
+ {
+ "fieldname": "state",
+ "fieldtype": "Data",
+ "label": "State",
+ "read_only": 1
+ },
+ {
+ "fieldname": "scopes",
+ "fieldtype": "Table",
+ "label": "Scopes",
+ "options": "OAuth Scope",
+ "read_only": 1
+ },
+ {
+ "fieldname": "success_uri",
+ "fieldtype": "Data",
+ "label": "Success URI",
+ "read_only": 1
+ },
+ {
+ "fieldname": "token_type",
+ "fieldtype": "Data",
+ "label": "Token Type",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "connected_app.provider_name",
+ "fieldname": "provider_name",
+ "fieldtype": "Data",
+ "label": "Provider Name",
+ "read_only": 1
+ }
+ ],
+ "links": [],
+ "modified": "2020-11-13 13:35:53.714352",
+ "modified_by": "Administrator",
+ "module": "Integrations",
+ "name": "Token Cache",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "delete": 1,
+ "read": 1,
+ "role": "System Manager"
+ },
+ {
+ "delete": 1,
+ "if_owner": 1,
+ "read": 1,
+ "role": "All"
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py
new file mode 100644
index 0000000000..7cac58fae0
--- /dev/null
+++ b/frappe/integrations/doctype/token_cache/token_cache.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2019, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+from datetime import datetime, timedelta
+
+import frappe
+from frappe import _
+from frappe.utils import cstr, cint
+from frappe.model.document import Document
+
+class TokenCache(Document):
+
+ def get_auth_header(self):
+ if self.access_token:
+ headers = {'Authorization': 'Bearer ' + self.get_password('access_token')}
+ return headers
+
+ raise frappe.exceptions.DoesNotExistError
+
+ def update_data(self, data):
+ """
+ Store data returned by authorization flow.
+
+ Params:
+ data - Dict with access_token, refresh_token, expires_in and scope.
+ """
+ token_type = cstr(data.get('token_type', '')).lower()
+ if token_type not in ['bearer', 'mac']:
+ frappe.throw(_('Received an invalid token type.'))
+ # 'Bearer' or 'MAC'
+ token_type = token_type.title() if token_type == 'bearer' else token_type.upper()
+
+ self.token_type = token_type
+ self.access_token = cstr(data.get('access_token', ''))
+ self.refresh_token = cstr(data.get('refresh_token', ''))
+ self.expires_in = cint(data.get('expires_in', 0))
+
+ new_scopes = data.get('scope')
+ if new_scopes:
+ if isinstance(new_scopes, str):
+ new_scopes = new_scopes.split(' ')
+ if isinstance(new_scopes, list):
+ self.scopes = None
+ for scope in new_scopes:
+ self.append('scopes', {'scope': scope})
+
+ self.state = None
+ self.save(ignore_permissions=True)
+ frappe.db.commit()
+ return self
+
+ def get_expires_in(self):
+ expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(self.expires_in)
+ return (datetime.now() - expiry_time).total_seconds()
+
+ def is_expired(self):
+ return self.get_expires_in() < 0
+
+ def get_json(self):
+ return {
+ 'access_token': self.get_password('access_token', ''),
+ 'refresh_token': self.get_password('refresh_token', ''),
+ 'expires_in': self.get_expires_in(),
+ 'token_type': self.token_type
+ }
diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py
index a750c8328c..07db778a2d 100644
--- a/frappe/integrations/oauth2.py
+++ b/frappe/integrations/oauth2.py
@@ -20,6 +20,7 @@ def get_oauth_server():
return frappe.local.oauth_server
def sanitize_kwargs(param_kwargs):
+ """Remove 'data' and 'cmd' keys, if present."""
arguments = param_kwargs
arguments.pop('data', None)
arguments.pop('cmd', None)
diff --git a/frappe/model/meta.py b/frappe/model/meta.py
index 53fcadce42..88ed1a7e78 100644
--- a/frappe/model/meta.py
+++ b/frappe/model/meta.py
@@ -484,6 +484,8 @@ class Meta(Document):
if not data.transactions:
# init groups
data.transactions = []
+
+ if not data.non_standard_fieldnames:
data.non_standard_fieldnames = {}
for link in dashboard_links:
diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py
index 2baf0c562c..2c9dc5d823 100644
--- a/frappe/model/rename_doc.py
+++ b/frappe/model/rename_doc.py
@@ -21,8 +21,16 @@ def update_document_title(doctype, docname, title_field=None, old_title=None, ne
docname = rename_doc(doctype=doctype, old=docname, new=new_name, merge=merge)
if old_title and new_title and not old_title == new_title:
- frappe.db.set_value(doctype, docname, title_field, new_title)
- frappe.msgprint(_('Saved'), alert=True, indicator='green')
+ try:
+ frappe.db.set_value(doctype, docname, title_field, new_title)
+ frappe.msgprint(_('Saved'), alert=True, indicator='green')
+ except Exception as e:
+ if frappe.db.is_duplicate_entry(e):
+ frappe.throw(
+ _("{0} {1} already exists").format(doctype, frappe.bold(docname)),
+ title=_("Duplicate Name"),
+ exc=frappe.DuplicateEntryError
+ )
return docname
diff --git a/frappe/patches/v13_0/website_theme_custom_scss.py b/frappe/patches/v13_0/website_theme_custom_scss.py
index 0035283428..a5f08324e8 100644
--- a/frappe/patches/v13_0/website_theme_custom_scss.py
+++ b/frappe/patches/v13_0/website_theme_custom_scss.py
@@ -2,9 +2,23 @@ import frappe
def execute():
frappe.reload_doctype('Website Theme')
+ frappe.reload_doc('website', 'doctype', 'website_theme_ignore_app')
+ frappe.reload_doc('website', 'doctype', 'color')
+
for theme in frappe.get_all('Website Theme'):
doc = frappe.get_doc('Website Theme', theme.name)
if not doc.get('custom_scss') and doc.theme_scss:
# move old theme to new theme
doc.custom_scss = doc.theme_scss
+
+ if doc.background_color:
+ setup_color_record(doc.background_color)
+
doc.save()
+
+def setup_color_record(color):
+ frappe.get_doc({
+ "doctype": "Color",
+ "__newname": color,
+ "color": color,
+ }).save()
diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js
index f3c51e0232..6df7094c26 100644
--- a/frappe/public/js/frappe/form/controls/code.js
+++ b/frappe/public/js/frappe/form/controls/code.js
@@ -10,7 +10,7 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
.appendTo(this.input_area);
this.expanded = false;
- this.$expand_button = $(``).click(() => {
+ this.$expand_button = $(``).click(() => {
this.expanded = !this.expanded;
this.refresh_height();
this.toggle_label();
@@ -38,8 +38,11 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
},
toggle_label() {
- const button_label = this.expanded ? __('Collapse') : __('Expand');
- this.$expand_button && this.$expand_button.text(button_label);
+ this.$expand_button && this.$expand_button.text(this.get_button_label());
+ },
+
+ get_button_label() {
+ return this.expanded ? __('Collapse', null, 'Shrink code field.') : __('Expand', null, 'Enlarge code field.');
},
set_language() {
diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js
index 9e4d1d82ec..dfd0f4d174 100644
--- a/frappe/public/js/frappe/form/controls/geolocation.js
+++ b/frappe/public/js/frappe/form/controls/geolocation.js
@@ -17,7 +17,7 @@ frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({
this.map_area.prependTo($input_wrapper);
this.$wrapper.find('.control-input').addClass("hidden");
- if ($input_wrapper.is(':visible')) {
+ if (this.frm) {
this.make_map();
} else {
$(document).on('frappe.ui.Dialog:shown', () => {
diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js
index 2b8956653b..be3f10fd0c 100644
--- a/frappe/public/js/frappe/form/formatters.js
+++ b/frappe/public/js/frappe/form/formatters.js
@@ -130,7 +130,7 @@ frappe.form.formatters = {
} else if(docfield && doctype) {
if (!frappe.model.can_select(doctype) && frappe.model.can_read(doctype)) {
return `
${__(options && options.label || value)}`;
diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js
index ec9cee9c39..466032dbef 100644
--- a/frappe/public/js/frappe/form/grid_row.js
+++ b/frappe/public/js/frappe/form/grid_row.js
@@ -568,13 +568,15 @@ export default class GridRow {
this.wrapper.removeClass("grid-row-open");
}
open_prev() {
- if(this.grid.grid_rows[this.doc.idx-2]) {
- this.grid.grid_rows[this.doc.idx-2].toggle_view(true);
+ const row_index = this.wrapper.index();
+ if (this.grid.grid_rows[row_index - 1]) {
+ this.grid.grid_rows[row_index - 1].toggle_view(true);
}
}
open_next() {
- if(this.grid.grid_rows[this.doc.idx]) {
- this.grid.grid_rows[this.doc.idx].toggle_view(true);
+ const row_index = this.wrapper.index();
+ if (this.grid.grid_rows[row_index + 1]) {
+ this.grid.grid_rows[row_index + 1].toggle_view(true);
} else {
this.grid.add_new_row(null, null, true);
}
diff --git a/frappe/public/js/frappe/form/script_helpers.js b/frappe/public/js/frappe/form/script_helpers.js
index 83ba191d4d..0465624975 100644
--- a/frappe/public/js/frappe/form/script_helpers.js
+++ b/frappe/public/js/frappe/form/script_helpers.js
@@ -16,17 +16,19 @@ window.refresh_field = function(n, docname, table_field) {
if(typeof n==typeof [])
refresh_many(n, docname, table_field);
- if (n && typeof n==='string' && table_field){
+ if (n && typeof n==='string' && table_field) {
var grid = cur_frm.fields_dict[table_field].grid,
- field = frappe.utils.filter_dict(grid.docfields, {fieldname: n});
- if (field && field.length){
+ field = frappe.utils.filter_dict(grid.docfields, {fieldname: n}),
+ grid_row = grid.grid_rows_by_docname[docname];
+
+ if (field && field.length) {
field = field[0];
var meta = frappe.meta.get_docfield(field.parent, field.fieldname, docname);
$.extend(field, meta);
- if (docname){
- cur_frm.fields_dict[table_field].grid.grid_rows_by_docname[docname].refresh_field(n);
+ if (grid_row) {
+ grid_row.refresh_field(n);
} else {
- cur_frm.fields_dict[table_field].grid.refresh();
+ grid.refresh();
}
}
} else if(cur_frm) {
diff --git a/frappe/public/js/frappe/form/sidebar/form_sidebar.js b/frappe/public/js/frappe/form/sidebar/form_sidebar.js
index eab09c1e10..eb70b255eb 100644
--- a/frappe/public/js/frappe/form/sidebar/form_sidebar.js
+++ b/frappe/public/js/frappe/form/sidebar/form_sidebar.js
@@ -99,7 +99,7 @@ frappe.ui.form.Sidebar = class {
__("{0} edited this {1}", [
frappe.user.full_name(this.frm.doc.modified_by).bold(),
"
" + comment_when(this.frm.doc.modified),
- ])
+ ], "For example, 'Jon Doe edited this 5 minutes ago'.")
);
this.sidebar
.find(".created-by")
@@ -107,7 +107,7 @@ frappe.ui.form.Sidebar = class {
__("{0} created this {1}", [
frappe.user.full_name(this.frm.doc.owner).bold(),
"
" + comment_when(this.frm.doc.creation),
- ])
+ ], "For example, 'Jon Doe created this 5 minutes ago'.")
);
this.refresh_like();
diff --git a/frappe/public/js/frappe/utils/user.js b/frappe/public/js/frappe/utils/user.js
index 311f208750..64db23d306 100644
--- a/frappe/public/js/frappe/utils/user.js
+++ b/frappe/public/js/frappe/utils/user.js
@@ -55,7 +55,7 @@ $.extend(frappe.user, {
name: 'Guest',
full_name: function(uid) {
return uid === frappe.session.user ?
- __("You") :
+ __("You", null, "Name of the current user. For example: You edited this 5 hours ago.") :
frappe.user_info(uid).fullname;
},
image: function(uid) {
diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js
index c69be04347..0389770783 100755
--- a/frappe/public/js/frappe/views/communication.js
+++ b/frappe/public/js/frappe/views/communication.js
@@ -672,10 +672,19 @@ frappe.views.CommunicationComposer = Class.extend({
}
},
- setup_earlier_reply: function() {
+ get_default_outgoing_email_account_signature: function() {
+ return frappe.db.get_value('Email Account', { 'default_outgoing': 1, 'add_signature': 1 }, 'signature');
+ },
+
+ setup_earlier_reply: async function() {
let fields = this.dialog.fields_dict;
let signature = frappe.boot.user.email_signature || "";
+ if (!signature) {
+ const res = await this.get_default_outgoing_email_account_signature();
+ signature = res.message.signature;
+ }
+
if(!frappe.utils.is_html(signature)) {
signature = signature.replace(/\n/g, "
");
}
@@ -756,4 +765,3 @@ frappe.views.CommunicationComposer = Class.extend({
return text.replace(/\n{3,}/g, '\n\n');
}
});
-
diff --git a/frappe/public/js/frappe/widgets/onboarding_widget.js b/frappe/public/js/frappe/widgets/onboarding_widget.js
index abacc6f354..8ef003cc67 100644
--- a/frappe/public/js/frappe/widgets/onboarding_widget.js
+++ b/frappe/public/js/frappe/widgets/onboarding_widget.js
@@ -444,7 +444,7 @@ export default class OnboardingWidget extends Widget {
set_actions() {
this.action_area.empty();
const dismiss = $(
- `