Merge pull request #17548 from ankush/fix_virtual_doctype

refactor!: Virtual DocType
This commit is contained in:
Ankush Menat 2022-07-22 17:25:49 +05:30 committed by GitHub
commit ac83a0fdda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 216 additions and 113 deletions

View file

@ -11,6 +11,7 @@ from frappe.desk.doctype.notification_log.notification_log import (
get_title,
get_title_html,
)
from frappe.model.utils import is_virtual_doctype
from frappe.exceptions import ImplicitCommitError
from frappe.model.document import Document
from frappe.utils import get_fullname
@ -152,7 +153,10 @@ def get_comments_from_parent(doc):
`_comments`
"""
try:
_comments = frappe.db.get_value(doc.reference_doctype, doc.reference_name, "_comments") or "[]"
if is_virtual_doctype(doc.reference_doctype):
_comments = "[]"
else:
_comments = frappe.db.get_value(doc.reference_doctype, doc.reference_name, "_comments") or "[]"
except Exception as e:
if frappe.db.is_missing_table_or_column(e):
@ -175,7 +179,7 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments):
not reference_doctype
or not reference_name
or frappe.db.get_value("DocType", reference_doctype, "issingle")
or frappe.db.get_value("DocType", reference_doctype, "is_virtual")
or is_virtual_doctype(reference_doctype)
):
return

View file

@ -17,7 +17,7 @@
"index_web_pages_for_search": 1,
"is_virtual": 1,
"links": [],
"modified": "2021-03-31 10:06:57.919697",
"modified": "2022-07-22 03:00:59.560061",
"modified_by": "Administrator",
"module": "Core",
"name": "test",
@ -36,7 +36,9 @@
"write": 1
}
],
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -1,43 +1,75 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# License: MIT. See LICENSE
import json
""" This is a virtual doctype controller for test/demo purposes.
# import frappe
- It uses a JSON file on disk as "backend".
- Key is docname and value is the document itself.
Example:
{
"doc1": {"name": "doc1", ...}
"doc2": {"name": "doc2", ...}
}
"""
import json
import os
import frappe
from frappe.model.document import Document
DATA_FILE = "data_file.json"
def get_current_data() -> dict[str, dict]:
"""Read data from disk"""
if not os.path.exists(DATA_FILE):
return {}
with open(DATA_FILE) as f:
return json.load(f)
def update_data(data: dict[str, dict]) -> None:
"""Flush updated data to disk"""
with open(DATA_FILE, "w+") as data_file:
json.dump(data, data_file)
class test(Document):
def db_insert(self):
def db_insert(self, *args, **kwargs):
d = self.get_valid_dict(convert_dates_to_str=True)
with open("data_file.json", "w+") as read_file:
json.dump(d, read_file)
data = get_current_data()
data[d.name] = d
update_data(data)
def load_from_db(self):
with open("data_file.json") as read_file:
d = json.load(read_file)
super(Document, self).__init__(d)
data = get_current_data()
d = data.get(self.name)
super(Document, self).__init__(d)
def db_update(self):
d = self.get_valid_dict(convert_dates_to_str=True)
with open("data_file.json", "w+") as read_file:
json.dump(d, read_file)
def db_update(self, *args, **kwargs):
# For this example insert and update are same operation,
# it might be different for you
self.db_insert(*args, **kwargs)
def get_list(self, args):
with open("data_file.json") as read_file:
return [json.load(read_file)]
def delete(self):
data = get_current_data()
data.pop(self.name, None)
update_data(data)
def get_value(self, fields, filters, **kwargs):
# return []
with open("data_file.json") as read_file:
return [json.load(read_file)]
@staticmethod
def get_list(args):
data = get_current_data()
return [frappe._dict(doc) for name, doc in data.items()]
def get_count(self, args):
# return []
with open("data_file.json") as read_file:
return [json.load(read_file)]
@staticmethod
def get_count(args):
data = get_current_data()
return len(data)
def get_stats(self, args):
# return []
with open("data_file.json") as read_file:
return [json.load(read_file)]
@staticmethod
def get_stats(args):
return {}

View file

@ -1,8 +1,67 @@
# Copyright (c) 2021, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
import unittest
import json
import os
import frappe
from frappe.core.doctype.test.test import DATA_FILE
from frappe.core.doctype.test.test import test as VirtDocType
from frappe.desk.form.save import savedocs
from frappe.tests.utils import FrappeTestCase
class Testtest(unittest.TestCase):
pass
class Testtest(FrappeTestCase):
def tearDown(self):
if os.path.exists(DATA_FILE):
os.remove(DATA_FILE)
def test_insert_update_and_load_from_desk(self):
"""Insert, update, reload and assert changes"""
frappe.response.docs = []
doc = json.dumps(
{
"docstatus": 0,
"doctype": "test",
"name": "new-test-1",
"__islocal": 1,
"__unsaved": 1,
"owner": "Administrator",
"test": "Original Data",
}
)
savedocs(doc, "Save")
docname = frappe.response.docs[0]["name"]
doc = frappe.get_doc("test", docname)
doc.test = "New Data"
savedocs(doc.as_json(), "Save")
doc.reload()
self.assertEqual(doc.test, "New Data")
def test_multiple_doc_insert_and_get_list(self):
doc1 = frappe.get_doc(doctype="test", test="first").insert()
doc2 = frappe.get_doc(doctype="test", test="second").insert()
docs = {doc1.name, doc2.name}
doc2.reload()
doc1.reload()
updated_docs = {doc1.name, doc2.name}
self.assertEqual(docs, updated_docs)
listed_docs = {d.name for d in VirtDocType.get_list({})}
self.assertEqual(docs, listed_docs)
def test_get_count(self):
args = {"doctype": "test", "filters": [], "fields": []}
self.assertIsInstance(VirtDocType.get_count(args), int)
def test_delete_doc(self):
doc = frappe.get_doc(doctype="test", test="data").insert()
frappe.delete_doc(doc.doctype, doc.name)
listed_docs = {d.name for d in VirtDocType.get_list({})}
self.assertNotIn(doc.name, listed_docs)

View file

@ -1,42 +0,0 @@
{
"actions": [],
"creation": "2021-01-13 12:47:03.572640",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"random"
],
"fields": [
{
"fieldname": "random",
"fieldtype": "Data",
"label": "random"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-01-13 12:47:03.572640",
"modified_by": "Administrator",
"module": "Custom",
"name": "Test rename new",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"route": "test-rename",
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -1,9 +0,0 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# License: MIT. See LICENSE
# import frappe
from frappe.model.document import Document
class Testrenamenew(Document):
pass

View file

@ -1,8 +0,0 @@
# Copyright (c) 2021, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
import unittest
class Testrenamenew(unittest.TestCase):
pass

View file

@ -11,6 +11,7 @@ import frappe.share
import frappe.utils
from frappe import _, _dict
from frappe.desk.form.document_follow import is_document_followed
from frappe.model.utils import is_virtual_doctype
from frappe.model.utils.user_settings import get_user_settings
from frappe.permissions import get_doc_permissions
from frappe.utils.data import cstr
@ -30,7 +31,7 @@ def getdoc(doctype, name, user=None):
if not name:
name = doctype
if not frappe.db.exists(doctype, name):
if not frappe.db.exists(doctype, name) and not is_virtual_doctype(doctype):
return []
doc = frappe.get_doc(doctype, name)

View file

@ -13,8 +13,8 @@ from frappe.core.doctype.access_log.access_log import make_access_log
from frappe.model import child_table_fields, default_fields, optional_fields
from frappe.model.base_document import get_controller
from frappe.model.db_query import DatabaseQuery
from frappe.model.utils import is_virtual_doctype
from frappe.utils import add_user_info, cstr, format_duration
from frappe.utils.caching import site_cache
@frappe.whitelist()
@ -24,7 +24,7 @@ def get():
# If virtual doctype get data from controller het_list method
if is_virtual_doctype(args.doctype):
controller = get_controller(args.doctype)
data = compress(controller(args.doctype).get_list(args))
data = compress(controller.get_list(args))
else:
data = compress(execute(**args), args=args)
return data
@ -37,7 +37,7 @@ def get_list():
if is_virtual_doctype(args.doctype):
controller = get_controller(args.doctype)
data = controller(args.doctype).get_list(args)
data = controller.get_list(args)
else:
# uncompressed (refactored from frappe.model.db_query.get_list)
data = execute(**args)
@ -52,7 +52,7 @@ def get_count():
if is_virtual_doctype(args.doctype):
controller = get_controller(args.doctype)
data = controller(args.doctype).get_count(args)
data = controller.get_count(args)
else:
distinct = "distinct " if args.distinct == "true" else ""
args.fields = [f"count({distinct}`tab{args.doctype}`.name) as total_count"]
@ -272,7 +272,7 @@ def compress(data, args=None):
values.append(new_row)
# add user info for assignments (avatar)
if row._assign:
if row.get("_assign", ""):
for user in json.loads(row._assign):
add_user_info(user, user_info)
@ -528,7 +528,7 @@ def get_sidebar_stats(stats, doctype, filters=None):
if is_virtual_doctype(doctype):
controller = get_controller(doctype)
args = {"stats": stats, "filters": filters}
data = controller(doctype).get_stats(args)
data = controller.get_stats(args)
else:
data = get_stats(stats, doctype, filters)
@ -731,8 +731,3 @@ def get_filters_cond(
else:
cond = ""
return cond
@site_cache(maxsize=128)
def is_virtual_doctype(doctype):
return frappe.db.get_value("DocType", doctype, "is_virtual")

View file

@ -11,6 +11,7 @@ from frappe import _, get_module_path
from frappe.desk.doctype.tag.tag import delete_tags_for_document
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.model.naming import revert_series_if_last
from frappe.model.utils import is_virtual_doctype
from frappe.utils.file_manager import remove_all
from frappe.utils.global_search import delete_for_document
from frappe.utils.password import delete_all_passwords_for
@ -57,11 +58,16 @@ def delete_doc(
doctype = frappe.form_dict.get("dt")
name = frappe.form_dict.get("dn")
is_virtual = is_virtual_doctype(doctype)
names = name
if isinstance(name, str) or isinstance(name, int):
names = [name]
for name in names or []:
if is_virtual:
frappe.get_doc(doctype, name).delete()
continue
# already deleted..?
if not frappe.db.exists(doctype, name):

View file

@ -179,7 +179,9 @@ class Document(BaseDocument):
if hasattr(self, "__setup__"):
self.__setup__()
reload = load_from_db
def reload(self):
"""Reload document from database"""
self.load_from_db()
def get_latest(self):
if not getattr(self, "latest", None):

View file

@ -7,6 +7,7 @@ import frappe
from frappe import _
from frappe.build import html_to_js_template
from frappe.utils import cstr
from frappe.utils.caching import site_cache
STANDARD_FIELD_CONVERSION_MAP = {
"name": "Link",
@ -99,3 +100,8 @@ def get_fetch_values(doctype, fieldname, value):
out[df.fieldname] = frappe.db.get_value(link_df.options, value, source_fieldname)
return out
@site_cache(maxsize=128)
def is_virtual_doctype(doctype):
return frappe.db.get_value("DocType", doctype, "is_virtual")

View file

@ -0,0 +1,52 @@
from typing import Protocol
import frappe
class VirtualDoctype(Protocol):
"""This class documents requirements that must be met by a doctype controller to function as virtual doctype
Additional requirements:
- DocType controller has to inherit from `frappe.model.document.Document` class
Note:
- "Backend" here means any storage service, it can be a database, flat file or network call to API.
"""
# ============ class/static methods ============
@staticmethod
def get_list(args) -> list[frappe._dict]:
"""Similar to reportview.get_list"""
...
@staticmethod
def get_count(args) -> int:
"""Similar to reportview.get_count, return total count of documents on listview."""
...
@staticmethod
def get_stats(args):
"""Similar to reportview.get_stats, return sidebar stats."""
...
# ============ instance methods ============
def db_insert(self, *args, **kwargs) -> None:
"""Serialize the `Document` object and insert it in backend."""
...
def load_from_db(self) -> None:
"""Using self.name initialize current document from backend data.
This is responsible for updatinng __dict__ of class with all the fields on doctype."""
...
def db_update(self, *args, **kwargs) -> None:
"""Serialize the `Document` object and update existing document in backend."""
...
def delete(self, *args, **kwargs) -> None:
"""Delete the current document from backend"""
...

View file

@ -296,22 +296,25 @@ def make_boilerplate(template, doc, opts=None):
custom_controller = "pass"
if doc.get("is_virtual"):
custom_controller = """
def db_insert(self):
def db_insert(self, *args, **kwargs):
pass
def load_from_db(self):
pass
def db_update(self):
def db_update(self, *args, **kwargs):
pass
def get_list(self, args):
@staticmethod
def get_list(args):
pass
def get_count(self, args):
@staticmethod
def get_count(args):
pass
def get_stats(self, args):
@staticmethod
def get_stats(args):
pass"""
with open(target_file_path, "w") as target: