diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 246bdbe096..3ef7db34f6 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -17,7 +17,7 @@ if [ "$TYPE" == "server" ]; then
fi
if [ "$DB" == "mariadb" ];then
- sudo apt update && sudo apt install mariadb-client-10.3
+ sudo apt install mariadb-client-10.3
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'";
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";
diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh
index f0e8016860..18bc4e6f9a 100644
--- a/.github/helper/install_dependencies.sh
+++ b/.github/helper/install_dependencies.sh
@@ -16,8 +16,4 @@ sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
sudo chmod o+x /usr/local/bin/wkhtmltopdf
# install cups
-sudo apt-get install libcups2-dev
-
-# install redis
-sudo apt-get install redis-server
-
+sudo apt update && sudo apt install libcups2-dev redis-server
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index dba13f9358..988c2dcc6c 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -2,8 +2,13 @@ name: 'Trigger Docker build on release'
on:
release:
types: [released]
+permissions:
+ contents: read
+
jobs:
curl:
+ permissions:
+ contents: none
name: 'Trigger Docker build on release'
runs-on: ubuntu-latest
container:
diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml
index 5e91063698..a0f77b43fd 100644
--- a/.github/workflows/docs-checker.yml
+++ b/.github/workflows/docs-checker.yml
@@ -3,6 +3,9 @@ on:
pull_request:
types: [ opened, synchronize, reopened, edited ]
+permissions:
+ contents: read
+
jobs:
docs-required:
name: 'Documentation Required'
diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml
index c8294886a0..224e380925 100644
--- a/.github/workflows/patch-mariadb-tests.yml
+++ b/.github/workflows/patch-mariadb-tests.yml
@@ -7,6 +7,9 @@ concurrency:
group: patch-mariadb-develop-${{ github.event.number }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 434f784461..e9936482b0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,7 +2,10 @@ name: Generate Semantic Release
on:
push:
branches:
- - [version-13, version-14-beta]
+ - version-14-beta
+permissions:
+ contents: read
+
jobs:
release:
name: Release
diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml
index 4edf74ba71..48104b8f16 100644
--- a/.github/workflows/server-mariadb-tests.yml
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -11,6 +11,9 @@ concurrency:
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
index 895af5184e..241b7ddf96 100644
--- a/.github/workflows/server-postgres-tests.yml
+++ b/.github/workflows/server-postgres-tests.yml
@@ -10,6 +10,9 @@ concurrency:
group: server-postgres-develop-${{ github.event.number }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index fc8093444e..06ad921a6a 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -10,6 +10,9 @@ concurrency:
group: ui-develop-${{ github.event.number }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.releaserc b/.releaserc
index 530a6c0767..c9ca71bbf5 100644
--- a/.releaserc
+++ b/.releaserc
@@ -1,11 +1,8 @@
{
- "branches": ["version-13"],
+ "branches": ["develop", {"name": "version-14-beta", "channel": "beta", "prerelease": true}],
"plugins": [
"@semantic-release/commit-analyzer", {
- "preset": "angular",
- "releaseRules": [
- {"breaking": true, "release": false}
- ]
+ "preset": "angular"
},
"@semantic-release/release-notes-generator",
[
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 2a48a1a860..00ed147c2c 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -1032,7 +1032,7 @@ def get_cached_doc(*args, **kwargs):
return doc
if key := can_cache_doc(args):
- # local cache
+ # local cache - has "ready" `Document` objects
if doc := local.document_cache.get(key):
return _respond(doc)
@@ -1040,9 +1040,16 @@ def get_cached_doc(*args, **kwargs):
if doc := cache().hget("document_cache", key):
return _respond(doc, True)
- # database
+ # Not found in local/redis, fetch from DB and store in cache
doc = get_doc(*args, **kwargs)
+ # Store in redis cache
+ key = get_document_cache_key(doc.doctype, doc.name)
+
+ local.document_cache[key] = doc
+ # Avoid setting in local.cache since there's separate cache
+ cache().hset("document_cache", key, doc.as_dict(), cache_locally=False)
+
return doc
@@ -1113,10 +1120,12 @@ def get_doc(*args, **kwargs):
doc = frappe.model.document.get_doc(*args, **kwargs)
- # set in cache
+ # Replace cache
if key := can_cache_doc(args):
- local.document_cache[key] = doc
- cache().hset("document_cache", key, doc.as_dict())
+ if key in local.document_cache:
+ local.document_cache[key] = doc
+ if cache().hexists("document_cache", key):
+ cache().hset("document_cache", key, doc.as_dict())
return doc
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py
index f3dfa4cf0a..0ca64e54c2 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.py
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py
@@ -298,8 +298,6 @@ def apply(doc=None, method=None, doctype=None, name=None):
if reopened:
break
- # print(f"Rule:{assignment_rule}\nDoc: {doc}\nReOpened: {reopened}")
-
assignment_rule.close_assignments(doc)
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
index 628a10d67e..70b48e1f0d 100644
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -144,10 +144,6 @@ def restore(
)
from frappe.utils.backups import Backup
- if not os.path.exists(sql_file_path):
- print("Invalid path", sql_file_path)
- sys.exit(1)
-
_backup = Backup(sql_file_path)
site = get_site(context)
diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py
index 887037dca1..2c8a65fafe 100755
--- a/frappe/core/doctype/communication/email.py
+++ b/frappe/core/doctype/communication/email.py
@@ -22,14 +22,6 @@ if TYPE_CHECKING:
from frappe.core.doctype.communication.communication import Communication
-OUTGOING_EMAIL_ACCOUNT_MISSING = _(
- """
- Unable to send mail because of a missing email account.
- Please setup default Email Account from Setup > Email > Email Account
-"""
-)
-
-
@frappe.whitelist()
def make(
doctype=None,
@@ -170,7 +162,12 @@ def _make(
if cint(send_email):
if not comm.get_outgoing_email_account():
- frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError)
+ frappe.throw(
+ _(
+ "Unable to send mail because of a missing email account. Please setup default Email Account from Setup > Email > Email Account"
+ ),
+ exc=frappe.OutgoingEmailError,
+ )
comm.send_email(
print_html=print_html,
diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py
index 68abba3c13..0263cfeac5 100644
--- a/frappe/core/doctype/communication/mixins.py
+++ b/frappe/core/doctype/communication/mixins.py
@@ -152,7 +152,7 @@ class CommunicationEmailMixin:
"doctype": self.reference_doctype,
"name": self.reference_name,
"print_format": print_format,
- "key": get_parent_doc(self).get_signature(),
+ "key": get_parent_doc(self).get_document_share_key(),
}
)
diff --git a/frappe/core/doctype/document_share_key/__init__.py b/frappe/core/doctype/document_share_key/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/core/doctype/document_share_key/document_share_key.js b/frappe/core/doctype/document_share_key/document_share_key.js
new file mode 100644
index 0000000000..c51233e10f
--- /dev/null
+++ b/frappe/core/doctype/document_share_key/document_share_key.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Document Share Key', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/document_share_key/document_share_key.json b/frappe/core/doctype/document_share_key/document_share_key.json
new file mode 100644
index 0000000000..b96fe09f0b
--- /dev/null
+++ b/frappe/core/doctype/document_share_key/document_share_key.json
@@ -0,0 +1,73 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "hash",
+ "creation": "2022-01-14 13:40:49.487646",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "reference_doctype",
+ "reference_docname",
+ "key",
+ "expires_on"
+ ],
+ "fields": [
+ {
+ "fieldname": "reference_doctype",
+ "fieldtype": "Link",
+ "label": "Reference Document Type",
+ "options": "DocType",
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "reference_docname",
+ "fieldtype": "Dynamic Link",
+ "label": "Reference Document Name",
+ "options": "reference_doctype",
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "key",
+ "fieldtype": "Data",
+ "label": "Key",
+ "read_only": 1
+ },
+ {
+ "fieldname": "expires_on",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Expires On",
+ "read_only": 1
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2022-01-14 13:57:28.050678",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Document Share Key",
+ "naming_rule": "Expression",
+ "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_only": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/document_share_key/document_share_key.py b/frappe/core/doctype/document_share_key/document_share_key.py
new file mode 100644
index 0000000000..88608b992c
--- /dev/null
+++ b/frappe/core/doctype/document_share_key/document_share_key.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from random import randrange
+
+import frappe
+from frappe.model.document import Document
+
+
+class DocumentShareKey(Document):
+ def before_insert(self):
+ self.key = frappe.generate_hash(length=randrange(25, 35))
+ if not self.expires_on and not self.flags.no_expiry:
+ self.expires_on = frappe.utils.add_days(
+ None, days=frappe.get_system_settings("document_share_key_expiry") or 90
+ )
+
+
+def is_expired(expires_on):
+ return expires_on and expires_on < frappe.utils.getdate()
diff --git a/frappe/core/doctype/document_share_key/test_document_share_key.py b/frappe/core/doctype/document_share_key/test_document_share_key.py
new file mode 100644
index 0000000000..10499fcc5d
--- /dev/null
+++ b/frappe/core/doctype/document_share_key/test_document_share_key.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+
+# import frappe
+import unittest
+
+
+class TestDocumentShareKey(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/package_import/package_import.py b/frappe/core/doctype/package_import/package_import.py
index 659017c498..43a985af9b 100644
--- a/frappe/core/doctype/package_import/package_import.py
+++ b/frappe/core/doctype/package_import/package_import.py
@@ -55,11 +55,11 @@ class PackageImport(Document):
for module in os.listdir(package_path):
module_path = os.path.join(package_path, module)
if os.path.isdir(module_path):
- get_doc_files(files, module_path)
+ files = get_doc_files(files, module_path)
# import files
for file in files:
- import_file_by_path(file, force=self.force, ignore_version=True, for_sync=True)
+ import_file_by_path(file, force=self.force, ignore_version=True)
log.append("Imported {}".format(file))
self.log = "\n".join(log)
diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
index 9665a20843..318b156dcd 100644
--- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
+++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
@@ -185,9 +185,12 @@ def insert_single_event(frequency: str, event: str, cron_format: str = None):
if not frappe.db.exists(
"Scheduled Job Type", {"method": event, "frequency": frequency, **cron_expr}
):
+ savepoint = "scheduled_job_type_creation"
try:
+ frappe.db.savepoint(savepoint)
doc.insert()
except frappe.DuplicateEntryError:
+ frappe.db.rollback(save_point=savepoint)
doc.delete()
doc.insert()
diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index 52ff5e38e4..c954e41202 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -1,6 +1,6 @@
{
"actions": [],
- "creation": "2014-04-17 16:53:52.640856",
+ "creation": "2022-01-06 03:18:16.326761",
"doctype": "DocType",
"document_type": "System",
"engine": "InnoDB",
@@ -34,12 +34,14 @@
"security",
"session_expiry",
"session_expiry_mobile",
+ "document_share_key_expiry",
"column_break_13",
"deny_multiple_sessions",
"allow_login_using_mobile_number",
"allow_login_using_user_name",
"allow_error_traceback",
"strip_exif_metadata_from_uploaded_images",
+ "allow_older_web_view_links",
"password_settings",
"logout_on_password_reset",
"force_user_to_reset_password",
@@ -482,6 +484,19 @@
"options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday"
},
{
+ "default": "30",
+ "description": "Number of days after which the document Web View link shared on email will be expired",
+ "fieldname": "document_share_key_expiry",
+ "fieldtype": "Int",
+ "label": "Document Share Key Expiry (in Days)"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_older_web_view_links",
+ "fieldtype": "Check",
+ "label": "Allow Older Web View Links (Insecure)"
+ },
+ {
"fieldname": "column_break_64",
"fieldtype": "Column Break"
},
diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js
index 6bbab0fdb3..001aae4da0 100644
--- a/frappe/core/doctype/user/user.js
+++ b/frappe/core/doctype/user/user.js
@@ -59,6 +59,10 @@ frappe.ui.form.on('User', {
onload: function(frm) {
frm.can_edit_roles = has_access_to_edit_user();
+ if (frm.is_new() && frm.roles_editor) {
+ frm.roles_editor.reset();
+ }
+
if (frm.can_edit_roles && !frm.is_new() && in_list(['System User', 'Website User'], frm.doc.user_type)) {
if (!frm.roles_editor) {
const role_area = $('
')
diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py
index 10ee4a503f..8a2a2663de 100644
--- a/frappe/custom/doctype/custom_field/custom_field.py
+++ b/frappe/custom/doctype/custom_field/custom_field.py
@@ -161,6 +161,7 @@ def create_custom_field(doctype, df, ignore_validate=False, is_system_generated=
custom_field.update(df)
custom_field.flags.ignore_validate = ignore_validate
custom_field.insert()
+ return custom_field
def create_custom_fields(custom_fields, ignore_validate=False, update=True):
diff --git a/frappe/database/database.py b/frappe/database/database.py
index f9c60f4639..8f4e91fa63 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -23,8 +23,6 @@ from frappe.query_builder.functions import Count
from frappe.query_builder.utils import DocType
from frappe.utils import cast, get_datetime, get_table_name, getdate, now, sbool
-from .query import Query
-
class Database(object):
"""
@@ -65,7 +63,15 @@ class Database(object):
self.password = password or frappe.conf.db_password
self.value_cache = {}
- self.query = Query()
+
+ @property
+ def query(self):
+ if not hasattr(self, "_query"):
+ from .query import Query
+
+ self._query = Query()
+ del Query
+ return self._query
def setup_type_map(self):
pass
@@ -195,6 +201,9 @@ class Database(object):
elif frappe.conf.db_type == "postgres":
# TODO: added temporarily
+ import traceback
+
+ traceback.print_stack()
print(e)
raise
@@ -914,6 +923,9 @@ class Database(object):
frappe.call(method[0], *(method[1] or []), **(method[2] or {}))
self.sql("commit")
+ if frappe.conf.db_type == "postgres":
+ # Postgres requires explicitly starting new transaction
+ self.begin()
frappe.local.rollback_observers = []
self.flush_realtime_log()
diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py
index 5584c098ce..9a7f2b43c4 100644
--- a/frappe/database/postgres/setup_db.py
+++ b/frappe/database/postgres/setup_db.py
@@ -6,6 +6,7 @@ import frappe
def setup_database(force, source_sql=None, verbose=False):
root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password)
root_conn.commit()
+ root_conn.sql("end")
root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(frappe.conf.db_name))
root_conn.sql("DROP USER IF EXISTS {0}".format(frappe.conf.db_name))
root_conn.sql("CREATE DATABASE `{0}`".format(frappe.conf.db_name))
diff --git a/frappe/database/query.py b/frappe/database/query.py
index f608539854..cfeafd6a37 100644
--- a/frappe/database/query.py
+++ b/frappe/database/query.py
@@ -268,14 +268,7 @@ class Query:
return conditions
if isinstance(filters, list):
for f in filters:
- if not isinstance(f, (list, tuple)):
- _operator = self.OPERATOR_MAP[filters[1].casefold()]
- if not isinstance(filters[0], str):
- conditions = make_function(filters[0], filters[2])
- break
- conditions = conditions.where(_operator(Field(filters[0]), filters[2]))
- break
- else:
+ if isinstance(f, (list, tuple)):
_operator = self.OPERATOR_MAP[f[-2].casefold()]
if len(f) == 4:
table_object = self.get_table(f[0])
@@ -283,6 +276,15 @@ class Query:
else:
_field = Field(f[0])
conditions = conditions.where(_operator(_field, f[-1]))
+ elif isinstance(f, dict):
+ conditions = self.dict_query(table, f, **kwargs)
+ else:
+ _operator = self.OPERATOR_MAP[filters[1].casefold()]
+ if not isinstance(filters[0], str):
+ conditions = make_function(filters[0], filters[2])
+ break
+ conditions = conditions.where(_operator(Field(filters[0]), filters[2]))
+ break
return self.add_conditions(conditions, **kwargs)
diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py
index 1436ecc1cd..2bf7347a4f 100644
--- a/frappe/desk/doctype/notification_settings/notification_settings.py
+++ b/frappe/desk/doctype/notification_settings/notification_settings.py
@@ -52,6 +52,7 @@ def toggle_notifications(user: str, enable: bool = False):
try:
settings = frappe.get_doc("Notification Settings", user)
except frappe.DoesNotExistError:
+ frappe.clear_last_message()
return
if settings.enabled != enable:
diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py
index 88216d3998..5149f8bf86 100644
--- a/frappe/desk/listview.py
+++ b/frappe/desk/listview.py
@@ -1,6 +1,12 @@
-# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
+from typing import Dict, List
+
import frappe
+from frappe.query_builder import Order
+from frappe.query_builder.functions import Count
+from frappe.query_builder.terms import SubQuery
+from frappe.query_builder.utils import DocType
@frappe.whitelist()
@@ -24,37 +30,36 @@ def set_list_settings(doctype, values):
@frappe.whitelist()
-def get_group_by_count(doctype, current_filters, field):
+def get_group_by_count(doctype: str, current_filters: str, field: str) -> List[Dict]:
current_filters = frappe.parse_json(current_filters)
- subquery_condition = ""
- subquery = frappe.get_all(doctype, filters=current_filters, run=False)
if field == "assigned_to":
- subquery_condition = " and `tabToDo`.reference_name in ({subquery})".format(subquery=subquery)
- return frappe.db.sql(
- """select `tabToDo`.allocated_to as name, count(*) as count
- from
- `tabToDo`, `tabUser`
- where
- `tabToDo`.status!='Cancelled' and
- `tabToDo`.allocated_to = `tabUser`.name and
- `tabUser`.user_type = 'System User'
- {subquery_condition}
- group by
- `tabToDo`.allocated_to
- order by
- count desc
- limit 50""".format(
- subquery_condition=subquery_condition
- ),
- as_dict=True,
- )
- else:
- return frappe.db.get_list(
- doctype,
- filters=current_filters,
- group_by="`tab{0}`.{1}".format(doctype, field),
- fields=["count(*) as count", "`{}` as name".format(field)],
- order_by="count desc",
- limit=50,
+ ToDo = DocType("ToDo")
+ User = DocType("User")
+ count = Count("*").as_("count")
+ filtered_records = frappe.db.query.build_conditions(doctype, current_filters).select("name")
+
+ return (
+ frappe.qb.from_(ToDo)
+ .from_(User)
+ .select(ToDo.allocated_to.as_("name"), count)
+ .where(
+ (ToDo.status != "Cancelled")
+ & (ToDo.allocated_to == User.name)
+ & (User.user_type == "System User")
+ & (ToDo.reference_name.isin(SubQuery(filtered_records)))
+ )
+ .groupby(ToDo.allocated_to)
+ .orderby(count, order=Order.desc)
+ .limit(50)
+ .run(as_dict=True)
)
+
+ return frappe.get_list(
+ doctype,
+ filters=current_filters,
+ group_by=f"`tab{doctype}`.{field}",
+ fields=["count(*) as count", f"`{field}` as name"],
+ order_by="count desc",
+ limit=50,
+ )
diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py
index 3f849bbcaa..8fc4b3f694 100755
--- a/frappe/desk/page/setup_wizard/setup_wizard.py
+++ b/frappe/desk/page/setup_wizard/setup_wizard.py
@@ -431,22 +431,13 @@ def make_records(records, debug=False):
if doc.meta.get_field(parent_link_field) and not doc.get(parent_link_field):
doc.flags.ignore_mandatory = True
+ savepoint = "setup_fixtures_creation"
try:
- doc.insert(ignore_permissions=True)
- frappe.db.commit()
-
- except frappe.DuplicateEntryError as e:
- # print("Failed to insert duplicate {0} {1}".format(doctype, doc.name))
-
- # pass DuplicateEntryError and continue
- if e.args and e.args[0] == doc.doctype and e.args[1] == doc.name:
- # make sure DuplicateEntryError is for the exact same doc and not a related doc
- frappe.clear_messages()
- else:
- raise
-
+ frappe.db.savepoint(savepoint)
+ doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
except Exception as e:
- frappe.db.rollback()
+ frappe.clear_last_message()
+ frappe.db.rollback(save_point=savepoint)
exception = record.get("__exception")
if exception:
config = _dict(exception)
@@ -461,3 +452,4 @@ def make_records(records, debug=False):
def show_document_insert_error():
print("Document Insert Error")
print(frappe.get_traceback())
+ frappe.log_error("Exception during Setup")
diff --git a/frappe/desk/search.py b/frappe/desk/search.py
index 639077da5e..eb1a2e82ba 100644
--- a/frappe/desk/search.py
+++ b/frappe/desk/search.py
@@ -348,7 +348,7 @@ def get_names_for_mentions(search_term):
def get_users_for_mentions():
- return frappe.get_list(
+ return frappe.get_all(
"User",
fields=["name as id", "full_name as value"],
filters={
@@ -361,7 +361,7 @@ def get_users_for_mentions():
def get_user_groups():
- return frappe.get_list(
+ return frappe.get_all(
"User Group", fields=["name as id", "name as value"], update={"is_group": True}
)
diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py
index 73ab13b851..a89b9a2747 100755
--- a/frappe/email/doctype/email_account/email_account.py
+++ b/frappe/email/doctype/email_account/email_account.py
@@ -23,10 +23,6 @@ from frappe.utils.error import raise_error_on_no_output
from frappe.utils.jinja import render_template
from frappe.utils.user import get_system_managers
-OUTGOING_EMAIL_ACCOUNT_MISSING = _(
- "Please setup default Email Account from Setup > Email > Email Account"
-)
-
class SentEmailInInbox(Exception):
pass
@@ -319,7 +315,7 @@ class EmailAccount(Document):
@classmethod
@raise_error_on_no_output(
keep_quiet=lambda: not cint(frappe.get_system_settings("setup_complete")),
- error_message=OUTGOING_EMAIL_ACCOUNT_MISSING,
+ error_message=_("Please setup default Email Account from Setup > Email > Email Account"),
error_type=frappe.OutgoingEmailError,
) # noqa
@cache_email_account("outgoing_email_account")
diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py
index 0cf388564f..550ee8164b 100644
--- a/frappe/email/doctype/newsletter/test_newsletter.py
+++ b/frappe/email/doctype/newsletter/test_newsletter.py
@@ -72,7 +72,7 @@ class TestNewsletterMixin:
"doctype": doctype,
**email_filters,
}
- ).insert()
+ ).insert(ignore_if_duplicate=True)
except Exception:
frappe.db.rollback(save_point=savepoint)
frappe.db.update(doctype, email_filters, "unsubscribed", 0)
diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py
index d621fd2bba..1211419de1 100644
--- a/frappe/email/smtp.py
+++ b/frappe/email/smtp.py
@@ -1,24 +1,12 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import smtplib
-import _socket
-
import frappe
from frappe import _
from frappe.utils import cint, cstr
-CONNECTION_FAILED = _("Could not connect to outgoing email server")
-AUTH_ERROR_TITLE = _("Invalid Credentials")
-AUTH_ERROR = _("Incorrect email or password. Please check your login credentials.")
-SOCKET_ERROR_TITLE = _("Incorrect Configuration")
-SOCKET_ERROR = _("Invalid Outgoing Mail Server or Port")
-SEND_MAIL_FAILED = _("Unable to send emails at this time")
-EMAIL_ACCOUNT_MISSING = _(
- "Email Account not setup. Please create a new Email Account from Setup > Email > Email Account"
-)
-
class InvalidEmailCredentials(frappe.ValidationError):
pass
@@ -65,7 +53,12 @@ class SMTPServer:
self._session = None
if not self.server:
- frappe.msgprint(EMAIL_ACCOUNT_MISSING, raise_exception=frappe.OutgoingEmailError)
+ frappe.msgprint(
+ _(
+ "Email Account not setup. Please create a new Email Account from Setup > Email > Email Account"
+ ),
+ raise_exception=frappe.OutgoingEmailError,
+ )
@property
def port(self):
@@ -93,7 +86,9 @@ class SMTPServer:
try:
_session = SMTP(self.server, self.port)
if not _session:
- frappe.msgprint(CONNECTION_FAILED, raise_exception=frappe.OutgoingEmailError)
+ frappe.msgprint(
+ _("Could not connect to outgoing email server"), raise_exception=frappe.OutgoingEmailError
+ )
self.secure_session(_session)
if self.login and self.password:
@@ -106,16 +101,12 @@ class SMTPServer:
self._session = _session
return self._session
- except smtplib.SMTPAuthenticationError as e:
+ except smtplib.SMTPAuthenticationError:
self.throw_invalid_credentials_exception()
- except _socket.error as e:
+ except OSError:
# Invalid mail server -- due to refusing connection
- frappe.throw(SOCKET_ERROR, title=SOCKET_ERROR_TITLE)
-
- except smtplib.SMTPException:
- frappe.msgprint(SEND_MAIL_FAILED)
- raise
+ frappe.throw(_("Invalid Outgoing Mail Server or Port"), title=_("Incorrect Configuration"))
def is_session_active(self):
if self._session:
@@ -130,4 +121,8 @@ class SMTPServer:
@classmethod
def throw_invalid_credentials_exception(cls):
- frappe.throw(AUTH_ERROR, title=AUTH_ERROR_TITLE, exc=InvalidEmailCredentials)
+ frappe.throw(
+ _("Incorrect email or password. Please check your login credentials."),
+ title=_("Invalid Credentials"),
+ exc=InvalidEmailCredentials,
+ )
diff --git a/frappe/exceptions.py b/frappe/exceptions.py
index a8569481d3..755c21c240 100644
--- a/frappe/exceptions.py
+++ b/frappe/exceptions.py
@@ -263,3 +263,15 @@ class ExecutableNotFound(FileNotFoundError):
class InvalidRemoteException(Exception):
pass
+
+
+class LinkExpired(ValidationError):
+ http_status_code = 410
+ title = "Link Expired"
+ message = "The link has expired"
+
+
+class InvalidKeyError(ValidationError):
+ http_status_code = 401
+ title = "Invalid Key"
+ message = "The document key is invalid"
diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py
index 996ce2d129..c101b5eb35 100644
--- a/frappe/model/db_query.py
+++ b/frappe/model/db_query.py
@@ -303,14 +303,8 @@ class DatabaseQuery(object):
linked_field = frappe.get_meta(self.doctype).get_field(linked_fieldname)
linked_doctype = linked_field.options
if linked_field.fieldtype == "Link":
- self.link_tables.append(
- frappe._dict(
- doctype=linked_doctype, fieldname=linked_fieldname, table_name=f"`tab{linked_doctype}`"
- )
- )
-
- field = field.replace(linked_fieldname, f"`tab{linked_doctype}`")
- field = field.replace(fieldname, f"`{fieldname}`")
+ self.append_link_table(linked_doctype, linked_fieldname)
+ field = f"`tab{linked_doctype}`.`{fieldname}`"
if alias:
field = f"{field} as {alias}"
self.fields[self.fields.index(original_field)] = field
@@ -432,6 +426,19 @@ class DatabaseQuery(object):
def append_table(self, table_name):
self.tables.append(table_name)
doctype = table_name[4:-1]
+ self.check_read_permission(doctype)
+
+ def append_link_table(self, doctype, fieldname):
+ for d in self.link_tables:
+ if d.doctype == doctype and d.fieldname == fieldname:
+ return
+
+ self.check_read_permission(doctype)
+ self.link_tables.append(
+ frappe._dict(doctype=doctype, fieldname=fieldname, table_name=f"`tab{doctype}`")
+ )
+
+ def check_read_permission(self, doctype):
ptype = "select" if frappe.only_has_select_perm(doctype) else "read"
if not self.flags.ignore_permissions and not frappe.has_permission(
diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py
index a6d29ed190..985cc53682 100644
--- a/frappe/model/delete_doc.py
+++ b/frappe/model/delete_doc.py
@@ -30,6 +30,7 @@ doctypes_to_skip = (
"Tag Link",
"Notification Log",
"Email Queue",
+ "Document Share Key",
)
diff --git a/frappe/model/document.py b/frappe/model/document.py
index d49baa6287..fa1f423d11 100644
--- a/frappe/model/document.py
+++ b/frappe/model/document.py
@@ -1383,6 +1383,30 @@ class Document(BaseDocument):
"""Returns signature (hash) for private URL."""
return hashlib.sha224(get_datetime_str(self.creation).encode()).hexdigest()
+ def get_document_share_key(self, expires_on=None, no_expiry=False):
+ if no_expiry:
+ expires_on = None
+
+ existing_key = frappe.db.exists(
+ "Document Share Key",
+ {
+ "reference_doctype": self.doctype,
+ "reference_docname": self.name,
+ "expires_on": expires_on,
+ },
+ )
+ if existing_key:
+ doc = frappe.get_doc("Document Share Key", existing_key)
+ else:
+ doc = frappe.new_doc("Document Share Key")
+ doc.reference_doctype = self.doctype
+ doc.reference_docname = self.name
+ doc.expires_on = expires_on
+ doc.flags.no_expiry = no_expiry
+ doc.insert(ignore_permissions=True)
+
+ return doc.key
+
def get_liked_by(self):
liked_by = getattr(self, "_liked_by", None)
if liked_by:
diff --git a/frappe/patches.txt b/frappe/patches.txt
index 6407453af7..6c46d5dcd9 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -200,3 +200,4 @@ frappe.patches.v14_0.remove_db_aggregation
frappe.patches.v14_0.update_color_names_in_kanban_board_column
frappe.patches.v14_0.update_is_system_generated_flag
frappe.patches.v14_0.update_auto_account_deletion_duration
+frappe.patches.v14_0.set_document_expiry_default
diff --git a/frappe/patches/v14_0/set_document_expiry_default.py b/frappe/patches/v14_0/set_document_expiry_default.py
new file mode 100644
index 0000000000..59a9db6c4d
--- /dev/null
+++ b/frappe/patches/v14_0/set_document_expiry_default.py
@@ -0,0 +1,9 @@
+import frappe
+
+
+def execute():
+ frappe.db.set_value(
+ "System Settings",
+ "System Settings",
+ {"document_share_key_expiry": 30, "allow_older_web_view_links": 1},
+ )
diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg
index bf4e02a7af..fbd72d6fb5 100644
--- a/frappe/public/icons/timeless/symbol-defs.svg
+++ b/frappe/public/icons/timeless/symbol-defs.svg
@@ -2,11 +2,11 @@
-
+
-
+
@@ -14,15 +14,15 @@
-
+
-
+
-
+
@@ -52,22 +52,22 @@
-
+
-
+
-
+
-
+
@@ -77,7 +77,7 @@
-
+
@@ -95,19 +95,19 @@
-
+
-
+
-
+
@@ -303,7 +303,7 @@
-
+
@@ -947,5 +947,8 @@
+
+
+
diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js
index 95abba616a..f3576b0201 100644
--- a/frappe/public/js/frappe/form/controls/data.js
+++ b/frappe/public/js/frappe/form/controls/data.js
@@ -58,7 +58,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp
this.has_input = true;
this.bind_change_event();
this.setup_autoname_check();
-
+ this.setup_copy_button();
if (this.df.options == 'URL') {
this.setup_url_field();
}
@@ -112,6 +112,18 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp
});
}
+ setup_copy_button() {
+ if (this.df.with_copy_button) {
+ this.$wrapper.find('.control-input').append(
+ `
`
+ ).find(".action-btn").click(() => {
+ frappe.utils.copy_to_clipboard(this.value);
+ });
+ }
+ }
+
setup_barcode_field() {
this.$wrapper.find('.control-input').append(
`
diff --git a/frappe/public/js/frappe/form/controls/date.js b/frappe/public/js/frappe/form/controls/date.js
index bea7e77bd1..a8b82604c9 100644
--- a/frappe/public/js/frappe/form/controls/date.js
+++ b/frappe/public/js/frappe/form/controls/date.js
@@ -64,6 +64,8 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat
dateFormat: date_format,
startDate: this.get_start_date(),
keyboardNav: false,
+ minDate: this.df.min_date,
+ maxDate: this.df.max_date,
firstDay: frappe.datetime.get_first_day_of_the_week_index(),
onSelect: () => {
this.$input.trigger('change');
diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js
index 87d7d73814..a997287a52 100644
--- a/frappe/public/js/frappe/form/form.js
+++ b/frappe/public/js/frappe/form/form.js
@@ -913,7 +913,7 @@ frappe.ui.form.Form = class FrappeForm {
} else {
frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, me.handle_save_fail(btn, on_error));
}
- };
+ }
savetrash() {
this.validate_form_action("Delete");
diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js
index f64ae39d1b..76fb782045 100644
--- a/frappe/public/js/frappe/list/base_list.js
+++ b/frappe/public/js/frappe/list/base_list.js
@@ -270,7 +270,6 @@ frappe.views.BaseList = class BaseList {
doctype: this.doctype,
stats: this.stats,
parent: this.$page.find(".layout-side-section"),
- // set_filter: this.set_filter.bind(this),
page: this.page,
list_view: this,
});
@@ -428,7 +427,7 @@ frappe.views.BaseList = class BaseList {
const filter = this.get_filters_for_args().filter(f => f[1] == fieldname)[0];
if (!filter) return;
return {
- 'like': filter[3].replace(/^%?|%$/g, ''),
+ 'like': filter[3]?.replace(/^%?|%$/g, ''),
'not set': null
}[filter[2]] || filter[3];
}
@@ -620,9 +619,7 @@ class FilterArea {
filters = [filter];
}
- filters = filters.filter((f) => {
- return !this.exists(f);
- });
+ filters = filters.filter(f => !this.exists(f));
const { non_standard_filters, promise } = this.set_standard_filter(
filters
diff --git a/frappe/public/js/frappe/list/list_sidebar.js b/frappe/public/js/frappe/list/list_sidebar.js
index 28c6eaab0b..5feeb1928e 100644
--- a/frappe/public/js/frappe/list/list_sidebar.js
+++ b/frappe/public/js/frappe/list/list_sidebar.js
@@ -7,7 +7,6 @@ frappe.provide('frappe.views');
// stats = list of fields
// doctype
// parent
-// set_filter = function called on click
frappe.views.ListSidebar = class ListSidebar {
constructor(opts) {
diff --git a/frappe/public/js/frappe/roles_editor.js b/frappe/public/js/frappe/roles_editor.js
index e23f808a82..05f58692f6 100644
--- a/frappe/public/js/frappe/roles_editor.js
+++ b/frappe/public/js/frappe/roles_editor.js
@@ -94,10 +94,14 @@ frappe.RoleEditor = class {
.css("max-width", "80vw");
}
show() {
- let user_roles = this.frm.doc.roles.map(a => a.role);
+ this.reset();
+ this.set_enable_disable();
+ }
+
+ reset() {
+ let user_roles = (this.frm.doc.roles || []).map(a => a.role);
this.multicheck.selected_options = user_roles;
this.multicheck.refresh_input();
- this.set_enable_disable();
}
set_roles_in_table() {
let roles = this.frm.doc.roles || [];
diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js
index 4c21cacf90..c19d696008 100644
--- a/frappe/public/js/frappe/ui/filters/filter.js
+++ b/frappe/public/js/frappe/ui/filters/filter.js
@@ -249,14 +249,21 @@ frappe.ui.Filter = class {
const filter_value = this.filter_list.get_filter_value(fieldname);
args[field_name] = filter_value;
}
- frappe
- .xcall(this.filters_config[condition].get_field, args)
- .then(field => {
- df.fieldtype = field.fieldtype;
- df.options = field.options;
- df.fieldname = fieldname;
- this.make_field(df, cur.fieldtype);
+ let setup_field = (field) => {
+ df.fieldtype = field.fieldtype;
+ df.options = field.options;
+ df.fieldname = fieldname;
+ this.make_field(df, cur.fieldtype);
+ }
+ if (this.filters_config[condition].data) {
+ let field = this.filters_config[condition].data;
+ setup_field(field);
+ } else {
+ frappe.xcall(this.filters_config[condition].get_field, args).then(field => {
+ this.filters_config[condition].data = field;
+ setup_field(field);
});
+ }
} else {
this.make_field(df, cur.fieldtype);
}
@@ -436,15 +443,17 @@ frappe.ui.filter_utils = {
val = val == 'Yes' ? 1 : 0;
}
- if (condition.indexOf('like', 'not like') !== -1) {
+ if (['like', 'not like'].includes(condition)) {
// automatically append wildcards
if (val && !(val.startsWith('%') || val.endsWith('%'))) {
val = '%' + val + '%';
}
- } else if (in_list(['in', 'not in'], condition)) {
+ } else if (['in', 'not in'].includes(condition)) {
if (val) {
val = val.split(',').map((v) => strip(v));
}
+ } else if (frappe.boot.additional_filters_config[condition]) {
+ val = field.value || val;
}
if (val === '%') {
val = '';
diff --git a/frappe/public/js/frappe/ui/keyboard.js b/frappe/public/js/frappe/ui/keyboard.js
index 85ce248175..f7ba2dd9ae 100644
--- a/frappe/public/js/frappe/ui/keyboard.js
+++ b/frappe/public/js/frappe/ui/keyboard.js
@@ -81,6 +81,7 @@ frappe.ui.keys.show_keyboard_shortcut_dialog = () => {
}
let html = shortcuts
.filter(s => s.condition ? s.condition() : true)
+ .filter(s => !!s.description)
.map(shortcut => {
let shortcut_label = shortcut.shortcut
.split('+')
@@ -94,6 +95,8 @@ frappe.ui.keys.show_keyboard_shortcut_dialog = () => {
${shortcut.description || ''} |
`;
}).join('');
+ if (!html) return '';
+
html = `
${heading}
${html}
diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js
index 9ff8fe96f3..97cfb78d93 100644
--- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js
+++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js
@@ -106,6 +106,7 @@ frappe.search.AwesomeBar = class AwesomeBar {
frappe.set_route(item.route);
}
$input.val("");
+ $input.trigger('blur');
});
$input.on("awesomplete-selectcomplete", function(e) {
diff --git a/frappe/public/js/frappe/views/workspace/blocks/block.js b/frappe/public/js/frappe/views/workspace/blocks/block.js
index 1df6b707fe..7b9d547b6b 100644
--- a/frappe/public/js/frappe/views/workspace/blocks/block.js
+++ b/frappe/public/js/frappe/views/workspace/blocks/block.js
@@ -214,10 +214,6 @@ export default class Block {
$button.find('.dropdown-list').toggleClass('hidden');
});
- $(document).click(() => {
- $button.find('.dropdown-list').addClass('hidden');
- });
-
$widget_control.prepend($button);
this.dropdown_list.forEach((item) => {
diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js
index 31e4f27e1f..d95925eea6 100644
--- a/frappe/public/js/frappe/views/workspace/workspace.js
+++ b/frappe/public/js/frappe/views/workspace/workspace.js
@@ -37,6 +37,7 @@ frappe.views.Workspace = class Workspace {
this.prepare_container();
this.setup_pages();
+ this.register_awesomebar_shortcut();
}
prepare_container() {
@@ -692,11 +693,6 @@ frappe.views.Workspace = class Workspace {
$button.filter('.dropdown-list').toggleClass('hidden');
});
- $(document).click(event => {
- event.stopPropagation();
- $('.dropdown-list:not(.hidden)').addClass('hidden');
- });
-
sidebar_control.append($button);
this.dropdown_list.forEach((i) => {
@@ -1228,4 +1224,18 @@ frappe.views.Workspace = class Workspace {
$('.desk-sidebar').removeClass('hidden');
$('.list-sidebar').find('.workspace-sidebar-skeleton').remove();
}
+
+ register_awesomebar_shortcut() {
+ 'abcdefghijklmnopqrstuvwxyz'.split('').forEach(letter => {
+ const default_shortcut = {
+ action: (e) => {
+ $("#navbar-search").focus();
+ return false; // don't prevent default = type the letter in awesomebar
+ },
+ page: this.page,
+ };
+ frappe.ui.keys.add_shortcut({shortcut: letter, ...default_shortcut});
+ frappe.ui.keys.add_shortcut({shortcut: `shift+${letter}`, ...default_shortcut});
+ });
+ }
};
diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss
index 1135fbb23d..9685c66ed9 100644
--- a/frappe/public/scss/common/controls.scss
+++ b/frappe/public/scss/common/controls.scss
@@ -127,6 +127,24 @@ select.form-control {
margin-bottom: 0;
}
}
+ .action-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ padding: 3px;
+ z-index: 3;
+ }
+
+ button.action-btn {
+ padding: 3px 5px;
+ background-color: var(--fg-color);
+ }
+
+ .link-btn {
+ @extend .action-btn;
+ background-color: none;
+ display: none;
+ }
}
.frappe-control:not([data-fieldtype='MultiSelectPills']):not([data-fieldtype='Table MultiSelect']) {
@@ -289,15 +307,6 @@ textarea.form-control {
position: relative;
}
-.link-btn {
- position: absolute;
- top: 4px;
- right: 4px;
- padding: 3px;
- display: none;
- z-index: 3;
-}
-
.phone-btn {
position: absolute;
top: 4px;
diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss
index cadb5bf330..e047747f5c 100644
--- a/frappe/public/scss/common/css_variables.scss
+++ b/frappe/public/scss/common/css_variables.scss
@@ -248,7 +248,7 @@
--border-radius-full: 999px;
--primary-color: #2490EF;
- --btn-height: 28px;
+ --btn-height: 30px;
// Checkbox
--checkbox-right-margin: var(--margin-xs);
diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss
index e30e0c3b94..25dcceec5b 100644
--- a/frappe/public/scss/desk/sidebar.scss
+++ b/frappe/public/scss/desk/sidebar.scss
@@ -492,4 +492,4 @@ body[data-route^="Module"] .main-menu {
.shared-user {
margin-bottom: 10px;
-}
+}
\ No newline at end of file
diff --git a/frappe/public/scss/print.bundle.scss b/frappe/public/scss/print.bundle.scss
index 61f56beaf8..3e8baddcb6 100644
--- a/frappe/public/scss/print.bundle.scss
+++ b/frappe/public/scss/print.bundle.scss
@@ -1,5 +1,8 @@
@import "frappe/public/css/bootstrap.css";
@import "./common/quill";
+
+@import "./desk/variables";
+@import "~bootstrap/scss/utilities/spacing";
@import "./desk/css_variables";
@import "./element/checkbox";
@@ -12,4 +15,23 @@
svg[data-barcode-value] > g {
fill: black !important;
}
+ .print-hide {
+ display: none !important;
+ }
}
+
+.action-banner {
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 20px;
+ font-size: var(--text-md);
+}
+
+.invalid-state {
+ display: grid;
+ place-content: center;
+ height: 100vh;
+ img {
+ margin: auto;
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss
index f25e4d6cc6..109bc8cbb4 100644
--- a/frappe/public/scss/website/index.scss
+++ b/frappe/public/scss/website/index.scss
@@ -125,6 +125,10 @@
align-items: center;
}
+.page_content {
+ min-height: 50vh;
+}
+
.breadcrumb-container {
margin-top: 1rem;
padding-top: 0.25rem;
diff --git a/frappe/templates/styles/card_style.css b/frappe/templates/styles/card_style.css
index 9e38ad70bf..a9639d8133 100644
--- a/frappe/templates/styles/card_style.css
+++ b/frappe/templates/styles/card_style.css
@@ -2,33 +2,32 @@
background-color: var(--bg-color);
}
-
-
.page-card {
- max-width: 360px;
- padding: 15px;
- margin: 70px auto;
- border-radius: 4px;
- background-color: var(--fg-color);
- /* box-shadow: var(--shadow-base); */
+ max-width: 360px;
+ padding: 15px;
+ margin: 70px auto;
+ border-radius: 4px;
+ background-color: var(--fg-color);
+ box-shadow: var(--shadow-base);
}
.for-reset-password {
- margin: 80px 0;
+ margin: 80px 0;
}
.for-reset-password .page-card {
- border: 0;
- max-width: 450px;
- margin: auto;
- border-radius: 10px;
+ border: 0;
+ max-width: 450px;
+ margin: auto;
+ border-radius: var(--border-radius-md);
+ padding: 40px 60px;
}
-@media (min-width: 567px) {
+@media (max-width: 425px) {
.for-reset-password .page-card {
- box-shadow: var(--shadow-base);
- padding: 40px 60px;
-
+ box-shadow: none;
+ background: none;
+ padding: 0px;
}
}
diff --git a/frappe/tests/__init__.py b/frappe/tests/__init__.py
index 5a44cae5f1..eda83bd14b 100644
--- a/frappe/tests/__init__.py
+++ b/frappe/tests/__init__.py
@@ -1,11 +1,13 @@
import frappe
-def update_system_settings(args):
+def update_system_settings(args, commit=False):
doc = frappe.get_doc("System Settings")
doc.update(args)
doc.flags.ignore_mandatory = 1
doc.save()
+ if commit:
+ frappe.db.commit()
def get_system_setting(key):
diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py
index 86e54cb866..73b5446404 100644
--- a/frappe/tests/test_db.py
+++ b/frappe/tests/test_db.py
@@ -182,10 +182,12 @@ class TestDB(unittest.TestCase):
self.assertIn("tabToDo", frappe.flags.touched_tables)
frappe.flags.touched_tables = set()
- create_custom_field("ToDo", {"label": "ToDo Custom Field"})
-
+ cf = create_custom_field("ToDo", {"label": "ToDo Custom Field"})
self.assertIn("tabToDo", frappe.flags.touched_tables)
self.assertIn("tabCustom Field", frappe.flags.touched_tables)
+ if cf:
+ cf.delete()
+ frappe.db.commit()
frappe.flags.in_migrate = False
frappe.flags.touched_tables.clear()
@@ -867,3 +869,18 @@ class TestDDLCommandsPost(unittest.TestCase):
self.assertIn(
"is null", frappe.db.get_values(user, filters={user.name: ("is", "not set")}, run=False).lower()
)
+
+
+@run_only_if(db_type_is.POSTGRES)
+class TestTransactionManagement(unittest.TestCase):
+ def test_create_proper_transactions(self):
+ def _get_transaction_id():
+ return frappe.db.sql("select txid_current()", pluck=True)
+
+ self.assertEqual(_get_transaction_id(), _get_transaction_id())
+
+ frappe.db.rollback()
+ self.assertEqual(_get_transaction_id(), _get_transaction_id())
+
+ frappe.db.commit()
+ self.assertEqual(_get_transaction_id(), _get_transaction_id())
diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py
index 5bda6a1d9d..00bca40268 100644
--- a/frappe/tests/test_document.py
+++ b/frappe/tests/test_document.py
@@ -6,9 +6,13 @@ from datetime import timedelta
from unittest.mock import patch
import frappe
+from frappe.app import make_form_dict
from frappe.desk.doctype.note.note import Note
from frappe.model.naming import make_autoname, parse_naming_series, revert_series_if_last
-from frappe.utils import cint, now_datetime
+from frappe.utils import cint, now_datetime, set_request
+from frappe.website.serve import get_response
+
+from . import update_system_settings
class CustomTestNote(Note):
@@ -357,3 +361,49 @@ class TestDocument(unittest.TestCase):
# setting None should init a table field to empty list
doc.set("user_emails", None)
self.assertEqual(doc.user_emails, [])
+
+
+class TestDocumentWebView(unittest.TestCase):
+ def get(self, path, user="Guest"):
+ frappe.set_user(user)
+ set_request(method="GET", path=path)
+ make_form_dict(frappe.local.request)
+ response = get_response()
+ frappe.set_user("Administrator")
+ return response
+
+ def test_web_view_link_authentication(self):
+ todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert()
+ document_key = todo.get_document_share_key()
+
+ # with old-style signature key
+ update_system_settings({"allow_older_web_view_links": True}, True)
+ old_document_key = todo.get_signature()
+ url = f"/ToDo/{todo.name}?key={old_document_key}"
+ self.assertEqual(self.get(url).status, "200 OK")
+
+ update_system_settings({"allow_older_web_view_links": False}, True)
+ self.assertEqual(self.get(url).status, "401 UNAUTHORIZED")
+
+ # with valid key
+ url = f"/ToDo/{todo.name}?key={document_key}"
+ self.assertEqual(self.get(url).status, "200 OK")
+
+ # with invalid key
+ invalid_key_url = f"/ToDo/{todo.name}?key=INVALID_KEY"
+ self.assertEqual(self.get(invalid_key_url).status, "401 UNAUTHORIZED")
+
+ # expire the key
+ document_key_doc = frappe.get_doc("Document Share Key", {"key": document_key})
+ document_key_doc.expires_on = "2020-01-01"
+ document_key_doc.save(ignore_permissions=True)
+
+ # with expired key
+ self.assertEqual(self.get(url).status, "410 GONE")
+
+ # without key
+ url_without_key = f"/ToDo/{todo.name}"
+ self.assertEqual(self.get(url_without_key).status, "403 FORBIDDEN")
+
+ # Logged-in user can access the page without key
+ self.assertEqual(self.get(url_without_key, "Administrator").status, "200 OK")
diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py
index 8bf76b3e13..928953fe1c 100644
--- a/frappe/tests/test_rename_doc.py
+++ b/frappe/tests/test_rename_doc.py
@@ -100,8 +100,6 @@ class TestRenameDoc(unittest.TestCase):
frappe.delete_doc("DocType", dt)
frappe.db.sql_ddl(f"DROP TABLE IF EXISTS `tab{dt}`")
- frappe.delete_doc_if_exists("Renamed Doc", "ToDo")
-
# reset original value of developer_mode conf
frappe.conf.developer_mode = self._original_developer_flag
diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py
index 4f4fca8bbf..2a8d27cd19 100644
--- a/frappe/tests/test_utils.py
+++ b/frappe/tests/test_utils.py
@@ -30,13 +30,17 @@ from frappe.utils import (
validate_url,
)
from frappe.utils.data import (
+ add_to_date,
cast,
+ get_first_day_of_week,
get_time,
get_timedelta,
+ getdate,
now_datetime,
nowtime,
validate_python_code,
)
+from frappe.utils.dateutils import get_dates_from_timegrain
from frappe.utils.diff import _get_value_from_version, get_version_diff, version_query
from frappe.utils.image import optimize_image, strip_exif_data
from frappe.utils.response import json_handler
@@ -445,6 +449,31 @@ class TestDateUtils(unittest.TestCase):
self.assertIsInstance(get_timedelta(str(timedelta_input)), timedelta)
self.assertIsInstance(get_timedelta(str(time_input)), timedelta)
+ def test_date_from_timegrain(self):
+ start_date = getdate("2021-01-01")
+
+ daily = get_dates_from_timegrain(start_date, add_to_date(start_date, days=6), "Daily")
+ self.assertEqual(len(daily), 7)
+ for idx, d in enumerate(daily):
+ self.assertEqual(d, add_to_date(start_date, days=idx))
+
+ start = get_first_day_of_week(start_date)
+ end = add_to_date(add_to_date(start, weeks=52), days=-1)
+ weekly = get_dates_from_timegrain(start, end, "Weekly")
+ self.assertEqual(len(weekly), 52)
+ for idx, d in enumerate(weekly, start=1):
+ self.assertEqual(d, add_to_date(start, days=7 * idx - 1))
+
+ quarterly = get_dates_from_timegrain(start_date, add_to_date(start_date, months=5), "Quarterly")
+ self.assertEqual(len(quarterly), 2)
+ for idx, d in enumerate(quarterly, start=1):
+ self.assertEqual(d, add_to_date(start_date, months=idx * 3, days=-1))
+
+ yearly = get_dates_from_timegrain(start_date, add_to_date(start_date, years=2), "Yearly")
+ self.assertEqual(len(yearly), 3)
+ for idx, d in enumerate(yearly, start=1):
+ self.assertEqual(d, add_to_date(start_date, years=idx, days=-1))
+
class TestResponse(unittest.TestCase):
def test_json_handler(self):
diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py
index 37ac611b4e..9478c4cf5f 100644
--- a/frappe/tests/test_website.py
+++ b/frappe/tests/test_website.py
@@ -118,7 +118,7 @@ class TestWebsite(unittest.TestCase):
def test_error_page(self):
set_request(method="GET", path="/_test/problematic_page")
response = get_response()
- self.assertEqual(response.status_code, 500)
+ self.assertEqual(response.status_code, 417)
def test_login(self):
set_request(method="GET", path="/login")
diff --git a/frappe/translations/fr.csv b/frappe/translations/fr.csv
index f17585fe88..69bc47d4f6 100644
--- a/frappe/translations/fr.csv
+++ b/frappe/translations/fr.csv
@@ -419,6 +419,7 @@ Also adding the dependent currency field {0},Ajout également du champ de devise
Always use Account's Email Address as Sender,Toujours utiliser l'adresse Email du compte comme Expéditeur,
Always use Account's Name as Sender's Name,Toujours utiliser le nom du compte comme nom de l'expéditeur,
Amend,Nouv. version
+amend,Nouv. version
Amending,Nouv. version en cours,
Amount Based On Field,Montant Basé sur le Champ,
Amount Field,Champ du Montant,
@@ -2296,7 +2297,7 @@ Show Line Breaks after Sections,Afficher les Sauts de Ligne après Sections,
Show Permissions,Afficher les Autorisations,
Show Preview Popup,Afficher l'aperçu Popup,
Show Relapses,Afficher les Rechutes,
-Show Report,Rapport d'émission,
+Show Report,Afficher le rapport,
Show Section Headings,Voir la Section Titres,
Show Sidebar,Afficher la Barre Latérale,
Show Title,Afficher le Titre,
@@ -4641,7 +4642,7 @@ Not permitted to view {0},Non autorisé à afficher {0},
Camera,Caméra,
Invalid filter: {0},Filtre non valide: {0},
Let's Get Started,Commençons,
-Reports & Masters,Rapports et masters,
+Reports & Masters,Ecrans principaux et Rapports,
New {0} {1} added to Dashboard {2},Nouveau {0} {1} ajouté au tableau de bord {2},
New {0} {1} created,Nouveau {0} {1} créé,
New {0} Created,Nouveau {0} créé,
@@ -4701,8 +4702,8 @@ Value cannot be negative for,La valeur ne peut pas être négative pour,
Value cannot be negative for {0}: {1},La valeur ne peut pas être négative pour {0}: {1},
Negative Value,Valeur négative,
Authentication failed while receiving emails from Email Account: {0}.,L'authentification a échoué lors de la réception des e-mails du compte de messagerie: {0}.,
-Message from server: {0},Message du serveur: {0},
-{0} edited this {1},{0} a édité {1},
+Message from server: {0},Message du serveur: {0}
+{0} edited this {1},{0} a édité {1}
{0} created this {1},{0} a créé {1}
Report an Issue,Signaler une anomalie
User Forum,Forum utilisateur
@@ -4720,3 +4721,8 @@ Don't have an account?,Vous n'avez pas de compte?
Left:alignment,Gauche
Right:alignment,Droite
Set Properties,Gérer les proriétés
+Create Workspace,Créer un espace de travail
+Always use this email address as sender address,Toujours utiliser cet email comme expediteur
+Always use this name as sender name,Toujours utiliser ce nom comme expediteur
+Login to {0},Se connecter à {0}
+Add / Remove Fields,Ajouter / Supprimer des colonnes
diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py
index b8f22e7ed7..d7412a444f 100644
--- a/frappe/utils/dateutils.py
+++ b/frappe/utils/dateutils.py
@@ -106,11 +106,10 @@ def get_dates_from_timegrain(from_date, to_date, timegrain="Daily"):
months = 1
elif "Quarterly" == timegrain:
months = 3
+ elif "Yearly" == timegrain:
+ months = 1
- if "Weekly" == timegrain:
- dates = [get_last_day_of_week(from_date)]
- else:
- dates = [get_period_ending(from_date, timegrain)]
+ dates = [get_period_ending(from_date, timegrain)]
while getdate(dates[-1]) < getdate(to_date):
if "Weekly" == timegrain:
@@ -163,16 +162,12 @@ def get_period_beginning(date, timegrain, as_str=True):
def get_period_ending(date, timegrain):
- date = getdate(date)
- if timegrain == "Daily":
- return date
- else:
- return getdate(
- {
- "Daily": date,
- "Weekly": get_last_day_of_week(date),
- "Monthly": get_last_day(date),
- "Quarterly": get_quarter_ending(date),
- "Yearly": get_year_ending(date),
- }[timegrain]
- )
+ return getdate(
+ {
+ "Daily": date,
+ "Weekly": get_last_day_of_week(date),
+ "Monthly": get_last_day(date),
+ "Quarterly": get_quarter_ending(date),
+ "Yearly": get_year_ending(date),
+ }[timegrain]
+ )
diff --git a/frappe/utils/print_format.py b/frappe/utils/print_format.py
index 13989490a5..028501f306 100644
--- a/frappe/utils/print_format.py
+++ b/frappe/utils/print_format.py
@@ -12,6 +12,8 @@ no_cache = 1
base_template_path = "www/printview.html"
standard_format = "templates/print_formats/standard.html"
+from frappe.www.printview import validate_print_permission
+
@frappe.whitelist()
def download_multi_pdf(doctype, name, format=None, no_letterhead=False, options=None):
@@ -115,8 +117,11 @@ def read_multi_pdf(output):
return filedata
-@frappe.whitelist()
+@frappe.whitelist(allow_guest=True)
def download_pdf(doctype, name, format=None, doc=None, no_letterhead=0):
+ doc = doc or frappe.get_doc(doctype, name)
+ validate_print_permission(doc)
+
html = frappe.get_print(doctype, name, format, doc=doc, no_letterhead=no_letterhead)
frappe.local.response.filename = "{name}.pdf".format(
name=name.replace(" ", "-").replace("/", "-")
diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py
index 0101355174..3f364821d1 100644
--- a/frappe/utils/redis_wrapper.py
+++ b/frappe/utils/redis_wrapper.py
@@ -30,7 +30,7 @@ class RedisWrapper(redis.Redis):
return "{0}|{1}".format(frappe.conf.db_name, key).encode("utf-8")
- def set_value(self, key, val, user=None, expires_in_sec=None, shared=False):
+ def set_value(self, key, val, user=None, expires_in_sec=None, shared=False, cache_locally=True):
"""Sets cache value.
:param key: Cache key
@@ -40,7 +40,7 @@ class RedisWrapper(redis.Redis):
"""
key = self.make_key(key, user, shared)
- if not expires_in_sec:
+ if not expires_in_sec and cache_locally:
frappe.local.cache[key] = val
try:
@@ -151,16 +151,17 @@ class RedisWrapper(redis.Redis):
def ltrim(self, key, start, stop):
return super(RedisWrapper, self).ltrim(self.make_key(key), start, stop)
- def hset(self, name, key, value, shared=False):
+ def hset(self, name: str, key: str, value, shared: bool = False, cache_locally: bool = True):
if key is None:
return
_name = self.make_key(name, shared=shared)
# set in local
- if _name not in frappe.local.cache:
- frappe.local.cache[_name] = {}
- frappe.local.cache[_name][key] = value
+ if cache_locally:
+ if _name not in frappe.local.cache:
+ frappe.local.cache[_name] = {}
+ frappe.local.cache[_name][key] = value
# set in redis
try:
@@ -168,6 +169,15 @@ class RedisWrapper(redis.Redis):
except redis.exceptions.ConnectionError:
pass
+ def hexists(self, name: str, key: str, shared: bool = False) -> bool:
+ if key is None:
+ return False
+ _name = self.make_key(name, shared=shared)
+ try:
+ return super(RedisWrapper, self).hexists(_name, key)
+ except redis.exceptions.ConnectionError:
+ return False
+
def hgetall(self, name):
value = super(RedisWrapper, self).hgetall(self.make_key(name))
return {key: pickle.loads(value) for key, value in value.items()}
diff --git a/frappe/website/doctype/website_settings/website_settings.json b/frappe/website/doctype/website_settings/website_settings.json
index aa17fa261f..8c46ef30bf 100644
--- a/frappe/website/doctype/website_settings/website_settings.json
+++ b/frappe/website/doctype/website_settings/website_settings.json
@@ -272,7 +272,7 @@
},
{
"default": "0",
- "description": "To use Google Indexing, enable Google Settings.",
+ "description": "To use Google Indexing, enable Google Settings.",
"fieldname": "enable_google_indexing",
"fieldtype": "Check",
"label": "Enable Google Indexing"
@@ -451,4 +451,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/website/page_renderers/error_page.py b/frappe/website/page_renderers/error_page.py
index 613809bfdc..6a3925967c 100644
--- a/frappe/website/page_renderers/error_page.py
+++ b/frappe/website/page_renderers/error_page.py
@@ -5,7 +5,13 @@ class ErrorPage(TemplatePage):
def __init__(self, path=None, http_status_code=None, exception=None):
path = "error"
super().__init__(path=path, http_status_code=http_status_code)
- self.http_status_code = getattr(exception, "http_status_code", None) or http_status_code or 500
+ self.exception = exception
def can_render(self):
return True
+
+ def init_context(self):
+ super().init_context()
+ self.context.http_status_code = getattr(self.exception, "http_status_code", None) or 500
+ self.context.error_title = getattr(self.exception, "title", None)
+ self.context.error_message = getattr(self.exception, "message", None)
diff --git a/frappe/website/page_renderers/template_page.py b/frappe/website/page_renderers/template_page.py
index 2ed8a62119..83f68d3716 100644
--- a/frappe/website/page_renderers/template_page.py
+++ b/frappe/website/page_renderers/template_page.py
@@ -212,19 +212,13 @@ class TemplatePage(BaseTemplatePage):
def run_pymodule_method(self, method_name):
if hasattr(self.pymodule, method_name):
- try:
- import inspect
+ import inspect
- method = getattr(self.pymodule, method_name)
- if inspect.getfullargspec(method).args:
- return method(self.context)
- else:
- return method()
- except (frappe.PermissionError, frappe.DoesNotExistError, frappe.Redirect):
- raise
- except Exception:
- if not frappe.flags.in_migrate:
- frappe.errprint(frappe.utils.get_traceback())
+ method = getattr(self.pymodule, method_name)
+ if inspect.getfullargspec(method).args:
+ return method(self.context)
+ else:
+ return method()
def render_template(self):
if self.template_path.endswith("min.js"):
diff --git a/frappe/www/error.html b/frappe/www/error.html
index d63daec759..142897c35a 100644
--- a/frappe/www/error.html
+++ b/frappe/www/error.html
@@ -23,15 +23,15 @@
- {{_("Uncaught Server Exception")}}
+ {{ error_title }}
-
{{_("There was an error building this page")}}
+
{{ error_message }}
- {{ _("Error Code: {0}").format('500') }}
+ {{ _("Error Code: {0}").format(http_status_code) }}