Merge branch 'develop' into form-builder-vue3

This commit is contained in:
Shariq Ansari 2022-10-23 03:04:16 +05:30 committed by GitHub
commit b97dec058e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 2135 additions and 1337 deletions

View file

@ -11,7 +11,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout Actions
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
repository: "frappe/backport"
path: ./actions

View file

@ -118,7 +118,8 @@ jobs:
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
BUILD_NUMBER: ${{ matrix.container }}
TOTAL_BUILDS: 2
- name: Upload coverage data
uses: actions/upload-artifact@v3

View file

@ -121,7 +121,8 @@ jobs:
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
BUILD_NUMBER: ${{ matrix.container }}
TOTAL_BUILDS: 2
- name: Upload coverage data
uses: actions/upload-artifact@v3

View file

@ -121,6 +121,7 @@ jobs:
DB: mariadb
- name: Verify yarn.lock
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: |
cd ~/frappe-bench/apps/frappe
yarn install --immutable --immutable-cache --check-cache
@ -173,7 +174,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Download artifacts
uses: actions/download-artifact@v3

View file

@ -24,6 +24,7 @@ context("Folder Navigation", () => {
it("Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct", () => {
//Navigating inside the Attachments folder
cy.wait(500);
cy.get('[title="Attachments"] > span').click();
//To check if the URL formed after visiting the attachments folder is correct
@ -36,6 +37,7 @@ context("Folder Navigation", () => {
cy.click_modal_primary_button("Create");
//Navigating inside the added folder in the Attachments folder
cy.wait(500);
cy.get('[title="Test Folder"] > span').click();
//To check if the URL is correct after visiting the Test Folder
@ -51,7 +53,12 @@ context("Folder Navigation", () => {
cy.click_modal_primary_button("Upload");
//To check if the added file is present in the Test Folder
cy.get("span.level-item > span").should("contain", "Test Folder");
cy.visit("/app/file/view/home/Attachments");
cy.wait(500);
cy.get("span.level-item > a > span").should("contain", "Test Folder");
cy.visit("/app/file/view/home/Attachments/Test%20Folder");
cy.wait(500);
cy.get(".list-row-container").eq(0).should("contain.text", "72402.jpg");
cy.get(".list-row-checkbox").eq(0).click();

View file

@ -0,0 +1,231 @@
context("View", () => {
before(() => {
cy.login();
cy.visit("/app/website");
});
it("Route to ToDo List View", () => {
cy.visit("/app/todo/view/list");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("List");
});
});
it("Route to ToDo Report View", () => {
cy.visit("/app/todo/view/report");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
it("Route to ToDo Dashboard View", () => {
cy.visit("/app/todo/view/dashboard");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Dashboard");
});
});
it("Route to ToDo Gantt View", () => {
cy.visit("/app/todo/view/gantt");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Gantt");
});
});
it("Route to ToDo Kanban View", () => {
cy.call("frappe.tests.ui_test_helpers.create_kanban").then(() => {
cy.visit("/app/note/view/kanban/_Note _Kanban");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Kanban");
});
});
});
it("Route to ToDo Calendar View", () => {
cy.visit("/app/todo/view/calendar");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Calendar");
});
});
it("Route to Custom Tree View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_tree_doctype").then(() => {
cy.visit("/app/custom-tree/view/tree");
cy.wait(500);
cy.window()
.its("cur_tree")
.then((list) => {
expect(list.view_name).to.equal("Tree");
});
});
});
it("Route to Custom Image View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_image_doctype").then(() => {
cy.visit("app/custom-image/view/image");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Image");
});
});
});
it("Route to Communication Inbox View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_inbox").then(() => {
cy.visit("app/communication/view/inbox");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Inbox");
});
});
});
it("Route to File View", () => {
cy.visit("app/file");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("File");
expect(list.current_folder).to.equal("Home");
});
cy.visit("app/file/view/home/Attachments");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("File");
expect(list.current_folder).to.equal("Home/Attachments");
});
});
it("Re-route to default view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("app/event");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Route to default view from app/{doctype}", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("/app/event");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Route to default view from app/{doctype}/view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("/app/event/view");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Force Route to default view from app/{doctype}", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", {
view: "Report",
force_reroute: true,
}).then(() => {
cy.visit("/app/event");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Force Route to default view from app/{doctype}/view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", {
view: "Report",
force_reroute: true,
}).then(() => {
cy.visit("/app/event/view");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Force Route to default view from app/{doctype}/view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", {
view: "Report",
force_reroute: true,
}).then(() => {
cy.visit("/app/event/view/list");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Validate Route History for Default View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("/app/event");
cy.visit("/app/event/view/list");
cy.location("pathname").should("eq", "/app/event/view/list");
cy.go("back");
cy.location("pathname").should("eq", "/app/event");
});
});
it("Route to Form", () => {
cy.call("frappe.tests.ui_test_helpers.create_note").then(() => {
cy.visit("/app/note/Routing Test");
cy.window()
.its("cur_frm")
.then((frm) => {
expect(frm.doc.title).to.equal("Routing Test");
});
});
});
it("Route to Settings Workspace", () => {
cy.visit("/app/settings");
cy.get(".title-text").should("contain", "Settings");
});
});

View file

@ -42,7 +42,7 @@ context("Workspace Blocks", () => {
cy.wait("@new_page");
});
it("Quick List Block", () => {
it.skip("Quick List Block", () => {
cy.create_records([
{
doctype: "ToDo",

View file

@ -18,9 +18,9 @@ module.exports = {
return fs
.readFile(filepath, "utf-8")
.then((content) => {
content = JSON.stringify(scrub_html_template(content));
content = scrub_html_template(content);
return {
contents: `\n\tfrappe.templates['${filename}'] = ${content};\n`,
contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n`,
watchFiles: [filepath],
};
})

View file

@ -3,6 +3,7 @@
import base64
import binascii
import json
from typing import Literal
from urllib.parse import urlencode, urlparse
import frappe
@ -49,106 +50,148 @@ def handle():
if len(parts) > 3:
name = parts[3]
if call == "method":
frappe.local.form_dict.cmd = doctype
return frappe.handler.handle()
return _RESTAPIHandler(call, doctype, name).get_response()
elif call == "resource":
if "run_method" in frappe.local.form_dict:
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(doctype, name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
class _RESTAPIHandler:
def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None):
self.call = call
self.doctype = doctype
self.name = name
if frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
def get_response(self):
"""Prepare and get response based on URL and form body.
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
else:
if name:
if frappe.local.request.method == "GET":
doc = frappe.get_doc(doctype, name)
if not doc.has_permission("read"):
raise frappe.PermissionError
frappe.local.response.update({"data": doc})
if frappe.local.request.method == "PUT":
data = get_request_form_data()
doc = frappe.get_doc(doctype, name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
if frappe.local.request.method == "DELETE":
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(doctype, name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
elif doctype:
if frappe.local.request.method == "GET":
# set fields for frappe.get_list
if frappe.local.form_dict.get("fields"):
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.local.form_dict.setdefault(
"limit_page_length",
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.local.form_dict.get(param)
if param_val is not None:
frappe.local.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
data = frappe.call(frappe.client.get_list, doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
if frappe.local.request.method == "POST":
# fetch data from from dict
data = get_request_form_data()
data.update({"doctype": doctype})
# insert document from request data
doc = frappe.get_doc(data).insert()
# set response data
frappe.local.response.update({"data": doc.as_dict()})
# commit for POST requests
frappe.db.commit()
else:
Note: most methods of this class directly operate on the response local.
"""
match self.call:
case "method":
return self.handle_method()
case "resource":
self.handle_resource()
case _:
raise frappe.DoesNotExistError
else:
raise frappe.DoesNotExistError
return build_response("json")
return build_response("json")
def handle_method(self):
frappe.local.form_dict.cmd = self.doctype
return frappe.handler.handle()
def handle_resource(self):
if self.doctype and self.name:
self.handle_document_resource()
elif self.doctype:
self.handle_doctype_resource()
else:
raise frappe.DoesNotExistError
def handle_document_resource(self):
if "run_method" in frappe.local.form_dict:
self.execute_doc_method()
return
match frappe.local.request.method:
case "GET":
self.get_doc()
case "PUT":
self.update_doc()
case "DELETE":
self.delete_doc()
case _:
raise frappe.DoesNotExistError
def handle_doctype_resource(self):
match frappe.local.request.method:
case "GET":
self.get_doc_list()
case "POST":
self.create_doc()
case _:
raise frappe.DoesNotExistError
def execute_doc_method(self):
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(self.doctype, self.name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
elif frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
def get_doc(self):
doc = frappe.get_doc(self.doctype, self.name)
if not doc.has_permission("read"):
raise frappe.PermissionError
frappe.local.response.update({"data": doc})
def update_doc(self):
data = get_request_form_data()
doc = frappe.get_doc(self.doctype, self.name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
def delete_doc(self):
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(self.doctype, self.name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
def get_doc_list(self):
if frappe.local.form_dict.get("fields"):
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.local.form_dict.setdefault(
"limit_page_length",
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.local.form_dict.get(param)
if param_val is not None:
frappe.local.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
def create_doc(self):
data = get_request_form_data()
data.update({"doctype": self.doctype})
# insert document from request data
doc = frappe.get_doc(data).insert()
# set response data
frappe.local.response.update({"data": doc.as_dict()})
# commit for POST requests
frappe.db.commit()
def get_request_form_data():

View file

@ -129,12 +129,18 @@ def clear_doctype_cache(doctype=None):
clear_single(doctype)
# clear all parent doctypes
for dt in frappe.get_all(
"DocField", "parent", dict(fieldtype=["in", frappe.model.table_fields], options=doctype)
):
clear_single(dt.parent)
# clear all parent doctypes
if not frappe.flags.in_install:
for dt in frappe.get_all(
"Custom Field", "dt", dict(fieldtype=["in", frappe.model.table_fields], options=doctype)
):
clear_single(dt.dt)
# clear all notifications
delete_notification_count_for(doctype)

View file

@ -270,7 +270,7 @@ def delete(doctype, name):
:param doctype: DocType of the document to be deleted
:param name: name of the document to be deleted"""
frappe.delete_doc(doctype, name, ignore_missing=False)
delete_doc(doctype, name)
@frappe.whitelist(methods=["POST", "PUT"])
@ -462,3 +462,24 @@ def insert_doc(doc) -> "Document":
return parent
return frappe.get_doc(doc).insert()
def delete_doc(doctype, name):
"""Deletes document
if doctype is a child table, then deletes the child record using the parent doc
so that the parent doc's `on_update` is called
"""
if frappe.is_table(doctype):
values = frappe.db.get_value(doctype, name, ["parenttype", "parent", "parentfield"])
if not values:
raise frappe.DoesNotExistError
parenttype, parent, parentfield = values
parent = frappe.get_doc(parenttype, parent)
for row in parent.get(parentfield):
if row.name == name:
parent.remove(row)
parent.save()
break
else:
frappe.delete_doc(doctype, name, ignore_missing=False)

View file

@ -819,9 +819,16 @@ def run_tests(
@click.option("--total-builds", help="Total number of builds", default=1)
@click.option("--with-coverage", is_flag=True, help="Build coverage file")
@click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests")
@click.option("--dry-run", is_flag=True, default=False, help="Dont actually run tests")
@pass_context
def run_parallel_tests(
context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False
context,
app,
build_number,
total_builds,
with_coverage=False,
use_orchestrator=False,
dry_run=False,
):
from traceback_with_variables import activate_by_import
@ -834,7 +841,13 @@ def run_parallel_tests(
else:
from frappe.parallel_test_runner import ParallelTestRunner
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)
ParallelTestRunner(
app,
site=site,
build_number=build_number,
total_builds=total_builds,
dry_run=dry_run,
)
@click.command(

View file

@ -1,11 +1,5 @@
import frappe
from frappe import _
from frappe.desk.moduleview import (
config_exists,
get_data,
get_module_link_items_from_list,
get_onboard_items,
)
def get_modules_from_all_apps_for_user(user: str = None) -> list[dict]:
@ -25,9 +19,6 @@ def get_modules_from_all_apps_for_user(user: str = None) -> list[dict]:
if module_name in empty_tables_by_module:
module["onboard_present"] = 1
# Set defaults links
module["links"] = get_onboard_items(module["app"], frappe.scrub(module_name))[:5]
return allowed_modules_list

View file

@ -228,11 +228,12 @@ def get_company_address(company):
def address_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_match_cond
doctype = "Address"
link_doctype = filters.pop("link_doctype")
link_name = filters.pop("link_name")
condition = ""
meta = frappe.get_meta("Address")
meta = frappe.get_meta(doctype)
for fieldname, value in filters.items():
if meta.get_field(fieldname) or fieldname in frappe.db.DEFAULT_COLUMNS:
condition += f" and {fieldname}={frappe.db.escape(value)}"

View file

@ -210,8 +210,9 @@ def update_contact(doc, method):
def contact_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_match_cond
doctype = "Contact"
if (
not frappe.get_meta("Contact").get_field(searchfield)
not frappe.get_meta(doctype).get_field(searchfield)
and searchfield not in frappe.db.DEFAULT_COLUMNS
):
return []

View file

@ -2,6 +2,7 @@
"actions": [],
"allow_import": 1,
"creation": "2013-01-29 10:47:14",
"default_view": "Inbox",
"description": "Keeps track of all communications",
"doctype": "DocType",
"document_type": "Setup",
@ -198,7 +199,6 @@
"label": "More Information"
},
{
"bold": 0,
"default": "Now",
"fieldname": "communication_date",
"fieldtype": "Datetime",
@ -395,7 +395,7 @@
"icon": "fa fa-comment",
"idx": 1,
"links": [],
"modified": "2022-03-30 11:24:25.728637",
"modified": "2022-05-09 00:13:45.310564",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",
@ -454,8 +454,9 @@
"sender_field": "sender",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"subject_field": "subject",
"title_field": "subject",
"track_changes": 1,
"track_seen": 1
}
}

View file

@ -27,6 +27,9 @@ frappe.ui.form.on("Data Export", {
reset_filter_and_field(frm);
}
},
export_without_main_header: (frm) => {
frm.refresh();
},
});
const can_export = (frm) => {
@ -58,8 +61,9 @@ const export_data = (frm) => {
select_columns: JSON.stringify(columns),
filters: frm.filter_list.get_filters().map((filter) => filter.slice(1, 4)),
file_type: frm.doc.file_type,
template: true,
template: !frm.doc.export_without_main_header,
with_data: 1,
export_without_column_meta: frm.doc.export_without_main_header ? true : false,
};
};

View file

@ -6,6 +6,7 @@
"engine": "InnoDB",
"field_order": [
"reference_doctype",
"export_without_main_header",
"column_break_2",
"file_type",
"section_break",
@ -47,12 +48,19 @@
"fieldname": "fields_multicheck",
"fieldtype": "HTML",
"label": "Fields Multicheck"
},
{
"default": "0",
"description": "Export the data without any header notes and column descriptions",
"fieldname": "export_without_main_header",
"fieldtype": "Check",
"label": "Export without main header"
}
],
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2022-08-03 12:20:53.658574",
"modified": "2022-09-28 03:51:02.404681",
"modified_by": "Administrator",
"module": "Core",
"name": "Data Export",

View file

@ -37,6 +37,7 @@ def export_data(
file_type="CSV",
template=False,
filters=None,
export_without_column_meta=False,
):
_doctype = doctype
if isinstance(_doctype, list):
@ -48,6 +49,15 @@ def export_data(
filters=filters,
method=parent_doctype,
)
template_bool = template
if isinstance(template, str):
template_bool = template.lower() == "true"
export_without_column_meta_bool = export_without_column_meta
if isinstance(export_without_column_meta, str):
export_without_column_meta_bool = export_without_column_meta.lower() == "true"
exporter = DataExporter(
doctype=doctype,
parent_doctype=parent_doctype,
@ -55,8 +65,9 @@ def export_data(
with_data=with_data,
select_columns=select_columns,
file_type=file_type,
template=template,
template=template_bool,
filters=filters,
export_without_column_meta=export_without_column_meta_bool,
)
exporter.build_response()
@ -72,6 +83,7 @@ class DataExporter:
file_type="CSV",
template=False,
filters=None,
export_without_column_meta=False,
):
self.doctype = doctype
self.parent_doctype = parent_doctype
@ -81,6 +93,7 @@ class DataExporter:
self.file_type = file_type
self.template = template
self.filters = filters
self.export_without_column_meta = export_without_column_meta
self.data_keys = get_data_keys()
self.prepare_args()
@ -117,7 +130,10 @@ class DataExporter:
if self.template:
self.add_main_header()
self.writer.writerow([""])
# No need of empty row at the start
if not self.export_without_column_meta:
self.writer.writerow([""])
self.tablerow = [self.data_keys.doctype]
self.labelrow = [_("Column Labels:")]
self.fieldrow = [self.data_keys.columns]
@ -310,12 +326,18 @@ class DataExporter:
return ""
def add_field_headings(self):
self.writer.writerow(self.tablerow)
if not self.export_without_column_meta:
self.writer.writerow(self.tablerow)
# Just include Labels in the first row
self.writer.writerow(self.labelrow)
self.writer.writerow(self.fieldrow)
self.writer.writerow(self.mandatoryrow)
self.writer.writerow(self.typerow)
self.writer.writerow(self.inforow)
if not self.export_without_column_meta:
self.writer.writerow(self.fieldrow)
self.writer.writerow(self.mandatoryrow)
self.writer.writerow(self.typerow)
self.writer.writerow(self.inforow)
if self.template:
self.writer.writerow([self.data_keys.data_separator])

View file

@ -572,12 +572,15 @@ class ImportFile:
######
def read_file(self, file_path):
def read_file(self, file_path: str):
extn = os.path.splitext(file_path)[1][1:]
file_content = None
with open(file_path, mode="rb") as f:
file_content = f.read()
file_name = frappe.db.get_value("File", {"file_url": file_path})
if file_name:
file = frappe.get_doc("File", file_name)
file_content = file.get_content()
return file_content, extn

View file

@ -59,6 +59,7 @@ frappe.ui.form.on("DocType", {
if (frm.is_new()) {
frm.events.set_default_permission(frm);
frm.set_value("default_view", "List");
} else {
frm.toggle_enable("engine", 0);
}
@ -70,12 +71,14 @@ frappe.ui.form.on("DocType", {
frm.cscript.autoname(frm);
frm.cscript.set_naming_rule_description(frm);
frm.trigger("setup_default_views");
},
istable: (frm) => {
if (frm.doc.istable && frm.is_new()) {
frm.set_value("autoname", "autoincrement");
frm.set_value("allow_rename", 0);
frm.set_value("default_view", null);
} else if (!frm.doc.istable && !frm.is_new()) {
frm.events.set_default_permission(frm);
}
@ -86,6 +89,18 @@ frappe.ui.form.on("DocType", {
frm.add_child("permissions", { role: "System Manager" });
}
},
is_tree: (frm) => {
frm.trigger("setup_default_views");
},
is_calendar_and_gantt: (frm) => {
frm.trigger("setup_default_views");
},
setup_default_views: (frm) => {
frappe.model.set_default_views_for_doctype(frm.doc.name, frm);
},
});
frappe.ui.form.on("DocField", {
@ -175,6 +190,10 @@ frappe.ui.form.on("DocField", {
fieldtype: function (frm) {
frm.trigger("max_attachments");
},
fields_add: (frm) => {
frm.trigger("setup_default_views");
},
});
extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({ frm: cur_frm }));

View file

@ -14,6 +14,7 @@
"istable",
"issingle",
"is_tree",
"is_calendar_and_gantt",
"editable_grid",
"quick_entry",
"cb01",
@ -53,6 +54,8 @@
"default_print_format",
"sort_field",
"sort_order",
"default_view",
"force_re_route_to_default_view",
"column_break_29",
"document_type",
"icon",
@ -320,7 +323,8 @@
"depends_on": "eval:!doc.istable",
"fieldname": "title_field",
"fieldtype": "Data",
"label": "Title Field"
"label": "Title Field",
"mandatory_depends_on": "eval:doc.show_title_field_in_link"
},
{
"depends_on": "eval:!doc.istable",
@ -605,6 +609,24 @@
"fieldname": "make_attachments_public",
"fieldtype": "Check",
"label": "Make Attachments Public by Default"
},
{
"fieldname": "default_view",
"fieldtype": "Select",
"label": "Default View"
},
{
"default": "0",
"fieldname": "force_re_route_to_default_view",
"fieldtype": "Check",
"label": "Force Re-route to Default View"
},
{
"default": "0",
"description": "Enables Calendar and Gantt views.",
"fieldname": "is_calendar_and_gantt",
"fieldtype": "Check",
"label": "Is Calendar and Gantt"
}
],
"icon": "fa fa-bolt",
@ -687,7 +709,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2022-08-24 06:42:27.779699",
"modified": "2022-10-12 14:13:27.315351",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -670,6 +670,18 @@ class TestDocType(FrappeTestCase):
self.assertEqual(test_json.test_json_field["hello"], "world")
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_custom_field_deletion(self):
"""Custom child tables whose doctype doesn't exist should be auto deleted."""
doctype = new_doctype(custom=0).insert().name
child = new_doctype(custom=0, istable=1).insert().name
field = "abc"
create_custom_fields({doctype: [{"fieldname": field, "fieldtype": "Table", "options": child}]})
frappe.delete_doc("DocType", child)
self.assertFalse(frappe.get_meta(doctype).get_field(field))
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_delete_doctype_with_customization(self):
from frappe.custom.doctype.property_setter.property_setter import make_property_setter

View file

@ -2,6 +2,7 @@
"actions": [],
"allow_import": 1,
"creation": "2012-12-12 11:19:22",
"default_view": "File",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
@ -169,10 +170,11 @@
"read_only": 1
}
],
"force_re_route_to_default_view": 1,
"icon": "fa fa-file",
"idx": 1,
"links": [],
"modified": "2022-09-13 15:50:15.508250",
"modified": "2022-09-13 15:50:15.508251",
"modified_by": "Administrator",
"module": "Core",
"name": "File",

View file

@ -422,7 +422,6 @@ class File(Document):
return os.path.exists(self.get_full_path())
def get_content(self) -> bytes:
"""Returns [`file_name`, `content`] for given file name `fname`"""
if self.is_folder:
frappe.throw(_("Cannot get file contents of a Folder"))

View file

@ -237,7 +237,7 @@ class User(Document):
)
def share_with_self(self):
frappe.share.add(
frappe.share.add_docshare(
self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True}
)
@ -901,6 +901,7 @@ def reset_password(user):
def user_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_filters_cond, get_match_cond
doctype = "User"
conditions = []
user_type_condition = "and user_type != 'Website User'"

View file

@ -0,0 +1,7 @@
// Copyright (c) 2022, Frappe Technologies and contributors
// For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Database Storage Usage By Tables"] = {
filters: [],
};

View file

@ -0,0 +1,28 @@
{
"add_total_row": 1,
"columns": [],
"creation": "2022-10-19 02:25:24.326791",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letter_head": "abc",
"modified": "2022-10-19 02:59:00.365307",
"modified_by": "Administrator",
"module": "Core",
"name": "Database Storage Usage By Tables",
"owner": "Administrator",
"prepared_report": 0,
"query": "",
"ref_doctype": "Error Log",
"report_name": "Database Storage Usage By Tables",
"report_type": "Script Report",
"roles": [
{
"role": "System Manager"
}
]
}

View file

@ -0,0 +1,40 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
COLUMNS = [
{"label": "Table", "fieldname": "table", "fieldtype": "Data", "width": 200},
{"label": "Size (MB)", "fieldname": "size", "fieldtype": "Float"},
{"label": "Data (MB)", "fieldname": "data_size", "fieldtype": "Float"},
{"label": "Index (MB)", "fieldname": "index_size", "fieldtype": "Float"},
]
def execute(filters=None):
frappe.only_for("System Manager")
data = frappe.db.multisql(
{
"mariadb": """
SELECT table_name AS `table`,
round(((data_length + index_length) / 1024 / 1024), 2) `size`,
round((data_length / 1024 / 1024), 2) as data_size,
round((index_length / 1024 / 1024), 2) as index_size
FROM information_schema.TABLES
ORDER BY (data_length + index_length) DESC;
""",
"postgres": """
SELECT
table_name as "table",
round(pg_total_relation_size(quote_ident(table_name)) / 1024 / 1024, 2) as "size",
round(pg_relation_size(quote_ident(table_name)) / 1024 / 1024, 2) as "data_size",
round(pg_indexes_size(quote_ident(table_name)) / 1024 / 1024, 2) as "index_size"
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY 2 DESC;
""",
},
as_dict=1,
)
return COLUMNS, data

View file

@ -0,0 +1,15 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt
from frappe.core.report.database_storage_usage_by_tables.database_storage_usage_by_tables import (
execute,
)
from frappe.tests.utils import FrappeTestCase
class TestDBUsageReport(FrappeTestCase):
def test_basic_query(self):
_, data = execute()
tables = [d.table for d in data]
self.assertFalse({"tabUser", "tabDocField"}.difference(tables))

View file

@ -102,6 +102,20 @@ class CustomField(Document):
# delete property setter entries
frappe.db.delete("Property Setter", {"doc_type": self.dt, "field_name": self.fieldname})
# update doctype layouts
doctype_layouts = frappe.get_all(
"DocType Layout", filters={"document_type": self.dt}, pluck="name"
)
for layout in doctype_layouts:
layout_doc = frappe.get_doc("DocType Layout", layout)
for field in layout_doc.fields:
if field.fieldname == self.fieldname:
layout_doc.remove(field)
layout_doc.save()
break
frappe.clear_cache(doctype=self.dt)
def validate_insert_after(self, meta):

View file

@ -72,6 +72,7 @@ frappe.ui.form.on("Customize Form", {
} else {
frm.refresh();
frm.trigger("setup_sortable");
frm.trigger("setup_default_views");
}
}
localStorage["customize_doctype"] = frm.doc.doc_type;
@ -82,8 +83,12 @@ frappe.ui.form.on("Customize Form", {
}
},
is_calendar_and_gantt: function (frm) {
frm.trigger("setup_default_views");
},
setup_sortable: function (frm) {
frm.doc.fields.forEach(function (f, i) {
frm.doc.fields.forEach(function (f) {
if (!f.is_custom_field) {
f._sortable = false;
}
@ -232,6 +237,10 @@ frappe.ui.form.on("Customize Form", {
frm.set_df_property("sort_field", "options", fields);
}
},
setup_default_views(frm) {
frappe.model.set_default_views_for_doctype(frm.doc.doc_type, frm);
},
});
// can't delete standard fields
@ -247,6 +256,7 @@ frappe.ui.form.on("Customize Form Field", {
var f = frappe.model.get_doc(cdt, cdn);
f.is_system_generated = false;
f.is_custom_field = true;
frm.trigger("setup_default_views");
},
});

View file

@ -13,6 +13,7 @@
"search_fields",
"column_break_5",
"istable",
"is_calendar_and_gantt",
"editable_grid",
"quick_entry",
"track_changes",
@ -35,6 +36,8 @@
"show_title_field_in_link",
"translated_doctype",
"default_print_format",
"default_view",
"force_re_route_to_default_view",
"column_break_29",
"show_preview_popup",
"email_settings_section",
@ -337,6 +340,25 @@
"fieldname": "make_attachments_public",
"fieldtype": "Check",
"label": "Make Attachments Public by Default"
},
{
"fieldname": "default_view",
"fieldtype": "Select",
"label": "Default View"
},
{
"default": "0",
"depends_on": "default_view",
"fieldname": "force_re_route_to_default_view",
"fieldtype": "Check",
"label": "Force Re-route to Default View"
},
{
"default": "0",
"description": "Enables Calendar and Gantt views.",
"fieldname": "is_calendar_and_gantt",
"fieldtype": "Check",
"label": "Is Calendar and Gantt"
}
],
"hide_toolbar": 1,
@ -345,7 +367,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-08-24 06:57:47.966331",
"modified": "2022-08-30 11:45:16.772277",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -586,6 +586,10 @@ doctype_properties = {
"naming_rule": "Data",
"autoname": "Data",
"show_title_field_in_link": "Check",
"translate_link_fields": "Check",
"is_calendar_and_gantt": "Check",
"default_view": "Select",
"force_re_route_to_default_view": "Check",
"translated_doctype": "Check",
}

View file

@ -54,7 +54,7 @@ class TestCustomizeForm(FrappeTestCase):
d = self.get_customize_form("Event")
self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")), 36)
self.assertEqual(len(d.get("fields")), 38)
d = self.get_customize_form("Event")
self.assertEqual(d.doc_type, "Event")

View file

@ -2,31 +2,104 @@
// For license information, please see license.txt
frappe.ui.form.on("DocType Layout", {
refresh: function (frm) {
frm.trigger("document_type");
frm.events.set_button(frm);
onload_post_render(frm) {
// disallow users from manually adding/deleting rows; this doctype should only
// be used for managing layout, and docfields and custom fields should be used
// to manage other field metadata (hidden, etc.)
frm.set_df_property("fields", "cannot_add_rows", true);
frm.set_df_property("fields", "cannot_delete_rows", true);
$(frm.wrapper).on("grid-move-row", (e, frm) => {
// refresh the layout after moving a row
frm.dirty();
});
},
document_type(frm) {
frm.set_fields_as_options("fields", frm.doc.document_type, null, [], "fieldname").then(
() => {
// child table empty? then show all fields as default
if (frm.doc.document_type) {
if (!(frm.doc.fields || []).length) {
for (let f of frappe.get_doc("DocType", frm.doc.document_type).fields) {
frm.add_child("fields", { fieldname: f.fieldname, label: f.label });
}
}
}
refresh(frm) {
frm.events.add_buttons(frm);
},
async document_type(frm) {
if (frm.doc.document_type) {
// refreshing the doctype fields resets the new name input field;
// once the fields are set, reset the name to the original input
if (frm.is_new()) {
const document_name = frm.doc.__newname || frm.doc.name;
}
);
frm.set_value("fields", []);
await frm.events.sync_fields(frm, false);
if (frm.is_new()) {
frm.doc.__newname = document_name;
frm.refresh_field("__newname");
}
}
},
set_button(frm) {
add_buttons(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__("Go to {0} List", [frm.doc.name]), () => {
window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
});
frm.add_custom_button(__("Sync {0} Fields", [frm.doc.name]), async () => {
await frm.events.sync_fields(frm, true);
});
}
},
async sync_fields(frm, notify) {
frappe.dom.freeze("Fetching fields...");
const response = await frm.call({ doc: frm.doc, method: "sync_fields" });
frm.refresh_field("fields");
frappe.dom.unfreeze();
if (!response.message) {
frappe.msgprint(__("No changes to sync"));
return;
}
frm.dirty();
if (notify) {
const addedFields = response.message.added;
const removedFields = response.message.removed;
const getChangedMessage = (fields) => {
let changes = "";
for (const field of fields) {
if (field.label) {
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()} (${
field.label
})</li>`;
} else {
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()}</li>`;
}
}
return changes;
};
let message = "";
if (addedFields.length) {
message += `The following fields have been added:<br><br><ul>${getChangedMessage(
addedFields
)}</ul>`;
}
if (removedFields.length) {
message += `The following fields have been removed:<br><br><ul>${getChangedMessage(
removedFields
)}</ul>`;
}
if (message) {
frappe.msgprint({
message: __(message),
indicator: "green",
title: __("Synced Fields"),
});
}
}
},
});

View file

@ -1,7 +1,7 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "Prompt",
"autoname": "prompt",
"creation": "2020-11-16 17:05:35.306846",
"doctype": "DocType",
"editable_grid": 1,
@ -19,7 +19,8 @@
"in_list_view": 1,
"label": "Document Type",
"options": "DocType",
"reqd": 1
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "fields",
@ -42,10 +43,11 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-12-10 15:01:04.352184",
"modified": "2022-09-01 03:22:33.973058",
"modified_by": "Administrator",
"module": "Custom",
"name": "DocType Layout",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@ -68,5 +70,6 @@
"route": "doctype-layout",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -1,11 +1,77 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# License: MIT. See LICENSE
from typing import TYPE_CHECKING
import frappe
from frappe.desk.utils import slug
from frappe.model.document import Document
if TYPE_CHECKING:
from frappe.core.doctype.docfield.docfield import DocField
class DocTypeLayout(Document):
def validate(self):
if not self.route:
self.route = slug(self.name)
@frappe.whitelist()
def sync_fields(self):
doctype_fields = frappe.get_meta(self.document_type).fields
if self.is_new():
added_fields = [field.fieldname for field in doctype_fields]
removed_fields = []
else:
doctype_fieldnames = {field.fieldname for field in doctype_fields}
layout_fieldnames = {field.fieldname for field in self.fields}
added_fields = list(doctype_fieldnames - layout_fieldnames)
removed_fields = list(layout_fieldnames - doctype_fieldnames)
if not (added_fields or removed_fields):
return
added = self.add_fields(added_fields, doctype_fields)
removed = self.remove_fields(removed_fields)
for index, field in enumerate(self.fields):
field.idx = index + 1
return {"added": added, "removed": removed}
def add_fields(self, added_fields: list[str], doctype_fields: list["DocField"]) -> list[dict]:
added = []
for field in added_fields:
field_details = next((f for f in doctype_fields if f.fieldname == field), None)
if not field_details:
continue
# remove 'doctype' data from the DocField to allow adding it to the layout
row = self.append("fields", field_details.as_dict(no_default_fields=True))
row_data = row.as_dict()
if field_details.get("insert_after"):
insert_after = next(
(f for f in self.fields if f.fieldname == field_details.insert_after),
None,
)
# initialize new row to just after the insert_after field
if insert_after:
self.fields.insert(insert_after.idx, row)
self.fields.pop()
row_data = {"idx": insert_after.idx + 1, "fieldname": row.fieldname, "label": row.label}
added.append(row_data)
return added
def remove_fields(self, removed_fields: list[str]) -> list[dict]:
removed = []
for field in removed_fields:
field_details = next((f for f in self.fields if f.fieldname == field), None)
if field_details:
self.remove(field_details)
removed.append(field_details.as_dict())
return removed

View file

@ -17,15 +17,16 @@ expected_settings_10_3_later = {
}
def get_mariadb_versions():
def get_mariadb_variables():
return frappe._dict(frappe.db.sql("show variables"))
def get_mariadb_version(version_string: str = ""):
# MariaDB classifies their versions as Major (1st and 2nd number), and Minor (3rd number)
# Example: Version 10.3.13 is Major Version = 10.3, Minor Version = 13
mariadb_variables = frappe._dict(frappe.db.sql("""show variables"""))
version_string = mariadb_variables.get("version").split("-")[0]
versions = {}
versions["major"] = version_string.split(".")[0] + "." + version_string.split(".")[1]
versions["minor"] = version_string.split(".")[2]
return versions
version_string = version_string or get_mariadb_variables().get("version")
version = version_string.split("-")[0]
return version.rsplit(".", 1)
def setup_database(force, source_sql, verbose, no_mariadb_socket=False):
@ -108,13 +109,13 @@ def import_db_from_sql(source_sql=None, verbose=False):
def check_database_settings():
versions = get_mariadb_versions()
if versions["major"] <= "10.2":
mariadb_variables = get_mariadb_variables()
versions = get_mariadb_version(mariadb_variables.get("version"))
if versions[0] <= "10.2":
expected_variables = expected_settings_10_2_earlier
else:
expected_variables = expected_settings_10_3_later
mariadb_variables = frappe._dict(frappe.db.sql("show variables"))
# Check each expected value vs. actuals:
result = True
for key, expected_value in expected_variables.items():
@ -124,18 +125,16 @@ def check_database_settings():
% (key, expected_value, mariadb_variables.get(key))
)
result = False
if not result:
print(
(
"=" * 80 + "\n"
"Creation of your site - {x} failed because MariaDB is not properly {sep}"
"{sep2}Creation of your site - {site} failed because MariaDB is not properly {sep}"
"configured. If using version 10.2.x or earlier, make sure you use the {sep}"
"the Barracuda storage engine. {sep}{sep}"
"Please verify the settings above in MariaDB's my.cnf. Restart MariaDB. And {sep}"
"then run `bench new-site {x}` again.{sep2}"
""
"=" * 80
).format(x=frappe.local.site, sep2="\n" * 2, sep="\n")
"the Barracuda storage engine.{sep2}"
"Please verify the above settings in MariaDB's my.cnf. Restart MariaDB.{sep}"
"And then run `bench new-site {site}` again.{sep2}"
).format(site=frappe.local.site, sep2="\n\n", sep="\n")
)
return result

View file

@ -5,6 +5,7 @@ from functools import cached_property
from types import BuiltinFunctionType
from typing import TYPE_CHECKING, Callable
import sqlparse
from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder
import frappe
@ -14,6 +15,7 @@ from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Criterion, Field, Order, Table, functions
from frappe.query_builder.functions import Function, SqlFunctions
from frappe.query_builder.utils import PseudoColumn
from frappe.utils.data import MARIADB_SPECIFIC_COMMENT
if TYPE_CHECKING:
from frappe.query_builder import DocType
@ -492,7 +494,8 @@ class Engine:
def get_fieldnames_from_child_table(self, doctype, fields):
# Hacky and flaky implementation of implicit joins.
# convert child_table.fieldname to `tabChild DocType`.`fieldname`
for idx, field in enumerate(fields, start=0):
_fields = []
for field in fields:
if "." in field and "tab" not in field:
alias = None
if " as " in field:
@ -506,12 +509,63 @@ class Engine:
field = f"`tab{self.linked_doctype}`.`{linked_fieldname}`"
if alias:
field = f"{field} as {alias}"
fields[idx] = field
_fields.append(field)
return _fields
def sanitize_fields(self, fields: str | list | tuple):
is_mariadb = frappe.db.db_type == "mariadb"
def _sanitize_field(field: str):
if not isinstance(field, str):
return field
stripped_field = sqlparse.format(field, strip_comments=True, keyword_case="lower")
if is_mariadb:
return MARIADB_SPECIFIC_COMMENT.sub("", stripped_field)
return stripped_field
if isinstance(fields, (list, tuple)):
return [_sanitize_field(field) for field in fields]
elif isinstance(fields, str):
return _sanitize_field(fields)
return fields
def set_fields(self, table, fields, **kwargs) -> list:
def get_list_fields(self, fields: list) -> list:
updated_fields = []
if issubclass(type(fields), Criterion) or "*" in fields:
return fields
# fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields)
for field in fields:
if not isinstance(field, Criterion) and field:
if " as " in field:
field, reference = field.split(" as ")
if "`" in field:
updated_fields.append(PseudoColumn(f"{field} as {reference}"))
else:
updated_fields.append(Field(field.strip()).as_(reference))
elif "`" in str(field):
updated_fields.append(PseudoColumn(field.strip()))
else:
updated_fields.append(Field(field))
return updated_fields
def get_string_fields(self, fields: str) -> Field:
if fields == "*":
return fields
if "`" in fields:
fields = PseudoColumn(fields)
if " as " in str(fields):
fields, reference = str(fields).split(" as ")
if "`" in str(fields):
fields = PseudoColumn(f"{fields} as {reference}")
else:
fields = Field(fields).as_(reference)
return fields
def set_fields(self, fields, **kwargs) -> list:
fields = kwargs.get("pluck") if kwargs.get("pluck") else fields or "name"
fields = self.sanitize_fields(fields)
if isinstance(fields, list) and None in fields and Field not in fields:
return None
function_objects = []
@ -535,39 +589,9 @@ class Engine:
is_list, is_str = True, False
if is_str:
if fields == "*":
return fields
if "`" in fields:
fields = PseudoColumn(fields)
if " as " in str(fields):
fields, reference = str(fields).split(" as ")
if "`" in str(fields):
fields = PseudoColumn(f"{fields} as {reference}")
else:
fields = Field(fields).as_(reference)
fields = self.get_string_fields(fields)
if not is_str and fields:
if issubclass(type(fields), Criterion):
return fields
updated_fields = []
if "*" in fields:
return fields
# fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields)
for field in fields:
if not isinstance(field, Criterion) and field:
if " as " in field:
field, reference = field.split(" as ")
if "`" in field:
updated_fields.append(PseudoColumn(f"{field} as {reference}"))
else:
updated_fields.append(Field(field.strip()).as_(reference))
elif "`" in str(field):
updated_fields.append(PseudoColumn(field.strip()))
else:
updated_fields.append(Field(field))
fields = updated_fields
fields = self.get_list_fields(fields)
# Need to check instance again since fields modified.
if not isinstance(fields, (list, tuple, set)):
@ -599,15 +623,17 @@ class Engine:
has_join = True
if has_join:
for idx, field in enumerate(fields):
def _update_pypika_fields(field):
if not is_pypika_function_object(field):
field = field if isinstance(field, str) else field.get_sql()
if not TABLE_PATTERN.search(str(field)):
fields[idx] = getattr(frappe.qb.DocType(table), field)
return getattr(frappe.qb.DocType(table), field)
else:
field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args]
field.args[0] = getattr(frappe.qb.DocType(table), field.args[0].get_sql())
fields[idx] = field
return field
fields = [_update_pypika_fields(field) for field in fields]
if len(self.tables) > 1:
primary_table = self.tables.pop(table)
@ -631,7 +657,7 @@ class Engine:
self.linked_doctype = None
self.fieldname = None
fields = self.set_fields(table, kwargs.get("field_objects") or fields, **kwargs)
fields = self.set_fields(kwargs.get("field_objects") or fields, **kwargs)
criterion = self.build_conditions(table, filters, **kwargs)
join = kwargs.get("join").replace(" ", "_") if kwargs.get("join") else "left_join"
criterion, fields = self.join_(criterion=criterion, fields=fields, table=table, join=join)

View file

@ -41,6 +41,18 @@ frappe.ui.form.on("Event", {
},
__("Add Participants")
);
const [ends_on_date] = frm.doc.ends_on
? frm.doc.ends_on.split(" ")
: frm.doc.starts_on.split(" ");
if (frm.doc.google_meet_link && frappe.datetime.now_date() <= ends_on_date) {
frm.dashboard.set_headline(
__("Join video conference with {0}", [
`<a target='_blank' href='${frm.doc.google_meet_link}'>Google Meet</a>`,
])
);
}
},
repeat_on: function (frm) {
if (frm.doc.repeat_on === "Every Day") {

View file

@ -22,12 +22,14 @@
"sender",
"all_day",
"sync_with_google_calendar",
"add_video_conferencing",
"sb_00",
"google_calendar",
"pulled_from_google_calendar",
"cb_00",
"google_calendar_id",
"cb_00",
"google_calendar_event_id",
"google_meet_link",
"pulled_from_google_calendar",
"section_break_13",
"repeat_on",
"repeat_till",
@ -225,7 +227,7 @@
},
{
"collapsible": 1,
"depends_on": "eval:doc.sync_with_google_calendar",
"depends_on": "eval:doc.sync_with_google_calendar || doc.pulled_from_google_calendar",
"fieldname": "sb_00",
"fieldtype": "Section Break",
"label": "Google Calendar"
@ -245,6 +247,7 @@
"fieldname": "google_calendar_event_id",
"fieldtype": "Data",
"label": "Google Calendar Event ID",
"no_copy": 1,
"read_only": 1
},
{
@ -272,12 +275,27 @@
"label": "Sender",
"options": "Email",
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:doc.sync_with_google_calendar",
"description": "via Google Meet",
"fieldname": "add_video_conferencing",
"fieldtype": "Check",
"label": "Add Video Conferencing"
},
{
"fieldname": "google_meet_link",
"fieldtype": "Data",
"label": "Google Meet Link",
"no_copy": 1,
"read_only": 1
}
],
"icon": "fa fa-calendar",
"idx": 1,
"links": [],
"modified": "2022-05-12 05:43:27.935510",
"modified": "2022-08-12 19:24:34.794098",
"modified_by": "Administrator",
"module": "Desk",
"name": "Event",

View file

@ -6,6 +6,7 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.doctype.notification_settings.notification_settings import (
is_email_notifications_enabled_for_type,
)
@ -55,6 +56,12 @@ class Event(Document):
if self.sync_with_google_calendar and not self.google_calendar:
frappe.throw(_("Select Google Calendar to which event should be synced."))
if not self.sync_with_google_calendar:
self.add_video_conferencing = 0
def before_save(self):
self.set_participants_email()
def on_update(self):
self.sync_communication()
@ -131,6 +138,22 @@ class Event(Document):
for participant in participants:
self.add_participant(participant["doctype"], participant["docname"])
def set_participants_email(self):
for participant in self.event_participants:
if participant.email:
continue
if participant.reference_doctype != "Contact":
participant_contact = get_default_contact(
participant.reference_doctype, participant.reference_docname
)
else:
participant_contact = participant.reference_docname
participant.email = (
frappe.get_value("Contact", participant_contact, "email_id") if participant_contact else None
)
@frappe.whitelist()
def delete_communication(event, reference_doctype, reference_docname):

View file

@ -6,7 +6,8 @@
"engine": "InnoDB",
"field_order": [
"reference_doctype",
"reference_docname"
"reference_docname",
"email"
],
"fields": [
{
@ -24,11 +25,17 @@
"label": "Reference Name",
"options": "reference_doctype",
"reqd": 1
},
{
"fieldname": "email",
"fieldtype": "Data",
"label": "Email",
"options": "Email"
}
],
"istable": 1,
"links": [],
"modified": "2022-08-03 12:20:50.466370",
"modified": "2022-10-18 17:49:33.549459",
"modified_by": "Administrator",
"module": "Desk",
"name": "Event Participants",

View file

@ -17,10 +17,10 @@ frappe.listview_settings["ToDo"] = {
return doc.reference_name;
},
get_label: function () {
return __("Open");
return __("Open", null, "Access");
},
get_description: function (doc) {
return __("Open {0}", [`${doc.reference_type} ${doc.reference_name}`]);
return __("Open {0}", [`${__(doc.reference_type)}: ${doc.reference_name}`]);
},
action: function (doc) {
frappe.set_route("Form", doc.reference_type, doc.reference_name);

View file

@ -1,615 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe import _
from frappe.boot import get_allowed_pages, get_allowed_reports
from frappe.cache_manager import (
build_domain_restriced_doctype_cache,
build_domain_restriced_page_cache,
build_table_count_cache,
)
from frappe.desk.doctype.desktop_icon.desktop_icon import clear_desktop_icons_cache, set_hidden
@frappe.whitelist()
def get(module):
"""Returns data (sections, list of reports, counts) to render module view in desk:
`/desk/#Module/[name]`."""
data = get_data(module)
out = {"data": data}
return out
@frappe.whitelist()
def hide_module(module):
set_hidden(module, frappe.session.user, 1)
clear_desktop_icons_cache()
def get_table_with_counts():
counts = frappe.cache().get_value("information_schema:counts")
if counts:
return counts
else:
return build_table_count_cache()
def get_data(module, build=True):
"""Get module data for the module view `desk/#Module/[name]`"""
doctype_info = get_doctype_info(module)
data = build_config_from_file(module)
if not data:
data = build_standard_config(module, doctype_info)
else:
add_custom_doctypes(data, doctype_info)
add_section(data, _("Custom Reports"), "fa fa-list-alt", get_report_list(module))
data = combine_common_sections(data)
data = apply_permissions(data)
# set_last_modified(data)
if build:
exists_cache = get_table_with_counts()
def doctype_contains_a_record(name):
exists = exists_cache.get(name)
if not exists:
if not frappe.db.get_value("DocType", name, "issingle"):
exists = frappe.db.count(name)
else:
exists = True
exists_cache[name] = exists
return exists
for section in data:
for item in section["items"]:
# Onboarding
# First disable based on exists of depends_on list
doctype = item.get("doctype")
dependencies = item.get("dependencies") or None
if not dependencies and doctype:
item["dependencies"] = [doctype]
dependencies = item.get("dependencies")
if dependencies:
incomplete_dependencies = [d for d in dependencies if not doctype_contains_a_record(d)]
if len(incomplete_dependencies):
item["incomplete_dependencies"] = incomplete_dependencies
if item.get("onboard"):
# Mark Spotlights for initial
if item.get("type") == "doctype":
name = item.get("name")
count = doctype_contains_a_record(name)
item["count"] = count
return data
def build_config_from_file(module):
"""Build module info from `app/config/desktop.py` files."""
data = []
module = frappe.scrub(module)
for app in frappe.get_installed_apps():
try:
data += get_config(app, module)
except ImportError:
pass
return filter_by_restrict_to_domain(data)
def filter_by_restrict_to_domain(data):
"""filter Pages and DocType depending on the Active Module(s)"""
doctypes = (
frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache()
)
pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache()
for d in data:
_items = []
for item in d.get("items", []):
item_type = item.get("type")
item_name = item.get("name")
if (item_name in pages) or (item_name in doctypes) or item_type == "report":
_items.append(item)
d.update({"items": _items})
return data
def build_standard_config(module, doctype_info):
"""Build standard module data from DocTypes."""
if not frappe.db.get_value("Module Def", module):
frappe.throw(_("Module Not Found"))
data = []
add_section(
data,
_("Documents"),
"fa fa-star",
[d for d in doctype_info if d.document_type in ("Document", "Transaction")],
)
add_section(
data,
_("Setup"),
"fa fa-cog",
[d for d in doctype_info if d.document_type in ("Master", "Setup", "")],
)
add_section(data, _("Standard Reports"), "fa fa-list", get_report_list(module, is_standard="Yes"))
return data
def add_section(data, label, icon, items):
"""Adds a section to the module data."""
if not items:
return
data.append({"label": label, "icon": icon, "items": items})
def add_custom_doctypes(data, doctype_info):
"""Adds Custom DocTypes to modules setup via `config/desktop.py`."""
add_section(
data,
_("Documents"),
"fa fa-star",
[d for d in doctype_info if (d.custom and d.document_type in ("Document", "Transaction"))],
)
add_section(
data,
_("Setup"),
"fa fa-cog",
[d for d in doctype_info if (d.custom and d.document_type in ("Setup", "Master", ""))],
)
def get_doctype_info(module):
"""Returns list of non child DocTypes for given module."""
active_domains = frappe.get_active_domains()
doctype_info = frappe.get_all(
"DocType",
filters={"module": module, "istable": 0},
or_filters={"ifnull(restrict_to_domain, '')": "", "restrict_to_domain": ("in", active_domains)},
fields=["'doctype' as type", "name", "description", "document_type", "custom", "issingle"],
order_by="custom asc, document_type desc, name asc",
)
for d in doctype_info:
d.document_type = d.document_type or ""
d.description = _(d.description or "")
return doctype_info
def combine_common_sections(data):
"""Combine sections declared in separate apps."""
sections = []
sections_dict = {}
for each in data:
if each["label"] not in sections_dict:
sections_dict[each["label"]] = each
sections.append(each)
else:
sections_dict[each["label"]]["items"] += each["items"]
return sections
def apply_permissions(data):
default_country = frappe.db.get_default("country")
user = frappe.get_user()
user.build_permissions()
allowed_pages = get_allowed_pages()
allowed_reports = get_allowed_reports()
new_data = []
for section in data:
new_items = []
for item in section.get("items") or []:
item = frappe._dict(item)
if item.country and item.country != default_country:
continue
if (
(item.type == "doctype" and item.name in user.can_read)
or (item.type == "page" and item.name in allowed_pages)
or (item.type == "report" and item.name in allowed_reports)
or item.type == "help"
):
new_items.append(item)
if new_items:
new_section = section.copy()
new_section["items"] = new_items
new_data.append(new_section)
return new_data
def get_disabled_reports():
if not hasattr(frappe.local, "disabled_reports"):
frappe.local.disabled_reports = {r.name for r in frappe.get_all("Report", {"disabled": 1})}
return frappe.local.disabled_reports
def get_config(app, module):
"""Load module info from `[app].config.[module]`."""
config = frappe.get_module(f"{app}.config.{module}")
config = config.get_data()
sections = [s for s in config if s.get("condition", True)]
disabled_reports = get_disabled_reports()
for section in sections:
items = []
for item in section["items"]:
if item["type"] == "report" and item["name"] in disabled_reports:
continue
# some module links might not have name
if not item.get("name"):
item["name"] = item.get("label")
if not item.get("label"):
item["label"] = _(item.get("name"))
items.append(item)
section["items"] = items
return sections
def config_exists(app, module):
try:
frappe.get_module(f"{app}.config.{module}")
return True
except ImportError:
return False
def add_setup_section(config, app, module, label, icon):
"""Add common sections to `/desk#Module/Setup`"""
try:
setup_section = get_setup_section(app, module, label, icon)
if setup_section:
config.append(setup_section)
except ImportError:
pass
def get_setup_section(app, module, label, icon):
"""Get the setup section from each module (for global Setup page)."""
config = get_config(app, module)
for section in config:
if section.get("label") == _("Setup"):
return {"label": label, "icon": icon, "items": section["items"]}
def get_onboard_items(app, module):
try:
sections = get_config(app, module)
except ImportError:
return []
onboard_items = []
fallback_items = []
if not sections:
doctype_info = get_doctype_info(module)
sections = build_standard_config(module, doctype_info)
for section in sections:
for item in section["items"]:
if item.get("onboard", 0) == 1:
onboard_items.append(item)
# in case onboard is not set
fallback_items.append(item)
if len(onboard_items) > 5:
return onboard_items
return onboard_items or fallback_items
@frappe.whitelist()
def get_links_for_module(app, module):
return [{"value": l.get("name"), "label": l.get("label")} for l in get_links(app, module)]
def get_links(app, module):
try:
sections = get_config(app, frappe.scrub(module))
except ImportError:
return []
links = []
for section in sections:
for item in section["items"]:
links.append(item)
return links
@frappe.whitelist()
def get_desktop_settings():
from frappe.config import get_modules_from_all_apps_for_user
all_modules = get_modules_from_all_apps_for_user()
home_settings = get_home_settings()
modules_by_name = {}
for m in all_modules:
modules_by_name[m["module_name"]] = m
module_categories = ["Modules", "Domains", "Places", "Administration"]
user_modules_by_category = {}
user_saved_modules_by_category = home_settings.modules_by_category or {}
user_saved_links_by_module = home_settings.links_by_module or {}
def apply_user_saved_links(module):
module = frappe._dict(module)
all_links = get_links(module.app, module.module_name)
module_links_by_name = {}
for link in all_links:
module_links_by_name[link["name"]] = link
if module.module_name in user_saved_links_by_module:
user_links = frappe.parse_json(user_saved_links_by_module[module.module_name])
module.links = [module_links_by_name[l] for l in user_links if l in module_links_by_name]
return module
for category in module_categories:
if category in user_saved_modules_by_category:
user_modules = user_saved_modules_by_category[category]
user_modules_by_category[category] = [
apply_user_saved_links(modules_by_name[m]) for m in user_modules if modules_by_name.get(m)
]
else:
user_modules_by_category[category] = [
apply_user_saved_links(m) for m in all_modules if m.get("category") == category
]
# filter out hidden modules
if home_settings.hidden_modules:
for category in user_modules_by_category:
hidden_modules = home_settings.hidden_modules or []
modules = user_modules_by_category[category]
user_modules_by_category[category] = [
module for module in modules if module.module_name not in hidden_modules
]
return user_modules_by_category
@frappe.whitelist()
def update_hidden_modules(category_map):
category_map = frappe.parse_json(category_map)
home_settings = get_home_settings()
saved_hidden_modules = home_settings.hidden_modules or []
for category in category_map:
config = frappe._dict(category_map[category])
saved_hidden_modules += config.removed or []
saved_hidden_modules = [d for d in saved_hidden_modules if d not in (config.added or [])]
if home_settings.get("modules_by_category") and home_settings.modules_by_category.get(category):
module_placement = [
d for d in (config.added or []) if d not in home_settings.modules_by_category[category]
]
home_settings.modules_by_category[category] += module_placement
home_settings.hidden_modules = saved_hidden_modules
set_home_settings(home_settings)
return get_desktop_settings()
@frappe.whitelist()
def update_global_hidden_modules(modules):
modules = frappe.parse_json(modules)
frappe.only_for("System Manager")
doc = frappe.get_doc("User", "Administrator")
doc.set("block_modules", [])
for module in modules:
doc.append("block_modules", {"module": module})
doc.save(ignore_permissions=True)
return get_desktop_settings()
@frappe.whitelist()
def update_modules_order(module_category, modules):
modules = frappe.parse_json(modules)
home_settings = get_home_settings()
home_settings.modules_by_category = home_settings.modules_by_category or {}
home_settings.modules_by_category[module_category] = modules
set_home_settings(home_settings)
@frappe.whitelist()
def update_links_for_module(module_name, links):
links = frappe.parse_json(links)
home_settings = get_home_settings()
home_settings.setdefault("links_by_module", {})
home_settings["links_by_module"].setdefault(module_name, None)
home_settings["links_by_module"][module_name] = links
set_home_settings(home_settings)
return get_desktop_settings()
@frappe.whitelist()
def get_options_for_show_hide_cards():
global_options = []
if "System Manager" in frappe.get_roles():
global_options = get_options_for_global_modules()
return {"user_options": get_options_for_user_blocked_modules(), "global_options": global_options}
@frappe.whitelist()
def get_options_for_global_modules():
from frappe.config import get_modules_from_all_apps
all_modules = get_modules_from_all_apps()
blocked_modules = frappe.get_doc("User", "Administrator").get_blocked_modules()
options = []
for module in all_modules:
module = frappe._dict(module)
options.append(
{
"category": module.category,
"label": module.label,
"value": module.module_name,
"checked": module.module_name not in blocked_modules,
}
)
return options
@frappe.whitelist()
def get_options_for_user_blocked_modules():
from frappe.config import get_modules_from_all_apps_for_user
all_modules = get_modules_from_all_apps_for_user()
home_settings = get_home_settings()
hidden_modules = home_settings.hidden_modules or []
options = []
for module in all_modules:
module = frappe._dict(module)
options.append(
{
"category": module.category,
"label": module.label,
"value": module.module_name,
"checked": module.module_name not in hidden_modules,
}
)
return options
def set_home_settings(home_settings):
frappe.cache().hset("home_settings", frappe.session.user, home_settings)
frappe.db.set_value("User", frappe.session.user, "home_settings", json.dumps(home_settings))
@frappe.whitelist()
def get_home_settings():
def get_from_db():
settings = frappe.db.get_value("User", frappe.session.user, "home_settings")
return frappe.parse_json(settings or "{}")
home_settings = frappe.cache().hget("home_settings", frappe.session.user, get_from_db)
return home_settings
def get_module_link_items_from_list(app, module, list_of_link_names):
try:
sections = get_config(app, frappe.scrub(module))
except ImportError:
return []
links = []
for section in sections:
for item in section["items"]:
if item.get("label", "") in list_of_link_names:
links.append(item)
return links
def set_last_modified(data):
for section in data:
for item in section["items"]:
if item["type"] == "doctype":
item["last_modified"] = get_last_modified(item["name"])
def get_last_modified(doctype):
def _get():
try:
last_modified = frappe.get_all(
doctype, fields=["max(modified)"], as_list=True, limit_page_length=1
)[0][0]
except Exception as e:
if frappe.db.is_table_missing(e):
last_modified = None
else:
raise
# hack: save as -1 so that it is cached
if last_modified is None:
last_modified = -1
return last_modified
last_modified = frappe.cache().hget("last_modified", doctype, _get)
if last_modified == -1:
last_modified = None
return last_modified
def get_report_list(module, is_standard="No"):
"""Returns list on new style reports for modules."""
reports = frappe.get_list(
"Report",
fields=["name", "ref_doctype", "report_type"],
filters={"is_standard": is_standard, "disabled": 0, "module": module},
order_by="name",
)
out = []
for r in reports:
out.append(
{
"type": "report",
"doctype": r.ref_doctype,
"is_query_report": 1
if r.report_type in ("Query Report", "Script Report", "Custom Report")
else 0,
"label": _(r.name),
"name": r.name,
}
)
return out

View file

@ -1,107 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import copy
import json
import frappe
@frappe.whitelist()
def get_data(doctypes, last_modified):
data_map = {}
for dump_report_map in frappe.get_hooks().dump_report_map:
data_map.update(frappe.get_attr(dump_report_map))
out = {}
doctypes = json.loads(doctypes)
last_modified = json.loads(last_modified)
for d in doctypes:
args = copy.deepcopy(data_map[d])
dt = d.find("[") != -1 and d[: d.find("[")] or d
out[dt] = {}
if args.get("from"):
modified_table = "item."
else:
modified_table = ""
conditions = order_by = ""
table = args.get("from") or ("`tab%s`" % dt)
if d in last_modified:
if not args.get("conditions"):
args["conditions"] = []
args["conditions"].append(modified_table + "modified > '" + last_modified[d] + "'")
out[dt]["modified_names"] = frappe.db.sql_list(
"""select %sname from %s
where %smodified > %s"""
% (modified_table, table, modified_table, "%s"),
last_modified[d],
)
if args.get("force_index"):
conditions = " force index (%s) " % args["force_index"]
if args.get("conditions"):
conditions += " where " + " and ".join(args["conditions"])
if args.get("order_by"):
order_by = " order by " + args["order_by"]
out[dt]["data"] = [
list(t)
for t in frappe.db.sql(
"""select {} from {} {} {}""".format(",".join(args["columns"]), table, conditions, order_by)
)
]
# last modified
modified_table = table
if "," in table:
modified_table = " ".join(table.split(",")[0].split(" ")[:-1])
tmp = frappe.db.sql(
"""select `modified`
from %s order by modified desc limit 1"""
% modified_table
)
out[dt]["last_modified"] = tmp and tmp[0][0] or ""
out[dt]["columns"] = list(map(lambda c: c.split(" as ")[-1], args["columns"]))
if args.get("links"):
out[dt]["links"] = args["links"]
for d in out:
unused_links = []
# only compress full dumps (not partial)
if out[d].get("links") and (d not in last_modified):
for link_key in out[d]["links"]:
link = out[d]["links"][link_key]
if link[0] in out and (link[0] not in last_modified):
# make a map of link ids
# to index
link_map = {}
doctype_data = out[link[0]]
col_idx = doctype_data["columns"].index(link[1])
for row_idx in range(len(doctype_data["data"])):
row = doctype_data["data"][row_idx]
link_map[row[col_idx]] = row_idx
for row in out[d]["data"]:
columns = list(out[d]["columns"])
if link_key in columns:
col_idx = columns.index(link_key)
# replace by id
if row[col_idx]:
row[col_idx] = link_map.get(row[col_idx])
else:
unused_links.append(link_key)
for link in unused_links:
del out[d]["links"][link]
return out

View file

@ -8,7 +8,6 @@ import re
import frappe
from frappe import _, is_whitelisted
from frappe.permissions import has_permission
from frappe.translate import get_translated_doctypes
from frappe.utils import cint, cstr, unique
@ -150,10 +149,6 @@ def search_widget(
filters = []
or_filters = []
translated_doctypes = frappe.cache().hget(
"translated_doctypes", "doctypes", get_translated_doctypes
)
# build from doctype
if txt:
field_types = [
@ -175,7 +170,7 @@ def search_widget(
for f in search_fields:
fmeta = meta.get_field(f.strip())
if (doctype not in translated_doctypes) and (
if not meta.translated_doctype and (
f == "name" or (fmeta and fmeta.fieldtype in field_types)
):
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
@ -191,26 +186,25 @@ def search_widget(
fields = list(set(fields + json.loads(filter_fields)))
formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields]
title_field_query = get_title_field_query(meta)
# Insert title field query after name
if title_field_query:
formatted_fields.insert(1, title_field_query)
# find relevance as location of search term from the beginning of string `name`. used for sorting results.
formatted_fields.append(
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")),
doctype=doctype,
)
)
if meta.show_title_field_in_link:
formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`")
# In order_by, `idx` gets second priority, because it stores link count
from frappe.model.db_query import get_order_by
order_by_based_on_meta = get_order_by(doctype, meta)
# 2 is the index of _relevance column
order_by = f"_relevance, {order_by_based_on_meta}, `tab{doctype}`.idx desc"
order_by = f"{order_by_based_on_meta}, `tab{doctype}`.idx desc"
if not meta.translated_doctype:
formatted_fields.append(
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")),
doctype=doctype,
)
)
order_by = f"_relevance, {order_by}"
ptype = "select" if frappe.only_has_select_perm(doctype) else "read"
ignore_permissions = (
@ -219,16 +213,13 @@ def search_widget(
else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))
)
if doctype in translated_doctypes:
page_length = None
values = frappe.get_list(
doctype,
filters=filters,
fields=formatted_fields,
or_filters=or_filters,
limit_start=start,
limit_page_length=page_length,
limit_page_length=None if meta.translated_doctype else page_length,
order_by=order_by,
ignore_permissions=ignore_permissions,
reference_doctype=reference_doctype,
@ -236,12 +227,15 @@ def search_widget(
strict=False,
)
if doctype in translated_doctypes:
if meta.translated_doctype:
# Filtering the values array so that query is included in very element
values = (
v
for v in values
if re.search(f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE)
result
for result in values
if any(
re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE)
for value in (result.values() if as_dict else result)
)
)
# Sorting the values array so that relevant results always come first
@ -250,12 +244,14 @@ def search_widget(
values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict))
# remove _relevance from results
if as_dict:
for r in values:
r.pop("_relevance")
frappe.response["values"] = values
else:
frappe.response["values"] = [r[:-1] for r in values]
if not meta.translated_doctype:
if as_dict:
for r in values:
r.pop("_relevance")
else:
values = [r[:-1] for r in values]
frappe.response["values"] = values
def get_std_fields_list(meta, key):
@ -275,39 +271,23 @@ def get_std_fields_list(meta, key):
return sflist
def get_title_field_query(meta):
title_field = meta.title_field if meta.title_field else None
show_title_field_in_link = (
meta.show_title_field_in_link if meta.show_title_field_in_link else None
)
field = None
def build_for_autosuggest(res: list[tuple], doctype: str) -> list[dict]:
def to_string(parts):
return ", ".join(
unique(_(cstr(part)) if meta.translated_doctype else cstr(part) for part in parts if part)
)
if title_field and show_title_field_in_link:
field = f"`tab{meta.name}`.{title_field} as `label`"
return field
def build_for_autosuggest(res, doctype):
results = []
meta = frappe.get_meta(doctype)
if not (meta.title_field and meta.show_title_field_in_link):
for r in res:
r = list(r)
results.append({"value": r[0], "description": ", ".join(unique(cstr(d) for d in r[1:] if d))})
if meta.show_title_field_in_link:
for item in res:
item = list(item)
label = item[1] # use title as label
item[1] = item[0] # show name in description instead of title
del item[2] # remove redundant title ("label") value
results.append({"value": item[0], "label": label, "description": to_string(item[1:])})
else:
title_field_exists = meta.title_field and meta.show_title_field_in_link
_from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists
for r in res:
r = list(r)
results.append(
{
"value": r[0],
"label": r[1] if title_field_exists else None,
"description": ", ".join(unique(cstr(d) for d in r[_from:] if d)),
}
)
results.extend({"value": item[0], "description": to_string(item[1:])} for item in res)
return results
@ -383,7 +363,7 @@ def get_user_groups():
def get_link_title(doctype, docname):
meta = frappe.get_meta(doctype)
if meta.title_field and meta.show_title_field_in_link:
if meta.show_title_field_in_link:
return frappe.db.get_value(doctype, docname, meta.title_field)
return docname

View file

@ -176,7 +176,7 @@ frappe.ui.form.on("Notification", {
},
callback: function (r) {
if (r.message && r.message.length > 0) {
frappe.msgprint(r.message);
frappe.msgprint(r.message.toString());
} else {
frappe.msgprint(__("No alerts for today"));
}

View file

@ -4,6 +4,7 @@
from datetime import datetime, timedelta
from urllib.parse import quote
from zoneinfo import ZoneInfo
import google.oauth2.credentials
import requests
@ -274,7 +275,7 @@ def sync_events_from_google_calendar(g_calendar, method=None):
if err.resp.status == 410:
set_encrypted_password("Google Calendar", account.name, "", "next_sync_token")
frappe.db.commit()
msg += " " + _("Sync token was invalid and has been resetted, Retry syncing.")
msg += " " + _("Sync token was invalid and has been reset, Retry syncing.")
frappe.msgprint(msg, title="Invalid Sync Token", indicator="blue")
else:
frappe.throw(msg)
@ -356,6 +357,7 @@ def insert_event_to_calendar(account, event, recurrence=None):
"google_calendar": account.name,
"google_calendar_id": account.google_calendar_id,
"google_calendar_event_id": event.get("id"),
"google_meet_link": event.get("hangoutLink"),
"pulled_from_google_calendar": 1,
}
calendar_event.update(
@ -373,6 +375,7 @@ def update_event_in_calendar(account, event, recurrence=None):
calendar_event = frappe.get_doc("Event", {"google_calendar_event_id": event.get("id")})
calendar_event.subject = event.get("summary")
calendar_event.description = event.get("description")
calendar_event.google_meet_link = event.get("hangoutLink")
calendar_event.update(
google_calendar_to_repeat_on(
recurrence=recurrence, start=event.get("start"), end=event.get("end")
@ -407,11 +410,30 @@ def insert_event_in_google_calendar(doc, method=None):
if doc.repeat_on:
event.update({"recurrence": repeat_on_to_google_calendar_recurrence_rule(doc)})
event.update({"attendees": get_attendees(doc)})
conference_data_version = 0
if doc.add_video_conferencing:
event.update({"conferenceData": get_conference_data(doc)})
conference_data_version = 1
try:
event = google_calendar.events().insert(calendarId=doc.google_calendar_id, body=event).execute()
frappe.db.set_value(
"Event", doc.name, "google_calendar_event_id", event.get("id"), update_modified=False
event = (
google_calendar.events()
.insert(
calendarId=doc.google_calendar_id, body=event, conferenceDataVersion=conference_data_version
)
.execute()
)
frappe.db.set_value(
"Event",
doc.name,
{"google_calendar_event_id": event.get("id"), "google_meet_link": event.get("hangoutLink")},
update_modified=False,
)
frappe.msgprint(_("Event Synced with Google Calendar."))
except HttpError as err:
frappe.throw(
@ -450,6 +472,7 @@ def update_event_in_google_calendar(doc, method=None):
.get(calendarId=doc.google_calendar_id, eventId=doc.google_calendar_event_id)
.execute()
)
event["summary"] = doc.subject
event["description"] = doc.description
event["recurrence"] = repeat_on_to_google_calendar_recurrence_rule(doc)
@ -462,9 +485,38 @@ def update_event_in_google_calendar(doc, method=None):
)
)
google_calendar.events().update(
calendarId=doc.google_calendar_id, eventId=doc.google_calendar_event_id, body=event
).execute()
conference_data_version = 0
if doc.add_video_conferencing:
event.update({"conferenceData": get_conference_data(doc)})
conference_data_version = 1
elif doc.get_doc_before_save().add_video_conferencing or event.get("hangoutLink"):
# remove google meet from google calendar event, if turning off add_video_conferencing
event.update({"conferenceData": None})
conference_data_version = 1
event.update({"attendees": get_attendees(doc)})
event = (
google_calendar.events()
.update(
calendarId=doc.google_calendar_id,
eventId=doc.google_calendar_event_id,
body=event,
conferenceDataVersion=conference_data_version,
)
.execute()
)
# if add_video_conferencing enabled or disabled during update, overwrite
frappe.db.set_value(
"Event",
doc.name,
{"google_meet_link": event.get("hangoutLink")},
update_modified=False,
)
doc.notify_update()
frappe.msgprint(_("Event Synced with Google Calendar."))
except HttpError as err:
frappe.throw(
@ -515,12 +567,20 @@ def google_calendar_to_repeat_on(start, end, recurrence=None):
Both have been mapped in a dict for easier mapping.
"""
repeat_on = {
"starts_on": get_datetime(start.get("date"))
if start.get("date")
else parser.parse(start.get("dateTime")).utcnow(),
"ends_on": get_datetime(end.get("date"))
if end.get("date")
else parser.parse(end.get("dateTime")).utcnow(),
"starts_on": (
get_datetime(start.get("date"))
if start.get("date")
else parser.parse(start.get("dateTime"))
.astimezone(ZoneInfo(get_time_zone()))
.replace(tzinfo=None)
),
"ends_on": (
get_datetime(end.get("date"))
if end.get("date")
else parser.parse(end.get("dateTime"))
.astimezone(ZoneInfo(get_time_zone()))
.replace(tzinfo=None)
),
"all_day": 1 if start.get("date") else 0,
"repeat_this_event": 1 if recurrence else 0,
"repeat_on": None,
@ -682,6 +742,39 @@ def get_recurrence_parameters(recurrence):
return frequency, until, byday
def get_conference_data(doc):
return {
"createRequest": {"requestId": doc.name, "conferenceSolutionKey": {"type": "hangoutsMeet"}},
"notes": doc.description,
}
def get_attendees(doc):
"""
Returns a list of dicts with attendee emails, if available in event_participants table
"""
attendees, email_not_found = [], []
for participant in doc.event_participants:
if participant.get("email"):
attendees.append({"email": participant.email})
else:
email_not_found.append(
{"dt": participant.reference_doctype, "dn": participant.reference_docname}
)
if email_not_found:
frappe.msgprint(
_("Google Calendar - Contact / email not found. Did not add attendee for -<br>{0}").format(
"<br>".join(f"{d.get('dt')} {d.get('dn')}" for d in email_not_found)
),
alert=True,
indicator="yellow",
)
return attendees
"""API Response
{
'kind': 'calendar#events',
@ -721,6 +814,32 @@ def get_recurrence_parameters(recurrence):
'recurrence': *recurrence,
'iCalUID': 'uid',
'sequence': 1,
'hangoutLink': 'https://meet.google.com/mee-ting-uri',
'conferenceData': {
'createRequest': {
'requestId': 'EV00001',
'conferenceSolutionKey': {
'type': 'hangoutsMeet'
},
'status': {
'statusCode': 'success'
}
},
'entryPoints': [
{
'entryPointType': 'video',
'uri': 'https://meet.google.com/mee-ting-uri',
'label': 'meet.google.com/mee-ting-uri'
}
],
'conferenceSolution': {
'key': {
'type': 'hangoutsMeet'
},
'name': 'Google Meet',
'iconUri': 'https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png'
},
'conferenceId': 'mee-ting-uri'
'reminders': {
'useDefault': True
}

View file

@ -95,6 +95,10 @@ def delete_doc(
update_flags(doc, flags, ignore_permissions)
check_permission_and_not_submitted(doc)
# delete custom table fields using this doctype.
frappe.db.delete(
"Custom Field", {"options": name, "fieldtype": ("in", frappe.model.table_fields)}
)
frappe.db.delete("__global_search", {"doctype": name})
delete_from_table(doctype, name, ignore_doctypes, None)

View file

@ -950,15 +950,19 @@ class Document(BaseDocument):
from frappe.email.doctype.notification.notification import evaluate_alert
if self.flags.notifications is None:
alerts = frappe.cache().hget("notifications", self.doctype)
if alerts is None:
alerts = frappe.get_all(
def _get_notifications():
"""returns enabled notifications for the current doctype"""
return frappe.get_all(
"Notification",
fields=["name", "event", "method"],
filters={"enabled": 1, "document_type": self.doctype},
)
frappe.cache().hset("notifications", self.doctype, alerts)
self.flags.notifications = alerts
self.flags.notifications = frappe.cache().hget(
"notifications", self.doctype, _get_notifications
)
if not self.flags.notifications:
return
@ -1174,6 +1178,9 @@ class Document(BaseDocument):
# to trigger notification on value change
self.run_method("before_change")
if self.name is None:
return
frappe.db.set_value(
self.doctype,
self.name,

View file

@ -18,11 +18,12 @@ if click_ctx:
class ParallelTestRunner:
def __init__(self, app, site, build_number=1, total_builds=1):
def __init__(self, app, site, build_number=1, total_builds=1, dry_run=False):
self.app = app
self.site = site
self.build_number = frappe.utils.cint(build_number) or 1
self.total_builds = frappe.utils.cint(total_builds)
self.dry_run = dry_run
self.setup_test_site()
self.run_tests()
@ -31,6 +32,9 @@ class ParallelTestRunner:
if not frappe.db:
frappe.connect()
if self.dry_run:
return
frappe.flags.in_test = True
frappe.clear_cache()
frappe.utils.scheduler.disable_scheduler()
@ -64,6 +68,10 @@ class ParallelTestRunner:
if not file_info:
return
if self.dry_run:
print("running tests from", "/".join(file_info))
return
frappe.set_user("Administrator")
path, filename = file_info
module = self.get_module(path, filename)
@ -108,12 +116,48 @@ class ParallelTestRunner:
sys.exit(1)
def get_test_file_list(self):
# Load balance based on total # of tests ~ each runner should get roughly same # of tests.
test_list = get_all_tests(self.app)
split_size = frappe.utils.ceil(len(test_list) / self.total_builds)
# [1,2,3,4,5,6] to [[1,2], [3,4], [4,6]] if split_size is 2
test_chunks = [test_list[x : x + split_size] for x in range(0, len(test_list), split_size)]
test_counts = [self.get_test_count(test) for test in test_list]
test_chunks = split_by_weight(test_list, test_counts, chunk_count=self.total_builds)
return test_chunks[self.build_number - 1]
@staticmethod
def get_test_count(test):
"""Get approximate count of tests inside a file"""
file_name = "/".join(test)
with open(file_name) as f:
test_count = f.read().count("def test_")
return test_count
def split_by_weight(work, weights, chunk_count):
"""Roughly split work by respective weight while keep ordering."""
expected_weight = sum(weights) // chunk_count
chunks = [[] for _ in range(chunk_count)]
chunk_no = 0
chunk_weight = 0
for task, weight in zip(work, weights):
if chunk_weight > expected_weight:
chunk_weight = 0
chunk_no += 1
assert chunk_no < chunk_count
chunks[chunk_no].append(task)
chunk_weight += weight
assert len(work) == sum(len(chunk) for chunk in chunks)
assert len(chunks) == chunk_count
return chunks
class ParallelTestResult(unittest.TextTestResult):
def startTest(self, test):

View file

@ -5,8 +5,10 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co
if (!value) {
this.datepicker.clear();
return;
} else if (value === "Today") {
} else if (value.toLowerCase() === "today") {
value = this.get_now_date();
} else if (value.toLowerCase() === "now") {
value = frappe.datetime.now_datetime();
}
value = this.format_for_input(value);
this.$input && this.$input.val(value);

View file

@ -89,10 +89,13 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
is_translatable() {
return in_list(frappe.boot?.translated_doctypes || [], this.get_options());
}
is_title_link() {
return in_list(frappe.boot.link_title_doctypes, this.get_options());
}
async set_link_title(value) {
const doctype = this.get_options();
if (!doctype || !in_list(frappe.boot.link_title_doctypes, doctype)) {
if (!doctype || !this.is_title_link()) {
this.translate_and_set_input_value(value, value);
return;
}
@ -207,7 +210,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
let _label = me.get_translated(d.label);
let html = d.html || "<strong>" + _label + "</strong>";
if (d.description && d.value !== d.description) {
if (
d.description &&
// for title links, we want to inlude the value in the description
// because it will not visible otherwise
(me.is_title_link() || d.value !== d.description)
) {
html += '<br><span class="small">' + __(d.description) + "</span>";
}
return $("<li></li>")

View file

@ -52,9 +52,7 @@ export default class Grid {
}
allow_on_grid_editing() {
if (frappe.utils.is_xs()) {
return false;
} else if ((this.meta && this.meta.editable_grid) || !this.meta) {
if ((this.meta && this.meta.editable_grid) || !this.meta) {
return true;
} else {
return false;
@ -66,17 +64,19 @@ export default class Grid {
<label class="control-label">${__(this.df.label || "")}</label>
<p class="text-muted small grid-description"></p>
<div class="grid-custom-buttons grid-field"></div>
<div class="form-grid">
<div class="grid-heading-row"></div>
<div class="grid-body">
<div class="rows"></div>
<div class="grid-empty text-center">
<img
src="/assets/frappe/images/ui-states/grid-empty-state.svg"
alt="Grid Empty State"
class="grid-empty-illustration"
>
${__("No Data")}
<div class="form-grid-container">
<div class="form-grid">
<div class="grid-heading-row"></div>
<div class="grid-body">
<div class="rows"></div>
<div class="grid-empty text-center">
<img
src="/assets/frappe/images/ui-states/grid-empty-state.svg"
alt="Grid Empty State"
class="grid-empty-illustration"
>
${__("No Data")}
</div>
</div>
</div>
</div>
@ -1011,6 +1011,7 @@ export default class Grid {
Int: (val) => cint(val),
Check: (val) => cint(val),
Float: (val) => flt(val),
Currency: (val) => flt(val),
};
// upload

View file

@ -254,7 +254,7 @@ export default class GridRow {
).appendTo(this.row);
this.row_index = $(
`<div class="row-index sortable-handle col hidden-xs">
`<div class="row-index sortable-handle col">
<span>${txt}</span>
</div>`
)
@ -268,7 +268,7 @@ export default class GridRow {
this.row_check = $(`<div class="row-check col search"></div>`).appendTo(this.row);
this.row_index = $(
`<div class="row-index col search hidden-xs">
`<div class="row-index col search">
<input type="text" class="form-control input-xs text-center" >
</div>`
).appendTo(this.row);
@ -327,7 +327,7 @@ export default class GridRow {
if (this.doc && !this.grid.df.in_place_edit) {
// remove row
if (!this.open_form_button) {
this.open_form_button = $('<div class="col col-xs-1"></div>').appendTo(this.row);
this.open_form_button = $('<div class="col"></div>').appendTo(this.row);
if (!this.configure_columns) {
this.open_form_button = $(`
@ -356,7 +356,7 @@ export default class GridRow {
if (this.configure_columns && this.frm) {
this.configure_columns_button = $(`
<div class="col grid-static-col col-xs-1 d-flex justify-content-center" style="cursor: pointer;">
<div class="col grid-static-col d-flex justify-content-center" style="cursor: pointer;">
<a>${frappe.utils.icon("setting-gear", "sm", "", "filter: opacity(0.5)")}</a>
</div>
`)
@ -366,7 +366,7 @@ export default class GridRow {
});
} else if (this.configure_columns && !this.frm) {
this.configure_columns_button = $(`
<div class="col grid-static-col col-xs-1"></div>
<div class="col grid-static-col"></div>
`).appendTo(this.row);
}
}
@ -688,7 +688,7 @@ export default class GridRow {
if (this.show_search) {
// last empty column
$(`<div class="col grid-static-col col-xs-1"></div>`).appendTo(this.row);
$(`<div class="col grid-static-col search"></div>`).appendTo(this.row);
}
}
@ -835,6 +835,60 @@ export default class GridRow {
: "";
add_class += ["Check"].indexOf(df.fieldtype) !== -1 ? " text-center" : "";
let grid;
let grid_container;
let inital_position_x = 0;
let start_x = 0;
let start_y = 0;
let input_in_focus = false;
let vertical = false;
let horizontal = false;
// prevent random layout shifts caused by widgets and on click position elements inside view (UX).
function on_input_focus(el) {
input_in_focus = true;
let container_width = grid_container.getBoundingClientRect().width;
let container_left = grid_container.getBoundingClientRect().left;
let grid_left = parseFloat(grid.style.left);
let element_left = el.offset().left;
let fieldtype = el.data("fieldtype");
let offset_right = container_width - (element_left + el.width());
let offset_left = 0;
let element_screen_x = element_left - container_left;
let element_position_x = container_width - (element_left - container_left);
if (["Date", "Time", "Datetime"].includes(fieldtype)) {
offset_left = element_position_x - 220;
}
if (["Link", "Dynamic Link"].includes(fieldtype)) {
offset_left = element_position_x - 250;
}
if (element_screen_x < 0) {
grid.style.left = `${grid_left - element_screen_x}px`;
} else if (offset_left < 0) {
grid.style.left = `${grid_left + offset_left}px`;
} else if (offset_right < 0) {
grid.style.left = `${grid_left + offset_right}px`;
}
}
// Delay date_picker widget to prevent temparary layout shift (UX).
function handle_date_picker() {
let date_time_picker = document.querySelectorAll(".datepicker.active")[0];
date_time_picker.classList.remove("active");
date_time_picker.style.width = "220px";
setTimeout(() => {
date_time_picker.classList.add("active");
}, 600);
}
var $col = $(
'<div class="col grid-static-col col-xs-' + colsize + " " + add_class + '"></div>'
)
@ -842,15 +896,68 @@ export default class GridRow {
.attr("data-fieldtype", df.fieldtype)
.data("df", df)
.appendTo(this.row)
.on("click", function () {
if (frappe.ui.form.editable_row === me) {
return;
// initialize grid for horizontal scroll on mobile devices.
.on("touchstart", function (event) {
grid_container = $(event.currentTarget).closest(".form-grid-container")[0];
grid = $(event.currentTarget).closest(".form-grid")[0];
grid.style.position != "relative" && $(grid).css("position", "relative");
!grid.style.left && $(grid).css("left", 0);
start_x = event.touches[0].clientX;
start_y = event.touches[0].clientY;
inital_position_x = -parseFloat(grid.style.left || 0) + start_x;
})
// calculate X and Y movement based on touch events.
.on("touchmove", function (event) {
if (input_in_focus) return;
let moved_x;
let moved_y;
if (!horizontal && !vertical) {
moved_x = Math.abs(start_x - event.touches[0].clientX);
moved_y = Math.abs(start_y - event.touches[0].clientY);
}
if (!vertical && moved_x > 16) {
horizontal = true;
} else if (!horizontal && moved_y > 16) {
vertical = true;
}
if (horizontal) {
event.preventDefault();
let grid_start = inital_position_x - event.touches[0].clientX;
let grid_end = grid.clientWidth - grid_container.clientWidth + 2;
if (grid_start < 0) {
grid_start = 0;
} else if (grid_start > grid_end) {
grid_start = grid_end;
}
grid.style.left = `-${grid_start}px`;
}
})
.on("touchend", function () {
vertical = false;
horizontal = false;
})
.on("click", function () {
if (frappe.ui.form.editable_row !== me) {
var out = me.toggle_editable_row();
}
var out = me.toggle_editable_row();
var col = this;
setTimeout(function () {
$(col).find('input[type="Text"]:first').focus();
}, 500);
let first_input_field = $(col).find('input[type="Text"]:first');
first_input_field.length && on_input_focus(first_input_field);
first_input_field.trigger("focus");
first_input_field.one("blur", () => (input_in_focus = false));
first_input_field.data("fieldtype") == "Date" && handle_date_picker();
return out;
});
@ -1149,6 +1256,10 @@ export default class GridRow {
return this;
}
show_form() {
if (frappe.utils.is_xs()) {
$(this.grid.form_grid).css("min-width", "0");
$(this.grid.form_grid).css("position", "unset");
}
if (!this.grid_form) {
this.grid_form = new GridRowForm({
row: this,
@ -1187,6 +1298,10 @@ export default class GridRow {
}
}
hide_form() {
if (frappe.utils.is_xs()) {
$(this.grid.form_grid).css("min-width", "738px");
$(this.grid.form_grid).css("position", "relative");
}
frappe.dom.unfreeze();
this.row.toggle(true);
if (!frappe.dom.is_element_in_modal(this.row)) {

View file

@ -144,8 +144,16 @@ frappe.ui.form.Layout = class Layout {
fieldname: "__details",
};
let first_tab = this.fields[1].fieldtype === "Tab Break" ? this.fields[1] : null;
if (!first_tab) {
this.fields.splice(1, 0, default_tab);
this.fields.splice(0, 0, default_tab);
} else {
// reshuffle __newname field to accomodate under 1st Tab Break
let newname_field = this.fields.find((df) => df.fieldname === "__newname");
if (newname_field && newname_field.get_status(this) === "Write") {
this.fields.splice(0, 1);
this.fields.splice(1, 0, newname_field);
}
}
}

View file

@ -167,13 +167,12 @@ frappe.ui.form.ScriptManager = class ScriptManager {
setup() {
const doctype = this.frm.meta;
const me = this;
let client_script;
let client_script = doctype.__js;
// process the custom script for this form
if (this.frm.doctype_layout) {
client_script = this.frm.doctype_layout.client_script;
} else {
client_script = doctype.__js;
// append the custom script for this form's layout
if (this.frm.doctype_layout?.client_script) {
// add a newline to avoid conflict with doctype JS
client_script += `\n${this.frm.doctype_layout.client_script}`;
}
if (client_script) {

View file

@ -196,7 +196,7 @@ frappe.views.BaseList = class BaseList {
Map: "map",
};
if (frappe.boot.desk_settings.view_switcher) {
if (frappe.boot.desk_settings.view_switcher && !this.meta.force_re_route_to_default_view) {
/* @preserve
for translation, don't remove
__("List View") __("Report View") __("Dashboard View") __("Gantt View"),

View file

@ -175,6 +175,7 @@ frappe.render_template = function (name, data) {
w.document.write(tree);
w.document.close();
});
frappe.render_pdf = function (html, opts = {}) {
//Create a form to place the HTML content
var formData = new FormData();
@ -197,8 +198,17 @@ frappe.render_pdf = function (html, opts = {}) {
var blob = new Blob([success.currentTarget.response], { type: "application/pdf" });
var objectUrl = URL.createObjectURL(blob);
//Open report in a new window
window.open(objectUrl);
// Create a hidden a tag to force set report name
// https://stackoverflow.com/questions/19327749/javascript-blob-filename-without-link
let hidden_a_tag = document.createElement("a");
document.body.appendChild(hidden_a_tag);
hidden_a_tag.style = "display: none";
hidden_a_tag.href = objectUrl;
hidden_a_tag.download = opts.report_name || "report.pdf";
// Open report in a new window
hidden_a_tag.click();
window.URL.revokeObjectURL(objectUrl);
}
};
xhr.send(formData);

View file

@ -391,7 +391,7 @@ $.extend(frappe.model, {
is_tree: function (doctype) {
if (!doctype) return false;
return frappe.boot.treeviews.indexOf(doctype) != -1;
return locals.DocType[doctype] && locals.DocType[doctype].is_tree;
},
is_fresh(doc) {
@ -796,6 +796,42 @@ $.extend(frappe.model, {
}
return frappe.model.numeric_fieldtypes.includes(fieldtype);
},
set_default_views_for_doctype(doctype, frm) {
frappe.model.with_doctype(doctype, () => {
let meta = frappe.get_meta(doctype);
let default_views = ["List", "Report", "Dashboard", "Kanban"];
if (meta.is_calendar_and_gantt && frappe.views.calendar[doctype]) {
let views = ["Calendar", "Gantt"];
default_views.push(...views);
}
if (meta.is_tree) {
default_views.push("Tree");
}
if (frm.doc.image_field) {
default_views.push("Image");
}
if (doctype === "Communication" && frappe.boot.email_accounts.length) {
default_views.push("Inbox");
}
if (
(frm.doc.fields.find((i) => i.fieldname === "latitude") &&
frm.doc.fields.find((i) => i.fieldname === "longitude")) ||
frm.doc.fields.find(
(i) => i.fieldname === "location" && i.fieldtype == "Geolocation"
)
) {
default_views.push("Map");
}
frm.set_df_property("default_view", "options", default_views);
});
},
});
// legacy

View file

@ -88,7 +88,21 @@ frappe.router = {
"dashboard",
"image",
"inbox",
"map",
],
list_views_route: {
list: "List",
kanban: "Kanban",
report: "Report",
calendar: "Calendar",
tree: "Tree",
gantt: "Gantt",
dashboard: "Dashboard",
image: "Image",
inbox: "Inbox",
file: "Home",
map: "Map",
},
layout_mapped: {},
is_app_route(path) {
@ -115,7 +129,7 @@ frappe.router = {
}
},
route() {
async route() {
// resolve the route from the URL or hash
// translate it so the objects are well defined
// and render the page as required
@ -126,22 +140,22 @@ frappe.router = {
if (this.re_route(sub_path)) return;
this.current_sub_path = sub_path;
this.current_route = this.parse();
this.current_route = await this.parse();
this.set_history(sub_path);
this.render();
this.set_title(sub_path);
this.trigger("change");
},
parse(route) {
async parse(route) {
route = this.get_sub_path_string(route).split("/");
if (!route) return [];
route = $.map(route, this.decode_component);
this.set_route_options_from_url();
return this.convert_to_standard_route(route);
return await this.convert_to_standard_route(route);
},
convert_to_standard_route(route) {
async convert_to_standard_route(route) {
// /app/settings = ["Workspaces", "Settings"]
// /app/private/settings = ["Workspaces", "private", "Settings"]
// /app/user = ["List", "User"]
@ -161,7 +175,7 @@ frappe.router = {
route = ["Workspaces", "private", frappe.workspaces[private_workspace].title];
} else if (this.routes[route[0]]) {
// route
route = this.set_doctype_route(route);
route = await this.set_doctype_route(route);
}
return route;
@ -174,40 +188,85 @@ frappe.router = {
set_doctype_route(route) {
let doctype_route = this.routes[route[0]];
// doctype route
if (route[1]) {
if (route[2] && route[1] === "view") {
route = this.get_standard_route_for_list(route, doctype_route);
} else {
return frappe.model.with_doctype(doctype_route.doctype).then(() => {
// doctype route
let meta = frappe.get_meta(doctype_route.doctype);
if (route[1] && route[1] === "view" && route[2]) {
route = this.get_standard_route_for_list(
route,
doctype_route,
meta.force_re_route_to_default_view && meta.default_view
? meta.default_view
: null
);
} else if (route[1] && route[1] !== "view" && !route[2]) {
let docname = route[1];
if (route.length > 2) {
docname = route.slice(1).join("/");
}
route = ["Form", doctype_route.doctype, docname];
} else if (frappe.model.is_single(doctype_route.doctype)) {
route = ["Form", doctype_route.doctype, doctype_route.doctype];
} else if (meta.default_view) {
route = [
"List",
doctype_route.doctype,
this.list_views_route[meta.default_view.toLowerCase()],
];
} else {
route = ["List", doctype_route.doctype, "List"];
}
} else if (frappe.model.is_single(doctype_route.doctype)) {
route = ["Form", doctype_route.doctype, doctype_route.doctype];
} else {
route = ["List", doctype_route.doctype, "List"];
}
if (doctype_route.doctype_layout) {
// set the layout
// reset the layout to avoid using incorrect views
this.doctype_layout = doctype_route.doctype_layout;
}
return route;
return route;
});
},
get_standard_route_for_list(route, doctype_route) {
get_standard_route_for_list(route, doctype_route, default_view) {
let standard_route;
if (route[2].toLowerCase() === "tree") {
let _route = default_view || route[2] || "";
if (_route.toLowerCase() === "tree") {
standard_route = ["Tree", doctype_route.doctype];
} else {
standard_route = ["List", doctype_route.doctype, frappe.utils.to_title_case(route[2])];
let new_route = this.list_views_route[_route.toLowerCase()];
let re_route = route[2].toLowerCase() !== new_route.toLowerCase();
if (re_route) {
/**
* In case of force_re_route, the url of the route should change,
* if the _route and route[2] are different, it means there is a default_view
* with force_re_route enabled.
*
* To change the url, to the correct view, the route[2] is changed with default_view
*
* Eg: If default_view is set to Report with force_re_route enabled and user routes
* to List,
* route: [todo, view, list]
* default_view: report
*
* replaces the list to report and re-routes to the new route but should be replaced in
* the history since the list route should not exist in history as we are rerouting it to
* report
*/
frappe.route_flags.replace_route = true;
route[2] = _route.toLowerCase();
this.set_route(route);
}
standard_route = [
"List",
doctype_route.doctype,
this.list_views_route[_route.toLowerCase()],
];
// calendar / kanban / dashboard / folder
if (route[3]) standard_route.push(...route.slice(3, route.length));
}
return standard_route;
},
@ -349,6 +408,7 @@ frappe.router = {
} else if (view === "tree") {
new_route = [this.slug(route[1]), "view", "tree"];
}
return new_route;
},

View file

@ -42,16 +42,13 @@ frappe.socketio = {
data.percent = (flt(data.progress[0]) / data.progress[1]) * 100;
}
if (data.percent) {
if (data.percent == 100) {
frappe.hide_progress();
} else {
frappe.show_progress(
data.title || __("Progress"),
data.percent,
100,
data.description
);
}
frappe.show_progress(
data.title || __("Progress"),
data.percent,
100,
data.description,
true
);
}
});

View file

@ -208,13 +208,10 @@ frappe.search.utils = {
},
});
}
if (in_list(frappe.boot.treeviews, item)) {
out.push(option("Tree", ["Tree", item], 0.05));
} else {
out.push(option("List", ["List", item], 0.05));
if (frappe.model.can_get_report(item)) {
out.push(option("Report", ["List", item, "Report"], 0.04));
}
out.push(option("List", ["List", item], 0.05));
if (frappe.model.can_get_report(item)) {
out.push(option("Report", ["List", item, "Report"], 0.04));
}
}
}

View file

@ -11,7 +11,9 @@ function prettyDate(date, mini) {
);
}
let diff = (new Date(frappe.datetime.now_datetime()).getTime() - date.getTime()) / 1000;
let diff =
(new Date(frappe.datetime.now_datetime().replace(/-/g, "/")).getTime() - date.getTime()) /
1000;
let day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0) return "";

View file

@ -1260,20 +1260,12 @@ Object.assign(frappe.utils, {
if (frappe.model.is_single(item.doctype)) {
route = doctype_slug;
} else {
if (!item.doc_view) {
if (frappe.model.is_tree(item.doctype)) {
item.doc_view = "Tree";
} else {
item.doc_view = "List";
}
}
switch (item.doc_view) {
case "List":
if (item.filters) {
frappe.route_options = item.filters;
}
route = doctype_slug;
route = `${doctype_slug}/view/list`;
break;
case "Tree":
route = `${doctype_slug}/view/tree`;
@ -1290,12 +1282,11 @@ Object.assign(frappe.utils, {
case "Calendar":
route = `${doctype_slug}/view/calendar/default`;
break;
case "Kanban":
route = `${doctype_slug}/view/kanban`;
break;
default:
frappe.throw({
message: __("Not a valid view:") + item.doc_view,
title: __("Unknown View"),
});
route = "";
route = doctype_slug;
}
}
} else if (type === "report") {

View file

@ -143,7 +143,7 @@ frappe.breadcrumbs = {
} else {
let route;
const doctype_route = frappe.router.slug(frappe.router.doctype_layout || doctype);
if (frappe.boot.treeviews.indexOf(doctype) !== -1) {
if (doctype_meta.is_tree) {
let view = frappe.model.user_settings[doctype].last_view || "Tree";
route = `${doctype_route}/view/${view}`;
} else {

View file

@ -74,7 +74,7 @@ frappe.views.FileView = class FileView extends frappe.views.ListView {
this.page_title = __("File Manager");
const route = frappe.get_route();
this.current_folder = route.slice(2).join("/");
this.current_folder = route.slice(2).join("/") || "Home";
this.filters = [["File", "folder", "=", this.current_folder, true]];
this.order_by = this.view_user_settings.order_by || "file_name asc";
@ -286,7 +286,7 @@ frappe.views.FileView = class FileView extends frappe.views.ListView {
}
get_breadcrumbs_html() {
const route = frappe.router.parse();
const route = frappe.get_route();
const folders = route.slice(2);
return folders

View file

@ -9,14 +9,9 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
const doctype = route[1];
const user_settings = frappe.get_user_settings(doctype)["Kanban"] || {};
if (!user_settings.last_kanban_board) {
frappe.msgprint({
title: __("Error"),
indicator: "red",
message: __("Missing parameter Kanban Board Name"),
});
frappe.set_route("List", doctype, "List");
return true;
return new frappe.views.KanbanView({ doctype: doctype });
}
route.push(user_settings.last_kanban_board);
frappe.set_route(route);
return true;
@ -28,9 +23,35 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
return "Kanban";
}
show() {
frappe.views.KanbanView.get_kanbans(this.doctype).then((kanbans) => {
if (!kanbans.length) {
return frappe.views.KanbanView.show_kanban_dialog(this.doctype, true);
} else if (kanbans.length && frappe.get_route().length !== 4) {
return frappe.views.KanbanView.show_kanban_dialog(this.doctype, true);
} else {
this.kanbans = kanbans;
return frappe.run_serially([
() => this.show_skeleton(),
() => this.fetch_meta(),
() => this.hide_skeleton(),
() => this.check_permissions(),
() => this.init(),
() => this.before_refresh(),
() => this.refresh(),
]);
}
});
}
setup_defaults() {
return super.setup_defaults().then(() => {
this.board_name = frappe.get_route()[3];
let get_board_name = () => {
return this.kanbans.length && this.kanbans[0].name;
};
this.board_name = frappe.get_route()[3] || get_board_name() || null;
this.page_title = __(this.board_name);
this.card_meta = this.get_card_meta();
this.page_length = 0;
@ -143,21 +164,22 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
render() {
const board_name = this.board_name;
if (this.kanban && board_name === this.kanban.board_name) {
this.kanban.update(this.data);
return;
if (!this.kanban) {
this.kanban = new frappe.views.KanbanBoard({
doctype: this.doctype,
board: this.board,
board_name: board_name,
cards: this.data,
card_meta: this.card_meta,
wrapper: this.$result,
cur_list: this,
user_settings: this.view_user_settings,
});
}
this.kanban = new frappe.views.KanbanBoard({
doctype: this.doctype,
board: this.board,
board_name: board_name,
cards: this.data,
card_meta: this.card_meta,
wrapper: this.$result,
cur_list: this,
user_settings: this.view_user_settings,
});
if (this.kanban && board_name === this.kanban.board_name) {
this.kanban.update(this.data);
}
}
get_card_meta() {

View file

@ -1383,6 +1383,14 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
layout_direction: frappe.utils.is_rtl() ? "rtl" : "ltr",
});
let filter_values = [],
name_len = 0;
for (var key of Object.keys(applied_filters)) {
name_len = name_len + applied_filters[key].toString().length;
if (name_len > 200) break;
filter_values.push(applied_filters[key]);
}
print_settings.report_name = `${__(this.report_name)}_${filter_values.join("_")}.pdf`;
frappe.render_pdf(html, print_settings);
}

View file

@ -37,6 +37,10 @@ frappe.views.TreeFactory = class TreeFactory extends frappe.views.Factory {
let treeview = frappe.views.trees[route[1]];
treeview && treeview.make_tree();
}
get view_name() {
return "Tree";
}
};
frappe.views.TreeView = class TreeView {
@ -196,6 +200,7 @@ frappe.views.TreeView = class TreeView {
});
cur_tree = this.tree;
cur_tree.view_name = "Tree";
this.post_render();
}

View file

@ -384,18 +384,22 @@ class ShortcutDialog extends WidgetDialog {
onchange: () => {
if (this.dialog.get_value("type") == "DocType") {
let doctype = this.dialog.get_value("link_to");
if (doctype && frappe.boot.single_types.includes(doctype)) {
this.hide_filters();
} else if (doctype) {
this.setup_filter(doctype);
this.show_filters();
}
frappe.model.with_doctype(doctype, () => {
let meta = frappe.get_meta(doctype);
const views = ["List", "Report Builder", "Dashboard", "New"];
if (frappe.boot.treeviews.includes(doctype)) views.push("Tree");
if (frappe.boot.calendars.includes(doctype)) views.push("Calendar");
if (doctype && frappe.boot.single_types.includes(doctype)) {
this.hide_filters();
} else if (doctype) {
this.setup_filter(doctype);
this.show_filters();
}
this.dialog.set_df_property("doc_view", "options", views.join("\n"));
const views = ["List", "Report Builder", "Dashboard", "New"];
if (meta.is_tree === "Tree") views.push("Tree");
if (frappe.boot.calendars.includes(doctype)) views.push("Calendar");
this.dialog.set_df_property("doc_view", "options", views.join("\n"));
});
} else {
this.hide_filters();
}
@ -405,7 +409,7 @@ class ShortcutDialog extends WidgetDialog {
fieldtype: "Select",
fieldname: "doc_view",
label: "DocType View",
options: "List\nReport Builder\nDashboard\nTree\nNew\nCalendar",
options: "List\nReport Builder\nDashboard\nTree\nNew\nCalendar\nKanban",
description: __(
"Which view of the associated DocType should this shortcut take you to?"
),

View file

@ -268,8 +268,8 @@
.editable-row .frappe-control {
padding-top: 0px !important;
padding-bottom: 0px !important;
margin-left: -5px !important;
margin-right: -5px !important;
margin-left: -1px !important;
margin-right: -1px !important;
}
}
@ -484,6 +484,31 @@
margin-bottom: 4px;
}
@media (max-width: map-get($grid-breakpoints, "md")) {
.form-grid-container {
overflow-x: clip;
.form-grid {
min-width: 738px;
}
}
.form-column.col-sm-6 .form-grid {
.row-index {
display: block;
}
}
}
@media (min-width: map-get($grid-breakpoints, "md")) {
.form-grid-container {
overflow-x: unset!important;
.form-grid {
position: unset!important;
}
}
}
@media (max-width: map-get($grid-breakpoints, "sm")) {
.form-in-grid .form-section .form-column {

View file

@ -13,7 +13,22 @@ from frappe.utils import cint
@frappe.whitelist()
def add(
def add(doctype, name, user=None, read=1, write=0, submit=0, share=0, everyone=0, notify=0):
"""Expose function without flags to the client-side"""
return add_docshare(
doctype,
name,
user=user,
read=read,
write=write,
submit=submit,
share=share,
everyone=everyone,
notify=notify,
)
def add_docshare(
doctype, name, user=None, read=1, write=0, submit=0, share=0, everyone=0, flags=None, notify=0
):
"""Share the given document with a user."""
@ -66,21 +81,29 @@ def remove(doctype, name, user, flags=None):
@frappe.whitelist()
def set_permission(doctype, name, user, permission_to, value=1, everyone=0):
"""Expose function without flags to the client-side"""
set_docshare_permission(doctype, name, user, permission_to, value=value, everyone=everyone)
def set_docshare_permission(doctype, name, user, permission_to, value=1, everyone=0, flags=None):
"""Set share permission."""
check_share_permission(doctype, name)
if not (flags or {}).get("ignore_share_permission"):
check_share_permission(doctype, name)
share_name = get_share_name(doctype, name, user, everyone)
value = int(value)
if not share_name:
if value:
share = add(doctype, name, user, everyone=everyone, **{permission_to: 1})
share = add_docshare(doctype, name, user, everyone=everyone, **{permission_to: 1}, flags=flags)
else:
# no share found, nothing to remove
share = {}
pass
else:
share = frappe.get_doc("DocShare", share_name)
if flags:
share.flags.update(flags)
share.flags.ignore_permissions = True
share.set(permission_to, value)

View file

@ -275,6 +275,12 @@ class TestMethodAPI(FrappeAPITestCase):
authorization_token = None
def test_404s(self):
response = self.get("/api/rest", {"sid": self.sid})
self.assertEqual(response.status_code, 404)
response = self.get("/api/resource/User/NonExistent@s.com", {"sid": self.sid})
self.assertEqual(response.status_code, 404)
class TestReadOnlyMode(FrappeAPITestCase):
"""During migration if read only mode can be enabled.

View file

@ -1,5 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
@ -15,12 +17,26 @@ class TestClient(FrappeTestCase):
def test_delete(self):
from frappe.client import delete
from frappe.desk.doctype.note.note import Note
todo = frappe.get_doc(dict(doctype="ToDo", description="description")).insert()
delete("ToDo", todo.name)
note = frappe.get_doc(
doctype="Note",
title=frappe.generate_hash(length=8),
content="test",
seen_by=[{"user": "Administrator"}],
).insert()
self.assertFalse(frappe.db.exists("ToDo", todo.name))
self.assertRaises(frappe.DoesNotExistError, delete, "ToDo", todo.name)
child_row_name = note.seen_by[0].name
with patch.object(Note, "save") as save:
delete("Note Seen By", child_row_name)
save.assert_called()
delete("Note", note.name)
self.assertFalse(frappe.db.exists("Note", note.name))
self.assertRaises(frappe.DoesNotExistError, delete, "Note", note.name)
self.assertRaises(frappe.DoesNotExistError, delete, "Note Seen By", child_row_name)
def test_http_valid_method_access(self):
from frappe.client import delete

View file

@ -9,8 +9,6 @@ class TestConfig(FrappeTestCase):
def test_get_modules(self):
frappe_modules = frappe.get_all("Module Def", filters={"app_name": "frappe"}, pluck="name")
all_modules_data = get_modules_from_all_apps_for_user()
first_module_entry = all_modules_data[0]
all_modules = [x["module_name"] for x in all_modules_data]
self.assertIn("links", first_module_entry)
self.assertIsInstance(all_modules_data, list)
self.assertFalse([x for x in frappe_modules if x not in all_modules])

View file

@ -163,6 +163,12 @@ class TestDocument(FrappeTestCase):
self.assertRaises(frappe.ValidationError, d.run_method, "validate")
self.assertRaises(frappe.ValidationError, d.save)
def test_db_set_no_query_on_new_docs(self):
user = frappe.new_doc("User")
user.db_set("user_type", "Magical Wizard")
with self.assertQueryCount(0):
user.db_set("user_type", "Magical Wizard")
def test_update_after_submit(self):
d = self.test_insert()
d.starts_on = "2014-09-09"

View file

@ -201,3 +201,9 @@ class TestQuery(FrappeTestCase):
fields=["name", "`tabNote Seen By`.`user` as seen_by"],
),
)
@run_only_if(db_type_is.MARIADB)
def test_comment_stripping(self):
self.assertNotIn(
"email", frappe.qb.engine.get_query("User", fields=["name", "#email"], filters={}).get_sql()
)

View file

@ -3,8 +3,11 @@
import frappe
from frappe.app import make_form_dict
from frappe.desk.search import get_names_for_mentions, search_link, search_widget
from frappe.tests.utils import FrappeTestCase
from frappe.utils import set_request
from frappe.website.serve import get_response
class TestSearch(FrappeTestCase):
@ -235,3 +238,22 @@ def teardown_test_link_field_order(TestCase):
)
TestCase.tree_doc.delete()
class TestWebsiteSearch(FrappeTestCase):
def get(self, path, user="Guest"):
frappe.set_user(user)
set_request(method="GET", path=path)
make_form_dict(frappe.local.request)
response = get_response()
frappe.set_user("Administrator")
return response
def test_basic_search(self):
no_search = self.get("/search")
self.assertEqual(no_search.status_code, 200)
response = self.get("/search?q=b")
self.assertEqual(response.status_code, 200)
self.assertIn("Search Results", response.get_data(as_text=True))

View file

@ -317,6 +317,22 @@ class TestWebsite(FrappeTestCase):
self.assertIn('<meta name="title" content="Test Title Metatag">', content)
self.assertIn('<meta name="description" content="Test Description for Metatag">', content)
def test_resolve_class(self):
from frappe.utils.jinja_globals import resolve_class
context = frappe._dict(primary=True)
self.assertEqual(resolve_class("test"), "test")
self.assertEqual(resolve_class("test", "test-2"), "test test-2")
self.assertEqual(resolve_class("test", {"test-2": False, "test-3": True}), "test test-3")
self.assertEqual(
resolve_class(["test1", "test2", context.primary and "primary"]), "test1 test2 primary"
)
content = '<a class="{{ resolve_class("btn btn-default", primary and "btn-primary") }}">Test</a>'
self.assertEqual(
frappe.render_template(content, context), '<a class="btn btn-default btn-primary">Test</a>'
)
def set_home_page_hook(key, value):
from frappe import hooks

View file

@ -43,16 +43,32 @@ def create_todo_records():
frappe.db.truncate("ToDo")
frappe.get_doc(
{"doctype": "ToDo", "date": add_to_date(now(), days=7), "description": "this is first todo"}
{
"doctype": "ToDo",
"date": add_to_date(now(), days=7),
"description": "this is first todo",
}
).insert()
frappe.get_doc(
{"doctype": "ToDo", "date": add_to_date(now(), days=-7), "description": "this is second todo"}
{
"doctype": "ToDo",
"date": add_to_date(now(), days=-7),
"description": "this is second todo",
}
).insert()
frappe.get_doc(
{"doctype": "ToDo", "date": add_to_date(now(), months=2), "description": "this is third todo"}
{
"doctype": "ToDo",
"date": add_to_date(now(), months=2),
"description": "this is third todo",
}
).insert()
frappe.get_doc(
{"doctype": "ToDo", "date": add_to_date(now(), months=-2), "description": "this is fourth todo"}
{
"doctype": "ToDo",
"date": add_to_date(now(), months=-2),
"description": "this is fourth todo",
}
).insert()
@ -431,3 +447,134 @@ def create_test_user():
user.append("roles", {"role": role})
user.save()
@frappe.whitelist()
def setup_tree_doctype():
frappe.delete_doc_if_exists("DocType", "Custom Tree")
frappe.get_doc(
{
"doctype": "DocType",
"module": "Core",
"custom": 1,
"fields": [
{"fieldname": "tree", "fieldtype": "Data", "label": "Tree"},
],
"permissions": [{"role": "System Manager", "read": 1}],
"name": "Custom Tree",
"is_tree": True,
"naming_rule": "By fieldname",
"autoname": "field:tree",
}
).insert()
if not frappe.db.exists("Custom Tree", "All Trees"):
frappe.get_doc({"doctype": "Custom Tree", "tree": "All Trees"}).insert()
@frappe.whitelist()
def setup_image_doctype():
frappe.delete_doc_if_exists("DocType", "Custom Image")
frappe.get_doc(
{
"doctype": "DocType",
"module": "Core",
"custom": 1,
"fields": [
{"fieldname": "image", "fieldtype": "Attach Image", "label": "Image"},
],
"permissions": [{"role": "System Manager", "read": 1}],
"name": "Custom Image",
"image_field": "image",
}
).insert()
@frappe.whitelist()
def setup_inbox():
frappe.db.sql("DELETE FROM `tabUser Email`")
user = frappe.get_doc("User", frappe.session.user)
user.append("user_emails", {"email_account": "Email Linking"})
user.save()
@frappe.whitelist()
def setup_default_view(view, force_reroute=None):
frappe.delete_doc_if_exists("Property Setter", "Event-main-default_view")
frappe.delete_doc_if_exists("Property Setter", "Event-main-force_re_route_to_default_view")
frappe.get_doc(
{
"is_system_generated": 0,
"doctype_or_field": "DocType",
"doc_type": "Event",
"property": "default_view",
"property_type": "Select",
"value": view,
"doctype": "Property Setter",
}
).insert()
if force_reroute:
frappe.get_doc(
{
"is_system_generated": 0,
"doctype_or_field": "DocType",
"doc_type": "Event",
"property": "force_re_route_to_default_view",
"property_type": "Check",
"value": "1",
"doctype": "Property Setter",
}
).insert()
@frappe.whitelist()
def create_note():
if not frappe.db.exists("Note", "Routing Test"):
frappe.get_doc({"doctype": "Note", "title": "Routing Test"}).insert()
@frappe.whitelist()
def create_kanban():
if not frappe.db.exists("Custom Field", "Note-kanban"):
frappe.get_doc(
{
"is_system_generated": 0,
"dt": "Note",
"label": "Kanban",
"fieldname": "kanban",
"insert_after": "seen_by",
"fieldtype": "Select",
"options": "Open\nClosed",
"doctype": "Custom Field",
}
).insert()
if not frappe.db.exists("Kanban Board", "_Note _Kanban"):
frappe.get_doc(
{
"doctype": "Kanban Board",
"name": "_Note _Kanban",
"kanban_board_name": "_Note _Kanban",
"reference_doctype": "Note",
"field_name": "kanban",
"private": 1,
"show_labels": 0,
"columns": [
{
"column_name": "Open",
"status": "Active",
"indicator": "Gray",
},
{
"column_name": "Closed",
"status": "Active",
"indicator": "Gray",
},
],
}
).insert()

View file

@ -69,16 +69,18 @@ class FrappeTestCase(unittest.TestCase):
@contextmanager
def assertQueryCount(self, count):
queries = []
def _sql_with_count(*args, **kwargs):
frappe.db.sql_query_count += 1
return orig_sql(*args, **kwargs)
ret = orig_sql(*args, **kwargs)
queries.append(frappe.db.last_query)
return ret
try:
orig_sql = frappe.db.sql
frappe.db.sql_query_count = 0
frappe.db.sql = _sql_with_count
yield
self.assertLessEqual(frappe.db.sql_query_count, count)
self.assertLessEqual(len(queries), count, msg="Queries executed: " + "\n\n".join(queries))
finally:
frappe.db.sql = orig_sql

View file

@ -1527,6 +1527,7 @@ Looks like something is wrong with this site's payment gateway configuration. No
Madam,gnädige Frau,
Main Section,Hauptbereich,
"Make ""name"" searchable in Global Search",Machen Sie &quot;name&quot; durchsuchbar in Global Search,
Make Attachments Public by Default, Anhänge im Standard als öffentlich markieren,
Make use of longer keyboard patterns,Nutzen Sie mehr Tastaturmuster,
Manage Third Party Apps,Verwalten von Apps von Drittanbietern,
Mandatory Information missing:,Pflichtangaben fehlen:,

1 A4 A4
1527 Madam gnädige Frau
1528 Main Section Hauptbereich
1529 Make "name" searchable in Global Search Machen Sie &quot;name&quot; durchsuchbar in Global Search
1530 Make Attachments Public by Default Anhänge im Standard als öffentlich markieren
1531 Make use of longer keyboard patterns Nutzen Sie mehr Tastaturmuster
1532 Manage Third Party Apps Verwalten von Apps von Drittanbietern
1533 Mandatory Information missing: Pflichtangaben fehlen:

View file

@ -136,12 +136,6 @@ def _create_app_boilerplate(dest, hooks, no_git=False):
touch_file(os.path.join(dest, hooks.app_name, hooks.app_name, "patches.txt"))
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "config", "desktop.py"), "w") as f:
f.write(frappe.as_unicode(desktop_template.format(**hooks)))
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "config", "docs.py"), "w") as f:
f.write(frappe.as_unicode(docs_template.format(**hooks)))
app_directory = os.path.join(dest, hooks.app_name)
if hooks.create_github_workflow:
@ -381,18 +375,6 @@ app_license = "{app_license}"
# ]
"""
desktop_template = """from frappe import _
def get_data():
return [
{{
"module_name": "{app_title}",
"type": "module",
"label": _("{app_title}")
}}
]
"""
setup_template = """from setuptools import setup, find_packages
with open("requirements.txt") as f:
@ -419,21 +401,7 @@ gitignore_template = """.DS_Store
*.egg-info
*.swp
tags
{app_name}/docs/current
node_modules/"""
docs_template = '''"""
Configuration for docs
"""
# source_link = "https://github.com/[org_name]/{app_name}"
# headline = "App that does everything"
# sub_heading = "Yes, you got that right the first time, everything"
def get_context(context):
context.brand_html = "{app_title}"
'''
node_modules"""
github_workflow_template = """
name: CI

View file

@ -2,12 +2,14 @@
# License: MIT. See LICENSE
def resolve_class(classes):
def resolve_class(*classes):
if classes and len(classes) == 1:
classes = classes[0]
if classes is None:
return ""
if isinstance(classes, str):
return classes
if classes is False:
return ""
if isinstance(classes, (list, tuple)):
return " ".join(resolve_class(c) for c in classes).strip()

View file

@ -218,7 +218,7 @@ class RedisWrapper(redis.Redis):
except redis.exceptions.ConnectionError:
pass
if value:
if value is not None:
value = pickle.loads(value)
frappe.local.cache[_name][key] = value
elif generator:

View file

@ -8,9 +8,12 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"title",
"published",
"route"
"title",
"description",
"column_break_4",
"route",
"preview_image"
],
"fields": [
{
@ -35,6 +38,20 @@
"label": "Route",
"read_only": 1,
"unique": 1
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "preview_image",
"fieldtype": "Attach Image",
"label": "Preview Image"
}
],
"has_web_view": 1,
@ -42,8 +59,15 @@
"idx": 1,
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"modified": "2020-09-29 10:48:36.886753",
"links": [
{
"group": "Posts",
"link_doctype": "Blog Post",
"link_fieldname": "blog_category"
}
],
"make_attachments_public": 1,
"modified": "2022-10-18 15:43:39.789982",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Category",
@ -71,6 +95,7 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}

View file

@ -94,11 +94,11 @@
"label": "Blog Intro"
},
{
"default": "Rich Text",
"default": "Markdown",
"fieldname": "content_type",
"fieldtype": "Select",
"label": "Content Type",
"options": "Rich Text\nMarkdown\nHTML",
"options": "Markdown\nRich Text\nHTML",
"reqd": 1
},
{
@ -215,7 +215,7 @@
"is_published_field": "published",
"links": [],
"make_attachments_public": 1,
"modified": "2022-08-24 07:10:08.620136",
"modified": "2022-10-18 10:09:10.550734",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",

View file

@ -204,13 +204,19 @@ def get_list_context(context=None):
title=_("Blog"),
)
category = frappe.utils.escape_html(
blog_settings = frappe.get_doc("Blog Settings").as_dict(no_default_fields=True)
list_context.update(blog_settings)
category_name = frappe.utils.escape_html(
frappe.local.form_dict.blog_category or frappe.local.form_dict.category
)
if category:
category_title = get_blog_category(category)
list_context.sub_title = _("Posts filed under {0}").format(category_title)
list_context.title = category_title
if category_name:
category = frappe.get_doc("Blog Category", category_name)
list_context.blog_introduction = category.description or _("Posts filed under {0}").format(
category.title
)
list_context.blog_title = category.title
list_context.preview_image = category.preview_image
elif frappe.local.form_dict.blogger:
blogger = frappe.db.get_value("Blogger", {"name": frappe.local.form_dict.blogger}, "full_name")
@ -225,12 +231,16 @@ def get_list_context(context=None):
else:
list_context.parents = [{"name": _("Home"), "route": "/"}]
blog_settings = frappe.get_doc("Blog Settings").as_dict(no_default_fields=True)
list_context.update(blog_settings)
if blog_settings.browse_by_category:
list_context.blog_categories = get_blog_categories()
list_context.metatags = {
"name": list_context.blog_title,
"title": list_context.blog_title,
"description": list_context.blog_introduction,
"image": list_context.preview_image,
}
return list_context
@ -265,10 +275,6 @@ def clear_blog_cache():
clear_cache("writers")
def get_blog_category(route):
return frappe.db.get_value("Blog Category", {"name": route}, "title") or route
def get_blog_list(
doctype, txt=None, filters=None, limit_start=0, limit_page_length=20, order_by=None
):

View file

@ -12,29 +12,30 @@
<p>{{ blog_introduction or '' }}</p>
</div>
</div>
</div>
<div class="col-md-4 align-self-end">
{%- if browse_by_category -%}
<label for="category-select" class="sr-only">{{ _("Browse by category") }}</label>
<select id="category-select" class="custom-select" onchange="window.location.pathname = this.value">
<option value="" {{ not frappe.form_dict.category and "selected" or "" }} disabled>
{{ _("Browse by category") }}
</option>
{%- if frappe.form_dict.category -%}
<option value="blog">{{ _("Show all blogs") }}</option>
{%- endif -%}
{%- for category in blog_categories -%}
<option value="{{ category.route }}" {{ frappe.form_dict.category == category.name and "selected" or "" }}>
{{ _(category.title) }}
</option>
{%- endfor -%}
</select>
<div style="max-width: 20rem">
<label for="category-select" class="sr-only">{{ _("Browse by category") }}</label>
<select id="category-select" class="custom-select" onchange="window.location.pathname = this.value">
<option value="" {{ not frappe.form_dict.category and "selected" or "" }} disabled>
{{ _("Browse by category") }}
</option>
{%- if frappe.form_dict.category -%}
<option value="blog">{{ _("Show all blogs") }}</option>
{%- endif -%}
{%- for category in blog_categories -%}
<option value="{{ category.route }}" {{ frappe.form_dict.category == category.name and "selected" or "" }}>
{{ _(category.title) }}
</option>
{%- endfor -%}
</select>
</div>
{%- endif -%}
</div>
</div>
<div class="blog-list-content">
<div class="website-list" data-doctype="{{ doctype }}" data-txt="{{ txt or '[notxt]' | e }}">
<div data-doctype="{{ doctype }}" data-txt="{{ txt or '[notxt]' | e }}">
{% if not result -%}
<div class="text-muted" style="min-height: 300px;">
{{ no_result_message or _("Nothing to show") }}
@ -54,10 +55,10 @@
{% block script %}
<script>
frappe.ready(() => {
let result_wrapper = $(".website-list .result");
let result_wrapper = $(".blog-list.result");
let next_start = {{ next_start or 0 }};
$(".website-list .btn-more").on("click", function() {
$(".blog-list-content .btn-more").on("click", function() {
let $btn = $(this);
let args = $.extend(frappe.utils.get_query_params(), {
doctype: "Blog Post",
@ -82,7 +83,7 @@
function toggle_more(show) {
if (!show) {
$(".website-list .more-block").addClass("hide");
$(".btn-more").addClass("hide");
}
}
});

View file

@ -7,11 +7,12 @@
"field_order": [
"blog_title",
"blog_introduction",
"preview_image",
"column_break",
"enable_social_sharing",
"show_cta_in_blog",
"allow_guest_to_comment",
"browse_by_category",
"show_cta_in_blog",
"cta_section",
"title",
"subtitle",
@ -49,13 +50,13 @@
"default": "0",
"fieldname": "show_cta_in_blog",
"fieldtype": "Check",
"label": "Show CTA in Blog"
"label": "Show \"Call to Action\" in Blog"
},
{
"depends_on": "eval:doc.show_cta_in_blog",
"fieldname": "cta_section",
"fieldtype": "Section Break",
"label": "CTA"
"label": "Call to Action"
},
{
"fieldname": "title",
@ -87,7 +88,8 @@
},
{
"fieldname": "section_break_12",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Rate Limits"
},
{
"default": "5",
@ -118,13 +120,18 @@
"fieldname": "like_limit",
"fieldtype": "Int",
"label": "Like limit"
},
{
"fieldname": "preview_image",
"fieldtype": "Attach Image",
"label": "Preview Image"
}
],
"icon": "fa fa-cog",
"idx": 1,
"issingle": 1,
"links": [],
"modified": "2022-07-12 17:45:49.108398",
"modified": "2022-10-18 15:01:36.202010",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Settings",

View file

@ -63,11 +63,13 @@
"link_fieldname": "blogger"
}
],
"make_attachments_public": 1,
"max_attachments": 1,
"modified": "2020-05-28 19:22:40.959895",
"modified": "2022-10-18 15:44:31.473178",
"modified_by": "Administrator",
"module": "Website",
"name": "Blogger",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@ -95,6 +97,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "full_name",
"track_changes": 1
}

View file

@ -27,3 +27,9 @@ class TestWebsiteSettings(FrappeTestCase):
break
else:
self.fail("Child items not found")
def test_redirect_setups(self):
ws = frappe.get_doc("Website Settings")
ws.append("route_redirects", {"source": "/engineering/(*.)", "target": "/development/(*.)"})
self.assertRaises(frappe.ValidationError, ws.validate)

View file

@ -11,14 +11,21 @@
"document_type": "Other",
"engine": "InnoDB",
"field_order": [
"home_tab",
"sb0",
"home_page",
"cb4",
"title_prefix",
"misc_section",
"app_name",
"disable_signup",
"column_break_9",
"app_logo",
"section_break_6",
"website_theme",
"website_theme_image",
"website_theme_image_link",
"navbar_tab",
"brand",
"banner_image",
"splash_image",
@ -38,17 +45,22 @@
"call_to_action_url",
"banner",
"banner_html",
"footer_tab",
"footer",
"footer_logo",
"copyright",
"address",
"footer_items",
"footer_details_section",
"hide_footer_signup",
"copyright",
"footer_logo",
"column_break_37",
"address",
"footer_powered",
"custom_footer_section",
"footer_template",
"footer_template_values",
"edit_footer_template_values",
"hide_footer_signup",
"integrations",
"analytics_section",
"enable_view_tracking",
"enable_google_indexing",
"authorize_api_indexing_access",
@ -57,18 +69,15 @@
"column_break_17",
"google_analytics_id",
"google_analytics_anonymize_ip",
"misc_section",
"app_name",
"app_logo",
"disable_signup",
"account_deletion_settings_section",
"auto_account_deletion",
"show_account_deletion_link",
"section_break_38",
"subdomain",
"head_html",
"robots_txt",
"route_redirects",
"account_deletion_settings_section",
"show_account_deletion_link",
"auto_account_deletion"
"redirects_tab",
"route_redirects"
],
"fields": [
{
@ -96,7 +105,8 @@
},
{
"fieldname": "section_break_6",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Theme"
},
{
"default": "Standard",
@ -143,7 +153,6 @@
"label": "Set Banner from Image"
},
{
"collapsible": 1,
"fieldname": "top_bar",
"fieldtype": "Section Break",
"label": "Navbar"
@ -175,11 +184,10 @@
"options": "HTML"
},
{
"collapsible": 1,
"collapsible_depends_on": "footer_items",
"fieldname": "footer",
"fieldtype": "Section Break",
"label": "Footer"
"label": "Footer Items"
},
{
"fieldname": "copyright",
@ -189,7 +197,7 @@
{
"description": "Address and other legal information you may want to put in the footer.",
"fieldname": "address",
"fieldtype": "Text Editor",
"fieldtype": "Small Text",
"label": "Address",
"max_height": "8rem"
},
@ -208,7 +216,7 @@
{
"collapsible": 1,
"fieldname": "integrations",
"fieldtype": "Section Break",
"fieldtype": "Tab Break",
"label": "Integrations"
},
{
@ -221,7 +229,6 @@
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "misc_section",
"fieldtype": "Section Break",
"label": "Login Page"
@ -249,8 +256,8 @@
{
"collapsible": 1,
"fieldname": "section_break_38",
"fieldtype": "Section Break",
"label": "HTML Header, Robots and Redirects"
"fieldtype": "Tab Break",
"label": "Header, Robots"
},
{
"description": "Added HTML in the &lt;head&gt; section of the web page, primarily used for website verification and SEO",
@ -393,7 +400,6 @@
"label": "App Logo"
},
{
"collapsible": 1,
"fieldname": "account_deletion_settings_section",
"fieldtype": "Section Break",
"label": "Account Deletion Settings"
@ -413,12 +419,56 @@
{
"fieldname": "footer_powered",
"fieldtype": "Small Text",
"label": "Footer \"Powered By\""
"label": "Footer \"Powered By\"",
"max_height": "2rem"
},
{
"fieldname": "splash_image",
"fieldtype": "Attach Image",
"label": "Splash Image"
},
{
"fieldname": "home_tab",
"fieldtype": "Tab Break",
"label": "Home"
},
{
"fieldname": "navbar_tab",
"fieldtype": "Tab Break",
"label": "Navbar"
},
{
"fieldname": "footer_tab",
"fieldtype": "Tab Break",
"label": "Footer"
},
{
"fieldname": "footer_details_section",
"fieldtype": "Section Break",
"label": "Footer Details"
},
{
"fieldname": "column_break_37",
"fieldtype": "Column Break"
},
{
"fieldname": "custom_footer_section",
"fieldtype": "Section Break",
"label": "Custom Footer"
},
{
"fieldname": "redirects_tab",
"fieldtype": "Tab Break",
"label": "Redirects"
},
{
"fieldname": "column_break_9",
"fieldtype": "Column Break"
},
{
"fieldname": "analytics_section",
"fieldtype": "Section Break",
"label": "Analytics"
}
],
"icon": "fa fa-cog",
@ -426,7 +476,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-05-27 12:33:29.019998",
"modified": "2022-10-18 09:50:24.621839",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Settings",
@ -451,4 +501,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View file

@ -1,5 +1,6 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import re
from urllib.parse import quote
import frappe
@ -16,6 +17,7 @@ class WebsiteSettings(Document):
self.validate_footer_items()
self.validate_home_page()
self.validate_google_settings()
self.validate_redirects()
def validate_home_page(self):
if frappe.flags.in_install:
@ -72,6 +74,16 @@ class WebsiteSettings(Document):
if self.enable_google_indexing and not frappe.db.get_single_value("Google Settings", "enable"):
frappe.throw(_("Enable Google API in Google Settings."))
def validate_redirects(self):
for idx, row in enumerate(self.route_redirects):
try:
source = row.source.strip("/ ") + "$"
re.compile(source)
re.sub(source, row.target, "")
except Exception as e:
if not frappe.flags.in_migrate:
frappe.throw(_("Invalid redirect regex in row #{}: {}").format(idx, str(e)))
def on_update(self):
self.clear_cache()

View file

@ -64,7 +64,9 @@ class BaseTemplatePage(BaseRenderer):
self.context.url_prefix += "/"
self.context.path = self.path
self.context.pathname = frappe.local.path if hasattr(frappe, "local") else self.path
self.context.pathname = (
getattr(frappe.local, "path", None) if hasattr(frappe, "local") else self.path
)
def update_website_context(self):
# apply context from hooks

View file

@ -92,17 +92,17 @@ def resolve_redirect(path, query_string=None):
Example:
website_redirect = [
# absolute location
{"source": "/from", "target": "https://mysite/from"},
website_redirect = [
# absolute location
{"source": "/from", "target": "https://mysite/from"},
# relative location
{"source": "/from", "target": "/main"},
# 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
]
# 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.get_all("Website Route Redirect", ["source", "target"], order_by=None)
@ -122,7 +122,12 @@ def resolve_redirect(path, query_string=None):
if rule.get("match_with_query_string"):
path_to_match = path + "?" + frappe.safe_decode(query_string)
if re.match(pattern, path_to_match):
try:
match = re.match(pattern, path_to_match)
except re.error as e:
frappe.log_error("Broken Redirect: " + pattern)
if 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)

Some files were not shown because too many files have changed in this diff Show more