diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 20ed7a61cd..909955c1df 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -18,6 +18,7 @@ context('Form', () => { cy.get('.primary-action').click(); cy.wait('@form_save').its('response.statusCode').should('eq', 200); cy.visit('/app/todo'); + cy.wait(300); cy.get('.title-text').should('be.visible').and('contain', 'To Do'); cy.get('.list-row').should('contain', 'this is a test todo'); }); diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 5154adb634..efa1959969 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -258,12 +258,17 @@ function get_watch_config() { async function clean_dist_folders(apps) { for (let app of apps) { let public_path = get_public_path(app); - await fs.promises.rmdir(path.resolve(public_path, "dist", "js"), { - recursive: true - }); - await fs.promises.rmdir(path.resolve(public_path, "dist", "css"), { - recursive: true - }); + let paths = [ + path.resolve(public_path, "dist", "js"), + path.resolve(public_path, "dist", "css") + ]; + for (let target of paths) { + if (fs.existsSync(target)) { + // rmdir is deprecated in node 16, this will work in both node 14 and 16 + let rmdir = fs.promises.rm || fs.promises.rmdir; + await rmdir(target, { recursive: true }); + } + } } } diff --git a/frappe/__init__.py b/frappe/__init__.py index 9d8c5d3607..01c7879a06 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1491,7 +1491,7 @@ def get_print(doctype=None, name=None, print_format=None, style=None, :param style: Print Format style. :param as_pdf: Return as PDF. Default False. :param password: Password to encrypt the pdf with. Default None""" - from frappe.website.render import build_page + from frappe.website.serve import get_response_content from frappe.utils.pdf import get_pdf local.form_dict.doctype = doctype @@ -1506,7 +1506,7 @@ def get_print(doctype=None, name=None, print_format=None, style=None, options = {'password': password} if not html: - html = build_page("printview") + html = get_response_content("printview") if as_pdf: return get_pdf(html, output = output, options = options) diff --git a/frappe/app.py b/frappe/app.py index 6f5023be93..920628dda4 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -16,9 +16,9 @@ import frappe.handler import frappe.auth import frappe.api import frappe.utils.response -import frappe.website.render from frappe.utils import get_site_name, sanitize_html from frappe.middlewares import StaticDataMiddleware +from frappe.website.serve import get_response from frappe.utils.error import make_error_snapshot from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request from frappe import _ @@ -72,7 +72,7 @@ def application(request): response = frappe.utils.response.download_private_file(request.path) elif request.method in ('GET', 'HEAD', 'POST'): - response = frappe.website.render.render() + response = get_response() else: raise NotFound @@ -266,8 +266,7 @@ def handle_exception(e): make_error_snapshot(e) if return_as_message: - response = frappe.website.render.render("message", - http_status_code=http_status_code) + response = get_response("message", http_status_code=http_status_code) return response diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 998e73a42c..d2afda1553 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -333,7 +333,7 @@ class AutoRepeat(Document): if self.reference_doctype and self.reference_document: res = get_contacts_linking_to(self.reference_doctype, self.reference_document, fields=['email_id']) res += get_contacts_linked_from(self.reference_doctype, self.reference_document, fields=['email_id']) - email_ids = list(set([d.email_id for d in res])) + email_ids = {d.email_id for d in res} if not email_ids: frappe.msgprint(_('No contacts linked to document'), alert=True) else: diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 52fba4568d..9f09f26be8 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -53,7 +53,7 @@ def clear_domain_cache(user=None): cache.delete_value(domain_cache_keys) def clear_global_cache(): - from frappe.website.render import clear_cache as clear_website_cache + from frappe.website.utils import clear_website_cache clear_doctype_cache() clear_website_cache() diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 8ef70d739c..c16de497ec 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -69,14 +69,14 @@ def watch(apps=None): def clear_cache(context): "Clear cache, doctype cache and defaults" import frappe.sessions - import frappe.website.render + from frappe.website.utils import clear_website_cache from frappe.desk.notifications import clear_notifications for site in context.sites: try: frappe.connect(site) frappe.clear_cache() clear_notifications() - frappe.website.render.clear_cache() + clear_website_cache() finally: frappe.destroy() if not context.sites: @@ -86,12 +86,12 @@ def clear_cache(context): @pass_context def clear_website_cache(context): "Clear website cache" - import frappe.website.render + from frappe.website.utils import clear_website_cache for site in context.sites: try: frappe.init(site=site) frappe.connect() - frappe.website.render.clear_cache() + clear_website_cache() finally: frappe.destroy() if not context.sites: diff --git a/frappe/contacts/address_and_contact.py b/frappe/contacts/address_and_contact.py index f21819ad98..77305168c1 100644 --- a/frappe/contacts/address_and_contact.py +++ b/frappe/contacts/address_and_contact.py @@ -153,7 +153,7 @@ def filter_dynamic_link_doctypes(doctype, txt, searchfield, start, page_len, fil doctypes = frappe.db.get_all("DocField", filters=filters, fields=["parent"], distinct=True, as_list=True) - doctypes = tuple([d for d in doctypes if re.search(txt+".*", _(d[0]), re.IGNORECASE)]) + doctypes = tuple(d for d in doctypes if re.search(txt+".*", _(d[0]), re.IGNORECASE)) filters.update({ "dt": ("not in", [d[0] for d in doctypes]) diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index bfcf91427d..755bc63064 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -257,7 +257,7 @@ def address_query(doctype, txt, searchfield, start, page_len, filters): def get_condensed_address(doc): fields = ["address_title", "address_line1", "address_line2", "city", "county", "state", "country"] - return ", ".join([doc.get(d) for d in fields if doc.get(d)]) + return ", ".join(doc.get(d) for d in fields if doc.get(d)) def update_preferred_address(address, field): frappe.db.set_value('Address', address, field, 0) diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py index e29bae25a2..2706ab1c30 100644 --- a/frappe/core/doctype/comment/comment.py +++ b/frappe/core/doctype/comment/comment.py @@ -9,7 +9,7 @@ from frappe.core.doctype.user.user import extract_mentions from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification,\ get_title, get_title_html from frappe.utils import get_fullname -from frappe.website.render import clear_cache +from frappe.website.utils import clear_cache from frappe.database.schema import add_column from frappe.exceptions import ImplicitCommitError diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index fed90b75ce..bb922f1f5d 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -449,7 +449,7 @@ class ImportFile: for row in data_without_first_row: row_values = row.get_values(parent_column_indexes) # if the row is blank, it's a child row doc - if all([v in INVALID_VALUES for v in row_values]): + if all(v in INVALID_VALUES for v in row_values): rows.append(row) continue # if we encounter a row which has values in parent columns, @@ -606,7 +606,7 @@ class Row: if df.fieldtype == "Select": select_options = get_select_options(df) if select_options and value not in select_options: - options_string = ", ".join([frappe.bold(d) for d in select_options]) + options_string = ", ".join(frappe.bold(d) for d in select_options) msg = _("Value must be one of {0}").format(options_string) self.warnings.append( {"row": self.row_number, "field": df_as_json(df), "message": msg,} @@ -902,7 +902,7 @@ class Column: if self.df.fieldtype == "Link": # find all values that dont exist - values = list(set([cstr(v) for v in self.column_values[1:] if v])) + values = list({cstr(v) for v in self.column_values[1:] if v}) exists = [ d.name for d in frappe.db.get_all(self.df.options, filters={"name": ("in", values)}) ] @@ -935,11 +935,11 @@ class Column: elif self.df.fieldtype == "Select": options = get_select_options(self.df) if options: - values = list(set([cstr(v) for v in self.column_values[1:] if v])) - invalid = list(set(values) - set(options)) + values = {cstr(v) for v in self.column_values[1:] if v} + invalid = values - set(options) if invalid: - valid_values = ", ".join([frappe.bold(o) for o in options]) - invalid_values = ", ".join([frappe.bold(i) for i in invalid]) + valid_values = ", ".join(frappe.bold(o) for o in options) + invalid_values = ", ".join(frappe.bold(i) for i in invalid) self.warnings.append( { "col": self.column_number, diff --git a/frappe/core/doctype/data_import_legacy/importer.py b/frappe/core/doctype/data_import_legacy/importer.py index 4080e70418..ceefff4410 100644 --- a/frappe/core/doctype/data_import_legacy/importer.py +++ b/frappe/core/doctype/data_import_legacy/importer.py @@ -177,7 +177,7 @@ def upload(rows = None, submit_after_import=None, ignore_encoding_errors=False, if d.get("name") and d["name"].startswith('"'): d["name"] = d["name"][1:-1] - if sum([0 if not val else 1 for val in d.values()]): + if sum(0 if not val else 1 for val in d.values()): d['doctype'] = dt if dt == doctype: doc.update(d) @@ -533,6 +533,6 @@ def get_parent_field(doctype, parenttype): def delete_child_rows(rows, doctype): """delete child rows for all parents""" - for p in list(set([r[1] for r in rows])): + for p in list(set(r[1] for r in rows)): if p: frappe.db.sql("""delete from `tab{0}` where parent=%s""".format(doctype), p) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 8a96fc89f6..3cdc45ea08 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -8,7 +8,6 @@ from frappe.cache_manager import clear_user_cache, clear_controller_cache # imports - module imports import frappe -import frappe.website.render from frappe import _ from frappe.utils import now, cint from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields, data_field_options @@ -23,6 +22,7 @@ from frappe.model.docfield import supports_translation from frappe.modules.import_file import get_file_path from frappe.model.meta import Meta from frappe.desk.utils import validate_route_conflict +from frappe.website.utils import clear_cache class InvalidFieldNameError(frappe.ValidationError): pass class UniqueFieldnameError(frappe.ValidationError): pass @@ -193,7 +193,7 @@ class DocType(Document): self.flags.update_fields_to_fetch_queries = [] - if set(old_fields_to_fetch) != set([df.fieldname for df in new_meta.get_fields_to_fetch()]): + if set(old_fields_to_fetch) != set(df.fieldname for df in new_meta.get_fields_to_fetch()): for df in new_meta.get_fields_to_fetch(): if df.fieldname not in old_fields_to_fetch: link_fieldname, source_fieldname = df.fetch_from.split('.', 1) @@ -248,7 +248,7 @@ class DocType(Document): frappe.throw(_('Field "route" is mandatory for Web Views'), title='Missing Field') # clear website cache - frappe.website.render.clear_cache() + clear_cache() def change_modified_of_parent(self): """Change the timestamp of parent DocType if the current one is a child to clear caches.""" @@ -550,11 +550,6 @@ class DocType(Document): from frappe.modules.export_file import export_to_files export_to_files(record_list=[['DocType', self.name]], create_init=True) - def import_doc(self): - """Import from standard folder `[module]/doctype/[name]/[name].json`.""" - from frappe.modules.import_module import import_from_files - import_from_files(record_list=[[self.module, 'doctype', self.name]]) - def make_controller_template(self): """Make boilerplate controller template.""" make_boilerplate("controller._py", self) @@ -762,7 +757,7 @@ def validate_fields(meta): invalid_fields = ('doctype',) if fieldname in invalid_fields: frappe.throw(_("{0}: Fieldname cannot be one of {1}") - .format(docname, ", ".join([frappe.bold(d) for d in invalid_fields]))) + .format(docname, ", ".join(frappe.bold(d) for d in invalid_fields))) def check_unique_fieldname(docname, fieldname): duplicates = list(filter(None, map(lambda df: df.fieldname==fieldname and str(df.idx) or None, fields))) @@ -996,7 +991,7 @@ def validate_fields(meta): if docfield.options and (docfield.options not in data_field_options): df_str = frappe.bold(_(docfield.label)) text_str = _("{0} is an invalid Data field.").format(df_str) + "
" * 2 + _("Only Options allowed for Data field are:") + "
" - df_options_str = "" + df_options_str = "" frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True) diff --git a/frappe/core/doctype/domain/domain.py b/frappe/core/doctype/domain/domain.py index 681824bb02..bbd20f3b70 100644 --- a/frappe/core/doctype/domain/domain.py +++ b/frappe/core/doctype/domain/domain.py @@ -110,7 +110,7 @@ class Domain(Document): # enable frappe.db.sql('''update `tabPortal Menu Item` set enabled=1 - where route in ({0})'''.format(', '.join(['"{0}"'.format(d) for d in self.data.allow_sidebar_items]))) + where route in ({0})'''.format(', '.join('"{0}"'.format(d) for d in self.data.allow_sidebar_items))) if self.data.remove_sidebar_items: # disable all @@ -118,4 +118,4 @@ class Domain(Document): # enable frappe.db.sql('''update `tabPortal Menu Item` set enabled=0 - where route in ({0})'''.format(', '.join(['"{0}"'.format(d) for d in self.data.remove_sidebar_items]))) + where route in ({0})'''.format(', '.join('"{0}"'.format(d) for d in self.data.remove_sidebar_items))) diff --git a/frappe/core/doctype/feedback/__init__.py b/frappe/core/doctype/feedback/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/feedback/feedback.js b/frappe/core/doctype/feedback/feedback.js new file mode 100644 index 0000000000..131f0e19d8 --- /dev/null +++ b/frappe/core/doctype/feedback/feedback.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Feedback', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json new file mode 100644 index 0000000000..cf8a180e27 --- /dev/null +++ b/frappe/core/doctype/feedback/feedback.json @@ -0,0 +1,86 @@ +{ + "actions": [], + "creation": "2021-06-03 19:02:55.328423", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "reference_name", + "column_break_3", + "email", + "rating", + "section_break_6", + "feedback" + ], + "fields": [ + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "reqd": 1 + }, + { + "fieldname": "rating", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Rating", + "precision": "1", + "reqd": 1 + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "feedback", + "fieldtype": "Small Text", + "label": "Feedback", + "reqd": 1 + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Reference Document Type", + "options": "\nBlog Post" + }, + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Name", + "options": "reference_doctype", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-06-14 15:11:26.005805", + "modified_by": "Administrator", + "module": "Core", + "name": "Feedback", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "reference_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/feedback/feedback.py b/frappe/core/doctype/feedback/feedback.py new file mode 100644 index 0000000000..655bed6eb1 --- /dev/null +++ b/frappe/core/doctype/feedback/feedback.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class Feedback(Document): + pass diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py new file mode 100644 index 0000000000..702f9d8ac1 --- /dev/null +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -0,0 +1,27 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +import frappe +import unittest + +class TestFeedback(unittest.TestCase): + def test_feedback_creation_updation(self): + from frappe.website.doctype.blog_post.test_blog_post import make_test_blog + test_blog = make_test_blog() + + frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") + + from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback + feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback','test@test.com') + + self.assertEqual(feedback.feedback, 'New feedback') + self.assertEqual(feedback.rating, 5) + + updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback', 'test@test.com') + + self.assertEqual(updated_feedback.feedback, 'Updated feedback') + self.assertEqual(updated_feedback.rating, 6) + + frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") + + test_blog.delete() \ No newline at end of file diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 3fa31cbf80..5b605504e8 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -13,7 +13,7 @@ from frappe.utils.password import update_password as _update_password, check_pas from frappe.desk.notifications import clear_notifications from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings, toggle_notifications from frappe.utils.user import get_system_managers -from frappe.website.utils import is_signup_enabled +from frappe.website.utils import is_signup_disabled from frappe.rate_limiter import rate_limit from frappe.utils.background_jobs import enqueue from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype @@ -839,7 +839,7 @@ def verify_password(password): @frappe.whitelist(allow_guest=True) def sign_up(email, full_name, redirect_to): - if not is_signup_enabled(): + if is_signup_disabled(): frappe.throw(_('Sign Up is disabled'), title='Not Allowed') user = frappe.db.get("User", {"email": email}) @@ -931,7 +931,7 @@ def user_query(doctype, txt, searchfield, start, page_len, filters): LIMIT %(page_len)s OFFSET %(start)s """.format( user_type_condition = user_type_condition, - standard_users=", ".join([frappe.db.escape(u) for u in STANDARD_USERS]), + standard_users=", ".join(frappe.db.escape(u) for u in STANDARD_USERS), key=searchfield, fcond=get_filters_cond(doctype, filters, conditions), mcond=get_match_cond(doctype) diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 42ca4d7a14..4aa5797c7f 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -16,11 +16,11 @@ class UserPermission(Document): self.validate_default_permission() def on_update(self): - frappe.cache().delete_value('user_permissions') + frappe.cache().hdel('user_permissions', self.user) frappe.publish_realtime('update_user_permissions') def on_trash(self): # pylint: disable=no-self-use - frappe.cache().delete_value('user_permissions') + frappe.cache().hdel('user_permissions', self.user) frappe.publish_realtime('update_user_permissions') def validate_user_permission(self): diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index e7d06c45f2..82ffb090f1 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -112,7 +112,7 @@ class UserType(Document): self.select_doctypes = [] select_doctypes = [] - user_doctypes = tuple([row.document_type for row in self.user_doctypes]) + user_doctypes = [row.document_type for row in self.user_doctypes] for doctype in user_doctypes: doc = frappe.get_meta(doctype) @@ -265,4 +265,4 @@ def apply_permissions_for_non_standard_user_type(doc, method=None): user_doc.update_children() add_user_permission(doc.doctype, doc.name, doc.get(data[1])) else: - frappe.db.set_value('User Permission', perm_data[0], 'user', doc.get(data[1])) \ No newline at end of file + frappe.db.set_value('User Permission', perm_data[0], 'user', doc.get(data[1])) diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 8bcc6cf059..1b8977acc4 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -355,9 +355,9 @@ class CustomizeForm(Document): def delete_custom_fields(self): meta = frappe.get_meta(self.doc_type) - fields_to_remove = (set([df.fieldname for df in meta.get("fields")]) - - set(df.fieldname for df in self.get("fields"))) - + fields_to_remove = ( + {df.fieldname for df in meta.get("fields")} - {df.fieldname for df in self.get("fields")} + ) for fieldname in fields_to_remove: df = meta.get("fields", {"fieldname": fieldname})[0] if df.get("is_custom_field"): diff --git a/frappe/database/database.py b/frappe/database/database.py index 7e8d2da43b..81e24cc7ad 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -335,7 +335,7 @@ class Database(object): values[key] = value[1] if isinstance(value[1], (tuple, list)): # value is a list in tuple ("in", ("A", "B")) - _rhs = " ({0})".format(", ".join([self.escape(v) for v in value[1]])) + _rhs = " ({0})".format(", ".join(self.escape(v) for v in value[1])) del values[key] if _operator not in ["=", "!=", ">", ">=", "<", "<=", "like", "in", "not in", "not like"]: @@ -1010,7 +1010,7 @@ class Database(object): :params values: list of list of values """ insert_list = [] - fields = ", ".join(["`"+field+"`" for field in fields]) + fields = ", ".join("`"+field+"`" for field in fields) for idx, value in enumerate(values): insert_list.append(tuple(value)) diff --git a/frappe/desk/doctype/form_tour/__init__.py b/frappe/desk/doctype/form_tour/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js new file mode 100644 index 0000000000..94c6806b50 --- /dev/null +++ b/frappe/desk/doctype/form_tour/form_tour.js @@ -0,0 +1,24 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Form Tour', { + setup: function(frm) { + frm.set_query("reference_doctype", function() { + return { + filters: { + istable: 0 + } + }; + }); + + frm.set_query("field", "steps", function() { + return { + query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", + filters: { + doctype: frm.doc.reference_doctype, + hidden: 0 + } + }; + }); + } +}); diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json new file mode 100644 index 0000000000..8e09a5d63a --- /dev/null +++ b/frappe/desk/doctype/form_tour/form_tour.json @@ -0,0 +1,75 @@ +{ + "actions": [], + "autoname": "field:title", + "creation": "2021-05-21 23:02:52.242721", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "reference_doctype", + "completed", + "section_break_3", + "steps" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Reference Document", + "options": "DocType", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "steps", + "fieldtype": "Table", + "label": "Steps", + "options": "Form Tour Step", + "reqd": 1 + }, + { + "default": "0", + "depends_on": "eval: doc.__islocal != 1", + "fieldname": "completed", + "fieldtype": "Check", + "label": "Mark as Completed" + }, + { + "fieldname": "section_break_3", + "fieldtype": "Section Break" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1, + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-05-26 19:36:59.093753", + "modified_by": "Administrator", + "module": "Desk", + "name": "Form Tour", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py new file mode 100644 index 0000000000..dd762395c4 --- /dev/null +++ b/frappe/desk/doctype/form_tour/form_tour.py @@ -0,0 +1,32 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + +class FormTour(Document): + pass + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_docfield_list(doctype, txt, searchfield, start, page_len, filters): + or_filters = [ + ['fieldname', 'like', '%' + txt + '%'], + ['label', 'like', '%' + txt + '%'], + ['fieldtype', 'like', '%' + txt + '%'] + ] + + parent_doctype = filters.pop('doctype') + excluded_fieldtypes = ['Column Break'] + excluded_fieldtypes += filters.get('excluded_fieldtypes', []) + + docfields = frappe.get_all( + doctype, + fields=["name as value", "label", "fieldtype"], + filters={'parent': parent_doctype, 'fieldtype': ['not in', excluded_fieldtypes]}, + or_filters=or_filters, + limit_start=start, + limit_page_length=page_len, + as_list=1, + ) + return docfields diff --git a/frappe/desk/doctype/form_tour/test_form_tour.py b/frappe/desk/doctype/form_tour/test_form_tour.py new file mode 100644 index 0000000000..a4a796ce41 --- /dev/null +++ b/frappe/desk/doctype/form_tour/test_form_tour.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestFormTour(unittest.TestCase): + pass diff --git a/frappe/desk/doctype/form_tour_step/__init__.py b/frappe/desk/doctype/form_tour_step/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.json b/frappe/desk/doctype/form_tour_step/form_tour_step.json new file mode 100644 index 0000000000..a772a2498a --- /dev/null +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.json @@ -0,0 +1,85 @@ +{ + "actions": [], + "creation": "2021-05-21 23:05:45.342114", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "field", + "title", + "description", + "column_break_2", + "position", + "fieldname", + "label", + "condition" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "columns": 4, + "fieldname": "description", + "fieldtype": "HTML Editor", + "in_list_view": 1, + "label": "Description", + "reqd": 1 + }, + { + "fieldname": "field", + "fieldtype": "Link", + "label": "Field", + "options": "DocField", + "reqd": 1 + }, + { + "fetch_from": "field.fieldname", + "fieldname": "fieldname", + "fieldtype": "Data", + "hidden": 1, + "label": "Fieldname", + "read_only": 1 + }, + { + "fetch_from": "field.label", + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "default": "Bottom", + "fieldname": "position", + "fieldtype": "Select", + "label": "Position", + "options": "Left\nLeft Center\nLeft Bottom\nTop\nTop Center\nTop Right\nRight\nRight Center\nRight Bottom\nBottom\nBottom Center\nBottom Right\nMid Center" + }, + { + "fieldname": "next_step_condition", + "fieldtype": "Code", + "label": "Next Step Condition", + "options": "JS" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-05-26 19:44:48.737453", + "modified_by": "Administrator", + "module": "Desk", + "name": "Form Tour Step", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.py b/frappe/desk/doctype/form_tour_step/form_tour_step.py new file mode 100644 index 0000000000..0df5665c63 --- /dev/null +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class FormTourStep(Document): + pass diff --git a/frappe/desk/doctype/global_search_settings/global_search_settings.py b/frappe/desk/doctype/global_search_settings/global_search_settings.py index 28a1ed8239..9112349c1b 100644 --- a/frappe/desk/doctype/global_search_settings/global_search_settings.py +++ b/frappe/desk/doctype/global_search_settings/global_search_settings.py @@ -21,7 +21,7 @@ class GlobalSearchSettings(Document): dts.append(dt.document_type) if core_dts: - core_dts = (", ".join([frappe.bold(dt) for dt in core_dts])) + core_dts = ", ".join(frappe.bold(dt) for dt in core_dts) frappe.throw(_("Core Modules {0} cannot be searched in Global Search.").format(core_dts)) if repeated_dts: @@ -60,7 +60,7 @@ def update_global_search_doctypes(): if search_doctypes.get(domain): global_search_doctypes.extend(search_doctypes.get(domain)) - doctype_list = set([dt.name for dt in frappe.get_all("DocType")]) + doctype_list = {dt.name for dt in frappe.get_all("DocType")} allowed_in_global_search = [] for dt in global_search_doctypes: diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index 3c67bb4668..4ea5c9cd7e 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -131,7 +131,7 @@ def update_tags(doc, tags): :param doc: Document to be added to global tags """ - new_tags = list(set([tag.strip() for tag in tags.split(",") if tag])) + new_tags = {tag.strip() for tag in tags.split(",") if tag} for tag in new_tags: if not frappe.db.exists("Tag Link", {"parenttype": doc.doctype, "parent": doc.name, "tag": tag}): @@ -186,4 +186,4 @@ def get_documents_for_tag(tag): @frappe.whitelist() def get_tags_list_for_awesomebar(): - return [t.name for t in frappe.get_list("Tag")] \ No newline at end of file + return [t.name for t in frappe.get_list("Tag")] diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 0329e0f7d2..0b5babc8d9 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -55,8 +55,7 @@ class Workspace(Document): for link in self.links: link = link.as_dict() if link.type == "Card Break": - - if card_links: + if card_links and (not current_card.only_for or current_card.only_for == frappe.get_system_settings('country')): current_card['links'] = card_links cards.append(current_card) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 7ca4e6f99c..ecd59f42bb 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -1,32 +1,25 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -import frappe +import email.utils +import functools import imaplib -import re -import json import socket import time -import functools - -import email.utils - -from frappe import _, are_emails_muted -from frappe.model.document import Document -from frappe.utils import (validate_email_address, cint, cstr, get_datetime, - DATE_FORMAT, strip, comma_or, sanitize_html, add_days, parse_addr) -from frappe.utils.user import is_system_user -from frappe.utils.jinja import render_template -from frappe.email.smtp import SMTPServer -from frappe.email.receive import EmailServer, InboundMail, SentEmailInInboxError -from poplib import error_proto -from dateutil.relativedelta import relativedelta from datetime import datetime, timedelta +from poplib import error_proto + +import frappe +from frappe import _, are_emails_muted, safe_encode from frappe.desk.form import assign_to -from frappe.utils.user import get_system_managers -from frappe.utils.background_jobs import enqueue, get_jobs -from frappe.utils.html_utils import clean_email_html -from frappe.utils.error import raise_error_on_no_output +from frappe.email.receive import EmailServer, InboundMail, SentEmailInInboxError +from frappe.email.smtp import SMTPServer from frappe.email.utils import get_port +from frappe.model.document import Document +from frappe.utils import cint, comma_or, cstr, parse_addr, validate_email_address +from frappe.utils.background_jobs import enqueue, get_jobs +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") @@ -577,8 +570,8 @@ class EmailAccount(Document): email_server.update_flag(uid_list=uid_list) # mark communication as read - docnames = ",".join([ "'%s'"%flag.get("communication") for flag in flags \ - if flag.get("action") == "Read" ]) + docnames = ",".join("'%s'"%flag.get("communication") for flag in flags \ + if flag.get("action") == "Read") self.set_communication_seen_status(docnames, seen=1) # mark communication as unread @@ -608,7 +601,6 @@ class EmailAccount(Document): def append_email_to_sent_folder(self, message): - email_server = None try: email_server = self.get_incoming_server(in_receive=True) @@ -622,7 +614,8 @@ class EmailAccount(Document): if email_server.imap: try: - email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message.encode()) + message = safe_encode(message) + email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message) except Exception: frappe.log_error() diff --git a/frappe/email/doctype/email_group/email_group.json b/frappe/email/doctype/email_group/email_group.json index c49de841e6..cb74249143 100644 --- a/frappe/email/doctype/email_group/email_group.json +++ b/frappe/email/doctype/email_group/email_group.json @@ -1,6 +1,7 @@ { "actions": [], "allow_import": 1, + "allow_rename": 1, "autoname": "field:title", "creation": "2015-03-18 06:08:32.729800", "doctype": "DocType", @@ -50,7 +51,7 @@ "link_fieldname": "email_group" } ], - "modified": "2020-09-24 16:41:55.286377", + "modified": "2021-06-15 11:25:13.556201", "modified_by": "Administrator", "module": "Email", "name": "Email Group", diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index dad473b8aa..e1e332f978 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -179,7 +179,14 @@ class SendMailContext: else: email_status = self.is_mail_sent_to_all() and 'Sent' email_status = email_status or (self.sent_to and 'Partially Sent') or 'Not Sent' - self.queue_doc.update_status(status = email_status, commit = True) + + update_fields = {'status': email_status} + if self.email_account_doc.is_exists_in_db(): + update_fields['email_account'] = self.email_account_doc.name + else: + update_fields['email_account'] = None + + self.queue_doc.update_status(**update_fields, commit = True) def log_exception(self, exc_type, exc_val, exc_tb): if exc_type: diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index cfd0df53a9..3abd339ed9 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -42,7 +42,7 @@ class TestNewsletter(unittest.TestCase): 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 = set([e.recipients[0].recipient for e in email_queue_list]) + recipients = {e.recipients[0].recipient for e in email_queue_list} self.assertTrue(set(emails).issubset(recipients)) def test_unsubscribe(self): diff --git a/frappe/email/inbox.py b/frappe/email/inbox.py index 5f8f516772..c6020e14e4 100644 --- a/frappe/email/inbox.py +++ b/frappe/email/inbox.py @@ -18,7 +18,7 @@ def get_email_accounts(user=None): "all_accounts": "" } - all_accounts = ",".join([ account.get("email_account") for account in accounts ]) + all_accounts = ",".join(account.get("email_account") for account in accounts) if len(accounts) > 1: email_accounts.append({ "email_account": all_accounts, diff --git a/frappe/email/queue.py b/frappe/email/queue.py index ca96981aa8..885a306cfb 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -6,15 +6,61 @@ from frappe import msgprint, _ from frappe.utils.verified_command import get_signed_params, verify_request from frappe.utils import get_url, now_datetime, cint -def get_emails_sent_this_month(): - return frappe.db.sql(""" - SELECT COUNT(*) FROM `tabEmail Queue` - WHERE `status`='Sent' AND EXTRACT(YEAR_MONTH FROM `creation`) = EXTRACT(YEAR_MONTH FROM NOW()) - """)[0][0] +def get_emails_sent_this_month(email_account=None): + """Get count of emails sent from a specific email account. -def get_emails_sent_today(): - return frappe.db.sql("""SELECT COUNT(`name`) FROM `tabEmail Queue` WHERE - `status` in ('Sent', 'Not Sent', 'Sending') AND `creation` > (NOW() - INTERVAL '24' HOUR)""")[0][0] + :param email_account: name of the email account used to send mail + + if email_account=None, email account filter is not applied while counting + """ + q = """ + SELECT + COUNT(*) + FROM + `tabEmail Queue` + WHERE + `status`='Sent' + AND + EXTRACT(YEAR_MONTH FROM `creation`) = EXTRACT(YEAR_MONTH FROM NOW()) + """ + + q_args = {} + if email_account is not None: + if email_account: + q += " AND email_account = %(email_account)s" + q_args['email_account'] = email_account + else: + q += " AND (email_account is null OR email_account='')" + + return frappe.db.sql(q, q_args)[0][0] + +def get_emails_sent_today(email_account=None): + """Get count of emails sent from a specific email account. + + :param email_account: name of the email account used to send mail + + if email_account=None, email account filter is not applied while counting + """ + q = """ + SELECT + COUNT(`name`) + FROM + `tabEmail Queue` + WHERE + `status` in ('Sent', 'Not Sent', 'Sending') + AND + `creation` > (NOW() - INTERVAL '24' HOUR) + """ + + q_args = {} + if email_account is not None: + if email_account: + q += " AND email_account = %(email_account)s" + q_args['email_account'] = email_account + else: + q += " AND (email_account is null OR email_account='')" + + return frappe.db.sql(q, q_args)[0][0] def get_unsubscribe_message(unsubscribe_message, expose_recipients): if unsubscribe_message: diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 9ad560aa4a..2e42008951 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -802,7 +802,7 @@ class InboundMail(Email): except frappe.DuplicateEntryError: # try and find matching parent parent_name = frappe.db.get_value(self.email_account.append_to, - {email_fileds.sender_field: email.from_email} + {email_fileds.sender_field: self.from_email} ) if parent_name: parent.name = parent_name diff --git a/frappe/installer.py b/frappe/installer.py index d7d885d60e..d4d8117fcb 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -282,10 +282,10 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) def post_install(rebuild_website=False): - from frappe.website import render + from frappe.website.utils import clear_website_cache if rebuild_website: - render.clear_cache() + clear_website_cache() init_singles() frappe.db.commit() @@ -537,7 +537,7 @@ def is_downgrade(sql_file_path, verbose=False): def is_partial(sql_file_path): with open(sql_file_path) as f: - header = " ".join([f.readline() for _ in range(5)]) + header = " ".join(f.readline() for _ in range(5)) if "Partial Backup" in header: return True return False diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index 122096cf6f..acc8b96679 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -79,7 +79,7 @@ class LDAPSettings(Document): def sync_roles(self, user, additional_groups=None): - current_roles = set([d.role for d in user.get("roles")]) + current_roles = set(d.role for d in user.get("roles")) needed_roles = set() needed_roles.add(self.default_role) diff --git a/frappe/migrate.py b/frappe/migrate.py index c984371927..d4060e6067 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -16,7 +16,7 @@ from frappe.utils.dashboard import sync_dashboards from frappe.utils.background_jobs import enqueue from frappe.cache_manager import clear_global_cache from frappe.desk.notifications import clear_notifications -from frappe.website import render +from frappe.website.utils import clear_website_cache from frappe.core.doctype.language.language import sync_languages from frappe.modules.utils import sync_customizations from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs @@ -79,7 +79,7 @@ Otherwise, check the server logs and ensure that all the required services are r frappe.get_doc('Portal Settings', 'Portal Settings').sync_menu() # syncs statics - render.clear_cache() + clear_website_cache() # updating installed applications data frappe.get_single('Installed Applications').update_versions() diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index dd93fbcc18..75122f5aba 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -165,7 +165,7 @@ def delete_fields(args_dict, delete=0): frappe.db.sql(""" DELETE FROM `tabSingles` WHERE doctype='%s' AND field IN (%s) - """ % (dt, ", ".join(["'{}'".format(f) for f in fields]))) + """ % (dt, ", ".join("'{}'".format(f) for f in fields))) else: existing_fields = frappe.db.multisql({ "mariadb": "DESC `tab%s`" % dt, @@ -188,7 +188,7 @@ def delete_fields(args_dict, delete=0): frappe.db.commit() query = "ALTER TABLE `tab%s` " % dt + \ - ", ".join(["DROP COLUMN `%s`" % f for f in fields_need_to_delete]) + ", ".join("DROP COLUMN `%s`" % f for f in fields_need_to_delete) frappe.db.sql(query) if frappe.db.db_type == 'postgres': diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 2f5154cfd9..af696e116d 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -354,7 +354,7 @@ class BaseDocument(object): frappe.db.sql("""INSERT INTO `tab{doctype}` ({columns}) VALUES ({values})""".format( doctype = self.doctype, - columns = ", ".join(["`"+c+"`" for c in columns]), + columns = ", ".join("`"+c+"`" for c in columns), values = ", ".join(["%s"] * len(columns)) ), list(d.values())) except Exception as e: @@ -397,7 +397,7 @@ class BaseDocument(object): frappe.db.sql("""UPDATE `tab{doctype}` SET {values} WHERE `name`=%s""".format( doctype = self.doctype, - values = ", ".join(["`"+c+"`=%s" for c in columns]) + values = ", ".join("`"+c+"`=%s" for c in columns) ), list(d.values()) + [name]) except Exception as e: if frappe.db.is_unique_key_violation(e): diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 1acccdc142..7ed681644f 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -43,8 +43,14 @@ class DatabaseQuery(object): # filters and fields swappable # its hard to remember what comes first - if (isinstance(fields, dict) - or (isinstance(fields, list) and fields and isinstance(fields[0], list))): + if ( + isinstance(fields, dict) + or ( + fields + and isinstance(fields, list) + and isinstance(fields[0], list) + ) + ): # if fields is given as dict/list of list, its probably filters filters, fields = fields, filters @@ -56,10 +62,7 @@ class DatabaseQuery(object): if fields: self.fields = fields else: - if pluck: - self.fields = ["`tab{0}`.`{1}`".format(self.doctype, pluck)] - else: - self.fields = ["`tab{0}`.`name`".format(self.doctype)] + self.fields = [f"`tab{self.doctype}`.`{pluck or 'name'}`"] if start: limit_start = start if page_length: limit_page_length = page_length @@ -70,7 +73,7 @@ class DatabaseQuery(object): self.docstatus = docstatus or [] self.group_by = group_by self.order_by = order_by - self.limit_start = 0 if (limit_start is False) else cint(limit_start) + self.limit_start = cint(limit_start) self.limit_page_length = cint(limit_page_length) if limit_page_length else None self.with_childnames = with_childnames self.debug = debug @@ -157,11 +160,10 @@ class DatabaseQuery(object): # left join parent, child tables for child in self.tables[1:]: - args.tables += " {join} {child} on ({child}.parent = {main}.name)".format(join=self.join, - child=child, main=self.tables[0]) + args.tables += f" {self.join} {child} on ({child}.parent = {self.tables[0]}.name)" if self.grouped_or_conditions: - self.conditions.append("({0})".format(" or ".join(self.grouped_or_conditions))) + self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})") args.conditions = ' and '.join(self.conditions) @@ -186,9 +188,9 @@ class DatabaseQuery(object): fields.append(field) elif "as" in field.lower().split(" "): col, _, new = field.split() - fields.append("`{0}` as {1}".format(col, new)) + fields.append(f"`{col}` as {new}") else: - fields.append("`{0}`".format(field)) + fields.append(f"`{field}`") args.fields = ", ".join(fields) @@ -260,10 +262,10 @@ class DatabaseQuery(object): if any(keyword in field.lower().split() for keyword in blacklisted_keywords): _raise_exception() - if any("({0}".format(keyword) in field.lower() for keyword in blacklisted_keywords): + if any(f"({keyword}" in field.lower() for keyword in blacklisted_keywords): _raise_exception() - if any("{0}(".format(keyword) in field.lower() for keyword in blacklisted_functions): + if any(f"{keyword}(" in field.lower() for keyword in blacklisted_functions): _raise_exception() if '@' in field.lower(): @@ -287,22 +289,30 @@ class DatabaseQuery(object): def extract_tables(self): """extract tables from fields""" - self.tables = ['`tab' + self.doctype + '`'] - + self.tables = [f"`tab{self.doctype}`"] + sql_functions = [ + "dayofyear(", + "extract(", + "locate(", + "strpos(", + "count(", + "sum(", + "avg(", + ] # add tables from fields if self.fields: - for f in self.fields: - if ( not ("tab" in f and "." in f) ) or ("locate(" in f) or ("strpos(" in f) or \ - ("count(" in f) or ("avg(" in f) or ("sum(" in f) or ("extract(" in f) or ("dayofyear(" in f): + for field in self.fields: + if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field): continue - table_name = f.split('.')[0] + table_name = field.split('.')[0] + if table_name.lower().startswith('group_concat('): table_name = table_name[13:] if table_name.lower().startswith('ifnull('): table_name = table_name[7:] if not table_name[0]=='`': - table_name = '`' + table_name + '`' + table_name = f"`{table_name}`" if not table_name in self.tables: self.append_table(table_name) @@ -311,8 +321,7 @@ class DatabaseQuery(object): doctype = table_name[4:-1] ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read' - if (not self.flags.ignore_permissions) and\ - (not frappe.has_permission(doctype, ptype=ptype)): + if not self.flags.ignore_permissions and not frappe.has_permission(doctype, ptype=ptype): frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(doctype)) raise frappe.PermissionError(doctype) @@ -326,7 +335,7 @@ class DatabaseQuery(object): if len(self.tables) > 1: for idx, field in enumerate(self.fields): if '.' not in field and not _in_standard_sql_methods(field): - self.fields[idx] = '{0}.{1}'.format(self.tables[0], field) + self.fields[idx] = f"{self.tables[0]}.{field}" def get_table_columns(self): try: @@ -375,7 +384,7 @@ class DatabaseQuery(object): if not self.flags.ignore_permissions: match_conditions = self.build_match_conditions() if match_conditions: - self.conditions.append("(" + match_conditions + ")") + self.conditions.append(f"({match_conditions})") def build_filter_conditions(self, filters, conditions, ignore_permissions=None): """build conditions from user filters""" @@ -407,8 +416,7 @@ class DatabaseQuery(object): if 'ifnull(' in f.fieldname: column_name = f.fieldname else: - column_name = '{tname}.{fname}'.format(tname=tname, - fname=f.fieldname) + column_name = f"{tname}.{f.fieldname}" can_be_null = True @@ -450,7 +458,7 @@ class DatabaseQuery(object): fallback = "''" value = [frappe.db.escape((v.name or '').strip(), percent=False) for v in result] if len(value): - value = "({0})".format(", ".join(value)) + value = f"({', '.join(value)})" else: value = "('')" # changing operator to IN as the above code fetches all the parent / child values and convert into tuple @@ -466,7 +474,7 @@ class DatabaseQuery(object): fallback = "''" value = [frappe.db.escape((v or '').strip(), percent=False) for v in values] if len(value): - value = "({0})".format(", ".join(value)) + value = f"({', '.join(value)})" else: value = "('')" else: @@ -503,7 +511,7 @@ class DatabaseQuery(object): can_be_null = True if 'ifnull' not in column_name: - column_name = 'ifnull({}, {})'.format(column_name, fallback) + column_name = f'ifnull({column_name}, {fallback})' elif df and df.fieldtype=="Date": value = frappe.db.format_date(f.value) @@ -540,21 +548,19 @@ class DatabaseQuery(object): # escape value if isinstance(value, str) and not f.operator.lower() == 'between': - value = "{0}".format(frappe.db.escape(value, percent=False)) + value = f"{frappe.db.escape(value, percent=False)}" - if (self.ignore_ifnull + if ( + self.ignore_ifnull or not can_be_null or (f.value and f.operator.lower() in ('=', 'like')) - or 'ifnull(' in column_name.lower()): + or 'ifnull(' in column_name.lower() + ): if f.operator.lower() == 'like' and frappe.conf.get('db_type') == 'postgres': f.operator = 'ilike' - condition = '{column_name} {operator} {value}'.format( - column_name=column_name, operator=f.operator, - value=value) + condition = f'{column_name} {f.operator} {value}' else: - condition = 'ifnull({column_name}, {fallback}) {operator} {value}'.format( - column_name=column_name, fallback=fallback, operator=f.operator, - value=value) + condition = f'ifnull({column_name}, {fallback}) {f.operator} {value}' return condition @@ -572,10 +578,12 @@ class DatabaseQuery(object): role_permissions = frappe.permissions.get_role_permissions(meta, user=self.user) self.shared = frappe.share.get_shared(self.doctype, self.user) - if (not meta.istable and + if ( + not meta.istable and not (role_permissions.get("select") or role_permissions.get("read")) and not self.flags.ignore_permissions and - not has_any_user_permission_for_doctype(self.doctype, self.user, self.reference_doctype)): + not has_any_user_permission_for_doctype(self.doctype, self.user, self.reference_doctype) + ): only_if_shared = True if not self.shared: frappe.throw(_("No permission to read {0}").format(self.doctype), frappe.PermissionError) @@ -585,8 +593,10 @@ class DatabaseQuery(object): else: #if has if_owner permission skip user perm check if role_permissions.get("has_if_owner_enabled") and role_permissions.get("if_owner", {}): - self.match_conditions.append("`tab{0}`.`owner` = {1}".format(self.doctype, - frappe.db.escape(self.user, percent=False))) + self.match_conditions.append( + f"`tab{self.doctype}`.`owner` = {frappe.db.escape(self.user, percent=False)}" + ) + # add user permission only if role has read perm elif role_permissions.get("read") or role_permissions.get("select"): # get user permissions @@ -605,8 +615,7 @@ class DatabaseQuery(object): # share is an OR condition, if there is a role permission if not only_if_shared and self.shared and conditions: - conditions = "({conditions}) or ({shared_condition})".format( - conditions=conditions, shared_condition=self.get_share_condition()) + conditions = f"({conditions}) or ({self.get_share_condition()})" return conditions @@ -614,8 +623,7 @@ class DatabaseQuery(object): return self.match_filters def get_share_condition(self): - return """`tab{0}`.name in ({1})""".format(self.doctype, ", ".join(["%s"] * len(self.shared))) % \ - tuple([frappe.db.escape(s, percent=False) for s in self.shared]) + return f"`tab{self.doctype}`.name in ({', '.join(frappe.db.escape(s, percent=False) for s in self.shared)})" def add_user_permissions(self, user_permissions): meta = frappe.get_meta(self.doctype) @@ -640,9 +648,7 @@ class DatabaseQuery(object): if frappe.get_system_settings("apply_strict_user_permissions"): condition = "" else: - empty_value_condition = "ifnull(`tab{doctype}`.`{fieldname}`, '')=''".format( - doctype=self.doctype, fieldname=df.get('fieldname') - ) + empty_value_condition = f"ifnull(`tab{self.doctype}`.`{df.get('fieldname')}`, '')=''" condition = empty_value_condition + " or " for permission in user_permission_values: @@ -650,9 +656,7 @@ class DatabaseQuery(object): docs.append(permission.get('doc')) # append docs based on user permission applicable on reference doctype - # this is useful when getting list of docs from a link field - # in this case parent doctype of the link # will be the reference doctype @@ -664,14 +668,9 @@ class DatabaseQuery(object): docs.append(permission.get('doc')) if docs: - condition += "`tab{doctype}`.`{fieldname}` in ({values})".format( - doctype=self.doctype, - fieldname=df.get('fieldname'), - values=", ".join( - [(frappe.db.escape(doc, percent=False)) for doc in docs]) - ) - - match_conditions.append("({condition})".format(condition=condition)) + values = ", ".join(frappe.db.escape(doc, percent=False) for doc in docs) + condition += f"`tab{self.doctype}`.`{df.get('fieldname')}` in ({values})" + match_conditions.append(f"({condition})") match_filters[df.get('options')] = docs if match_conditions: @@ -721,17 +720,17 @@ class DatabaseQuery(object): # `idx desc, modified desc` # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc - args.order_by = ', '.join(['`tab{0}`.`{1}` {2}'.format(self.doctype, - f.split()[0].strip(), f.split()[1].strip()) for f in meta.sort_field.split(',')]) + args.order_by = ', '.join( + f"`tab{self.doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}" for f in meta.sort_field.split(',') + ) else: sort_field = meta.sort_field or 'modified' sort_order = (meta.sort_field and meta.sort_order) or 'desc' - - args.order_by = "`tab{0}`.`{1}` {2}".format(self.doctype, sort_field or "modified", sort_order or "desc") + args.order_by = f"`tab{self.doctype}`.`{sort_field or 'modified'}` {sort_order or 'desc'}" # draft docs always on top if hasattr(meta, 'is_submittable') and meta.is_submittable: - args.order_by = "`tab{0}`.docstatus asc, {1}".format(self.doctype, args.order_by) + args.order_by = f"`tab{self.doctype}`.docstatus asc, {args.order_by}" def validate_order_by_and_group_by(self, parameters): """Check order by, group by so that atleast one column is selected and does not have subquery""" @@ -802,17 +801,16 @@ def get_order_by(doctype, meta): # `idx desc, modified desc` # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc - order_by = ', '.join(['`tab{0}`.`{1}` {2}'.format(doctype, - f.split()[0].strip(), f.split()[1].strip()) for f in meta.sort_field.split(',')]) + order_by = ', '.join(f"`tab{doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}" for f in meta.sort_field.split(',')) + else: sort_field = meta.sort_field or 'modified' sort_order = (meta.sort_field and meta.sort_order) or 'desc' - - order_by = "`tab{0}`.`{1}` {2}".format(doctype, sort_field or "modified", sort_order or "desc") + order_by = f"`tab{doctype}`.`{sort_field or 'modified'}` {sort_order or 'desc'}" # draft docs always on top if meta.is_submittable: - order_by = "`tab{0}`.docstatus asc, {1}".format(doctype, order_by) + order_by = f"`tab{doctype}`.docstatus asc, {order_by}" return order_by diff --git a/frappe/model/meta.py b/frappe/model/meta.py index b67c41c990..b212324208 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -664,7 +664,7 @@ def trim_tables(doctype=None): and not f.startswith("_")] if columns_to_remove: print(doctype, "columns removed:", columns_to_remove) - columns_to_remove = ", ".join(["drop `{0}`".format(c) for c in columns_to_remove]) + columns_to_remove = ", ".join("drop `{0}`".format(c) for c in columns_to_remove) query = """alter table `tab{doctype}` {columns}""".format( doctype=doctype, columns=columns_to_remove) frappe.db.sql_ddl(query) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index fc5b3ca9fe..9b8ac2574d 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -141,7 +141,7 @@ def update_user_settings(old, new, link_fields): if not link_fields: return # find the user settings for the linked doctypes - linked_doctypes = set([d.parent for d in link_fields if not d.issingle]) + linked_doctypes = {d.parent for d in link_fields if not d.issingle} user_settings_details = frappe.db.sql('''SELECT `user`, `doctype`, `data` FROM `__UserSettings` WHERE `data` like %s diff --git a/frappe/permissions.py b/frappe/permissions.py index c25a7c3947..07b4a2e68f 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -308,7 +308,7 @@ def has_controller_permissions(doc, ptype, user=None): return None def get_doctypes_with_read(): - return list(set([p.parent if type(p.parent) == str else p.parent.encode('UTF8') for p in get_valid_perms()])) + return list({p.parent if type(p.parent) == str else p.parent.encode('UTF8') for p in get_valid_perms()}) def get_valid_perms(doctype=None, user=None): '''Get valid permissions for the current user from DocPerm and Custom DocPerm''' diff --git a/frappe/printing/doctype/print_settings/print_settings.json b/frappe/printing/doctype/print_settings/print_settings.json index d64cb4c6d3..31962be050 100644 --- a/frappe/printing/doctype/print_settings/print_settings.json +++ b/frappe/printing/doctype/print_settings/print_settings.json @@ -148,7 +148,7 @@ "label": "Print Style" }, { - "default": "Modern", + "default": "Redesign", "fieldname": "print_style", "fieldtype": "Link", "in_list_view": 1, @@ -183,7 +183,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-10-22 23:42:09.471022", + "modified": "2021-02-15 14:16:18.474254", "modified_by": "Administrator", "module": "Printing", "name": "Print Settings", diff --git a/frappe/public/js/frappe/form/controls/text_editor.js b/frappe/public/js/frappe/form/controls/text_editor.js index 059b0f76f8..99e87c5f21 100644 --- a/frappe/public/js/frappe/form/controls/text_editor.js +++ b/frappe/public/js/frappe/form/controls/text_editor.js @@ -1,7 +1,10 @@ import Quill from 'quill'; import ImageResize from 'quill-image-resize'; +import MagicUrl from 'quill-magic-url'; + Quill.register('modules/imageResize', ImageResize); +Quill.register('modules/magicUrl', MagicUrl); const CodeBlockContainer = Quill.import('formats/code-block-container'); CodeBlockContainer.tagName = 'PRE'; Quill.register(CodeBlockContainer, true); @@ -148,7 +151,8 @@ frappe.ui.form.ControlTextEditor = class ControlTextEditor extends frappe.ui.for modules: { toolbar: this.get_toolbar_options(), table: true, - imageResize: {} + imageResize: {}, + magicUrl: true }, theme: 'snow' }; diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index c1c95d94cf..eb7a6edc5d 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -5,6 +5,7 @@ frappe.ui.form.Dashboard = class FormDashboard { constructor(opts) { $.extend(this, opts); this.setup_dashboard_sections(); + this.set_open_count = frappe.utils.throttle(this.set_open_count, 500); } setup_dashboard_sections() { diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index ab83ed2f71..115a62e098 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -190,6 +190,7 @@ class FormTimeline extends BaseTimeline { } doc.owner = doc.sender; doc.user_full_name = doc.sender_full_name; + doc.content = frappe.dom.remove_script_and_style(doc.content); let communication_content = $(frappe.render_template('timeline_message_box', { doc })); if (allow_reply) { this.setup_reply(communication_content, doc); @@ -248,6 +249,7 @@ class FormTimeline extends BaseTimeline { } get_comment_timeline_content(doc) { + doc.content = frappe.dom.remove_script_and_style(doc.content); const comment_content = $(frappe.render_template('timeline_message_box', { doc })); this.setup_comment_actions(comment_content, doc); return comment_content; diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 35ebf9274d..a24c6ab0d6 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1607,7 +1607,9 @@ frappe.ui.form.Form = class FrappeForm { } show_tour(on_finish) { - if (!Array.isArray(frappe.tour[this.doctype])) { + const tour_info = frappe.tour[this.doctype]; + + if (!Array.isArray(tour_info)) { return; } @@ -1619,23 +1621,29 @@ frappe.ui.form.Form = class FrappeForm { keyboardControl: true, nextBtnText: 'Next', prevBtnText: 'Previous', - opacity: 0.25, - onNext: () => { - if (!driver.hasNextStep()) { - on_finish && on_finish(); - } - } + opacity: 0.25 }); this.layout.sections.forEach(section => section.collapse(false)); - let steps = frappe.tour[this.doctype].map(step => { + let steps = tour_info.map(step => { let field = this.get_docfield(step.fieldname); return { element: `.frappe-control[data-fieldname='${step.fieldname}']`, popover: { title: step.title || field.label, - description: step.description + description: step.description, + position: step.position || 'bottom' + }, + onNext: () => { + const next_condition_satisfied = this.layout.evaluate_depends_on_value(step.next_step_condition || true); + if (!next_condition_satisfied) { + driver.preventMove(); + } + + if (!driver.hasNextStep()) { + on_finish && on_finish(); + } } }; }); diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index 89c34ed80c..b9a838688d 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -221,9 +221,13 @@ frappe.form.formatters = { Tag: function(value) { var html = ""; $.each((value || "").split(","), function(i, v) { - if(v) html+= ''+v +''; + if (v) html += ` + + ${v} + `; }); return html; }, @@ -310,6 +314,7 @@ frappe.form.get_formatter = function(fieldtype) { frappe.format = function(value, df, options, doc) { if(!df) df = {"fieldtype":"Data"}; + if (df.fieldname == '_user_tags') df.fieldtype = 'Tag'; var fieldtype = df.fieldtype || "Data"; // format Dynamic Link as a Link diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index a77791d0a2..ebc3fa19f5 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -210,9 +210,9 @@ export default class Grid { delete_all_rows() { frappe.confirm(__("Are you sure you want to delete all rows?"), () => { - this.frm.doc[this.df.fieldname] = []; - $(this.parent).find('.rows').empty(); - this.grid_rows = []; + this.grid_rows.forEach(row => { + row.remove(); + }); this.frm.script_manager.trigger(this.df.fieldname + "_delete", this.doctype); this.wrapper.find('.grid-heading-row .grid-row-check:checked:first').prop('checked', 0); @@ -236,6 +236,10 @@ export default class Grid { } refresh_remove_rows_button() { + if (this.df.cannot_delete_rows) { + return; + } + this.remove_rows_button.toggleClass('hidden', this.wrapper.find('.grid-body .grid-row-check:checked:first').length ? false : true); this.remove_all_rows_button.toggleClass('hidden', diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 33db00dede..8c51314066 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -569,6 +569,9 @@ export default class GridRow { .find('.grid-insert-row-below, .grid-insert-row, .grid-duplicate-row, .grid-append-row') .toggle(!cannot_add_rows); + this.wrapper.find('.grid-delete-row') + .toggle(!(this.grid.df && this.grid.df.cannot_delete_rows)); + frappe.dom.freeze("", "dark"); if (cur_frm) cur_frm.cur_grid = this; this.wrapper.addClass("grid-row-open"); diff --git a/frappe/public/js/frappe/ui/filters/filter_list.js b/frappe/public/js/frappe/ui/filters/filter_list.js index 611ab024bf..72312d7f13 100644 --- a/frappe/public/js/frappe/ui/filters/filter_list.js +++ b/frappe/public/js/frappe/ui/filters/filter_list.js @@ -323,9 +323,12 @@ frappe.ui.FilterGroup = class { } add_filters_to_filter_group(filters) { - filters.forEach((filter) => { - this.add_filter(filter[0], filter[1], filter[2], filter[3]); - }); + if (filters.length) { + this.toggle_empty_filters(false); + filters.forEach((filter) => { + this.add_filter(filter[0], filter[1], filter[2], filter[3]); + }); + } } add(filters, refresh = true) { diff --git a/frappe/public/js/frappe/ui/group_by/group_by.js b/frappe/public/js/frappe/ui/group_by/group_by.js index 3ebf9c9d3d..692d675c62 100644 --- a/frappe/public/js/frappe/ui/group_by/group_by.js +++ b/frappe/public/js/frappe/ui/group_by/group_by.js @@ -381,10 +381,11 @@ frappe.ui.GroupBy = class { this.group_by_fields = {}; this.all_fields = {}; - let fields = this.report_view.meta.fields.filter((f) => + const fields = this.report_view.meta.fields.filter((f) => ['Select', 'Link', 'Data', 'Int', 'Check'].includes(f.fieldtype) ); - this.group_by_fields[this.doctype] = fields; + const tag_field = {fieldname: '_user_tags', fieldtype: 'Data', label: __('Tags')}; + this.group_by_fields[this.doctype] = fields.concat(tag_field); this.all_fields[this.doctype] = this.report_view.meta.fields; const standard_fields_filter = (df) => diff --git a/frappe/public/js/frappe/ui/messages.js b/frappe/public/js/frappe/ui/messages.js index 2e8ba7d206..067fed233c 100644 --- a/frappe/public/js/frappe/ui/messages.js +++ b/frappe/public/js/frappe/ui/messages.js @@ -26,13 +26,13 @@ frappe.throw = function(msg) { frappe.confirm = function(message, confirm_action, reject_action) { var d = new frappe.ui.Dialog({ - title: __("Confirm"), - primary_action_label: __("Yes"), + title: __("Confirm", null, "Title of confirmation dialog"), + primary_action_label: __("Yes", null, "Approve confirmation dialog"), primary_action: () => { confirm_action && confirm_action(); d.hide(); }, - secondary_action_label: __("No"), + secondary_action_label: __("No", null, "Dismiss confirmation dialog"), secondary_action: () => d.hide(), }); @@ -88,9 +88,9 @@ frappe.prompt = function(fields, callback, title, primary_label) { if(!$.isArray(fields)) fields = [fields]; var d = new frappe.ui.Dialog({ fields: fields, - title: title || __("Enter Value"), + title: title || __("Enter Value", null, "Title of prompt dialog"), }); - d.set_primary_action(primary_label || __("Submit"), function() { + d.set_primary_action(primary_label || __("Submit", null, "Primary action of prompt dialog"), function() { var values = d.get_values(); if(!values) { return; diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index c5ef0d0184..22fdf476b8 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -377,11 +377,12 @@ frappe.ui.Page = class Page { }); } - add_actions_menu_item(label, click, standard) { + add_actions_menu_item(label, click, standard, shortcut) { return this.add_dropdown_item({ label, click, standard, + shortcut, parent: this.actions, show_parent: false }); diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index b29b6b87e6..6a324f6034 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -410,7 +410,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { x_fields.push({ label: col.content, fieldname: col.id, - value: col.id, + value: col.id, }); // numeric values in y @@ -1024,8 +1024,12 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { return docfield.fieldtype === 'Date' ? 'right' : 'left'; })(); + let id = fieldname; + // child table column - const id = doctype !== this.doctype ? `${doctype}:${fieldname}` : fieldname; + if (doctype !== this.doctype && fieldname !== '_aggregate_column') { + id = `${doctype}:${fieldname}`; + } let width = (docfield ? cint(docfield.width) : null) || null; if (this.report_doc) { diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index eefb78c29a..3f5a4acd73 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -271,18 +271,19 @@ class ShortcutDialog extends WidgetDialog { } process_data(data) { - let stats_filter = {}; if (this.dialog.get_value("type") == "DocType" && this.filter_group) { let filters = this.filter_group.get_filters(); + let stats_filter = null; if (filters.length) { + stats_filter = {}; filters.forEach((arr) => { stats_filter[arr[1]] = [arr[2], arr[3]]; }); - - data.stats_filter = JSON.stringify(stats_filter); + stats_filter = JSON.stringify(stats_filter); } + data.stats_filter = stats_filter; } data.label = data.label diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss index 83fc4461d6..c939c6de39 100644 --- a/frappe/public/scss/common/controls.scss +++ b/frappe/public/scss/common/controls.scss @@ -241,6 +241,7 @@ textarea.form-control { // rating .rating { + cursor: pointer; --star-fill: var(--gray-300); .star-hover { --star-fill: var(--yellow-100); @@ -248,6 +249,24 @@ textarea.form-control { .star-click { --star-fill: var(--yellow-300); } + + .rating-box { + background-color: var(--gray-300); + border-radius: 5px; + font-size: 14px; + text-align: center; + padding: 2px; + cursor: pointer; + width: 25px; + height: 25px; + margin: 4px 2px; + } + .rating-hover { + background-color: var(--yellow-100); + } + .rating-click { + background-color: var(--yellow-300); + } } .frappe-control .control-value { diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index aac949b1bf..57d0583b35 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -53,7 +53,12 @@ display: none; } +.form-grid .grid-heading-row .template-row { + margin-left: 20px; +} + .form-grid .template-row { + width: calc(100% - 30px); padding: 8px 15px; } diff --git a/frappe/public/scss/common/quill.scss b/frappe/public/scss/common/quill.scss index 6f6e09dc70..12706d6b7f 100644 --- a/frappe/public/scss/common/quill.scss +++ b/frappe/public/scss/common/quill.scss @@ -176,6 +176,7 @@ } .ql-editor.read-mode { + height: unset; padding: 0; .mention { --user-mention-bg-color: var(--control-bg); diff --git a/frappe/public/scss/website/blog.scss b/frappe/public/scss/website/blog.scss index 9918b490c5..ea82efed21 100644 --- a/frappe/public/scss/website/blog.scss +++ b/frappe/public/scss/website/blog.scss @@ -14,6 +14,10 @@ position: relative; width: 100%; + .card { + border: 1px solid var(--border-color) + } + .card-body { display: flex; flex-direction: column; diff --git a/frappe/public/scss/website/markdown.scss b/frappe/public/scss/website/markdown.scss index c5f44d20d8..6f009df393 100644 --- a/frappe/public/scss/website/markdown.scss +++ b/frappe/public/scss/website/markdown.scss @@ -48,7 +48,6 @@ $font-sizes-mobile: ( } li { - text-indent: 0.25rem; padding-top: 1px; padding-bottom: 1px; } diff --git a/frappe/search/website_search.py b/frappe/search/website_search.py index 566c0f6a6d..1fb84531b0 100644 --- a/frappe/search/website_search.py +++ b/frappe/search/website_search.py @@ -9,7 +9,7 @@ from whoosh.fields import ID, TEXT, Schema import frappe from frappe.search.full_text_search import FullTextSearch from frappe.utils import set_request, update_progress_bar -from frappe.website.render import render_page +from frappe.website.serve import get_response_content INDEX_NAME = "web_routes" @@ -63,7 +63,7 @@ class WebsiteSearch(FullTextSearch): try: set_request(method="GET", path=route) - content = render_page(route) + content = get_response_content(route) soup = BeautifulSoup(content, "html.parser") page_content = soup.find(class_="page_content") text_content = page_content.text if page_content else "" diff --git a/frappe/sessions.py b/frappe/sessions.py index 1bc78448e7..4d922d6769 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -167,7 +167,8 @@ def get_csrf_token(): def generate_csrf_token(): frappe.local.session.data.csrf_token = frappe.generate_hash() - frappe.local.session_obj.update(force=True) + if not frappe.flags.in_test: + frappe.local.session_obj.update(force=True) class Session: def __init__(self, user, resume=False, full_name=None, user_type=None): diff --git a/frappe/templates/includes/comments/comments.html b/frappe/templates/includes/comments/comments.html index c490bedd72..935fa5367e 100644 --- a/frappe/templates/includes/comments/comments.html +++ b/frappe/templates/includes/comments/comments.html @@ -49,8 +49,10 @@ {% endif %} \ No newline at end of file diff --git a/frappe/templates/includes/feedback/feedback.py b/frappe/templates/includes/feedback/feedback.py new file mode 100644 index 0000000000..1830a3e09e --- /dev/null +++ b/frappe/templates/includes/feedback/feedback.py @@ -0,0 +1,63 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt +from __future__ import unicode_literals + +import frappe + +from frappe import _ + +@frappe.whitelist(allow_guest=True) +def add_feedback(reference_doctype, reference_name, rating, feedback, feedback_email): + doc = frappe.get_doc(reference_doctype, reference_name) + if doc.disable_feedback == 1: + return + + doc = frappe.new_doc('Feedback') + doc.reference_doctype = reference_doctype + doc.reference_name = reference_name + doc.rating = rating + doc.feedback = feedback + doc.email = feedback_email + doc.save(ignore_permissions=True) + + subject = _('New Feedback on {0}: {1}').format(reference_doctype, reference_name) + send_mail(doc, subject) + return doc + +@frappe.whitelist() +def update_feedback(reference_doctype, reference_name, rating, feedback, feedback_email): + doc = frappe.get_doc(reference_doctype, reference_name) + if doc.disable_feedback == 1: + return + + filters = { + "email": feedback_email, + "reference_doctype": reference_doctype, + "reference_name": reference_name + } + d = frappe.get_all('Feedback', filters=filters, limit=1) + doc = frappe.get_doc('Feedback', d[0].name) + doc.rating = rating + doc.feedback = feedback + doc.save(ignore_permissions=True) + + subject = _('Feedback updated on {0}: {1}').format(reference_doctype, reference_name) + send_mail(doc, subject) + return doc + +def send_mail(feedback, subject): + doc = frappe.get_doc(feedback.reference_doctype, feedback.reference_name) + + message = ("

{0} ({1})

".format(feedback.feedback, feedback.rating) + + "

{2}

".format(frappe.utils.get_request_site_address(), + feedback.name, + _("View Feedback"))) + + # notify creator + frappe.sendmail( + recipients=frappe.db.get_value('User', doc.owner, 'email') or doc.owner, + subject=subject, + message=message, + reference_doctype=doc.doctype, + reference_name=doc.name + ) diff --git a/frappe/templates/includes/full_index.html b/frappe/templates/includes/full_index.html index a7443c482a..eb8fb322f6 100644 --- a/frappe/templates/includes/full_index.html +++ b/frappe/templates/includes/full_index.html @@ -3,11 +3,6 @@ {% for item in children_map[route] %}
  • {{ item.title }} - {# - {% if children_map[item.route] %} - {{ make_item_list(item.route, children_map) }} - {% endif %} - #}
  • {% endfor %} diff --git a/frappe/templates/test/_test_base.html b/frappe/templates/test/_test_base.html index a0b1a83c97..17caf8df1b 100644 --- a/frappe/templates/test/_test_base.html +++ b/frappe/templates/test/_test_base.html @@ -1,8 +1,20 @@ - + + {%- block style %} + {% if colocated_css -%} + + {%- endif %} + {%- endblock -%} + + {% include "templates/includes/breadcrumbs.html" %}

    This is for testing

    {% block content %}{% endblock %} + {%- block script %} + {% if colocated_js -%} + + {%- endif %} + {%- endblock %} diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 1f99e55fb8..0c30fbbd00 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -175,6 +175,7 @@ def run_tests_for_module(module, verbose=False, tests=(), profile=False, junit_x for doctype in module.test_dependencies: make_test_records(doctype, verbose=verbose) + frappe.db.commit() return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, junit_xml_output=junit_xml_output) def _run_unittest(modules, verbose=False, tests=(), profile=False, junit_xml_output=False): diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 7e77aab779..29939fea1c 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -39,6 +39,11 @@ class TestResourceAPI(unittest.TestCase): for name in self.GENERATED_DOCUMENTS: frappe.delete_doc_if_exists(self.DOCTYPE, name) + def setUp(self): + # commit to ensure consistency in session (postgres CI randomly fails) + if frappe.conf.db_type == "postgres": + frappe.db.commit() + @property def sid(self): if not getattr(self, "_sid", None): diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index b6cd0b575c..07bdf8791e 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -80,7 +80,7 @@ def exists_in_backup(doctypes, file): ) with gzip.open(file, "rb") as f: content = f.read().decode("utf8") - return all([predicate.format(doctype).lower() in content.lower() for doctype in doctypes]) + return all(predicate.format(doctype).lower() in content.lower() for doctype in doctypes) class BaseTestCommands(unittest.TestCase): @@ -355,12 +355,12 @@ class TestCommands(BaseTestCommands): # test 2: bare functionality for single site self.execute("bench --site {site} list-apps") self.assertEqual(self.returncode, 0) - list_apps = set([ + list_apps = set( _x.split()[0] for _x in self.stdout.split("\n") - ]) + ) doctype = frappe.get_single("Installed Applications").installed_applications if doctype: - installed_apps = set([x.app_name for x in doctype]) + installed_apps = set(x.app_name for x in doctype) else: installed_apps = set(frappe.get_installed_apps()) self.assertSetEqual(list_apps, installed_apps) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 42ebd05b67..89975b46d6 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -21,6 +21,18 @@ class TestReportview(unittest.TestCase): def test_basic(self): self.assertTrue({"name":"DocType"} in DatabaseQuery("DocType").execute(limit_page_length=None)) + def test_extract_tables(self): + db_query = DatabaseQuery("DocType") + add_custom_field("DocType", 'test_tab_field', 'Data') + + db_query.fields = ["tabNote.creation", "test_tab_field", "tabDocType.test_tab_field"] + db_query.extract_tables() + self.assertIn("`tabNote`", db_query.tables) + self.assertIn("`tabDocType`", db_query.tables) + self.assertNotIn("test_tab_field", db_query.tables) + + clear_custom_fields("DocType") + def test_build_match_conditions(self): clear_user_permissions_for_doctype('Blog Post', 'test2@example.com') diff --git a/frappe/tests/test_recorder.py b/frappe/tests/test_recorder.py index 08dbde0144..d9386ca25b 100644 --- a/frappe/tests/test_recorder.py +++ b/frappe/tests/test_recorder.py @@ -7,7 +7,7 @@ import unittest import frappe import frappe.recorder from frappe.utils import set_request -from frappe.website.render import render_page +from frappe.website.serve import get_response_content import sqlparse @@ -121,5 +121,5 @@ class TestRecorder(unittest.TestCase): self.assertEqual(call['exact_copies'], query[1]) def test_error_page_rendering(self): - content = render_page("error") + content = get_response_content("error") self.assertIn("Error", content) diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index 52ddc5ef71..6f265d9b94 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -1,38 +1,41 @@ import unittest import frappe -from frappe.website import render -from frappe.website.utils import get_home_page from frappe.utils import set_request +from frappe.website.serve import get_response, get_response_content +from frappe.website.utils import (build_response, clear_website_cache, get_home_page) class TestWebsite(unittest.TestCase): + def setUp(self): + frappe.set_user('Guest') - def test_home_page_for_role(self): - frappe.delete_doc_if_exists('User', 'test-user-for-home-page@example.com') - frappe.delete_doc_if_exists('Role', 'home-page-test') - frappe.delete_doc_if_exists('Web Page', 'home-page-test') + def tearDown(self): + frappe.set_user('Administrator') + + def test_home_page(self): + frappe.set_user('Administrator') + # test home page via role user = frappe.get_doc(dict( doctype='User', email='test-user-for-home-page@example.com', - first_name='test')).insert() + first_name='test')).insert(ignore_if_duplicate=True) role = frappe.get_doc(dict( doctype = 'Role', role_name = 'home-page-test', desk_access = 0, - home_page = '/home-page-test' - )).insert() + )).insert(ignore_if_duplicate=True) user.add_roles(role.name) user.save() + frappe.db.set_value('Role', 'home-page-test', 'home_page', 'home-page-test') frappe.set_user('test-user-for-home-page@example.com') self.assertEqual(get_home_page(), 'home-page-test') frappe.set_user('Administrator') - role.home_page = '' - role.save() + frappe.db.set_value('Role', 'home-page-test', 'home_page', '') # home page via portal settings frappe.db.set_value('Portal Settings', None, 'default_portal_home', 'test-portal-home') @@ -41,10 +44,45 @@ class TestWebsite(unittest.TestCase): frappe.cache().hdel('home_page', frappe.session.user) self.assertEqual(get_home_page(), 'test-portal-home') - def test_page_load(self): + frappe.db.set_value("Portal Settings", None, "default_portal_home", '') + clear_website_cache() + + # home page via website settings + frappe.db.set_value("Website Settings", None, "home_page", 'contact') + self.assertEqual(get_home_page(), 'contact') + + frappe.db.set_value("Website Settings", None, "home_page", None) + clear_website_cache() + + # fallback homepage + self.assertEqual(get_home_page(), 'me') + + # fallback homepage for guest frappe.set_user('Guest') + self.assertEqual(get_home_page(), 'login') + frappe.set_user('Administrator') + + # test homepage via hooks + clear_website_cache() + set_home_page_hook('get_website_user_home_page', 'frappe.www._test._test_home_page.get_website_user_home_page') + self.assertEqual(get_home_page(), '_test/_test_folder') + + clear_website_cache() + set_home_page_hook('website_user_home_page', 'login') + self.assertEqual(get_home_page(), 'login') + + clear_website_cache() + set_home_page_hook('home_page', 'about') + self.assertEqual(get_home_page(), 'about') + + clear_website_cache() + set_home_page_hook('role_home_page', {'home-page-test': 'home-page-test'}) + self.assertEqual(get_home_page(), 'home-page-test') + + + def test_page_load(self): set_request(method='POST', path='login') - response = render.render() + response = get_response() self.assertEqual(response.status_code, 200) @@ -52,14 +90,52 @@ class TestWebsite(unittest.TestCase): self.assertTrue('// login.js' in html) self.assertTrue('' in html) + + def test_static_page(self): + set_request(method='GET', path='/_test/static-file-test.png') + response = get_response() + self.assertEqual(response.status_code, 200) + + def test_error_page(self): + set_request(method='GET', path='/_test/problematic_page') + response = get_response() + self.assertEqual(response.status_code, 500) + + def test_login(self): + set_request(method='GET', path='/login') + response = get_response() + self.assertEqual(response.status_code, 200) + + html = frappe.safe_decode(response.get_data()) + + self.assertTrue('// login.js' in html) + self.assertTrue('' in html) + + def test_app(self): frappe.set_user('Administrator') + set_request(method='GET', path='/app') + response = get_response() + self.assertEqual(response.status_code, 200) + + html = frappe.safe_decode(response.get_data()) + self.assertTrue('window.app = true;' in html) + frappe.local.session_obj = None + + def test_not_found(self): + set_request(method='GET', path='/_test/missing') + response = get_response() + self.assertEqual(response.status_code, 404) + def test_redirect(self): import frappe.hooks + frappe.set_user('Administrator') + frappe.hooks.website_redirects = [ dict(source=r'/testfrom', target=r'://testto1'), dict(source=r'/testfromregex.*', target=r'://testto2'), - dict(source=r'/testsub/(.*)', target=r'://testto3/\1') + dict(source=r'/testsub/(.*)', target=r'://testto3/\1'), + dict(source=r'/courses/course\?course=(.*)', target=r'/courses/\1', match_with_query_string=True), ] website_settings = frappe.get_doc('Website Settings') @@ -69,32 +145,82 @@ class TestWebsite(unittest.TestCase): }) website_settings.save() - frappe.cache().delete_key('app_hooks') - frappe.cache().delete_key('website_redirects') - set_request(method='GET', path='/testfrom') - response = render.render() + response = get_response() self.assertEqual(response.status_code, 301) self.assertEqual(response.headers.get('Location'), r'://testto1') set_request(method='GET', path='/testfromregex/test') - response = render.render() + response = get_response() self.assertEqual(response.status_code, 301) self.assertEqual(response.headers.get('Location'), r'://testto2') set_request(method='GET', path='/testsub/me') - response = render.render() + response = get_response() self.assertEqual(response.status_code, 301) self.assertEqual(response.headers.get('Location'), r'://testto3/me') set_request(method='GET', path='/test404') - response = render.render() + response = get_response() self.assertEqual(response.status_code, 404) set_request(method='GET', path='/testsource') - response = render.render() + response = get_response() self.assertEqual(response.status_code, 301) self.assertEqual(response.headers.get('Location'), '/testtarget') + set_request(method='GET', path='/courses/course?course=data') + response = get_response() + self.assertEqual(response.status_code, 301) + self.assertEqual(response.headers.get('Location'), '/courses/data') + delattr(frappe.hooks, 'website_redirects') frappe.cache().delete_key('app_hooks') + + def test_custom_page_renderer(self): + import frappe.hooks + frappe.hooks.page_renderer = ['frappe.tests.test_website.CustomPageRenderer'] + frappe.cache().delete_key('app_hooks') + set_request(method='GET', path='/custom') + response = get_response() + self.assertEqual(response.status_code, 3984) + + set_request(method='GET', path='/new') + content = get_response_content() + self.assertIn("
    Custom Page Response
    ", content) + + set_request(method='GET', path='/random') + response = get_response() + self.assertEqual(response.status_code, 404) + + delattr(frappe.hooks, 'page_renderer') + frappe.cache().delete_key('app_hooks') + + def test_printview_page(self): + content = get_response_content('/Language/en') + self.assertIn(' {% endif %} + {% if not disable_feedback %} +
    + {% include 'templates/includes/feedback/feedback.html' %} +
    + {% endif %} ", content) + self.assertIn("background-color: var(--bg-color);", content) + + def test_breadcrumbs(self): + content = get_response_content('/_test/_test_folder/_test_page') + self.assertIn('Test TOC', content) + self.assertIn(' Test Page', content) + + content = get_response_content('/_test/_test_folder/index') + self.assertIn(' Test', content) + self.assertIn('Test TOC', content) + + def test_downloadable_file(self): + pass diff --git a/frappe/website/doctype/web_page/web_page.py b/frappe/website/doctype/web_page/web_page.py index cea14d3bbe..05f5cac546 100644 --- a/frappe/website/doctype/web_page/web_page.py +++ b/frappe/website/doctype/web_page/web_page.py @@ -3,20 +3,17 @@ import re -import requests -import requests.exceptions from jinja2.exceptions import TemplateSyntaxError import frappe from frappe import _ -from frappe.utils import get_datetime, now, strip_html, quoted +from frappe.utils import get_datetime, now, quoted, strip_html from frappe.utils.jinja import render_template -from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow -from frappe.website.router import resolve_route -from frappe.website.utils import (extract_title, find_first_image, get_comment_list, - get_html_content_based_on_type) -from frappe.website.website_generator import WebsiteGenerator from frappe.utils.safe_exec import safe_exec +from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow +from frappe.website.utils import (extract_title, find_first_image, + get_comment_list, get_html_content_based_on_type) +from frappe.website.website_generator import WebsiteGenerator class WebPage(WebsiteGenerator): @@ -53,6 +50,7 @@ class WebPage(WebsiteGenerator): if self.enable_comments: context.comment_list = get_comment_list(self.doctype, self.name) + context.guest_allowed = True context.update({ "style": self.css or "", @@ -183,32 +181,6 @@ def check_publish_status(): frappe.db.set_value("Web Page", page.name, "published", 1) - -def check_broken_links(): - cnt = 0 - for p in frappe.db.sql("select name, main_section from `tabWeb Page`", as_dict=True): - for link in re.findall('href=["\']([^"\']*)["\']', p.main_section): - if link.startswith("http"): - try: - res = requests.get(link) - except requests.exceptions.SSLError: - res = frappe._dict({"status_code": "SSL Error"}) - except requests.exceptions.ConnectionError: - res = frappe._dict({"status_code": "Connection Error"}) - - if res.status_code!=200: - print("[{0}] {1}: {2}".format(res.status_code, p.name, link)) - cnt += 1 - else: - link = link[1:] # remove leading / - link = link.split("#")[0] - - if not resolve_route(link): - print(p.name + ":" + link) - cnt += 1 - - print("{0} links broken".format(cnt)) - def get_web_blocks_html(blocks): '''Converts a list of blocks into Raw HTML and extracts out their scripts for deduplication''' diff --git a/frappe/website/doctype/web_template/test_web_template.py b/frappe/website/doctype/web_template/test_web_template.py index 45e35c4626..2f2dbdc40a 100644 --- a/frappe/website/doctype/web_template/test_web_template.py +++ b/frappe/website/doctype/web_template/test_web_template.py @@ -5,8 +5,7 @@ import frappe import unittest from bs4 import BeautifulSoup from frappe.utils import set_request -from frappe.website.render import render - +from frappe.website.serve import get_response class TestWebTemplate(unittest.TestCase): def test_render_web_template_with_values(self): @@ -34,7 +33,7 @@ class TestWebTemplate(unittest.TestCase): self.create_web_page() set_request(method="GET", path="test-web-template") - response = render() + response = get_response() self.assertEqual(response.status_code, 200) @@ -56,7 +55,7 @@ class TestWebTemplate(unittest.TestCase): frappe.conf.developer_mode = 1 set_request(method="GET", path="test-web-template") - response = render() + response = get_response() self.assertEqual(response.status_code, 200) html = frappe.safe_decode(response.get_data()) diff --git a/frappe/website/doctype/web_template/web_template.py b/frappe/website/doctype/web_template/web_template.py index 891a0c3679..6905680523 100644 --- a/frappe/website/doctype/web_template/web_template.py +++ b/frappe/website/doctype/web_template/web_template.py @@ -7,7 +7,7 @@ from shutil import rmtree import frappe from frappe.model.document import Document -from frappe.website.render import clear_cache +from frappe.website.utils import clear_cache from frappe import _ from frappe.modules.export_file import ( write_document_file, diff --git a/frappe/website/doctype/website_route_meta/test_website_route_meta.py b/frappe/website/doctype/website_route_meta/test_website_route_meta.py index 1f927abafc..c55dcce1ca 100644 --- a/frappe/website/doctype/website_route_meta/test_website_route_meta.py +++ b/frappe/website/doctype/website_route_meta/test_website_route_meta.py @@ -4,7 +4,7 @@ import frappe import unittest from frappe.utils import set_request -from frappe.website.render import render +from frappe.website.serve import get_response test_dependencies = ['Blog Post'] class TestWebsiteRouteMeta(unittest.TestCase): @@ -29,7 +29,7 @@ class TestWebsiteRouteMeta(unittest.TestCase): # set request on this route set_request(path=blog.route) - response = render() + response = get_response() self.assertTrue(response.status_code, 200) diff --git a/frappe/website/doctype/website_script/website_script.py b/frappe/website/doctype/website_script/website_script.py index 111beeaf2a..6ec10291cd 100644 --- a/frappe/website/doctype/website_script/website_script.py +++ b/frappe/website/doctype/website_script/website_script.py @@ -13,5 +13,5 @@ class WebsiteScript(Document): """clear cache""" frappe.clear_cache(user = 'Guest') - from frappe.website.render import clear_cache + from frappe.website.utils import clear_cache clear_cache() \ No newline at end of file diff --git a/frappe/website/doctype/website_settings/website_settings.py b/frappe/website/doctype/website_settings/website_settings.py index 03a1aecc5f..1ccd106c38 100644 --- a/frappe/website/doctype/website_settings/website_settings.py +++ b/frappe/website/doctype/website_settings/website_settings.py @@ -1,14 +1,13 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt +from urllib.parse import quote import frappe from frappe import _ -from frappe.utils import get_request_site_address, encode -from frappe.model.document import Document -from urllib.parse import quote -from frappe.website.router import resolve_route -from frappe.website.doctype.website_theme.website_theme import add_website_theme from frappe.integrations.doctype.google_settings.google_settings import get_auth_url +from frappe.model.document import Document +from frappe.utils import encode, get_request_site_address +from frappe.website.doctype.website_theme.website_theme import add_website_theme INDEXING_SCOPES = "https://www.googleapis.com/auth/indexing" @@ -22,7 +21,8 @@ class WebsiteSettings(Document): def validate_home_page(self): if frappe.flags.in_install: return - if self.home_page and not resolve_route(self.home_page): + from frappe.website.path_resolver import PathResolver + if self.home_page and not PathResolver(self.home_page).is_valid_path(): frappe.msgprint(_("Invalid Home Page") + " (Standard pages - index, login, products, blog, about, contact)") self.home_page = '' @@ -68,7 +68,7 @@ class WebsiteSettings(Document): # clear web cache (for menus!) frappe.clear_cache(user = 'Guest') - from frappe.website.render import clear_cache + from frappe.website.utils import clear_cache clear_cache() # clears role based home pages diff --git a/frappe/website/doctype/website_slideshow/website_slideshow.py b/frappe/website/doctype/website_slideshow/website_slideshow.py index d31adbf986..8566475b33 100644 --- a/frappe/website/doctype/website_slideshow/website_slideshow.py +++ b/frappe/website/doctype/website_slideshow/website_slideshow.py @@ -14,7 +14,7 @@ class WebsiteSlideshow(Document): def on_update(self): # a slide show can be in use and any change in it should get reflected - from frappe.website.render import clear_cache + from frappe.website.utils import clear_cache clear_cache() def validate_images(self): @@ -22,7 +22,7 @@ class WebsiteSlideshow(Document): files = map(lambda row: row.image, self.slideshow_items) if files: result = frappe.get_all("File", filters={ "file_url":("in", list(files)) }, fields="is_private") - if any([file.is_private for file in result]): + if any(file.is_private for file in result): frappe.throw(_("All Images attached to Website Slideshow should be public")) def get_slideshow(doc): diff --git a/frappe/website/page/__init__.py b/frappe/website/page/__init__.py deleted file mode 100644 index 8b13789179..0000000000 --- a/frappe/website/page/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frappe/website/page_renderers/base_renderer.py b/frappe/website/page_renderers/base_renderer.py new file mode 100644 index 0000000000..14d4448e6c --- /dev/null +++ b/frappe/website/page_renderers/base_renderer.py @@ -0,0 +1,25 @@ +import frappe +from frappe.website.utils import build_response + +class BaseRenderer(object): + def __init__(self, path=None, http_status_code=None): + self.headers = None + self.http_status_code = http_status_code or 200 + if not path: + path = frappe.local.request.path + self.path = path.strip('/ ') + self.basepath = '' + self.basename = '' + self.name = '' + self.route = '' + self.file_dir = None + + def can_render(self): + raise NotImplementedError + + def render(self): + raise NotImplementedError + + def build_response(self, data, http_status_code=None, headers=None): + return build_response(self.path, data, http_status_code or self.http_status_code, headers or self.headers) + diff --git a/frappe/website/page_renderers/base_template_page.py b/frappe/website/page_renderers/base_template_page.py new file mode 100644 index 0000000000..7802e6e7f6 --- /dev/null +++ b/frappe/website/page_renderers/base_template_page.py @@ -0,0 +1,70 @@ +import frappe +from frappe.website.doctype.website_settings.website_settings import get_website_settings +from frappe.website.page_renderers.base_renderer import BaseRenderer +from frappe.website.website_components.metatags import MetaTags + + +class BaseTemplatePage(BaseRenderer): + def __init__(self, path, http_status_code=None): + super().__init__(path=path, http_status_code=http_status_code) + self.template_path = '' + + def init_context(self): + self.context = frappe._dict() + self.context.update(get_website_settings()) + self.context.update(frappe.local.conf.get("website_context") or {}) + + def add_csrf_token(self, html): + if frappe.local.session: + csrf_token = frappe.local.session.data.csrf_token + return html.replace("", + f'') + + return html + + def post_process_context(self): + self.tags = MetaTags(self.path, self.context).tags + self.context.metatags = self.tags + self.set_base_template_if_missing() + self.set_title_with_prefix() + self.update_website_context() + # context sends us a new template path + self.template_path = self.context.template or self.template_path + self.context._context_dict = self.context + self.set_missing_values() + + def set_base_template_if_missing(self): + if not self.context.base_template_path: + app_base = frappe.get_hooks("base_template") + self.context.base_template_path = app_base[-1] if app_base else "templates/base.html" + + def set_title_with_prefix(self): + if (self.context.title_prefix and self.context.title + and not self.context.title.startswith(self.context.title_prefix)): + self.context.title = '{0} - {1}'.format(self.context.title_prefix, self.context.title) + + def set_missing_values(self): + # set using frappe.respond_as_web_page + if hasattr(frappe.local, 'response') and frappe.local.response.get('context'): + self.context.update(frappe.local.response.context) + + # to be able to inspect the context dict + # Use the macro "inspect" from macros.html + self.context.canonical = frappe.utils.get_url(frappe.utils.escape_html(self.path)) + + if "url_prefix" not in self.context: + self.context.url_prefix = "" + + if self.context.url_prefix and self.context.url_prefix[-1]!='/': + self.context.url_prefix += '/' + + self.context.path = self.path + self.context.pathname = frappe.local.path if hasattr(frappe, 'local') else self.path + + def update_website_context(self): + # apply context from hooks + update_website_context = frappe.get_hooks('update_website_context') + for method in update_website_context: + values = frappe.get_attr(method)(self.context) + if values: + self.context.update(values) diff --git a/frappe/website/page_renderers/document_page.py b/frappe/website/page_renderers/document_page.py new file mode 100644 index 0000000000..f1741c681f --- /dev/null +++ b/frappe/website/page_renderers/document_page.py @@ -0,0 +1,87 @@ +import frappe +from frappe.model.document import get_controller +from frappe.website.page_renderers.base_template_page import BaseTemplatePage +from frappe.website.utils import build_response +from frappe.website.router import (get_doctypes_with_web_view, + get_page_info_from_web_page_with_dynamic_routes) + + +class DocumentPage(BaseTemplatePage): + def can_render(self): + ''' + Find a document with matching `route` from all doctypes with `has_web_view`=1 + ''' + if self.search_in_doctypes_with_web_view(): + return True + + if self.search_web_page_dynamic_routes(): + return True + + return False + + def search_in_doctypes_with_web_view(self): + for doctype in get_doctypes_with_web_view(): + filters = dict(route=self.path) + meta = frappe.get_meta(doctype) + condition_field = self.get_condition_field(meta) + + if condition_field: + filters[condition_field] = 1 + + try: + self.docname = frappe.db.get_value(doctype, filters, 'name') + if self.docname: + self.doctype = doctype + return True + except Exception as e: + if not frappe.db.is_missing_column(e): + raise e + + def search_web_page_dynamic_routes(self): + d = get_page_info_from_web_page_with_dynamic_routes(self.path) + if d: + self.doctype = 'Web Page' + self.docname = d.name + return True + else: + return False + + def render(self): + self.doc = frappe.get_doc(self.doctype, self.docname) + self.init_context() + self.update_context() + self.post_process_context() + html = frappe.get_template(self.template_path).render(self.context) + html = self.add_csrf_token(html) + + return build_response(self.path, html, self.http_status_code or 200, self.headers) + + def update_context(self): + self.context.doc = self.doc + self.context.update(self.context.doc.as_dict()) + self.context.update(self.context.doc.get_page_info()) + + self.template_path = self.context.template or self.template_path + + if not self.template_path: + self.template_path = self.context.doc.meta.get_web_template() + + if hasattr(self.doc, "get_context"): + ret = self.doc.get_context(self.context) + + if ret: + self.context.update(ret) + + for prop in ("no_cache", "sitemap"): + if prop not in self.context: + self.context[prop] = getattr(self.doc, prop, False) + + def get_condition_field(self, meta): + condition_field = None + if meta.is_published_field: + condition_field = meta.is_published_field + elif not meta.custom: + controller = get_controller(meta.name) + condition_field = controller.website.condition_field + + return condition_field diff --git a/frappe/website/page_renderers/error_page.py b/frappe/website/page_renderers/error_page.py new file mode 100644 index 0000000000..3501c77765 --- /dev/null +++ b/frappe/website/page_renderers/error_page.py @@ -0,0 +1,10 @@ +from frappe.website.page_renderers.template_page import TemplatePage + +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 + + def can_render(self): + return True diff --git a/frappe/website/page_renderers/list_page.py b/frappe/website/page_renderers/list_page.py new file mode 100644 index 0000000000..61c781ea14 --- /dev/null +++ b/frappe/website/page_renderers/list_page.py @@ -0,0 +1,11 @@ +import frappe +from frappe.website.page_renderers.template_page import TemplatePage + +class ListPage(TemplatePage): + def can_render(self): + return frappe.db.exists('DocType', self.path, True) + + def render(self): + frappe.local.form_dict.doctype = self.path + self.set_standard_path('list') + return super().render() diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py new file mode 100644 index 0000000000..af510fecfc --- /dev/null +++ b/frappe/website/page_renderers/not_found_page.py @@ -0,0 +1,34 @@ +import os +from urllib.parse import urlparse + +import frappe +from frappe.website.page_renderers.template_page import TemplatePage +from frappe.website.utils import can_cache + +HOMEPAGE_PATHS = ('/', '/index', 'index') + +class NotFoundPage(TemplatePage): + def __init__(self, path, http_status_code): + self.request_path = path + self.request_url = frappe.local.request.url if hasattr(frappe.local, 'request') else '' + path = '404' + http_status_code = 404 + super().__init__(path=path, http_status_code=http_status_code) + + def can_render(self): + return True + + def render(self): + if self.can_cache_404(): + frappe.cache().hset('website_404', self.request_url, True) + return super().render() + + def can_cache_404(self): + # do not cache 404 for custom homepages + return can_cache() and self.request_url and not self.is_custom_home_page() + + def is_custom_home_page(self): + url_parts = urlparse(self.request_url) + request_url = os.path.splitext(url_parts.path)[0] + request_path = os.path.splitext(self.request_path)[0] + return request_url in HOMEPAGE_PATHS and request_path not in HOMEPAGE_PATHS diff --git a/frappe/website/page_renderers/not_permitted_page.py b/frappe/website/page_renderers/not_permitted_page.py new file mode 100644 index 0000000000..e69299f5c5 --- /dev/null +++ b/frappe/website/page_renderers/not_permitted_page.py @@ -0,0 +1,24 @@ +import frappe +from frappe import _ +from frappe.website.page_renderers.template_page import TemplatePage +from frappe.utils import cstr + +class NotPermittedPage(TemplatePage): + def __init__(self, path=None, http_status_code=None, exception=''): + frappe.local.message = cstr(exception) + super().__init__(path=path, http_status_code=http_status_code) + self.http_status_code = 403 + + def can_render(self): + return True + + def render(self): + frappe.local.message_title = _("Not Permitted") + frappe.local.response['context'] = dict( + indicator_color = 'red', + primary_action = '/login', + primary_label = _('Login'), + fullpage=True + ) + self.set_standard_path('message') + return super().render() diff --git a/frappe/website/page_renderers/print_page.py b/frappe/website/page_renderers/print_page.py new file mode 100644 index 0000000000..05d4026e2b --- /dev/null +++ b/frappe/website/page_renderers/print_page.py @@ -0,0 +1,23 @@ +import frappe +from frappe.website.page_renderers.template_page import TemplatePage + +class PrintPage(TemplatePage): + ''' + default path returns a printable object (based on permission) + /Quotation/Q-0001 + ''' + def can_render(self): + parts = self.path.split('/', 1) + if len(parts)==2: + if (frappe.db.exists('DocType', parts[0], True) + and frappe.db.exists(parts[0], parts[1], True)): + return True + + return False + + def render(self): + parts = self.path.split('/', 1) + frappe.form_dict.doctype = parts[0] + frappe.form_dict.name = parts[1] + self.set_standard_path('printview') + return super().render() diff --git a/frappe/website/page_renderers/redirect_page.py b/frappe/website/page_renderers/redirect_page.py new file mode 100644 index 0000000000..2049c375e8 --- /dev/null +++ b/frappe/website/page_renderers/redirect_page.py @@ -0,0 +1,16 @@ +import frappe +from frappe.website.utils import build_response + +class RedirectPage(object): + def __init__(self, path, http_status_code=301): + self.path = path + self.http_status_code = http_status_code + + def can_render(self): + return True + + def render(self): + return build_response(self.path, "", 301, { + "Location": frappe.flags.redirect_location or (frappe.local.response or {}).get('location'), + "Cache-Control": "no-store, no-cache, must-revalidate" + }) diff --git a/frappe/website/page_renderers/static_page.py b/frappe/website/page_renderers/static_page.py new file mode 100644 index 0000000000..632e9b4302 --- /dev/null +++ b/frappe/website/page_renderers/static_page.py @@ -0,0 +1,41 @@ +import mimetypes +import os + +from werkzeug.wrappers import Response +from werkzeug.wsgi import wrap_file + +import frappe +from frappe.website.page_renderers.base_renderer import BaseRenderer + +UNSUPPORTED_STATIC_PAGE_TYPES = ('html', 'md', 'js', 'xml', 'css', 'txt', 'py', 'json') + +class StaticPage(BaseRenderer): + def __init__(self, path, http_status_code=None): + super().__init__(path=path, http_status_code=http_status_code) + self.set_file_path() + + def set_file_path(self): + self.file_path = '' + if not self.is_valid_file_path(): + return + for app in frappe.get_installed_apps(): + file_path = frappe.get_app_path(app, 'www') + '/' + self.path + if os.path.isfile(file_path): + self.file_path = file_path + + def can_render(self): + return self.is_valid_file_path() and self.file_path + + def is_valid_file_path(self): + if ('.' not in self.path): + return False + extension = self.path.rsplit('.', 1)[-1] + if extension in UNSUPPORTED_STATIC_PAGE_TYPES: + return False + return True + + def render(self): + f = open(self.file_path, 'rb') + response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True) + response.mimetype = mimetypes.guess_type(self.file_path)[0] or 'application/octet-stream' + return response diff --git a/frappe/website/page_renderers/template_page.py b/frappe/website/page_renderers/template_page.py new file mode 100644 index 0000000000..5e6e57e33a --- /dev/null +++ b/frappe/website/page_renderers/template_page.py @@ -0,0 +1,281 @@ +import io +import os +import click + +import frappe +from frappe.website.router import get_page_info +from frappe.website.page_renderers.base_template_page import BaseTemplatePage +from frappe.website.router import get_base_template +from frappe.website.utils import (extract_comment_tag, extract_title, get_next_link, + get_toc, get_frontmatter, cache_html, get_sidebar_items, build_response) + +WEBPAGE_PY_MODULE_PROPERTIES = ("base_template_path", "template", "no_cache", "sitemap", "condition_field") + +COMMENT_PROPERTY_KEY_VALUE_MAP = { + "no-breadcrumbs": ("no_breadcrumbs", 1), + "show-sidebar": ("show_sidebar", 1), + "add-breadcrumbs": ("add_breadcrumbs", 1), + "no-header": ("no_header", 1), + "add-next-prev-links": ("add_next_prev_links", 1), + "no-cache": ("no_cache", 1), + "no-sitemap": ("sitemap", 0), + "sitemap": ("sitemap", 1) +} + +class TemplatePage(BaseTemplatePage): + def __init__(self, path, http_status_code=None): + super().__init__(path=path, http_status_code=http_status_code) + self.set_template_path() + + def set_template_path(self): + ''' + Searches for file matching the path in the /www + and /templates/pages folders and sets path if match is found + ''' + folders = get_start_folders() + for app in frappe.get_installed_apps(frappe_last=True): + app_path = frappe.get_app_path(app) + + for dirname in folders: + search_path = os.path.join(app_path, dirname, self.path) + for file_path in self.get_index_path_options(search_path): + if os.path.isfile(file_path): + self.app = app + self.app_path = app_path + self.file_dir = dirname + self.basename = os.path.splitext(file_path)[0] + self.template_path = os.path.relpath(file_path, self.app_path) + self.basepath = os.path.dirname(file_path) + self.filename = os.path.basename(file_path) + self.name = os.path.splitext(self.filename)[0] + return + + def can_render(self): + return hasattr(self, 'template_path') and bool(self.template_path) + + @staticmethod + def get_index_path_options(search_path): + return (frappe.as_unicode(f'{search_path}{d}') for d in ('', '.html', '.md', '/index.html', '/index.md')) + + def render(self): + return build_response(self.path, self.get_html(), self.http_status_code, self.headers) + + @cache_html + def get_html(self): + # context object should be separate from self for security + # because it will be accessed via the user defined template + self.init_context() + + self.set_pymodule() + self.setup_template() + self.update_context() + self.post_process_context() + + html = self.render_template() + html = self.update_toc(html) + html = self.add_csrf_token(html) + + return html + + def post_process_context(self): + self.set_user_info() + self.add_sidebar_and_breadcrumbs() + super(TemplatePage, self).post_process_context() + + def add_sidebar_and_breadcrumbs(self): + if self.basepath: + self.context.sidebar_items = get_sidebar_items(self.context.website_sidebar, self.basepath) + + if self.context.add_breadcrumbs and not self.context.parents: + parent_path = os.path.dirname(self.path) + if self.path.endswith('index'): + # in case of index page move one directory up for parent path + parent_path = os.path.dirname(parent_path) + + for parent_file_path in self.get_index_path_options(parent_path): + parent_file_path = os.path.join(self.app_path, self.file_dir, parent_file_path) + if os.path.isfile(parent_file_path): + parent_page_context = get_page_info(parent_file_path, self.app, self.file_dir) + if parent_page_context: + self.context.parents = [dict(route=os.path.dirname(self.path), title=parent_page_context.title)] + break + + def set_pymodule(self): + ''' + A template may have a python module with a `get_context` method along with it in the + same folder. Also the hyphens will be coverted to underscore for python module names. + This method sets the pymodule_name if it exists. + ''' + template_basepath = os.path.splitext(self.template_path)[0] + self.pymodule_name = None + + # replace - with _ in the internal modules names + self.pymodule_path = os.path.join(os.path.dirname(template_basepath), os.path.basename(template_basepath.replace("-", "_")) + ".py") + + if os.path.exists(os.path.join(self.app_path, self.pymodule_path)): + self.pymodule_name = self.app + "." + self.pymodule_path.replace(os.path.sep, ".")[:-3] + + def setup_template(self): + '''Setup template source, frontmatter and markdown conversion''' + self.source = self.get_raw_template() + self.extract_frontmatter() + self.convert_from_markdown() + + def update_context(self): + self.set_page_properties() + self.set_properties_from_source() + self.load_colocated_files() + self.context.build_version = frappe.utils.get_build_version() + + if self.pymodule_name: + self.pymodule = frappe.get_module(self.pymodule_name) + self.set_pymodule_properties() + + data = self.run_pymodule_method('get_context') + # some methods may return a "context" object + if data: + self.context.update(data) + # TODO: self.context.children = self.run_pymodule_method('get_children') + + self.context.developer_mode = frappe.conf.developer_mode + if self.context.http_status_code: + self.http_status_code = self.context.http_status_code + + def set_pymodule_properties(self): + for prop in WEBPAGE_PY_MODULE_PROPERTIES: + if hasattr(self.pymodule, prop): + self.context[prop] = getattr(self.pymodule, prop) + + def set_page_properties(self): + self.context.base_template = self.context.base_template \ + or get_base_template(self.path) \ + or 'templates/web.html' + self.context.basepath = self.basepath + self.context.basename = self.basename + self.context.name = self.name + self.context.path = self.path + self.context.route = self.path + self.context.template = self.template_path + + def set_properties_from_source(self): + if not self.source: + return + context = self.context + if not context.title: + context.title = extract_title(self.source, self.path) + + base_template = extract_comment_tag(self.source, 'base_template') + if base_template: + context.base_template = base_template + + if (context.base_template + and "{%- extends" not in self.source + and "{% extends" not in self.source + and "" not in self.source): + self.source = '''{{% extends "{0}" %}} + {{% block page_content %}}{1}{{% endblock %}}'''.format(context.base_template, self.source) + + self.set_properties_via_comments() + + def set_properties_via_comments(self): + for comment, (context_key, value) in COMMENT_PROPERTY_KEY_VALUE_MAP.items(): + comment_tag = f"" + if comment_tag in self.source: + self.context[context_key] = value + click.echo(f'\n⚠️ DEPRECATION WARNING: {comment_tag} will be deprecated on 2021-12-31.') + click.echo(f'Please remove it from {self.template_path} in {self.app}') + + def run_pymodule_method(self, method): + if hasattr(self.pymodule, method): + try: + return getattr(self.pymodule, method)(self.context) + except (frappe.PermissionError, frappe.DoesNotExistError, frappe.Redirect): + raise + except Exception: + if not frappe.flags.in_migrate: + frappe.errprint(frappe.utils.get_traceback()) + + def render_template(self): + if self.source: + html = frappe.render_template(self.source, self.context) + elif self.template_path: + if self.path.endswith('min.js'): + html = self.get_raw_template() # static + else: + html = frappe.get_template(self.template_path).render(self.context) + + return html + + def extends_template(self): + return (self.template_path.endswith(('.html', '.md')) + and ('{%- extends' in self.source + or '{% extends' in self.source)) + + def get_raw_template(self): + return frappe.get_jloader().get_source(frappe.get_jenv(), self.template_path)[0] + + def load_colocated_files(self): + '''load co-located css/js files with the same name''' + js_path = self.basename + '.js' + if os.path.exists(js_path) and '{% block script %}' not in self.source: + self.context.colocated_js = self.get_colocated_file(js_path) + + css_path = self.basename + '.css' + if os.path.exists(css_path) and '{% block style %}' not in self.source: + self.context.colocated_css = self.get_colocated_file(css_path) + + def get_colocated_file(self, path): + with io.open(path, 'r', encoding = 'utf-8') as f: + return f.read() + + def extract_frontmatter(self): + if not self.template_path.endswith(('.md', '.html')): + return + + try: + # values will be used to update self + res = get_frontmatter(self.source) + if res['attributes']: + self.context.update(res['attributes']) + self.source = res['body'] + except Exception: + pass + + def convert_from_markdown(self): + if self.template_path.endswith('.md'): + self.source = frappe.utils.md_to_html(self.source) + self.context.page_toc_html = self.source.toc_html + + if not self.context.show_sidebar: + self.source = '
    ' + self.source + '
    ' + + def update_toc(self, html): + if '{index}' in html: + html = html.replace('{index}', get_toc(self.path)) + + if '{next}' in html: + html = html.replace('{next}', get_next_link(self.path)) + + return html + + def set_standard_path(self, path): + self.app = 'frappe' + self.app_path = frappe.get_app_path('frappe') + self.path = path + self.template_path = 'www/{path}.html'.format(path=path) + + def set_missing_values(self): + super().set_missing_values() + # for backward compatibility + self.context.docs_base_url = '/docs' + + def set_user_info(self): + from frappe.utils.user import get_fullname_and_avatar + info = get_fullname_and_avatar(frappe.session.user) + self.context["fullname"] = info.fullname + self.context["user_image"] = info.avatar + self.context["user"] = info.name + + +def get_start_folders(): + return frappe.local.flags.web_pages_folders or ('www', 'templates/pages') diff --git a/frappe/website/page_renderers/web_form.py b/frappe/website/page_renderers/web_form.py new file mode 100644 index 0000000000..786aeef3d1 --- /dev/null +++ b/frappe/website/page_renderers/web_form.py @@ -0,0 +1,10 @@ +from frappe.website.page_renderers.document_page import DocumentPage +import frappe + +class WebFormPage(DocumentPage): + def can_render(self): + webform_name = frappe.db.exists("Web Form", {'route': self.path}, cache=True) + if webform_name: + self.doctype = 'Web Form' + self.docname = webform_name + return bool(webform_name) diff --git a/frappe/website/path_resolver.py b/frappe/website/path_resolver.py new file mode 100644 index 0000000000..bedd9f19ae --- /dev/null +++ b/frappe/website/path_resolver.py @@ -0,0 +1,155 @@ +import re +import click + +from werkzeug.routing import Rule + +import frappe +from frappe.website.page_renderers.document_page import DocumentPage +from frappe.website.page_renderers.list_page import ListPage +from frappe.website.page_renderers.not_found_page import NotFoundPage +from frappe.website.page_renderers.print_page import PrintPage +from frappe.website.page_renderers.redirect_page import RedirectPage +from frappe.website.page_renderers.static_page import StaticPage +from frappe.website.page_renderers.template_page import TemplatePage +from frappe.website.page_renderers.web_form import WebFormPage +from frappe.website.router import evaluate_dynamic_routes +from frappe.website.utils import can_cache, get_home_page + + +class PathResolver(): + def __init__(self, path): + self.path = path.strip('/ ') + + def resolve(self): + '''Returns endpoint and a renderer instance that can render the endpoint''' + request = frappe._dict() + if hasattr(frappe.local, 'request'): + request = frappe.local.request or request + + # check if the request url is in 404 list + if request.url and can_cache() and frappe.cache().hget('website_404', request.url): + return self.path, NotFoundPage(self.path) + + try: + resolve_redirect(self.path, request.query_string) + except frappe.Redirect: + return frappe.flags.redirect_location, RedirectPage(self.path) + + endpoint = resolve_path(self.path) + custom_renderers = self.get_custom_page_renderers() + renderers = custom_renderers + [StaticPage, WebFormPage, TemplatePage, ListPage, DocumentPage, PrintPage, NotFoundPage] + + for renderer in renderers: + renderer_instance = renderer(endpoint, 200) + if renderer_instance.can_render(): + return endpoint, renderer_instance + + return endpoint, NotFoundPage(endpoint) + + def is_valid_path(self): + _endpoint, renderer_instance = self.resolve() + return not isinstance(renderer_instance, NotFoundPage) + + @staticmethod + def get_custom_page_renderers(): + custom_renderers = [] + for renderer_path in frappe.get_hooks('page_renderer') or []: + try: + renderer = frappe.get_attr(renderer_path) + if not hasattr(renderer, 'can_render'): + click.echo(f'{renderer.__name__} does not have can_render method') + continue + if not hasattr(renderer, 'render'): + click.echo(f'{renderer.__name__} does not have render method') + continue + + custom_renderers.append(renderer) + + except Exception: + click.echo(f'Failed to load page renderer. Import path: {renderer_path}') + + return custom_renderers + + + +def resolve_redirect(path, query_string=None): + ''' + Resolve redirects from hooks + + Example: + + website_redirect = [ + # absolute location + {"source": "/from", "target": "https://mysite/from"}, + + # relative location + {"source": "/from", "target": "/main"}, + + # use regex + {"source": r"/from/(.*)", "target": r"/main/\1"} + # use r as a string prefix if you use regex groups or want to escape any string literal + ] + ''' + redirects = frappe.get_hooks('website_redirects') + redirects += frappe.db.get_all('Website Route Redirect', ['source', 'target']) + + if not redirects: return + + redirect_to = frappe.cache().hget('website_redirects', path) + + if redirect_to: + frappe.flags.redirect_location = redirect_to + raise frappe.Redirect + + for rule in redirects: + pattern = rule['source'].strip('/ ') + '$' + path_to_match = path + if rule.get('match_with_query_string'): + path_to_match = path + '?' + frappe.safe_decode(query_string) + + if re.match(pattern, path_to_match): + redirect_to = re.sub(pattern, rule['target'], path_to_match) + frappe.flags.redirect_location = redirect_to + frappe.cache().hset('website_redirects', path_to_match, redirect_to) + raise frappe.Redirect + + +def resolve_path(path): + if not path: + path = "index" + + if path.endswith('.html'): + path = path[:-5] + + if path == "index": + path = get_home_page() + + frappe.local.path = path + + if path != "index": + path = resolve_from_map(path) + + return path + +def resolve_from_map(path): + '''transform dynamic route to a static one from hooks and route defined in doctype''' + rules = [Rule(r["from_route"], endpoint=r["to_route"], defaults=r.get("defaults")) + for r in get_website_rules()] + + return evaluate_dynamic_routes(rules, path) or path + +def get_website_rules(): + '''Get website route rules from hooks and DocType route''' + def _get(): + rules = frappe.get_hooks("website_route_rules") + for d in frappe.get_all('DocType', 'name, route', dict(has_web_view=1)): + if d.route: + rules.append(dict(from_route = '/' + d.route.strip('/'), to_route=d.name)) + + return rules + + if frappe.local.dev_server: + # dont cache in development + return _get() + + return frappe.cache().get_value('website_route_rules', _get) diff --git a/frappe/website/purifycss.py b/frappe/website/purifycss.py deleted file mode 100644 index bac68b881b..0000000000 --- a/frappe/website/purifycss.py +++ /dev/null @@ -1,42 +0,0 @@ -''' -Check for unused CSS Classes - -sUpdate source and target apps below and run from CLI - - bench --site [sitename] execute frappe.website.purifycss.purify.css - -''' - -import frappe, re, os - -source = frappe.get_app_path('frappe_theme', 'public', 'less', 'frappe_theme.less') -target_apps = ['erpnext_com', 'frappe_io', 'translator', 'chart_of_accounts_builder', 'frappe_theme'] - -def purifycss(): - with open(source, 'r') as f: - src = f.read() - - classes = [] - for line in src.splitlines(): - line = line.strip() - if not line: - continue - if line[0]=='@': - continue - classes.extend(re.findall('\.([^0-9][^ :&.{,(]*)', line)) - - classes = list(set(classes)) - - for app in target_apps: - for basepath, folders, files in os.walk(frappe.get_app_path(app)): - for fname in files: - if fname.endswith('.html') or fname.endswith('.md'): - #print 'checking {0}...'.format(fname) - with open(os.path.join(basepath, fname), 'r') as f: - src = f.read() - for c in classes: - if c in src: - classes.remove(c) - - for c in sorted(classes): - print(c) diff --git a/frappe/website/redirect.py b/frappe/website/redirect.py deleted file mode 100644 index 3194895d95..0000000000 --- a/frappe/website/redirect.py +++ /dev/null @@ -1,39 +0,0 @@ -import re, frappe - -def resolve_redirect(path): - ''' - Resolve redirects from hooks - - Example: - - website_redirect = [ - # absolute location - {"source": "/from", "target": "https://mysite/from"}, - - # relative location - {"source": "/from", "target": "/main"}, - - # use regex - {"source": r"/from/(.*)", "target": r"/main/\1"} - # use r as a string prefix if you use regex groups or want to escape any string literal - ] - ''' - redirects = frappe.get_hooks('website_redirects') - redirects += frappe.db.get_all('Website Route Redirect', ['source', 'target']) - - if not redirects: return - - redirect_to = frappe.cache().hget('website_redirects', path) - - if redirect_to: - frappe.flags.redirect_location = redirect_to - raise frappe.Redirect - - for rule in redirects: - pattern = rule['source'].strip('/ ') + '$' - if re.match(pattern, path): - redirect_to = re.sub(pattern, rule['target'], path) - frappe.flags.redirect_location = redirect_to - frappe.cache().hset('website_redirects', path, redirect_to) - raise frappe.Redirect - diff --git a/frappe/website/render.py b/frappe/website/render.py deleted file mode 100644 index 2b4a5e2dab..0000000000 --- a/frappe/website/render.py +++ /dev/null @@ -1,369 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt - -import frappe -from frappe import _ -import frappe.sessions -from frappe.utils import cstr -import os, mimetypes, json -import re - -from werkzeug.wrappers import Response -from werkzeug.routing import Rule -from werkzeug.wsgi import wrap_file - -from frappe.website.context import get_context -from frappe.website.redirect import resolve_redirect -from frappe.website.utils import (get_home_page, can_cache, delete_page_cache, - get_toc, get_next_link) -from frappe.website.router import clear_sitemap, evaluate_dynamic_routes -from frappe.translate import guess_language - -class PageNotFoundError(Exception): pass - -def render(path=None, http_status_code=None): - """render html page""" - if not path: - path = frappe.local.request.path - - try: - path = path.strip('/ ') - raise_if_disabled(path) - resolve_redirect(path) - path = resolve_path(path) - data = None - - # if in list of already known 404s, send it - if can_cache() and frappe.cache().hget('website_404', frappe.request.url): - data = render_page('404') - http_status_code = 404 - elif is_static_file(path): - return get_static_file_response() - elif is_web_form(path): - data = render_web_form(path) - else: - try: - data = render_page_by_language(path) - except frappe.PageDoesNotExistError: - doctype, name = get_doctype_from_path(path) - if doctype and name: - path = "printview" - frappe.local.form_dict.doctype = doctype - frappe.local.form_dict.name = name - elif doctype: - path = "list" - frappe.local.form_dict.doctype = doctype - else: - # 404s are expensive, cache them! - frappe.cache().hset('website_404', frappe.request.url, True) - data = render_page('404') - http_status_code = 404 - - if not data: - try: - data = render_page(path) - except frappe.PermissionError as e: - data, http_status_code = render_403(e, path) - - except frappe.PermissionError as e: - data, http_status_code = render_403(e, path) - - except frappe.Redirect as e: - raise e - - except Exception: - path = "error" - data = render_page(path) - http_status_code = 500 - - data = add_csrf_token(data) - - except frappe.Redirect: - return build_response(path, "", 301, { - "Location": frappe.flags.redirect_location or (frappe.local.response or {}).get('location'), - "Cache-Control": "no-store, no-cache, must-revalidate" - }) - - return build_response(path, data, http_status_code or 200) - -def is_static_file(path): - if ('.' not in path): - return False - extn = path.rsplit('.', 1)[-1] - if extn in ('html', 'md', 'js', 'xml', 'css', 'txt', 'py', 'json'): - return False - - for app in frappe.get_installed_apps(): - file_path = frappe.get_app_path(app, 'www') + '/' + path - if os.path.exists(file_path): - frappe.flags.file_path = file_path - return True - - return False - -def is_web_form(path): - return bool(frappe.get_all("Web Form", filters={'route': path})) - -def render_web_form(path): - data = render_page(path) - return data - -def get_static_file_response(): - try: - f = open(frappe.flags.file_path, 'rb') - except IOError: - raise NotFound - - response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True) - response.mimetype = mimetypes.guess_type(frappe.flags.file_path)[0] or 'application/octet-stream' - return response - -def build_response(path, data, http_status_code, headers=None): - # build response - response = Response() - response.data = set_content_type(response, data, path) - response.status_code = http_status_code - response.headers["X-Page-Name"] = path.encode("ascii", errors="xmlcharrefreplace") - response.headers["X-From-Cache"] = frappe.local.response.from_cache or False - - add_preload_headers(response) - if headers: - for key, val in headers.items(): - response.headers[key] = val.encode("ascii", errors="xmlcharrefreplace") - - return response - - -def add_preload_headers(response): - from bs4 import BeautifulSoup - - try: - preload = [] - soup = BeautifulSoup(response.data, "lxml") - for elem in soup.find_all('script', src=re.compile(".*")): - preload.append(("script", elem.get("src"))) - - for elem in soup.find_all('link', rel="stylesheet"): - preload.append(("style", elem.get("href"))) - - links = [] - for _type, link in preload: - links.append("<{}>; rel=preload; as={}".format(link, _type)) - - if links: - response.headers["Link"] = ",".join(links) - except Exception: - import traceback - traceback.print_exc() - - -def render_page_by_language(path): - translated_languages = frappe.get_hooks("translated_languages_for_website") - user_lang = guess_language(translated_languages) - if translated_languages and user_lang in translated_languages: - try: - if path and path != "index": - lang_path = '{0}/{1}'.format(user_lang, path) - else: - lang_path = user_lang # index - - return render_page(lang_path) - except frappe.DoesNotExistError: - return render_page(path) - - else: - return render_page(path) - -def render_page(path): - """get page html""" - out = None - - if can_cache(): - # return rendered page - page_cache = frappe.cache().hget("website_page", path) - if page_cache and frappe.local.lang in page_cache: - out = page_cache[frappe.local.lang] - - if out: - frappe.local.response.from_cache = True - return out - - return build(path) - -def build(path): - if not frappe.db: - frappe.connect() - - try: - return build_page(path) - except frappe.DoesNotExistError: - hooks = frappe.get_hooks() - if hooks.website_catch_all: - path = hooks.website_catch_all[0] - return build_page(path) - else: - raise - except Exception: - raise - -def build_page(path): - if not getattr(frappe.local, "path", None): - frappe.local.path = path - - context = get_context(path) - - if context.source: - html = frappe.render_template(context.source, context) - elif context.template: - if path.endswith('min.js'): - html = frappe.get_jloader().get_source(frappe.get_jenv(), context.template)[0] - else: - html = frappe.get_template(context.template).render(context) - - if '{index}' in html: - html = html.replace('{index}', get_toc(context.route)) - - if '{next}' in html: - html = html.replace('{next}', get_next_link(context.route)) - - # html = frappe.get_template(context.base_template_path).render(context) - - if can_cache(context.no_cache): - page_cache = frappe.cache().hget("website_page", path) or {} - page_cache[frappe.local.lang] = html - frappe.cache().hset("website_page", path, page_cache) - - return html - -def resolve_path(path): - if not path: - path = "index" - - if path.endswith('.html'): - path = path[:-5] - - if path == "index": - path = get_home_page() - - frappe.local.path = path - - if path != "index": - path = resolve_from_map(path) - - return path - -def resolve_from_map(path): - '''transform dynamic route to a static one from hooks and route defined in doctype''' - rules = [Rule(r["from_route"], endpoint=r["to_route"], defaults=r.get("defaults")) - for r in get_website_rules()] - - return evaluate_dynamic_routes(rules, path) or path - -def get_website_rules(): - '''Get website route rules from hooks and DocType route''' - def _get(): - rules = frappe.get_hooks("website_route_rules") - for d in frappe.get_all('DocType', 'name, route', dict(has_web_view=1)): - if d.route: - rules.append(dict(from_route = '/' + d.route.strip('/'), to_route=d.name)) - - return rules - - if frappe.local.dev_server: - # dont cache in development - return _get() - - return frappe.cache().get_value('website_route_rules', _get) - -def set_content_type(response, data, path): - if isinstance(data, dict): - response.mimetype = 'application/json' - response.charset = 'utf-8' - data = json.dumps(data) - return data - - response.mimetype = 'text/html' - response.charset = 'utf-8' - - if "." in path: - content_type, encoding = mimetypes.guess_type(path) - if content_type: - response.mimetype = content_type - if encoding: - response.charset = encoding - - return data - -def clear_cache(path=None): - '''Clear website caches - - :param path: (optional) for the given path''' - for key in ('website_generator_routes', 'website_pages', - 'website_full_index', 'sitemap_routes'): - frappe.cache().delete_value(key) - - frappe.cache().delete_value("website_404") - if path: - frappe.cache().hdel('website_redirects', path) - delete_page_cache(path) - else: - clear_sitemap() - frappe.clear_cache("Guest") - for key in ('portal_menu_items', 'home_page', 'website_route_rules', - 'doctypes_with_web_view', 'website_redirects', 'page_context', - 'website_page'): - frappe.cache().delete_value(key) - - for method in frappe.get_hooks("website_clear_cache"): - frappe.get_attr(method)(path) - -def render_403(e, pathname): - frappe.local.message = cstr(e) - frappe.local.message_title = _("Not Permitted") - frappe.local.response['context'] = dict( - indicator_color = 'red', - primary_action = '/login', - primary_label = _('Login'), - fullpage=True - ) - return render_page("message"), e.http_status_code - -def get_doctype_from_path(path): - doctypes = frappe.db.sql_list("select name from tabDocType") - - parts = path.split("/") - - doctype = parts[0] - name = parts[1] if len(parts) > 1 else None - - if doctype in doctypes: - return doctype, name - - # try scrubbed - doctype = doctype.replace("_", " ").title() - if doctype in doctypes: - return doctype, name - - return None, None - -def add_csrf_token(data): - if frappe.local.session: - return data.replace("", ''.format( - frappe.local.session.data.csrf_token)) - else: - return data - -def raise_if_disabled(path): - routes = frappe.db.get_all('Portal Menu Item', - fields=['route', 'enabled'], - filters={ - 'enabled': 0, - 'route': ['like', '%{0}'.format(path)] - } - ) - - for r in routes: - _path = r.route.lstrip('/') - if path == _path and not r.enabled: - raise frappe.PermissionError - diff --git a/frappe/website/router.py b/frappe/website/router.py index aa74d140c1..a9e2f68fe5 100644 --- a/frappe/website/router.py +++ b/frappe/website/router.py @@ -6,131 +6,9 @@ import os import re import frappe -from frappe.model.document import get_controller -from frappe.website.utils import can_cache, delete_page_cache, extract_comment_tag, extract_title +from frappe.website.utils import extract_title from werkzeug.routing import Map, Rule, NotFound -def resolve_route(path): - """Returns the page route object based on searching in pages and generators. - The `www` folder is also a part of generator **Web Page**. - - The only exceptions are `/about` and `/contact` these will be searched in Web Pages - first before checking the standard pages.""" - - if path not in ("about", "contact"): - context = get_page_info_from_template(path) - if context: - return context - return get_page_context_from_doctype(path) - else: - context = get_page_context_from_doctype(path) - if context: - return context - return get_page_info_from_template(path) - -def get_page_context(path): - page_context = None - if can_cache(): - page_context_cache = frappe.cache().hget("page_context", path) or {} - page_context = page_context_cache.get(frappe.local.lang, None) - - if not page_context: - page_context = make_page_context(path) - if can_cache(page_context.no_cache): - page_context_cache[frappe.local.lang] = page_context - frappe.cache().hset("page_context", path, page_context_cache) - - return page_context - -def make_page_context(path): - context = resolve_route(path) - if not context: - raise frappe.PageDoesNotExistError - - context.doctype = context.ref_doctype - - if context.page_title: - context.title = context.page_title - - context.pathname = frappe.local.path - - return context - -def get_page_info_from_template(path): - '''Return page_info from path''' - for app in frappe.get_installed_apps(frappe_last=True): - app_path = frappe.get_app_path(app) - - folders = get_start_folders() - - for start in folders: - search_path = os.path.join(app_path, start, path) - options = (search_path, search_path + '.html', search_path + '.md', - search_path + '/index.html', search_path + '/index.md') - for o in options: - option = frappe.as_unicode(o) - if os.path.exists(option) and not os.path.isdir(option): - return get_page_info(option, app, start, app_path=app_path) - - return None - -def get_page_context_from_doctype(path): - page_info = get_page_info_from_doctypes(path) - if not page_info: - page_info = get_page_info_from_web_page_with_dynamic_routes(path) - - if page_info: - return frappe.get_doc(page_info.get("doctype"), - page_info.get("name")).get_page_info() - -def clear_sitemap(): - delete_page_cache("*") - -def get_all_page_context_from_doctypes(): - ''' - Get all doctype generated routes (for sitemap.xml) - ''' - routes = frappe.cache().get_value("website_generator_routes") - if not routes: - routes = get_page_info_from_doctypes() - frappe.cache().set_value("website_generator_routes", routes) - - return routes - -def get_page_info_from_doctypes(path=None): - ''' - Find a document with matching `route` from all doctypes with `has_web_view`=1 - ''' - routes = {} - for doctype in get_doctypes_with_web_view(): - filters = {} - controller = get_controller(doctype) - meta = frappe.get_meta(doctype) - - condition_field = (meta.is_published_field or - # custom doctypes dont have controllers and no website attribute - (controller.website.condition_field if not meta.custom else None)) - - if condition_field: - filters[condition_field] = 1 - - if path: - filters['route'] = path - - try: - for r in frappe.get_all(doctype, fields = ['name', 'route', 'modified'], - filters = filters, limit = 1): - - routes[r.route] = {"doctype": doctype, "name": r.name, "modified": r.modified} - - # just want one path, return it! - if path: - return routes[r.route] - except Exception as e: - if not frappe.db.is_missing_column(e): raise e - - return routes - def get_page_info_from_web_page_with_dynamic_routes(path): ''' Query Web Page with dynamic_route = 1 and evaluate if any of the routes match @@ -229,7 +107,7 @@ def get_page_info(path, app, start, basepath=None, app_path=None, fname=None): if basepath is None: basepath = os.path.dirname(path) - page_name, extn = fname.rsplit(".", 1) + page_name, extn = os.path.splitext(fname) # add website route page_info = frappe._dict() @@ -265,14 +143,12 @@ def get_page_info(path, app, start, basepath=None, app_path=None, fname=None): # get the source setup_source(page_info) - # extract properties from HTML comments - load_properties_from_source(page_info) + if not page_info.title: + page_info.title = extract_title(page_info.source, page_info.route) # extract properties from controller attributes load_properties_from_controller(page_info) - page_info.build_version = frappe.utils.get_build_version() - return page_info def get_frontmatter(string): @@ -375,47 +251,6 @@ def setup_index(page_info): with open(index_txt_path, 'r') as f: page_info.index = f.read().splitlines() -def load_properties_from_source(page_info): - '''Load properties like no_cache, title from source html''' - - if not page_info.title: - page_info.title = extract_title(page_info.source, page_info.route) - - base_template = extract_comment_tag(page_info.source, 'base_template') - if base_template: - page_info.base_template = base_template - - if (page_info.base_template - and "{%- extends" not in page_info.source - and "{% extends" not in page_info.source - and "" not in page_info.source): - page_info.source = '''{{% extends "{0}" %}} - {{% block page_content %}}{1}{{% endblock %}}'''.format(page_info.base_template, page_info.source) - - if "" in page_info.source: - page_info.no_breadcrumbs = 1 - - if "" in page_info.source: - page_info.show_sidebar = 1 - - if "" in page_info.source: - page_info.add_breadcrumbs = 1 - - if "" in page_info.source: - page_info.no_header = 1 - - if "" in page_info.source: - page_info.add_next_prev_links = 1 - - if "" in page_info.source: - page_info.no_cache = 1 - - if "" in page_info.source: - page_info.sitemap = 0 - - if "" in page_info.source: - page_info.sitemap = 1 - def load_properties_from_controller(page_info): if not page_info.controller: return @@ -432,8 +267,10 @@ def get_doctypes_with_web_view(): def _get(): installed_apps = frappe.get_installed_apps() doctypes = frappe.get_hooks("website_generators") - doctypes += [d.name for d in frappe.get_all('DocType', 'name, module', - dict(has_web_view=1)) if frappe.local.module_app[frappe.scrub(d.module)] in installed_apps] + doctypes_with_web_view = frappe.get_all('DocType', fields=['name', 'module'], + filters=dict(has_web_view=1)) + module_app_map = frappe.local.module_app + doctypes += [d.name for d in doctypes_with_web_view if module_app_map[frappe.scrub(d.module)] in installed_apps] return doctypes return frappe.cache().get_value('doctypes_with_web_view', _get) diff --git a/frappe/website/serve.py b/frappe/website/serve.py new file mode 100644 index 0000000000..fe7fc77064 --- /dev/null +++ b/frappe/website/serve.py @@ -0,0 +1,29 @@ +import frappe +from frappe.website.page_renderers.error_page import ErrorPage +from frappe.website.page_renderers.not_permitted_page import NotPermittedPage +from frappe.website.page_renderers.redirect_page import RedirectPage +from frappe.website.path_resolver import PathResolver + + +def get_response(path=None, http_status_code=200): + """Resolves path and renders page""" + response = None + path = path or frappe.local.request.path + endpoint = path + + try: + path_resolver = PathResolver(path) + endpoint, renderer_instance = path_resolver.resolve() + response = renderer_instance.render() + except frappe.Redirect: + return RedirectPage(endpoint or path, http_status_code).render() + except frappe.PermissionError as e: + response = NotPermittedPage(endpoint, http_status_code, exception=e).render() + except Exception as e: + response = ErrorPage(exception=e).render() + + return response + +def get_response_content(path=None, http_status_code=200): + response = get_response(path, http_status_code) + return str(response.data, 'utf-8') diff --git a/frappe/website/utils.py b/frappe/website/utils.py index aa98595e2d..0f5f182ea2 100644 --- a/frappe/website/utils.py +++ b/frappe/website/utils.py @@ -1,9 +1,18 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -import re +import json +import mimetypes import os -import frappe +import re +from functools import wraps +import yaml +from six import iteritems +from werkzeug.wrappers import Response + +import frappe +from frappe import _ +from frappe.model.document import Document from frappe.utils import md_to_html @@ -128,14 +137,8 @@ def get_home_page_via_hooks(): return home_page -def is_signup_enabled(): - if getattr(frappe.local, "is_signup_enabled", None) is None: - frappe.local.is_signup_enabled = True - if frappe.utils.cint(frappe.db.get_value("Website Settings", - "Website Settings", "disable_signup")): - frappe.local.is_signup_enabled = False - - return frappe.local.is_signup_enabled +def is_signup_disabled(): + return frappe.db.get_single_value('Website Settings', 'disable_signup', True) def cleanup_page_name(title): """make page name from title""" @@ -145,91 +148,15 @@ def cleanup_page_name(title): name = title.lower() name = re.sub(r'[~!@#$%^&*+()<>,."\'\?]', '', name) name = re.sub('[:/]', '-', name) - name = '-'.join(name.split()) - # replace repeating hyphens name = re.sub(r"(-)\1+", r"\1", name) - return name[:140] -def get_shade(color, percent): - color, color_format = detect_color_format(color) - r, g, b, a = color - - avg = (float(int(r) + int(g) + int(b)) / 3) - # switch dark and light shades - if avg > 128: - percent = -percent - - # stronger diff for darker shades - if percent < 25 and avg < 64: - percent = percent * 2 - - new_color = [] - for channel_value in (r, g, b): - new_color.append(get_shade_for_channel(channel_value, percent)) - - r, g, b = new_color - - return format_color(r, g, b, a, color_format) - - -def detect_color_format(color): - if color.startswith("rgba"): - color_format = "rgba" - color = [c.strip() for c in color[5:-1].split(",")] - - elif color.startswith("rgb"): - color_format = "rgb" - color = [c.strip() for c in color[4:-1].split(",")] + [1] - - else: - # assume hex - color_format = "hex" - - if color.startswith("#"): - color = color[1:] - - if len(color) == 3: - # hex in short form like #fff - color = "{0}{0}{1}{1}{2}{2}".format(*tuple(color)) - - color = [int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), 1] - - return color, color_format - - -def get_shade_for_channel(channel_value, percent): - v = int(channel_value) + int(int('ff', 16) * (float(percent)/100)) - if v < 0: - v=0 - if v > 255: - v=255 - - return v - - -def format_color(r, g, b, a, color_format): - if color_format == "rgba": - return "rgba({0}, {1}, {2}, {3})".format(r, g, b, a) - - elif color_format == "rgb": - return "rgb({0}, {1}, {2})".format(r, g, b) - - else: - # assume hex - return "#{0}{1}{2}".format(convert_to_hex(r), convert_to_hex(g), convert_to_hex(b)) - - -def convert_to_hex(channel_value): - h = hex(channel_value)[2:] - - if len(h) < 2: - h = "0" + h - - return h +def get_shade(color, percent=None): + frappe.msgprint(_('get_shade method has been deprecated.')) + return color def abs_url(path): """Deconstructs and Reconstructs a URL into an absolute URL or a URL relative from root '/'""" @@ -359,25 +286,6 @@ def extract_comment_tag(source, tag): return None -def add_missing_headers(): - '''Walk and add missing headers in docs (to be called from bench execute)''' - path = frappe.get_app_path('erpnext', 'docs') - for basepath, folders, files in os.walk(path): - for fname in files: - if fname.endswith('.md'): - with open(os.path.join(basepath, fname), 'r') as f: - content = frappe.as_unicode(f.read()) - - if not content.startswith('# ') and not '

    ' in content: - with open(os.path.join(basepath, fname), 'w') as f: - if fname=='index.md': - fname = os.path.basename(basepath) - else: - fname = fname[:-3] - h = fname.replace('_', ' ').replace('-', ' ').title() - content = '# {0}\n\n'.format(h) + content - f.write(content.encode('utf-8')) - def get_html_content_based_on_type(doc, fieldname, content_type): ''' Set content based on content_type @@ -393,3 +301,208 @@ def get_html_content_based_on_type(doc, fieldname, content_type): content = '' return content + + +def clear_cache(path=None): + '''Clear website caches + :param path: (optional) for the given path''' + for key in ('website_generator_routes', 'website_pages', + 'website_full_index', 'sitemap_routes'): + frappe.cache().delete_value(key) + + frappe.cache().delete_value("website_404") + if path: + frappe.cache().hdel('website_redirects', path) + delete_page_cache(path) + else: + clear_sitemap() + frappe.clear_cache("Guest") + for key in ('portal_menu_items', 'home_page', 'website_route_rules', + 'doctypes_with_web_view', 'website_redirects', 'page_context', + 'website_page'): + frappe.cache().delete_value(key) + + for method in frappe.get_hooks("website_clear_cache"): + frappe.get_attr(method)(path) + +def clear_website_cache(path=None): + clear_cache(path) + +def clear_sitemap(): + delete_page_cache("*") + +def get_frontmatter(string): + "Reference: https://github.com/jonbeebe/frontmatter" + frontmatter = "" + body = "" + result = re.compile(r'^\s*(?:---|\+\+\+)(.*?)(?:---|\+\+\+)\s*(.+)$', re.S | re.M).search(string) + if result: + frontmatter = result.group(1) + body = result.group(2) + + return { + "attributes": yaml.safe_load(frontmatter), + "body": body, + } + +def get_sidebar_items(parent_sidebar, basepath): + import frappe.www.list + sidebar_items = [] + + hooks = frappe.get_hooks('look_for_sidebar_json') + look_for_sidebar_json = hooks[0] if hooks else frappe.flags.look_for_sidebar + + if basepath and look_for_sidebar_json: + sidebar_items = get_sidebar_items_from_sidebar_file(basepath, look_for_sidebar_json) + + if not sidebar_items and parent_sidebar: + sidebar_items = frappe.get_all('Website Sidebar Item', + filters=dict(parent=parent_sidebar), fields=['title', 'route', '`group`'], + order_by='idx asc') + + if not sidebar_items: + sidebar_items = get_portal_sidebar_items() + + return sidebar_items + + +def get_portal_sidebar_items(): + sidebar_items = frappe.cache().hget('portal_menu_items', frappe.session.user) + if sidebar_items is None: + sidebar_items = [] + roles = frappe.get_roles() + portal_settings = frappe.get_doc('Portal Settings', 'Portal Settings') + + def add_items(sidebar_items, items): + for d in items: + if d.get('enabled') and ((not d.get('role')) or d.get('role') in roles): + sidebar_items.append(d.as_dict() if isinstance(d, Document) else d) + + if not portal_settings.hide_standard_menu: + add_items(sidebar_items, portal_settings.get('menu')) + + if portal_settings.custom_menu: + add_items(sidebar_items, portal_settings.get('custom_menu')) + + items_via_hooks = frappe.get_hooks('portal_menu_items') + if items_via_hooks: + for i in items_via_hooks: + i['enabled'] = 1 + add_items(sidebar_items, items_via_hooks) + + frappe.cache().hset('portal_menu_items', frappe.session.user, sidebar_items) + + return sidebar_items + +def get_sidebar_items_from_sidebar_file(basepath, look_for_sidebar_json): + sidebar_items = [] + sidebar_json_path = get_sidebar_json_path(basepath, look_for_sidebar_json) + if not sidebar_json_path: + return sidebar_items + + with open(sidebar_json_path, 'r') as sidebarfile: + try: + sidebar_json = sidebarfile.read() + sidebar_items = json.loads(sidebar_json) + except json.decoder.JSONDecodeError: + frappe.throw('Invalid Sidebar JSON at ' + sidebar_json_path) + + return sidebar_items + +def get_sidebar_json_path(path, look_for=False): + '''Get _sidebar.json path from directory path + :param path: path of the current diretory + :param look_for: if True, look for _sidebar.json going upwards from given path + :return: _sidebar.json path + ''' + if os.path.split(path)[1] == 'www' or path == '/' or not path: + return '' + + sidebar_json_path = os.path.join(path, '_sidebar.json') + if os.path.exists(sidebar_json_path): + return sidebar_json_path + else: + if look_for: + return get_sidebar_json_path(os.path.split(path)[0], look_for) + else: + return '' + +def cache_html(func): + @wraps(func) + def cache_html_decorator(*args, **kwargs): + if can_cache(): + html = None + page_cache = frappe.cache().hget("website_page", args[0].path) + if page_cache and frappe.local.lang in page_cache: + html = page_cache[frappe.local.lang] + if html: + frappe.local.response.from_cache = True + return html + html = func(*args, **kwargs) + context = args[0].context + if can_cache(context.no_cache): + page_cache = frappe.cache().hget("website_page", args[0].path) or {} + page_cache[frappe.local.lang] = html + frappe.cache().hset("website_page", args[0].path, page_cache) + + return html + + return cache_html_decorator + +def build_response(path, data, http_status_code, headers=None): + # build response + response = Response() + response.data = set_content_type(response, data, path) + response.status_code = http_status_code + response.headers["X-Page-Name"] = path.encode("ascii", errors="xmlcharrefreplace") + response.headers["X-From-Cache"] = frappe.local.response.from_cache or False + + add_preload_headers(response) + if headers: + for key, val in iteritems(headers): + response.headers[key] = val.encode("ascii", errors="xmlcharrefreplace") + + return response + +def set_content_type(response, data, path): + if isinstance(data, dict): + response.mimetype = 'application/json' + response.charset = 'utf-8' + data = json.dumps(data) + return data + + response.mimetype = 'text/html' + response.charset = 'utf-8' + + # ignore paths ending with .com to avoid unnecessary download + # https://bugs.python.org/issue22347 + if "." in path and not path.endswith('.com'): + content_type, encoding = mimetypes.guess_type(path) + if content_type: + response.mimetype = content_type + if encoding: + response.charset = encoding + + return data + +def add_preload_headers(response): + from bs4 import BeautifulSoup + + try: + preload = [] + soup = BeautifulSoup(response.data, "lxml") + for elem in soup.find_all('script', src=re.compile(".*")): + preload.append(("script", elem.get("src"))) + + for elem in soup.find_all('link', rel="stylesheet"): + preload.append(("style", elem.get("href"))) + + links = [] + for _type, link in preload: + links.append("<{}>; rel=preload; as={}".format(link, _type)) + + if links: + response.headers["Link"] = ",".join(links) + except Exception: + import traceback + traceback.print_exc() diff --git a/frappe/website/website_components/metatags.py b/frappe/website/website_components/metatags.py new file mode 100644 index 0000000000..045bef8fe1 --- /dev/null +++ b/frappe/website/website_components/metatags.py @@ -0,0 +1,68 @@ +import frappe + +class MetaTags(): + def __init__(self, path, context): + self.path = path + self.context = context + self.tags = frappe._dict(self.context.get("metatags") or {}) + self.init_metatags_from_context() + self.set_opengraph_tags() + self.set_twitter_tags() + self.set_meta_published_on() + self.set_metatags_from_website_route_meta() + + def init_metatags_from_context(self): + for key in ('title', 'description', 'image', 'author', 'url', 'published_on'): + if key not in self.tags and self.context.get(key): + self.tags[key] = self.context[key] + + if not self.tags.get('title'): + self.tags['title'] = self.context.get('name') + + if self.tags.get('image'): + self.tags['image'] = frappe.utils.get_url(self.tags['image']) + + self.tags["language"] = frappe.local.lang or "en" + + def set_opengraph_tags(self): + if "og:type" not in self.tags: + self.tags["og:type"] = "article" + + for key in ('title', 'description', 'image', 'author', 'url'): + if self.tags.get(key): + self.tags['og:' + key] = self.tags.get(key) + + def set_twitter_tags(self): + for key in ('title', 'description', 'image', 'author', 'url'): + if self.tags.get(key): + self.tags['twitter:' + key] = self.tags.get(key) + + if self.tags.get('image'): + self.tags['twitter:card'] = "summary_large_image" + else: + self.tags["twitter:card"] = "summary" + + def set_meta_published_on(self): + if "published_on" in self.tags: + self.tags["datePublished"] = self.tags["published_on"] + del self.tags["published_on"] + + def set_metatags_from_website_route_meta(self): + ''' + Get meta tags from Website Route meta + they can override the defaults set above + ''' + route = self.path + if route == '': + # homepage + route = frappe.db.get_single_value('Website Settings', 'home_page') + + route_exists = (route + and not route.endswith(('.js', '.css')) + and frappe.db.exists('Website Route Meta', route)) + + if route_exists: + website_route_meta = frappe.get_doc('Website Route Meta', route) + for meta_tag in website_route_meta.meta_tags: + d = meta_tag.get_meta_dict() + self.tags.update(d) diff --git a/frappe/website/website_generator.py b/frappe/website/website_generator.py index 351f2f1832..3afe486944 100644 --- a/frappe/website/website_generator.py +++ b/frappe/website/website_generator.py @@ -4,7 +4,7 @@ import frappe from frappe.model.document import Document from frappe.website.utils import cleanup_page_name -from frappe.website.render import clear_cache +from frappe.website.utils import clear_cache from frappe.modules import get_module_name from frappe.search.website_search import update_index_for_path, remove_document_from_index @@ -128,6 +128,8 @@ class WebsiteGenerator(Document): if not route.page_title: route.page_title = self.get(self.get_title_field()) + route.title = route.page_title + return route def send_indexing_request(self, operation_type='URL_UPDATED'): diff --git a/frappe/www/404.py b/frappe/www/404.py index f064a66c17..1e6bdc177d 100644 --- a/frappe/www/404.py +++ b/frappe/www/404.py @@ -1,2 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt + +def get_context(context): + context.http_status_code = 404 diff --git a/frappe/www/_test/_sidebar.json b/frappe/www/_test/_sidebar.json new file mode 100644 index 0000000000..a2567e94da --- /dev/null +++ b/frappe/www/_test/_sidebar.json @@ -0,0 +1,6 @@ +[ + { + "route": "/_test/_test_folder", + "title": "Test Sidebar" + } +] diff --git a/frappe/www/_test/_test_folder/__init__.py b/frappe/www/_test/_test_folder/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/www/_test/_test_folder/_test_page.css b/frappe/www/_test/_test_folder/_test_page.css new file mode 100644 index 0000000000..e42b809085 --- /dev/null +++ b/frappe/www/_test/_test_folder/_test_page.css @@ -0,0 +1,3 @@ +body { + background-color: var(--bg-color); +} \ No newline at end of file diff --git a/frappe/www/_test/_test_folder/_test_page.html b/frappe/www/_test/_test_folder/_test_page.html new file mode 100644 index 0000000000..123d619e38 --- /dev/null +++ b/frappe/www/_test/_test_folder/_test_page.html @@ -0,0 +1,5 @@ +{% block content %} +{% include "templates/includes/web_sidebar.html" %} +

    Test content

    +{next} +{% endblock %} diff --git a/frappe/www/_test/_test_folder/_test_page.js b/frappe/www/_test/_test_folder/_test_page.js new file mode 100644 index 0000000000..6e0c1f3a87 --- /dev/null +++ b/frappe/www/_test/_test_folder/_test_page.js @@ -0,0 +1 @@ +console.log('test data'); \ No newline at end of file diff --git a/frappe/www/_test/_test_folder/_test_page.py b/frappe/www/_test/_test_folder/_test_page.py new file mode 100644 index 0000000000..1813a06bac --- /dev/null +++ b/frappe/www/_test/_test_folder/_test_page.py @@ -0,0 +1,3 @@ +def get_context(context): + context.base_template_path = 'frappe/templates/test/_test_base.html' + context.add_breadcrumbs = 1 diff --git a/frappe/www/_test/_test_folder/_test_toc.md b/frappe/www/_test/_test_folder/_test_toc.md new file mode 100644 index 0000000000..02cc3c82be --- /dev/null +++ b/frappe/www/_test/_test_folder/_test_toc.md @@ -0,0 +1,19 @@ +--- +title: Test TOC +add_breadcrumbs: 1 +show_sidebar: 0 + +metatags: + description: Test Description. + keywords: Frappe Framework. +--- + +# Level 1 + +## Level 1.1 + +## Level 1.2 + +## Level 1.3 + +### Level 1.3.1 diff --git a/frappe/www/_test/_test_folder/index.md b/frappe/www/_test/_test_folder/index.md new file mode 100644 index 0000000000..1a5a9e7f81 --- /dev/null +++ b/frappe/www/_test/_test_folder/index.md @@ -0,0 +1,9 @@ +--- +title: Test TOC +add_breadcrumbs: 1 +show_sidebar: 1 +--- + +# Index + +{index} \ No newline at end of file diff --git a/frappe/www/_test/_test_folder/new.csv/__init__.py b/frappe/www/_test/_test_folder/new.csv/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/www/_test/_test_folder/new.csv/index.html b/frappe/www/_test/_test_folder/new.csv/index.html new file mode 100644 index 0000000000..7a1bb69558 --- /dev/null +++ b/frappe/www/_test/_test_folder/new.csv/index.html @@ -0,0 +1,12 @@ + + + + + + + Document + + + Test Page + + diff --git a/frappe/www/_test/_test_home_page.py b/frappe/www/_test/_test_home_page.py new file mode 100644 index 0000000000..936399c700 --- /dev/null +++ b/frappe/www/_test/_test_home_page.py @@ -0,0 +1,2 @@ +def get_website_user_home_page(user): + return '/_test/_test_folder' \ No newline at end of file diff --git a/frappe/www/_test/index.html b/frappe/www/_test/index.html new file mode 100644 index 0000000000..0dff60b400 --- /dev/null +++ b/frappe/www/_test/index.html @@ -0,0 +1 @@ +{index} \ No newline at end of file diff --git a/frappe/www/_test/problematic_page.html b/frappe/www/_test/problematic_page.html new file mode 100644 index 0000000000..5e194421d2 --- /dev/null +++ b/frappe/www/_test/problematic_page.html @@ -0,0 +1 @@ +{% raise %} diff --git a/frappe/www/_test/static-file-test.png b/frappe/www/_test/static-file-test.png new file mode 100644 index 0000000000..b51db82f82 Binary files /dev/null and b/frappe/www/_test/static-file-test.png differ diff --git a/frappe/www/app.py b/frappe/www/app.py index b0fa19df9b..27505c8131 100644 --- a/frappe/www/app.py +++ b/frappe/www/app.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt no_cache = 1 -base_template_path = "templates/www/app.html" import os, re import frappe diff --git a/frappe/www/error.py b/frappe/www/error.py index dcbfb38e1f..11151ef766 100644 --- a/frappe/www/error.py +++ b/frappe/www/error.py @@ -6,5 +6,7 @@ no_cache = 1 def get_context(context): if frappe.flags.in_migrate: return + context.http_status_code = 500 + print(frappe.get_traceback().encode("utf-8")) return {"error": frappe.get_traceback().replace("<", "<").replace(">", ">") } diff --git a/frappe/www/list.py b/frappe/www/list.py index 881aaf085b..5e4e491c80 100644 --- a/frappe/www/list.py +++ b/frappe/www/list.py @@ -3,7 +3,7 @@ import frappe, json from frappe.utils import cint, quoted -from frappe.website.render import resolve_path +from frappe.website.path_resolver import resolve_path from frappe.model.document import get_controller, Document from frappe import _ diff --git a/frappe/www/me.html b/frappe/www/me.html index 402fdecf59..eb97c566d8 100644 --- a/frappe/www/me.html +++ b/frappe/www/me.html @@ -4,7 +4,6 @@ {% block header %}

    {{ _("My Account") }}

    {% endblock %} {% block page_content %} -