chore: release v14version-14
@@ -128,6 +128,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 | |||
@@ -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, | |||
): | |||
with CodeCoverage(with_coverage, app): | |||
site = get_site(context) | |||
@@ -832,7 +839,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( | |||
@@ -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)}" | |||
@@ -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 [] | |||
@@ -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 | |||
@@ -320,7 +320,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", | |||
@@ -687,7 +688,7 @@ | |||
"link_fieldname": "reference_doctype" | |||
} | |||
], | |||
"modified": "2022-08-24 06:42:27.779699", | |||
"modified": "2022-09-02 12:05:59.589751", | |||
"modified_by": "Administrator", | |||
"module": "Core", | |||
"name": "DocType", | |||
@@ -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")) | |||
@@ -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'" | |||
@@ -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): | |||
@@ -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"), | |||
}); | |||
} | |||
} | |||
}, | |||
}); |
@@ -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 | |||
} |
@@ -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 |
@@ -27,7 +27,6 @@ from frappe.database.utils import ( | |||
from frappe.exceptions import DoesNotExistError, ImplicitCommitError | |||
from frappe.model.utils.link_count import flush_local_link_count | |||
from frappe.query_builder.functions import Count | |||
from frappe.query_builder.utils import DocType | |||
from frappe.utils import cast as cast_fieldtype | |||
from frappe.utils import get_datetime, get_table_name, getdate, now, sbool | |||
@@ -857,7 +856,7 @@ class Database: | |||
:param modified_by: Set this user as `modified_by`. | |||
:param update_modified: default True. Set as false, if you don't want to update the timestamp. | |||
:param debug: Print the query in the developer / js console. | |||
:param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit. | |||
:param for_update: [DEPRECATED] This function now performs updates in single query, locking is not required. | |||
""" | |||
is_single_doctype = not (dn and dt != dn) | |||
to_update = field if isinstance(field, dict) else {field: val} | |||
@@ -879,19 +878,11 @@ class Database: | |||
frappe.clear_document_cache(dt, dt) | |||
else: | |||
table = DocType(dt) | |||
if for_update: | |||
docnames = tuple( | |||
self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True) | |||
) or (NullValue(),) | |||
query = frappe.qb.update(table).where(table.name.isin(docnames)) | |||
for docname in docnames: | |||
frappe.clear_document_cache(dt, docname) | |||
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) | |||
if isinstance(dn, str): | |||
frappe.clear_document_cache(dt, dn) | |||
else: | |||
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) | |||
# TODO: Fix this; doesn't work rn - gavin@frappe.io | |||
# frappe.cache().hdel_keys(dt, "document_cache") | |||
# Workaround: clear all document caches | |||
@@ -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 | |||
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: 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) | |||
) | |||
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 |
@@ -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")); | |||
} | |||
@@ -44,44 +44,6 @@ class TestEventProducer(FrappeTestCase): | |||
self.pull_producer_data() | |||
self.assertFalse(frappe.db.exists("ToDo", producer_doc.name)) | |||
@run_only_if(db_type_is.MARIADB) | |||
def test_multiple_doctypes_sync(self): | |||
# TODO: This test is extremely flaky with Postgres. Rewrite this! | |||
producer = get_remote_site() | |||
# insert todo and note in producer | |||
producer_todo = insert_into_producer(producer, "test multiple doc sync") | |||
producer_note1 = frappe._dict(doctype="Note", title="test multiple doc sync 1") | |||
delete_on_remote_if_exists(producer, "Note", {"title": producer_note1["title"]}) | |||
frappe.db.delete("Note", {"title": producer_note1["title"]}) | |||
producer_note1 = producer.insert(producer_note1) | |||
producer_note2 = frappe._dict(doctype="Note", title="test multiple doc sync 2") | |||
delete_on_remote_if_exists(producer, "Note", {"title": producer_note2["title"]}) | |||
frappe.db.delete("Note", {"title": producer_note2["title"]}) | |||
producer_note2 = producer.insert(producer_note2) | |||
# update in producer | |||
producer_todo["description"] = "test multiple doc update sync" | |||
producer_todo = producer.update(producer_todo) | |||
producer_note1["content"] = "testing update sync" | |||
producer_note1 = producer.update(producer_note1) | |||
producer.delete("Note", producer_note2.name) | |||
self.pull_producer_data() | |||
# check inserted | |||
self.assertTrue(frappe.db.exists("ToDo", producer_todo.name)) | |||
# check update | |||
local_todo = frappe.get_doc("ToDo", producer_todo.name) | |||
self.assertEqual(local_todo.description, producer_todo.description) | |||
local_note1 = frappe.get_doc("Note", producer_note1.name) | |||
self.assertEqual(local_note1.content, producer_note1.content) | |||
# check delete | |||
self.assertFalse(frappe.db.exists("Note", producer_note2.name)) | |||
def test_child_table_sync_with_dependencies(self): | |||
producer = get_remote_site() | |||
producer_user = frappe._dict( | |||
@@ -517,10 +517,10 @@ def google_calendar_to_repeat_on(start, end, recurrence=None): | |||
repeat_on = { | |||
"starts_on": get_datetime(start.get("date")) | |||
if start.get("date") | |||
else parser.parse(start.get("dateTime")).utcnow(), | |||
else parser.parse(start.get("dateTime")).astimezone().replace(tzinfo=None), | |||
"ends_on": get_datetime(end.get("date")) | |||
if end.get("date") | |||
else parser.parse(end.get("dateTime")).utcnow(), | |||
else parser.parse(end.get("dateTime")).astimezone().replace(tzinfo=None), | |||
"all_day": 1 if start.get("date") else 0, | |||
"repeat_this_event": 1 if recurrence else 0, | |||
"repeat_on": None, | |||
@@ -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): | |||
@@ -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>") | |||
@@ -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 | |||
@@ -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) | |||
// 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) { | |||
return; | |||
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)) { | |||
@@ -141,8 +141,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); | |||
} | |||
} | |||
} | |||
@@ -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) { | |||
@@ -190,12 +190,8 @@ frappe.router = { | |||
} else { | |||
route = ["List", doctype_route.doctype, "List"]; | |||
} | |||
if (doctype_route.doctype_layout) { | |||
// set the layout | |||
this.doctype_layout = doctype_route.doctype_layout; | |||
} | |||
// reset the layout to avoid using incorrect views | |||
this.doctype_layout = doctype_route.doctype_layout; | |||
return route; | |||
}, | |||
@@ -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 | |||
); | |||
} | |||
}); | |||
@@ -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 { | |||
@@ -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) | |||
@@ -734,7 +734,7 @@ class TestDBSetValue(FrappeTestCase): | |||
frappe.db.get_value("ToDo", todo.name, ["modified", "modified_by"]), | |||
) | |||
def test_for_update(self): | |||
def test_set_value(self): | |||
self.todo1.reload() | |||
with patch.object(Database, "sql") as sql_called: | |||
@@ -745,28 +745,23 @@ class TestDBSetValue(FrappeTestCase): | |||
f"{self.todo1.description}-edit by `test_for_update`", | |||
) | |||
first_query = sql_called.call_args_list[0].args[0] | |||
second_query = sql_called.call_args_list[1].args[0] | |||
self.assertTrue(sql_called.call_count == 2) | |||
self.assertTrue("FOR UPDATE" in first_query) | |||
if frappe.conf.db_type == "postgres": | |||
from frappe.database.postgres.database import modify_query | |||
self.assertTrue(modify_query("UPDATE `tabToDo` SET") in second_query) | |||
self.assertTrue(modify_query("UPDATE `tabToDo` SET") in first_query) | |||
if frappe.conf.db_type == "mariadb": | |||
self.assertTrue("UPDATE `tabToDo` SET" in second_query) | |||
self.assertTrue("UPDATE `tabToDo` SET" in first_query) | |||
def test_cleared_cache(self): | |||
self.todo2.reload() | |||
frappe.get_cached_doc(self.todo2.doctype, self.todo2.name) # init cache | |||
with patch.object(frappe, "clear_document_cache") as clear_cache: | |||
frappe.db.set_value( | |||
self.todo2.doctype, | |||
self.todo2.name, | |||
"description", | |||
f"{self.todo2.description}-edit by `test_cleared_cache`", | |||
) | |||
clear_cache.assert_called() | |||
description = f"{self.todo2.description}-edit by `test_cleared_cache`" | |||
frappe.db.set_value(self.todo2.doctype, self.todo2.name, "description", description) | |||
cached_doc = frappe.get_cached_doc(self.todo2.doctype, self.todo2.name) | |||
self.assertEqual(cached_doc.description, description) | |||
def test_update_alias(self): | |||
args = (self.todo1.doctype, self.todo1.name, "description", "Updated by `test_update_alias`") | |||
@@ -50,6 +50,20 @@ class TestPerformance(FrappeTestCase): | |||
with self.assertQueryCount(0): | |||
frappe.get_meta("User") | |||
def test_set_value_query_count(self): | |||
frappe.db.set_value("User", "Administrator", "interest", "Nothing") | |||
with self.assertQueryCount(1): | |||
frappe.db.set_value("User", "Administrator", "interest", "Nothing") | |||
with self.assertQueryCount(1): | |||
frappe.db.set_value("User", {"user_type": "System User"}, "interest", "Nothing") | |||
with self.assertQueryCount(1): | |||
frappe.db.set_value( | |||
"User", {"user_type": "System User"}, {"interest": "Nothing", "bio": "boring person"} | |||
) | |||
def test_controller_caching(self): | |||
get_controller("User") | |||
@@ -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)) |
@@ -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 | |||
@@ -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() | |||
@@ -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 | |||
@@ -1,4 +1,4 @@ | |||
from jinja2 import utils | |||
import markupsafe | |||
import frappe | |||
from frappe import _ | |||
@@ -10,7 +10,7 @@ from frappe.utils.global_search import web_search | |||
def get_context(context): | |||
context.no_cache = 1 | |||
if frappe.form_dict.q: | |||
query = str(utils.escape(sanitize_html(frappe.form_dict.q))) | |||
query = str(markupsafe.escape(sanitize_html(frappe.form_dict.q))) | |||
context.title = _("Search Results for") | |||
context.query = query | |||
context.route = "/search" | |||
@@ -20,7 +20,7 @@ dependencies = [ | |||
"PyPika~=0.48.9", | |||
"PyQRCode~=1.2.1", | |||
"PyYAML~=5.4.1", | |||
"RestrictedPython~=5.1", | |||
"RestrictedPython~=5.2", | |||
"WeasyPrint==52.5", | |||
"Werkzeug~=2.2.2", | |||
"Whoosh~=2.7.4", | |||