Przeglądaj źródła

Merge pull request #18450 from frappe/version-14-hotfix

chore: release v14
version-14
Ankush Menat 2 lat temu
committed by GitHub
rodzic
commit
02c808974a
Nie znaleziono w bazie danych klucza dla tego podpisu ID klucza GPG: 4AEE18F83AFDEB23
35 zmienionych plików z 605 dodań i 229 usunięć
  1. +1
    -0
      .github/workflows/ui-tests.yml
  2. +15
    -2
      frappe/commands/utils.py
  3. +2
    -1
      frappe/contacts/doctype/address/address.py
  4. +2
    -1
      frappe/contacts/doctype/contact/contact.py
  5. +6
    -3
      frappe/core/doctype/data_import/importer.py
  6. +3
    -2
      frappe/core/doctype/doctype/doctype.json
  7. +0
    -1
      frappe/core/doctype/file/file.py
  8. +2
    -1
      frappe/core/doctype/user/user.py
  9. +14
    -0
      frappe/custom/doctype/custom_field/custom_field.py
  10. +89
    -16
      frappe/custom/doctype/doctype_layout/doctype_layout.js
  11. +6
    -3
      frappe/custom/doctype/doctype_layout/doctype_layout.json
  12. +66
    -0
      frappe/custom/doctype/doctype_layout/doctype_layout.py
  13. +4
    -13
      frappe/database/database.py
  14. +43
    -63
      frappe/desk/search.py
  15. +1
    -1
      frappe/email/doctype/notification/notification.js
  16. +0
    -38
      frappe/event_streaming/doctype/event_producer/test_event_producer.py
  17. +2
    -2
      frappe/integrations/doctype/google_calendar/google_calendar.py
  18. +48
    -4
      frappe/parallel_test_runner.py
  19. +10
    -2
      frappe/public/js/frappe/form/controls/link.js
  20. +15
    -14
      frappe/public/js/frappe/form/grid.js
  21. +127
    -12
      frappe/public/js/frappe/form/grid_row.js
  22. +9
    -1
      frappe/public/js/frappe/form/layout.js
  23. +5
    -6
      frappe/public/js/frappe/form/script_manager.js
  24. +2
    -6
      frappe/public/js/frappe/router.js
  25. +7
    -10
      frappe/public/js/frappe/socketio_client.js
  26. +27
    -2
      frappe/public/scss/common/grid.scss
  27. +26
    -3
      frappe/share.py
  28. +9
    -14
      frappe/tests/test_db.py
  29. +14
    -0
      frappe/tests/test_perf.py
  30. +22
    -0
      frappe/tests/test_search.py
  31. +16
    -0
      frappe/tests/test_website.py
  32. +6
    -4
      frappe/utils/jinja_globals.py
  33. +3
    -1
      frappe/website/page_renderers/base_template_page.py
  34. +2
    -2
      frappe/www/search.py
  35. +1
    -1
      pyproject.toml

+ 1
- 0
.github/workflows/ui-tests.yml Wyświetl plik

@@ -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


+ 15
- 2
frappe/commands/utils.py Wyświetl plik

@@ -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(


+ 2
- 1
frappe/contacts/doctype/address/address.py Wyświetl plik

@@ -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)}"


+ 2
- 1
frappe/contacts/doctype/contact/contact.py Wyświetl plik

@@ -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 []


+ 6
- 3
frappe/core/doctype/data_import/importer.py Wyświetl plik

@@ -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



+ 3
- 2
frappe/core/doctype/doctype/doctype.json Wyświetl plik

@@ -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",


+ 0
- 1
frappe/core/doctype/file/file.py Wyświetl plik

@@ -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"))



+ 2
- 1
frappe/core/doctype/user/user.py Wyświetl plik

@@ -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'"


+ 14
- 0
frappe/custom/doctype/custom_field/custom_field.py Wyświetl plik

@@ -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):


+ 89
- 16
frappe/custom/doctype/doctype_layout/doctype_layout.js Wyświetl plik

@@ -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"),
});
}
}
},
});

+ 6
- 3
frappe/custom/doctype/doctype_layout/doctype_layout.json Wyświetl plik

@@ -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
}

+ 66
- 0
frappe/custom/doctype/doctype_layout/doctype_layout.py Wyświetl plik

@@ -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

+ 4
- 13
frappe/database/database.py Wyświetl plik

@@ -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


+ 43
- 63
frappe/desk/search.py Wyświetl plik

@@ -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

+ 1
- 1
frappe/email/doctype/notification/notification.js Wyświetl plik

@@ -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"));
}


+ 0
- 38
frappe/event_streaming/doctype/event_producer/test_event_producer.py Wyświetl plik

@@ -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(


+ 2
- 2
frappe/integrations/doctype/google_calendar/google_calendar.py Wyświetl plik

@@ -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,


+ 48
- 4
frappe/parallel_test_runner.py Wyświetl plik

@@ -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):


+ 10
- 2
frappe/public/js/frappe/form/controls/link.js Wyświetl plik

@@ -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>")


+ 15
- 14
frappe/public/js/frappe/form/grid.js Wyświetl plik

@@ -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


+ 127
- 12
frappe/public/js/frappe/form/grid_row.js Wyświetl plik

@@ -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)) {


+ 9
- 1
frappe/public/js/frappe/form/layout.js Wyświetl plik

@@ -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);
}
}
}



+ 5
- 6
frappe/public/js/frappe/form/script_manager.js Wyświetl plik

@@ -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) {


+ 2
- 6
frappe/public/js/frappe/router.js Wyświetl plik

@@ -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;
},



+ 7
- 10
frappe/public/js/frappe/socketio_client.js Wyświetl plik

@@ -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
);
}
});



+ 27
- 2
frappe/public/scss/common/grid.scss Wyświetl plik

@@ -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 {


+ 26
- 3
frappe/share.py Wyświetl plik

@@ -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)



+ 9
- 14
frappe/tests/test_db.py Wyświetl plik

@@ -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`")


+ 14
- 0
frappe/tests/test_perf.py Wyświetl plik

@@ -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")


+ 22
- 0
frappe/tests/test_search.py Wyświetl plik

@@ -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))

+ 16
- 0
frappe/tests/test_website.py Wyświetl plik

@@ -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


+ 6
- 4
frappe/utils/jinja_globals.py Wyświetl plik

@@ -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()


+ 3
- 1
frappe/website/page_renderers/base_template_page.py Wyświetl plik

@@ -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


+ 2
- 2
frappe/www/search.py Wyświetl plik

@@ -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"


+ 1
- 1
pyproject.toml Wyświetl plik

@@ -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",


Ładowanie…
Anuluj
Zapisz