Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix!: better "Export customizations" #25918

Merged
merged 5 commits into from Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion frappe/custom/doctype/customize_form/customize_form.js
Expand Up @@ -218,7 +218,10 @@ frappe.ui.form.on("Customize Form", {
fieldtype: "Check",
fieldname: "with_permissions",
label: __("Export Custom Permissions"),
default: 1,
description: __(
"Exported permissions will be force-synced on every migrate overriding any other customization."
),
default: 0,
},
],
function (data) {
Expand Down
45 changes: 29 additions & 16 deletions frappe/modules/utils.py
Expand Up @@ -110,6 +110,8 @@ def sync_customizations(app=None):
data = json.loads(f.read())
if data.get("sync_on_migrate"):
sync_customizations_for_doctype(data, folder, fname)
elif frappe.flags.in_install and app:
sync_customizations_for_doctype(data, folder, fname)


def sync_customizations_for_doctype(data: dict, folder: str, filename: str = ""):
Expand All @@ -130,23 +132,34 @@ def _insert(data):
doc = frappe.get_doc(data)
doc.db_insert()

if custom_doctype != "Custom Field":
frappe.db.delete(custom_doctype, {doctype_fieldname: doc_type})

for d in data[key]:
_insert(d)

else:
for d in data[key]:
field = frappe.db.get_value("Custom Field", {"dt": doc_type, "fieldname": d["fieldname"]})
if not field:
d["owner"] = "Administrator"
match custom_doctype:
case "Custom Field":
for d in data[key]:
field = frappe.db.get_value(
"Custom Field", {"dt": doc_type, "fieldname": d["fieldname"]}
)
if not field:
d["owner"] = "Administrator"
_insert(d)
else:
custom_field = frappe.get_doc("Custom Field", field)
custom_field.flags.ignore_validate = True
custom_field.update(d)
custom_field.db_update()
case "Property Setter":
# Property setter implement their own deduplication, we can just sync them as is
for d in data[key]:
if d.get("doc_type") == doc_type:
d["doctype"] = "Property Setter"
doc = frappe.get_doc(d)
doc.flags.validate_fields_for_doctype = False
doc.insert()
case "Custom DocPerm":
# TODO/XXX: Docperm have no "sync" as of now. They get OVERRIDDEN on sync.
frappe.db.delete("Custom DocPerm", {"parent": doc_type})

for d in data[key]:
_insert(d)
else:
custom_field = frappe.get_doc("Custom Field", field)
custom_field.flags.ignore_validate = True
custom_field.update(d)
custom_field.db_update()

for doc_type in doctypes:
# only sync the parent doctype and child doctype if there isn't any other child table json file
Expand Down
153 changes: 76 additions & 77 deletions frappe/tests/test_modules.py
@@ -1,11 +1,14 @@
import os
import shutil
import unittest
from contextlib import contextmanager
from pathlib import Path

import frappe
from frappe import scrub
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.model.meta import trim_table
from frappe.modules import export_customizations, export_module_json, get_module_path
from frappe.modules.utils import export_doc, sync_customizations
Expand All @@ -31,55 +34,15 @@ def delete_path(path):
class TestUtils(FrappeTestCase):
def setUp(self):
self._dev_mode = frappe.local.conf.developer_mode
self._in_import = frappe.local.flags.in_import

frappe.local.conf.developer_mode = True

if self._testMethodName == "test_export_module_json_no_export":
frappe.local.flags.in_import = True

if self._testMethodName in ("test_export_customizations", "test_sync_customizations"):
df = {
"fieldname": "test_export_customizations_field",
"label": "Custom Data Field",
"fieldtype": "Data",
}
self.custom_field = create_custom_field("Note", df=df)

if self._testMethodName == "test_export_doc":
self.note = frappe.new_doc("Note")
self.note.title = frappe.generate_hash(length=10)
self.note.save()

if self._testMethodName == "test_make_boilerplate":
self.doctype = new_doctype("Test DocType Boilerplate")
self.doctype.insert()

def tearDown(self):
frappe.db.rollback()
frappe.local.conf.developer_mode = self._dev_mode
frappe.local.flags.in_import = self._in_import

if self._testMethodName in ("test_export_customizations", "test_sync_customizations"):
self.custom_field.delete()
trim_table("Note", dry_run=False)
delattr(self, "custom_field")
delete_path(frappe.get_module_path("Desk", "Note"))

if self._testMethodName == "test_export_doc":
self.note.delete()
delattr(self, "note")

if self._testMethodName == "test_make_boilerplate":
self.doctype.delete(force=True)
scrubbed = frappe.scrub(self.doctype.name)
self.addCleanup(
delete_path,
path=frappe.get_app_path("frappe", "core", "doctype", scrubbed),
)
frappe.db.sql_ddl("DROP TABLE `tabTest DocType Boilerplate`")
delattr(self, "doctype")
frappe.local.flags.pop("in_import", None)

def test_export_module_json_no_export(self):
frappe.local.flags.in_import = True
doc = frappe.get_last_doc("DocType")
self.assertIsNone(export_module_json(doc=doc, is_standard=True, module=doc.module))

Expand Down Expand Up @@ -115,36 +78,41 @@ def test_export_module_json(self):
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_export_customizations(self):
file_path = export_customizations(module="Custom", doctype="Note")
self.addCleanup(delete_file, path=file_path)
self.assertTrue(file_path.endswith("/custom/custom/note.json"))
self.assertTrue(os.path.exists(file_path))
with note_customizations():
file_path = export_customizations(module="Custom", doctype="Note")
self.addCleanup(delete_file, path=file_path)
self.assertTrue(file_path.endswith("/custom/custom/note.json"))
self.assertTrue(os.path.exists(file_path))

@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_sync_customizations(self):
custom_field = frappe.get_doc(
"Custom Field", {"dt": "Note", "fieldname": "test_export_customizations_field"}
)

file_path = export_customizations(module="Custom", doctype="Note", sync_on_migrate=True)
custom_field.db_set("modified", now_datetime())
custom_field.reload()
with note_customizations() as (custom_field, property_setter):
file_path = export_customizations(module="Custom", doctype="Note", sync_on_migrate=True)
custom_field.db_set("modified", now_datetime())
custom_field.reload()

# Untracked property setter
custom_prop_setter = make_property_setter(
"Note", fieldname="content", property="bold", value="1", property_type="Check"
)

self.assertTrue(file_path.endswith("/custom/custom/note.json"))
self.assertTrue(os.path.exists(file_path))
last_modified_before = custom_field.modified
self.assertTrue(file_path.endswith("/custom/custom/note.json"))
self.assertTrue(os.path.exists(file_path))
last_modified_before = custom_field.modified

sync_customizations(app="frappe")
sync_customizations(app="frappe")
self.assertTrue(property_setter.doctype, property_setter.name)
self.assertTrue(custom_prop_setter.doctype, custom_prop_setter.name)

self.assertTrue(file_path.endswith("/custom/custom/note.json"))
self.assertTrue(os.path.exists(file_path))
custom_field.reload()
last_modified_after = custom_field.modified
self.assertTrue(file_path.endswith("/custom/custom/note.json"))
self.assertTrue(os.path.exists(file_path))
custom_field.reload()
last_modified_after = custom_field.modified

self.assertNotEqual(last_modified_after, last_modified_before)
self.addCleanup(delete_file, path=file_path)
self.assertNotEqual(last_modified_after, last_modified_before)
self.addCleanup(delete_file, path=file_path)

def test_reload_doc(self):
frappe.db.set_value("DocType", "Note", "migration_hash", "", update_modified=False)
Expand All @@ -171,24 +139,55 @@ def test_reload_doc(self):
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_export_doc(self):
exported_doc_path = frappe.get_app_path(
"frappe", "desk", "note", self.note.name, f"{self.note.name}.json"
note = frappe.new_doc("Note")
note.title = frappe.generate_hash(length=10)
note.save()
export_doc(doctype="Note", name=note.name)
exported_doc_path = Path(
frappe.get_app_path("frappe", "desk", "note", note.name, f"{note.name}.json")
)
folder_path = os.path.abspath(os.path.dirname(exported_doc_path))
export_doc(doctype="Note", name=self.note.name)
self.addCleanup(delete_path, path=folder_path)
self.assertTrue(os.path.exists(exported_doc_path))
self.addCleanup(delete_path, path=exported_doc_path.parent.parent)

@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_make_boilerplate(self):
scrubbed = frappe.scrub(self.doctype.name)
self.assertFalse(
os.path.exists(frappe.get_app_path("frappe", "core", "doctype", scrubbed, f"{scrubbed}.json"))
)
self.doctype.custom = False
self.doctype.save()
self.assertTrue(
os.path.exists(frappe.get_app_path("frappe", "core", "doctype", scrubbed, f"{scrubbed}.json"))
with temp_doctype() as doctype:
scrubbed = frappe.scrub(doctype.name)
path = frappe.get_app_path("frappe", "core", "doctype", scrubbed, f"{scrubbed}.json")
self.assertFalse(os.path.exists(path))
doctype.custom = False
doctype.save()
self.assertTrue(os.path.exists(path))


@contextmanager
def temp_doctype():
try:
doctype = new_doctype().insert()
yield doctype
finally:
doctype.delete(force=True)
frappe.db.sql_ddl(f"DROP TABLE `tab{doctype.name}`")


@contextmanager
def note_customizations():
try:
df = {
"fieldname": "test_export_customizations_field",
"label": "Custom Data Field",
"fieldtype": "Data",
}
custom_field = create_custom_field("Note", df=df)

property_setter = make_property_setter(
"Note", fieldname="content", property="bold", value="1", property_type="Check"
)
yield custom_field, property_setter
finally:
custom_field.delete()
property_setter.delete()
trim_table("Note", dry_run=False)
delete_path(frappe.get_module_path("Desk", "Note"))