Merge branch 'develop' into add_pdf_backend_hook
This commit is contained in:
commit
c8b40b1805
78 changed files with 5842 additions and 5647 deletions
|
|
@ -1021,6 +1021,9 @@ def has_permission(
|
|||
)
|
||||
|
||||
if throw and not out:
|
||||
if doc:
|
||||
frappe.permissions.check_doctype_permission(doctype, ptype)
|
||||
|
||||
document_label = f"{_(doctype)} {doc if isinstance(doc, str) else doc.name}" if doc else _(doctype)
|
||||
frappe.flags.error_message = _("No permission for {0}").format(document_label)
|
||||
raise frappe.PermissionError
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ user_cache_keys = (
|
|||
)
|
||||
|
||||
doctype_cache_keys = (
|
||||
"doctype_form_meta",
|
||||
"last_modified",
|
||||
"linked_doctypes",
|
||||
"workflow",
|
||||
|
|
@ -139,6 +138,9 @@ def _clear_doctype_cache_from_redis(doctype: str | None = None):
|
|||
|
||||
def clear_single(dt):
|
||||
frappe.clear_document_cache(dt)
|
||||
# Wild card for all keys containing this doctype.
|
||||
# this can be excessive but this function isn't called often... ideally.
|
||||
frappe.client_cache.delete_keys(f"*{dt}*")
|
||||
frappe.cache.hdel_names(doctype_cache_keys, dt)
|
||||
clear_meta_cache(dt)
|
||||
|
||||
|
|
|
|||
|
|
@ -233,6 +233,8 @@ def start_worker_pool(queue, quiet=False, num_workers=2, burst=False):
|
|||
@click.option("--site", help="site name")
|
||||
@pass_context
|
||||
def ready_for_migration(context: CliCtxObj, site=None):
|
||||
import time
|
||||
|
||||
from frappe.utils.doctor import any_job_pending
|
||||
|
||||
if not site:
|
||||
|
|
@ -240,7 +242,14 @@ def ready_for_migration(context: CliCtxObj, site=None):
|
|||
|
||||
try:
|
||||
frappe.init(site)
|
||||
pending_jobs = any_job_pending(site=site)
|
||||
pending_jobs = False
|
||||
|
||||
# HACK: Check at least 3 times, 1 second apart.
|
||||
# Rare edge case: Scheduler hasn't seen 'maintenance_mode=1` yet
|
||||
# and takes more than 3 second to schedule.
|
||||
for _ in range(3):
|
||||
pending_jobs |= any_job_pending(site=site)
|
||||
time.sleep(1)
|
||||
|
||||
if pending_jobs:
|
||||
print(f"NOT READY for migration: site {site} has pending background jobs")
|
||||
|
|
|
|||
|
|
@ -567,23 +567,12 @@ def list_apps(context: CliCtxObj, format):
|
|||
@pass_context
|
||||
def add_db_index(context: CliCtxObj, doctype, column):
|
||||
"Adds a new DB index and creates a property setter to persist it."
|
||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||
|
||||
columns = column # correct naming
|
||||
for site in context.sites:
|
||||
frappe.init(site)
|
||||
frappe.connect()
|
||||
try:
|
||||
frappe.db.add_index(doctype, columns)
|
||||
if len(columns) == 1:
|
||||
make_property_setter(
|
||||
doctype,
|
||||
columns[0],
|
||||
property="search_index",
|
||||
value="1",
|
||||
property_type="Check",
|
||||
for_doctype=False, # Applied on docfield
|
||||
)
|
||||
frappe.db.commit()
|
||||
finally:
|
||||
frappe.destroy()
|
||||
|
|
|
|||
|
|
@ -921,6 +921,10 @@ class TestBenchBuild(IntegrationTestCase):
|
|||
|
||||
|
||||
class TestDBUtils(BaseTestCommands):
|
||||
@skipIf(
|
||||
not (frappe.conf.db_type == "mariadb"),
|
||||
"Only for MariaDB",
|
||||
)
|
||||
def test_db_add_index(self):
|
||||
field = "reset_password_key"
|
||||
self.execute("bench --site {site} add-database-index --doctype User --column " + field, {})
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@
|
|||
"idx": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-03-23 16:01:30.219380",
|
||||
"modified": "2025-02-20 19:19:29.427081",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Communication",
|
||||
|
|
@ -427,6 +427,7 @@
|
|||
"role": "All"
|
||||
}
|
||||
],
|
||||
"row_format": "Compressed",
|
||||
"search_fields": "subject",
|
||||
"sender_field": "sender",
|
||||
"sort_field": "creation",
|
||||
|
|
@ -436,4 +437,4 @@
|
|||
"title_field": "subject",
|
||||
"track_changes": 1,
|
||||
"track_seen": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -79,8 +79,7 @@ def make(
|
|||
)
|
||||
|
||||
if doctype and name:
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.check_permission("email")
|
||||
frappe.has_permission(doctype, doc=name, ptype="email", throw=True)
|
||||
|
||||
return _make(
|
||||
doctype=doctype,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
],
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:02:17.664513",
|
||||
"modified": "2025-02-20 19:22:00.734438",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Deleted Document",
|
||||
|
|
@ -73,6 +73,7 @@
|
|||
"role": "System Manager"
|
||||
}
|
||||
],
|
||||
"row_format": "Compressed",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"is_calendar_and_gantt",
|
||||
"editable_grid",
|
||||
"quick_entry",
|
||||
"grid_page_length",
|
||||
"cb01",
|
||||
"track_changes",
|
||||
"track_seen",
|
||||
|
|
@ -94,6 +95,7 @@
|
|||
"advanced",
|
||||
"engine",
|
||||
"migration_hash",
|
||||
"row_format",
|
||||
"connections_tab"
|
||||
],
|
||||
"fields": [
|
||||
|
|
@ -671,6 +673,22 @@
|
|||
"fieldname": "fields_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Fields"
|
||||
},
|
||||
{
|
||||
"default": "Dynamic",
|
||||
"fieldname": "row_format",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 1,
|
||||
"label": "Row Format",
|
||||
"options": "Dynamic\nCompressed"
|
||||
},
|
||||
{
|
||||
"default": "50",
|
||||
"depends_on": "istable",
|
||||
"fieldname": "grid_page_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Grid Page Length",
|
||||
"non_negative": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-bolt",
|
||||
|
|
@ -753,7 +771,7 @@
|
|||
"link_fieldname": "reference_doctype"
|
||||
}
|
||||
],
|
||||
"modified": "2024-11-30 16:09:21.536704",
|
||||
"modified": "2025-02-20 19:05:52.119679",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
@ -790,4 +808,4 @@
|
|||
"states": [],
|
||||
"track_changes": 1,
|
||||
"translated_doctype": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ class DocType(Document):
|
|||
engine: DF.Literal["InnoDB", "MyISAM"]
|
||||
fields: DF.Table[DocField]
|
||||
force_re_route_to_default_view: DF.Check
|
||||
grid_page_length: DF.Int
|
||||
has_web_view: DF.Check
|
||||
hide_toolbar: DF.Check
|
||||
icon: DF.Data | None
|
||||
|
|
@ -158,6 +159,7 @@ class DocType(Document):
|
|||
read_only: DF.Check
|
||||
restrict_to_domain: DF.Link | None
|
||||
route: DF.Data | None
|
||||
row_format: DF.Literal["Dynamic", "Compressed"]
|
||||
search_fields: DF.Data | None
|
||||
sender_field: DF.Data | None
|
||||
sender_name_field: DF.Data | None
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from frappe.desk.form.load import getdoc
|
|||
from frappe.model.delete_doc import delete_controllers
|
||||
from frappe.model.sync import remove_orphan_doctypes
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
from frappe.utils import get_table_name
|
||||
|
||||
|
||||
class UnitTestDoctype(UnitTestCase):
|
||||
|
|
@ -806,6 +807,30 @@ class TestDocType(IntegrationTestCase):
|
|||
doc.submit()
|
||||
frappe.get_meta(doctype.name).as_dict()
|
||||
|
||||
def test_row_compression(self):
|
||||
if frappe.db.db_type != "mariadb":
|
||||
return
|
||||
|
||||
compressed_dt = new_doctype(row_format="Compressed").insert().name
|
||||
dynamic_dt = new_doctype().insert().name
|
||||
|
||||
information_schema = frappe.qb.Schema("information_schema")
|
||||
|
||||
def get_format(dt):
|
||||
return (
|
||||
frappe.qb.from_(information_schema.tables)
|
||||
.select("row_format")
|
||||
.where(
|
||||
(information_schema.tables.table_schema == frappe.conf.db_name)
|
||||
& (information_schema.tables.table_name == get_table_name(dt))
|
||||
)
|
||||
.run()[0][0]
|
||||
.upper()
|
||||
)
|
||||
|
||||
self.assertEqual(get_format(compressed_dt), "COMPRESSED")
|
||||
self.assertEqual(get_format(dynamic_dt), "DYNAMIC")
|
||||
|
||||
|
||||
def new_doctype(
|
||||
name: str | None = None,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
frappe.ui.form.on("File", {
|
||||
refresh: function (frm) {
|
||||
frm.add_custom_button(__("View File"), () => {
|
||||
if (!frappe.utils.is_url(frm.doc.file_url)) {
|
||||
window.open(window.location.origin + frm.doc.file_url);
|
||||
}
|
||||
});
|
||||
if (frm.doc.file_url) {
|
||||
frm.add_custom_button(__("View File"), () => {
|
||||
if (!frappe.utils.is_url(frm.doc.file_url)) {
|
||||
window.open(window.location.origin + frm.doc.file_url);
|
||||
} else {
|
||||
window.open(frm.doc.file_url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!frm.doc.is_folder) {
|
||||
// add download button
|
||||
frm.add_custom_button(__("Download"), () => frm.trigger("download"), "fa fa-download");
|
||||
|
|
@ -32,9 +37,6 @@ frappe.ui.form.on("File", {
|
|||
if (frm.doc.file_name && frm.doc.file_name.split(".").splice(-1)[0] === "zip") {
|
||||
frm.add_custom_button(__("Unzip"), () => frm.trigger("unzip"));
|
||||
}
|
||||
if (frm.doc.file_url) {
|
||||
frm.add_web_link(frm.doc.file_url, __("View file"));
|
||||
}
|
||||
},
|
||||
|
||||
preview_file: function (frm) {
|
||||
|
|
|
|||
|
|
@ -382,8 +382,11 @@ class File(Document):
|
|||
filters = {
|
||||
"content_hash": self.content_hash,
|
||||
"is_private": self.is_private,
|
||||
"name": ("!=", self.name),
|
||||
}
|
||||
|
||||
if self.name:
|
||||
filters.update({"name": ("!=", self.name)})
|
||||
|
||||
if self.attached_to_doctype and self.attached_to_name:
|
||||
filters.update(
|
||||
{
|
||||
|
|
@ -658,7 +661,11 @@ class File(Document):
|
|||
if duplicate_file:
|
||||
file_doc: File = frappe.get_cached_doc("File", duplicate_file.name)
|
||||
if file_doc.exists_on_disk():
|
||||
self.file_url = duplicate_file.file_url
|
||||
if self.exists_on_disk():
|
||||
if not self.file_url:
|
||||
self.file_url = duplicate_file.file_url
|
||||
else:
|
||||
self.file_url = duplicate_file.file_url
|
||||
file_exists = True
|
||||
|
||||
if not file_exists:
|
||||
|
|
|
|||
|
|
@ -128,14 +128,6 @@ def add_indexes(indexes):
|
|||
def _add_index(table, column):
|
||||
doctype = get_doctype_name(table)
|
||||
frappe.db.add_index(doctype, [column])
|
||||
make_property_setter(
|
||||
doctype,
|
||||
column,
|
||||
property="search_index",
|
||||
value="1",
|
||||
property_type="Check",
|
||||
for_doctype=False, # Applied on docfield
|
||||
)
|
||||
frappe.msgprint(
|
||||
_("Index created successfully on column {0} of doctype {1}").format(column, doctype),
|
||||
alert=True,
|
||||
|
|
|
|||
|
|
@ -192,6 +192,8 @@ def execute_event(doc: str):
|
|||
|
||||
def run_scheduled_job(scheduled_job_type: str, job_type: str | None = None):
|
||||
"""This is a wrapper function that runs a hooks.scheduler_events method"""
|
||||
if frappe.conf.maintenance_mode:
|
||||
raise frappe.InReadOnlyMode("Scheduled jobs can't run in maintenance mode.")
|
||||
try:
|
||||
frappe.get_doc("Scheduled Job Type", scheduled_job_type).execute()
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ class ServerScript(Document):
|
|||
{
|
||||
"method": frappe.scrub(f"{self.name}-{self.event_frequency}"),
|
||||
"frequency": self.event_frequency,
|
||||
"cron_format": self.cron_format,
|
||||
"cron_format": self.cron_format if self.event_frequency == "Cron" else "",
|
||||
"stopped": self.disabled,
|
||||
}
|
||||
).save()
|
||||
|
|
|
|||
|
|
@ -402,7 +402,7 @@
|
|||
},
|
||||
{
|
||||
"default": "4",
|
||||
"description": "Will run scheduled jobs only once a day for inactive sites. Default 4 days if set to 0.",
|
||||
"description": "Will run scheduled jobs only once a day for inactive sites. Set it to 0 to avoid automatically disabling the scheduler.",
|
||||
"fieldname": "dormant_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Run Jobs only Daily if Inactive For (Days)"
|
||||
|
|
@ -704,7 +704,7 @@
|
|||
"icon": "fa fa-cog",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-03 16:23:09.410614",
|
||||
"modified": "2025-02-21 20:06:55.499937",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
@ -719,8 +719,9 @@
|
|||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -558,7 +558,8 @@
|
|||
"fieldtype": "Datetime",
|
||||
"label": "Last Active",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"description": "Stores the JSON of last known versions of various installed apps. It is used to show release notes.",
|
||||
|
|
@ -888,7 +889,7 @@
|
|||
"link_fieldname": "user"
|
||||
}
|
||||
],
|
||||
"modified": "2024-12-31 19:35:17.052698",
|
||||
"modified": "2025-02-21 20:11:36.150167",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "User",
|
||||
|
|
@ -920,6 +921,7 @@
|
|||
],
|
||||
"quick_entry": 1,
|
||||
"route": "user",
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "full_name",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "creation",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:04:01.627592",
|
||||
"modified": "2025-02-20 19:20:33.616072",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Version",
|
||||
|
|
@ -73,6 +73,7 @@
|
|||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Compressed",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:04:01.764239",
|
||||
"modified": "2025-02-20 19:21:33.012224",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "View Log",
|
||||
|
|
@ -50,6 +50,7 @@
|
|||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Compressed",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"properties",
|
||||
"label",
|
||||
"search_fields",
|
||||
"grid_page_length",
|
||||
"link_filters",
|
||||
"column_break_5",
|
||||
"istable",
|
||||
|
|
@ -399,6 +400,13 @@
|
|||
"fieldname": "sender_name_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Sender Name Field"
|
||||
},
|
||||
{
|
||||
"depends_on": "istable",
|
||||
"fieldname": "grid_page_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Grid Page Length",
|
||||
"non_negative": 1
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
|
|
@ -407,7 +415,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:02:15.670853",
|
||||
"modified": "2025-02-21 20:16:50.501895",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom",
|
||||
"name": "Customize Form",
|
||||
|
|
@ -425,6 +433,7 @@
|
|||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "doc_type",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class CustomizeForm(Document):
|
|||
email_append_to: DF.Check
|
||||
fields: DF.Table[CustomizeFormField]
|
||||
force_re_route_to_default_view: DF.Check
|
||||
grid_page_length: DF.Int
|
||||
image_field: DF.Data | None
|
||||
is_calendar_and_gantt: DF.Check
|
||||
istable: DF.Check
|
||||
|
|
@ -743,6 +744,7 @@ doctype_properties = {
|
|||
"default_view": "Select",
|
||||
"force_re_route_to_default_view": "Check",
|
||||
"translated_doctype": "Check",
|
||||
"grid_page_length": "Int",
|
||||
}
|
||||
|
||||
docfield_properties = {
|
||||
|
|
|
|||
|
|
@ -407,14 +407,27 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
|
|||
def add_index(self, doctype: str, fields: list, index_name: str | None = None):
|
||||
"""Creates an index with given fields if not already created.
|
||||
Index name will be `fieldname1_fieldname2_index`"""
|
||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||
|
||||
index_name = index_name or self.get_index_name(fields)
|
||||
table_name = get_table_name(doctype)
|
||||
if not self.has_index(table_name, index_name):
|
||||
self.commit()
|
||||
self.sql(
|
||||
"""ALTER TABLE `{}`
|
||||
ADD INDEX `{}`({})""".format(table_name, index_name, ", ".join(fields))
|
||||
ADD INDEX IF NOT EXISTS `{}`({})""".format(table_name, index_name, ", ".join(fields))
|
||||
)
|
||||
# Ensure that DB migration doesn't clear this index, assuming this is manually added
|
||||
# via code or console.
|
||||
if len(fields) == 1 and not (frappe.flags.in_install or frappe.flags.in_migrate):
|
||||
make_property_setter(
|
||||
doctype,
|
||||
fields[0],
|
||||
property="search_index",
|
||||
value="1",
|
||||
property_type="Check",
|
||||
for_doctype=False, # Applied on docfield
|
||||
)
|
||||
|
||||
def add_unique(self, doctype, fields, constraint_name=None):
|
||||
if isinstance(fields, str):
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class MariaDBTable(DBTable):
|
|||
idx int not null default '0',
|
||||
{additional_definitions})
|
||||
ENGINE={engine}
|
||||
ROW_FORMAT=DYNAMIC
|
||||
ROW_FORMAT={(self.meta.get("row_format") or "Dynamic").upper()}
|
||||
CHARACTER SET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci"""
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ class Workspace:
|
|||
|
||||
def get_cached(self, cache_key, fallback_fn):
|
||||
value = frappe.cache.get_value(cache_key, user=frappe.session.user)
|
||||
if value:
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
value = fallback_fn()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ from frappe.model.document import Document
|
|||
from frappe.utils.background_jobs import get_queue, get_queue_list, get_redis_conn
|
||||
from frappe.utils.caching import redis_cache
|
||||
from frappe.utils.data import add_to_date
|
||||
from frappe.utils.scheduler import get_scheduler_status, get_scheduler_tick, is_schduler_process_running
|
||||
from frappe.utils.scheduler import (
|
||||
get_scheduler_status,
|
||||
get_scheduler_tick,
|
||||
is_dormant,
|
||||
is_schduler_process_running,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -52,6 +57,8 @@ def health_check(step: str):
|
|||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if frappe.flags.in_test:
|
||||
raise
|
||||
frappe.log(frappe.get_traceback())
|
||||
# nosemgrep
|
||||
frappe.msgprint(
|
||||
|
|
@ -151,7 +158,6 @@ class SystemHealthReport(Document):
|
|||
# This just checks connection life
|
||||
self.test_job_id = frappe.enqueue("frappe.ping", at_front=True).id
|
||||
self.background_jobs_check = "queued"
|
||||
self.scheduler_status = get_scheduler_status().get("status")
|
||||
workers = frappe.get_all("RQ Worker")
|
||||
self.total_background_workers = len(workers)
|
||||
queue_summary = defaultdict(list)
|
||||
|
|
@ -182,11 +188,20 @@ class SystemHealthReport(Document):
|
|||
|
||||
@health_check("Scheduler")
|
||||
def fetch_scheduler(self):
|
||||
scheduler_enabled = get_scheduler_status().get("status") == "active"
|
||||
|
||||
if not is_schduler_process_running():
|
||||
self.scheduler_status = "Process Not Found"
|
||||
elif is_dormant():
|
||||
self.scheduler_status = "Dormant"
|
||||
elif scheduler_enabled:
|
||||
self.scheduler_status = "Active"
|
||||
else:
|
||||
self.scheduler_status = "Inactive"
|
||||
|
||||
lower_threshold = add_to_date(None, days=-7, as_datetime=True)
|
||||
# Exclude "maybe" curently executing job
|
||||
upper_threshold = add_to_date(None, minutes=-30, as_datetime=True)
|
||||
scheduler_running = get_scheduler_status().get("status") == "active" and is_schduler_process_running()
|
||||
self.scheduler_status = "Active" if scheduler_running else "Inactive"
|
||||
|
||||
mariadb_query = """
|
||||
SELECT scheduled_job_type,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from frappe.desk.doctype.notification_log.notification_log import (
|
|||
get_title_html,
|
||||
)
|
||||
from frappe.desk.form.document_follow import follow_document
|
||||
from frappe.utils import escape_html
|
||||
from frappe.utils.data import strip_html
|
||||
|
||||
|
||||
class DuplicateToDoError(frappe.ValidationError):
|
||||
|
|
@ -57,10 +57,6 @@ def add(args=None, *, ignore_permissions=False):
|
|||
users_with_duplicate_todo = []
|
||||
shared_with_users = []
|
||||
|
||||
description = escape_html(
|
||||
args.get("description", _("Assignment for {0} {1}").format(args["doctype"], args["name"]))
|
||||
)
|
||||
|
||||
for assign_to in frappe.parse_json(args.get("assign_to")):
|
||||
filters = {
|
||||
"reference_type": args["doctype"],
|
||||
|
|
@ -76,13 +72,18 @@ def add(args=None, *, ignore_permissions=False):
|
|||
else:
|
||||
from frappe.utils import nowdate
|
||||
|
||||
description = str(args.get("description", ""))
|
||||
has_content = strip_html(description) or "<img" in description
|
||||
if not has_content:
|
||||
args["description"] = _("Assignment for {0} {1}").format(args["doctype"], args["name"])
|
||||
|
||||
d = frappe.get_doc(
|
||||
{
|
||||
"doctype": "ToDo",
|
||||
"allocated_to": assign_to,
|
||||
"reference_type": args["doctype"],
|
||||
"reference_name": args["name"],
|
||||
"description": description,
|
||||
"description": args.get("description"),
|
||||
"priority": args.get("priority", "Medium"),
|
||||
"status": "Open",
|
||||
"date": args.get("date", nowdate()),
|
||||
|
|
@ -122,7 +123,7 @@ def add(args=None, *, ignore_permissions=False):
|
|||
d.reference_type,
|
||||
d.reference_name,
|
||||
action="ASSIGN",
|
||||
description=description,
|
||||
description=args.get("description"),
|
||||
)
|
||||
|
||||
if shared_with_users:
|
||||
|
|
|
|||
|
|
@ -504,6 +504,13 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di
|
|||
# dynamic link_context
|
||||
if doctype_fieldname := link_context.get("doctype_fieldname"):
|
||||
filters.append([linked_doctype, doctype_fieldname, "=", doctype])
|
||||
# check for child table that no one links to
|
||||
if linked_doctype_meta.istable:
|
||||
if not (
|
||||
frappe.db.exists("DocField", {"options": linked_doctype})
|
||||
or frappe.db.exists(linked_doctype, {"parenttype": doctype, "parent": name})
|
||||
):
|
||||
continue
|
||||
ret = frappe.get_list(
|
||||
doctype=linked_doctype, fields=fields, filters=filters, or_filters=or_filters, order_by=None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,14 +36,17 @@ ASSET_KEYS = (
|
|||
def get_meta(doctype, cached=True) -> "FormMeta":
|
||||
# don't cache for developer mode as js files, templates may be edited
|
||||
cached = cached and not frappe.conf.developer_mode
|
||||
key = f"doctype_form_meta::{doctype}"
|
||||
if cached:
|
||||
meta = frappe.cache.hget("doctype_form_meta", doctype)
|
||||
meta = frappe.client_cache.get_value(key)
|
||||
if not meta:
|
||||
# Cache miss - explicitly get meta from DB to avoid
|
||||
# Cache miss - explicitly get meta from DB to avoid mismatches
|
||||
meta = FormMeta(doctype, cached=False)
|
||||
frappe.cache.hset("doctype_form_meta", doctype, meta)
|
||||
frappe.client_cache.set_value(key, meta)
|
||||
else:
|
||||
meta = FormMeta(doctype)
|
||||
# NOTE: In developer mode use cached `Meta` for better DX
|
||||
# In prod don't use cached meta when explicitly requesting from DB.
|
||||
meta = FormMeta(doctype, cached=frappe.conf.developer_mode)
|
||||
|
||||
return meta
|
||||
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ def savedocs(doc, action):
|
|||
|
||||
# action
|
||||
doc.docstatus = {
|
||||
"Save": DocStatus.draft(),
|
||||
"Submit": DocStatus.submitted(),
|
||||
"Update": DocStatus.submitted(),
|
||||
"Cancel": DocStatus.cancelled(),
|
||||
"Save": DocStatus.DRAFT,
|
||||
"Submit": DocStatus.SUMBITTED,
|
||||
"Update": DocStatus.SUMBITTED,
|
||||
"Cancel": DocStatus.CANCELLED,
|
||||
}[action]
|
||||
|
||||
if doc.docstatus.is_submitted():
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
"smtp_port",
|
||||
"column_break_38",
|
||||
"no_smtp_authentication",
|
||||
"always_bcc",
|
||||
"signature_section",
|
||||
"add_signature",
|
||||
"signature",
|
||||
|
|
@ -699,12 +700,19 @@
|
|||
"fieldname": "sent_folder_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Sent Folder Name"
|
||||
},
|
||||
{
|
||||
"description": "Use this, for example, if all sent emails should also be send to an archive.",
|
||||
"fieldname": "always_bcc",
|
||||
"fieldtype": "Data",
|
||||
"label": "Always BCC Address",
|
||||
"options": "Email"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-inbox",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-04 23:30:37.622353",
|
||||
"modified": "2024-12-30 11:25:58.427173",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Email Account",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class EmailAccount(Document):
|
|||
from frappe.types import DF
|
||||
|
||||
add_signature: DF.Check
|
||||
always_bcc: DF.Data | None
|
||||
always_use_account_email_id_as_sender: DF.Check
|
||||
always_use_account_name_as_sender_name: DF.Check
|
||||
api_key: DF.Data | None
|
||||
|
|
@ -887,6 +888,7 @@ def pull(now=False):
|
|||
.select(
|
||||
doctype.name,
|
||||
doctype.auth_method,
|
||||
doctype.backend_app_flow,
|
||||
doctype.connected_app,
|
||||
doctype.connected_user,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@
|
|||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:03:24.379339",
|
||||
"modified": "2025-02-20 19:21:09.652451",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Email Queue",
|
||||
|
|
@ -170,6 +170,7 @@
|
|||
"role": "System Manager"
|
||||
}
|
||||
],
|
||||
"row_format": "Compressed",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -690,6 +690,9 @@ class QueueBuilder:
|
|||
return attachments
|
||||
|
||||
def prepare_email_content(self):
|
||||
email_account = self.get_outgoing_email_account()
|
||||
if isinstance(self._bcc, list) and email_account.always_bcc:
|
||||
self._bcc.append(email_account.always_bcc)
|
||||
mail = get_email(
|
||||
recipients=self.final_recipients(),
|
||||
sender=self.sender,
|
||||
|
|
@ -700,7 +703,7 @@ class QueueBuilder:
|
|||
reply_to=self.reply_to,
|
||||
cc=self.final_cc(),
|
||||
bcc=self.bcc,
|
||||
email_account=self.get_outgoing_email_account(),
|
||||
email_account=email_account,
|
||||
expose_recipients=self.expose_recipients,
|
||||
inline_images=self.inline_images,
|
||||
header=self.header,
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ class ConnectedApp(Document):
|
|||
|
||||
return token_cache
|
||||
|
||||
def get_backend_app_token(self):
|
||||
def get_backend_app_token(self, include_client_id=None):
|
||||
"""Get an Access Token for the Cloud-Registered Service Principal"""
|
||||
# There is no User assigned to the app, so we give it an empty string,
|
||||
# otherwise it will assign the logged in user.
|
||||
|
|
@ -163,7 +163,11 @@ class ConnectedApp(Document):
|
|||
client = BackendApplicationClient(client_id=self.client_id, scope=self.get_scopes())
|
||||
oauth_session = OAuth2Session(client=client)
|
||||
|
||||
token = oauth_session.fetch_token(self.token_uri, client_secret=self.get_password("client_secret"))
|
||||
token = oauth_session.fetch_token(
|
||||
self.token_uri,
|
||||
client_secret=self.get_password("client_secret"),
|
||||
include_client_id=include_client_id,
|
||||
)
|
||||
|
||||
token_cache.update_data(token)
|
||||
token_cache.save(ignore_permissions=True)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"fields": [
|
||||
{
|
||||
"fieldname": "url",
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Text",
|
||||
"label": "URL",
|
||||
"read_only": 1
|
||||
},
|
||||
|
|
@ -74,7 +74,8 @@
|
|||
"fieldname": "webhook",
|
||||
"fieldtype": "Link",
|
||||
"label": "Webhook",
|
||||
"options": "Webhook"
|
||||
"options": "Webhook",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
|
|
@ -88,7 +89,7 @@
|
|||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-14 05:52:41.583612",
|
||||
"modified": "2025-02-25 15:16:24.028963",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Integrations",
|
||||
"name": "Webhook Request Log",
|
||||
|
|
@ -108,6 +109,7 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Compressed",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class WebhookRequestLog(Document):
|
|||
reference_doctype: DF.Data | None
|
||||
reference_document: DF.Data | None
|
||||
response: DF.Code | None
|
||||
url: DF.Data | None
|
||||
url: DF.Text | None
|
||||
user: DF.Link | None
|
||||
webhook: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
4088
frappe/locale/bs.po
4088
frappe/locale/bs.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -209,6 +209,7 @@ class SiteMigration:
|
|||
connection_id = frappe.db.sql("select connection_id()")[0][0]
|
||||
for process in processes:
|
||||
sleeping = process.get("Command") == "Sleep"
|
||||
user = str(process.get("User")).lower()
|
||||
sleeping_since = cint(process.get("Time")) or 0
|
||||
pid = process.get("Id")
|
||||
|
||||
|
|
@ -218,6 +219,7 @@ class SiteMigration:
|
|||
and process.db == frappe.conf.db_name
|
||||
and sleeping
|
||||
and sleeping_since > idle_limit
|
||||
and user != "system user"
|
||||
):
|
||||
try:
|
||||
frappe.db.sql(f"kill {pid}")
|
||||
|
|
|
|||
|
|
@ -53,8 +53,21 @@ DOCTYPE_TABLE_FIELDS = [
|
|||
]
|
||||
|
||||
TABLE_DOCTYPES_FOR_DOCTYPE = {df["fieldname"]: df["options"] for df in DOCTYPE_TABLE_FIELDS}
|
||||
|
||||
# child tables cannot have child tables
|
||||
TABLE_DOCTYPES_FOR_DOCTYPE_TABLES = {}
|
||||
|
||||
DOCTYPES_FOR_DOCTYPE = {"DocType", *TABLE_DOCTYPES_FOR_DOCTYPE.values()}
|
||||
|
||||
UNPICKLABLE_KEYS = (
|
||||
"meta",
|
||||
"permitted_fieldnames",
|
||||
"_parent_doc",
|
||||
"_weakref",
|
||||
"_table_fieldnames",
|
||||
"_valid_columns",
|
||||
)
|
||||
|
||||
|
||||
def get_controller(doctype):
|
||||
"""Return the locally cached **class** object of the given DocType.
|
||||
|
|
@ -64,9 +77,6 @@ def get_controller(doctype):
|
|||
:param doctype: DocType name as string.
|
||||
"""
|
||||
|
||||
if frappe.local.dev_server or frappe.flags.in_migrate:
|
||||
return import_controller(doctype)
|
||||
|
||||
site_controllers = frappe.controllers.setdefault(frappe.local.site, {})
|
||||
if doctype not in site_controllers:
|
||||
site_controllers[doctype] = import_controller(doctype)
|
||||
|
|
@ -80,7 +90,7 @@ def import_controller(doctype):
|
|||
|
||||
module_name = "Core"
|
||||
if doctype not in DOCTYPES_FOR_DOCTYPE:
|
||||
doctype_info = frappe.db.get_value("DocType", doctype, fieldname="*")
|
||||
doctype_info = frappe.db.get_value("DocType", doctype, ("module", "custom", "is_tree"), as_dict=True)
|
||||
if doctype_info:
|
||||
if doctype_info.custom:
|
||||
return NestedSet if doctype_info.is_tree else Document
|
||||
|
|
@ -125,7 +135,8 @@ class BaseDocument:
|
|||
"doctype",
|
||||
"meta",
|
||||
"flags",
|
||||
"parent_doc",
|
||||
"_weakref",
|
||||
"_parent_doc",
|
||||
"_table_fields",
|
||||
"_valid_columns",
|
||||
"_doc_before_save",
|
||||
|
|
@ -140,7 +151,6 @@ class BaseDocument:
|
|||
if d.get("doctype"):
|
||||
self.doctype = d["doctype"]
|
||||
|
||||
self._table_fieldnames = {df.fieldname for df in self._get_table_fields()}
|
||||
self.update(d)
|
||||
self.dont_update_if_missing = []
|
||||
|
||||
|
|
@ -158,6 +168,10 @@ class BaseDocument:
|
|||
def permitted_fieldnames(self):
|
||||
return get_permitted_fields(doctype=self.doctype, parenttype=getattr(self, "parenttype", None))
|
||||
|
||||
@cached_property
|
||||
def _weakref(self):
|
||||
return weakref.ref(self)
|
||||
|
||||
def __getstate__(self):
|
||||
"""Return a copy of `__dict__` excluding unpicklable values like `meta`.
|
||||
|
||||
|
|
@ -174,9 +188,9 @@ class BaseDocument:
|
|||
def remove_unpicklable_values(self, state):
|
||||
"""Remove unpicklable values before pickling"""
|
||||
|
||||
state.pop("meta", None)
|
||||
state.pop("permitted_fieldnames", None)
|
||||
state.pop("_parent_doc", None)
|
||||
for key in UNPICKLABLE_KEYS:
|
||||
if key in state:
|
||||
del state[key]
|
||||
|
||||
def update(self, d):
|
||||
"""Update multiple fields of a doctype using a dictionary of key-value pairs.
|
||||
|
|
@ -192,9 +206,9 @@ class BaseDocument:
|
|||
if "name" in d:
|
||||
self.name = d["name"]
|
||||
|
||||
ignore_children = hasattr(self, "flags") and self.flags.ignore_children
|
||||
as_value = not self._table_fieldnames or self.flags.get("ignore_children", False)
|
||||
for key, value in d.items():
|
||||
self.set(key, value, as_value=ignore_children)
|
||||
self.set(key, value, as_value=as_value)
|
||||
|
||||
return self
|
||||
|
||||
|
|
@ -276,16 +290,19 @@ class BaseDocument:
|
|||
|
||||
if position == -1:
|
||||
table.append(d)
|
||||
|
||||
if not getattr(d, "idx", False):
|
||||
d.idx = len(table)
|
||||
else:
|
||||
# insert at specific position
|
||||
table.insert(position, d)
|
||||
|
||||
# re number idx
|
||||
for i, _d in enumerate(table):
|
||||
_d.idx = i + 1
|
||||
for i, _d in enumerate(table, 1):
|
||||
_d.idx = i
|
||||
|
||||
# reference parent document but with weak reference, parent_doc will be deleted if self is garbage collected.
|
||||
d.parent_doc = weakref.ref(self)
|
||||
d._parent_doc = self._weakref
|
||||
|
||||
return d
|
||||
|
||||
|
|
@ -293,10 +310,10 @@ class BaseDocument:
|
|||
def parent_doc(self):
|
||||
parent_doc_ref = getattr(self, "_parent_doc", None)
|
||||
|
||||
if isinstance(parent_doc_ref, BaseDocument):
|
||||
return parent_doc_ref
|
||||
elif isinstance(parent_doc_ref, weakref.ReferenceType):
|
||||
if isinstance(parent_doc_ref, weakref.ReferenceType):
|
||||
return parent_doc_ref()
|
||||
elif isinstance(parent_doc_ref, BaseDocument):
|
||||
return parent_doc_ref
|
||||
|
||||
@parent_doc.setter
|
||||
def parent_doc(self, value):
|
||||
|
|
@ -333,25 +350,30 @@ class BaseDocument:
|
|||
value["doctype"] = doctype
|
||||
value = get_controller(doctype)(value)
|
||||
|
||||
value.parent = self.name
|
||||
value.parenttype = self.doctype
|
||||
value.parentfield = key
|
||||
__dict = value.__dict__
|
||||
__dict["parent"] = self.name
|
||||
__dict["parenttype"] = self.doctype
|
||||
__dict["parentfield"] = key
|
||||
|
||||
if value.docstatus is None:
|
||||
value.docstatus = DocStatus.draft()
|
||||
if __dict.get("docstatus") is None:
|
||||
__dict["docstatus"] = DocStatus.DRAFT
|
||||
|
||||
if not getattr(value, "idx", None):
|
||||
if table := getattr(self, key, None):
|
||||
value.idx = len(table) + 1
|
||||
else:
|
||||
value.idx = 1
|
||||
|
||||
if not getattr(value, "name", None):
|
||||
value.__dict__["__islocal"] = 1
|
||||
value.__dict__["__temporary_name"] = frappe.generate_hash(length=10)
|
||||
if not __dict.get("name"):
|
||||
__dict["__islocal"] = 1
|
||||
__dict["__temporary_name"] = frappe.generate_hash(length=10)
|
||||
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def _table_fieldnames(self) -> dict:
|
||||
if self.doctype == "DocType":
|
||||
return TABLE_DOCTYPES_FOR_DOCTYPE
|
||||
|
||||
if self.doctype in DOCTYPES_FOR_DOCTYPE:
|
||||
return TABLE_DOCTYPES_FOR_DOCTYPE_TABLES
|
||||
|
||||
return self.meta._table_doctypes
|
||||
|
||||
def _get_table_fields(self):
|
||||
"""
|
||||
To get table fields during Document init
|
||||
|
|
@ -449,27 +471,36 @@ class BaseDocument:
|
|||
without worrying about whether or not they have values
|
||||
"""
|
||||
|
||||
if not self._table_fieldnames:
|
||||
return
|
||||
|
||||
__dict = self.__dict__
|
||||
|
||||
for fieldname in self._table_fieldnames:
|
||||
if self.__dict__.get(fieldname) is None:
|
||||
self.__dict__[fieldname] = []
|
||||
if __dict.get(fieldname) is None:
|
||||
__dict[fieldname] = []
|
||||
|
||||
def init_valid_columns(self):
|
||||
for key in default_fields:
|
||||
if key not in self.__dict__:
|
||||
self.__dict__[key] = None
|
||||
__dict = self.__dict__
|
||||
|
||||
if self.__dict__[key] is None:
|
||||
if key == "docstatus":
|
||||
self.docstatus = DocStatus.draft()
|
||||
elif key == "idx":
|
||||
self.__dict__[key] = 0
|
||||
if __dict.get("docstatus") is None:
|
||||
__dict["docstatus"] = DocStatus.DRAFT
|
||||
|
||||
for key in self.get_valid_columns():
|
||||
if key not in self.__dict__:
|
||||
self.__dict__[key] = None
|
||||
if __dict.get("idx") is None:
|
||||
__dict["idx"] = 0
|
||||
|
||||
for key in self._valid_columns:
|
||||
if key not in __dict:
|
||||
__dict[key] = None
|
||||
|
||||
def get_valid_columns(self) -> list[str]:
|
||||
if self.doctype not in frappe.local.valid_columns:
|
||||
return self._valid_columns
|
||||
|
||||
@cached_property
|
||||
def _valid_columns(self) -> list[str]:
|
||||
valid_columns_cache = frappe.local.valid_columns
|
||||
|
||||
if self.doctype not in valid_columns_cache:
|
||||
if self.doctype in DOCTYPES_FOR_DOCTYPE:
|
||||
from frappe.model.meta import get_table_columns
|
||||
|
||||
|
|
@ -477,20 +508,29 @@ class BaseDocument:
|
|||
else:
|
||||
valid = self.meta.get_valid_columns()
|
||||
|
||||
frappe.local.valid_columns[self.doctype] = valid
|
||||
valid_columns_cache[self.doctype] = valid
|
||||
|
||||
return frappe.local.valid_columns[self.doctype]
|
||||
return valid_columns_cache[self.doctype]
|
||||
|
||||
def is_new(self) -> bool:
|
||||
return self.get("__islocal")
|
||||
|
||||
@property
|
||||
def docstatus(self):
|
||||
return DocStatus(cint(self.get("docstatus")))
|
||||
def docstatus(self) -> DocStatus:
|
||||
value = self.__dict__.get("docstatus")
|
||||
|
||||
if not isinstance(value, DocStatus):
|
||||
value = DocStatus(value or 0)
|
||||
self.__dict__["docstatus"] = value
|
||||
|
||||
return value
|
||||
|
||||
@docstatus.setter
|
||||
def docstatus(self, value):
|
||||
self.__dict__["docstatus"] = DocStatus(cint(value))
|
||||
def docstatus(self, value) -> None:
|
||||
if not isinstance(value, DocStatus):
|
||||
value = DocStatus(value or 0)
|
||||
|
||||
self.__dict__["docstatus"] = value
|
||||
|
||||
def as_dict(
|
||||
self,
|
||||
|
|
@ -544,17 +584,17 @@ class BaseDocument:
|
|||
return frappe.as_json(self.as_dict())
|
||||
|
||||
def get_table_field_doctype(self, fieldname):
|
||||
try:
|
||||
return self.meta.get_field(fieldname).options
|
||||
except AttributeError:
|
||||
if self.doctype == "DocType" and (table_doctype := TABLE_DOCTYPES_FOR_DOCTYPE.get(fieldname)):
|
||||
return table_doctype
|
||||
|
||||
raise
|
||||
return self._table_fieldnames.get(fieldname)
|
||||
|
||||
def get_parentfield_of_doctype(self, doctype):
|
||||
fieldname = [df.fieldname for df in self.meta.get_table_fields() if df.options == doctype]
|
||||
return fieldname[0] if fieldname else None
|
||||
return next(
|
||||
(
|
||||
fieldname
|
||||
for fieldname, child_doctype in self._table_fieldnames.items()
|
||||
if child_doctype == doctype
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def db_insert(self, ignore_if_duplicate=False):
|
||||
"""INSERT the document (with valid columns) in the database.
|
||||
|
|
@ -743,8 +783,8 @@ class BaseDocument:
|
|||
elif df.fieldtype in ("Float", "Currency", "Percent"):
|
||||
self.set(df.fieldname, flt(self.get(df.fieldname)))
|
||||
|
||||
if self.docstatus is not None:
|
||||
self.docstatus = DocStatus(cint(self.docstatus))
|
||||
# calling the docstatus property does the job
|
||||
self.docstatus
|
||||
|
||||
def _get_missing_mandatory_fields(self):
|
||||
"""Get mandatory fields that do not have any values"""
|
||||
|
|
@ -872,7 +912,7 @@ class BaseDocument:
|
|||
df.fieldname != "amended_from"
|
||||
and (is_submittable or self.meta.is_submittable)
|
||||
and frappe.get_meta(doctype).is_submittable
|
||||
and cint(frappe.db.get_value(doctype, docname, "docstatus")) == DocStatus.cancelled()
|
||||
and DocStatus(frappe.db.get_value(doctype, docname, "docstatus") or 0).is_cancelled()
|
||||
):
|
||||
cancelled_links.append((df.fieldname, docname, get_msg(df, docname)))
|
||||
|
||||
|
|
|
|||
|
|
@ -871,7 +871,7 @@ class DatabaseQuery:
|
|||
value = value.replace("\\", "\\\\").replace("%", "%%")
|
||||
|
||||
elif f.operator == "=" and df and df.fieldtype in ["Link", "Data"]: # TODO: Refactor if possible
|
||||
value = f.value or "''"
|
||||
value = cstr(f.value) or "''"
|
||||
fallback = "''"
|
||||
|
||||
elif f.fieldname == "name":
|
||||
|
|
|
|||
|
|
@ -4,22 +4,29 @@
|
|||
|
||||
class DocStatus(int):
|
||||
def is_draft(self):
|
||||
return self == self.draft()
|
||||
return self == DocStatus.DRAFT
|
||||
|
||||
def is_submitted(self):
|
||||
return self == self.submitted()
|
||||
return self == DocStatus.SUMBITTED
|
||||
|
||||
def is_cancelled(self):
|
||||
return self == self.cancelled()
|
||||
return self == DocStatus.CANCELLED
|
||||
|
||||
@classmethod
|
||||
def draft(cls):
|
||||
return cls(0)
|
||||
# following methods have been kept for backwards compatibility
|
||||
|
||||
@classmethod
|
||||
def submitted(cls):
|
||||
return cls(1)
|
||||
@staticmethod
|
||||
def draft():
|
||||
return DocStatus.DRAFT
|
||||
|
||||
@classmethod
|
||||
def cancelled(cls):
|
||||
return cls(2)
|
||||
@staticmethod
|
||||
def submitted():
|
||||
return DocStatus.SUMBITTED
|
||||
|
||||
@staticmethod
|
||||
def cancelled():
|
||||
return DocStatus.CANCELLED
|
||||
|
||||
|
||||
DocStatus.DRAFT = DocStatus(0)
|
||||
DocStatus.SUMBITTED = DocStatus(1)
|
||||
DocStatus.CANCELLED = DocStatus(2)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import json
|
|||
import time
|
||||
from collections.abc import Generator, Iterable
|
||||
from contextlib import contextmanager
|
||||
from functools import singledispatchmethod, wraps
|
||||
from functools import wraps
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union, overload
|
||||
|
||||
|
|
@ -34,8 +34,8 @@ if TYPE_CHECKING:
|
|||
from frappe.core.doctype.docfield.docfield import DocField
|
||||
|
||||
|
||||
DOCUMENT_LOCK_EXPIRTY = 12 * 60 * 60 # All locks expire in 12 hours automatically
|
||||
DOCUMENT_LOCK_SOFT_EXPIRY = 60 * 60 # Let users force-unlock after 60 minutes
|
||||
DOCUMENT_LOCK_EXPIRTY = 3 * 60 * 60 # All locks expire in 3 hours automatically
|
||||
DOCUMENT_LOCK_SOFT_EXPIRY = 30 * 60 # Let users force-unlock after 30 minutes
|
||||
|
||||
|
||||
@simple_singledispatch
|
||||
|
|
@ -174,7 +174,6 @@ class Document(BaseDocument, DocRef):
|
|||
self._init_dispatch(args[0], *args[1:], **kwargs)
|
||||
elif kwargs:
|
||||
self._init_from_kwargs(kwargs)
|
||||
|
||||
else:
|
||||
raise ValueError("Illegal arguments")
|
||||
|
||||
|
|
@ -193,28 +192,29 @@ class Document(BaseDocument, DocRef):
|
|||
if kwargs: # ad-hoc overrides
|
||||
self._init_from_kwargs(kwargs)
|
||||
|
||||
@singledispatchmethod
|
||||
def _init_dispatch(self, arg, *args, **kwargs):
|
||||
if isinstance(arg, str):
|
||||
name = args[0] if args else arg
|
||||
return self._init_known_doc(arg, name, **kwargs)
|
||||
|
||||
if isinstance(arg, dict):
|
||||
return self._init_from_kwargs(arg)
|
||||
|
||||
if isinstance(arg, DocRef):
|
||||
return self._init_known_doc(arg.doctype, arg.name, **kwargs)
|
||||
|
||||
raise ValueError(f"Unsupported argument type: {type(arg)}")
|
||||
|
||||
@_init_dispatch.register(str)
|
||||
def _init_str(self, doctype, *args, **kwargs):
|
||||
# use doctype as name for single
|
||||
name = doctype if not args else args[0]
|
||||
self._init_known_doc(doctype, name, **kwargs)
|
||||
|
||||
@_init_dispatch.register(DocRef)
|
||||
def _init_docref(self, doc_ref, **kwargs):
|
||||
self._init_known_doc(doc_ref.doctype, doc_ref.name, **kwargs)
|
||||
|
||||
@_init_dispatch.register(dict)
|
||||
def _init_dict(self, arg_dict, **kwargs):
|
||||
# discard any further keyword args
|
||||
self._init_from_kwargs(arg_dict)
|
||||
|
||||
@property
|
||||
def is_locked(self):
|
||||
return file_lock.lock_exists(self.get_signature())
|
||||
signature = self.get_signature()
|
||||
if not file_lock.lock_exists(signature):
|
||||
return False
|
||||
|
||||
if file_lock.lock_age(signature) > DOCUMENT_LOCK_EXPIRTY:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def load_from_db(self) -> "Self":
|
||||
"""Load document and children from database and create properties
|
||||
|
|
@ -270,20 +270,20 @@ class Document(BaseDocument, DocRef):
|
|||
return self
|
||||
|
||||
def load_children_from_db(self):
|
||||
for df in self._get_table_fields():
|
||||
for fieldname, child_doctype in self._table_fieldnames.items():
|
||||
# Make sure not to query the DB for a child table, if it is a virtual one.
|
||||
# During frappe is installed, the property "is_virtual" is not available in tabDocType, so
|
||||
# we need to filter those cases for the access to frappe.db.get_value() as it would crash otherwise.
|
||||
if hasattr(self, "doctype") and not hasattr(self, "module") and is_virtual_doctype(df.options):
|
||||
self.set(df.fieldname, [])
|
||||
if hasattr(self, "doctype") and not hasattr(self, "module") and is_virtual_doctype(child_doctype):
|
||||
self.set(fieldname, [])
|
||||
continue
|
||||
|
||||
if self.doctype == "DocType":
|
||||
# This special handling is required because of bootstrapping code that doesn't
|
||||
# handle failures correctly.
|
||||
children = frappe.db.get_values(
|
||||
df.options,
|
||||
{"parent": self.name, "parenttype": self.doctype, "parentfield": df.fieldname},
|
||||
child_doctype,
|
||||
{"parent": self.name, "parenttype": self.doctype, "parentfield": fieldname},
|
||||
"*",
|
||||
as_dict=True,
|
||||
order_by="idx asc",
|
||||
|
|
@ -297,14 +297,14 @@ class Document(BaseDocument, DocRef):
|
|||
AND `parenttype`= %(parenttype)s
|
||||
AND `parentfield`= %(parentfield)s
|
||||
ORDER BY `idx` ASC {for_update}""".format(
|
||||
table_name=get_table_name(df.options, wrap_in_backticks=True),
|
||||
table_name=get_table_name(child_doctype, wrap_in_backticks=True),
|
||||
for_update="FOR UPDATE" if self.flags.for_update else "",
|
||||
),
|
||||
{"parent": self.name, "parenttype": self.doctype, "parentfield": df.fieldname},
|
||||
{"parent": self.name, "parenttype": self.doctype, "parentfield": fieldname},
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
self.set(df.fieldname, children or [])
|
||||
self.set(fieldname, children or [])
|
||||
|
||||
return self
|
||||
|
||||
|
|
@ -446,8 +446,30 @@ class Document(BaseDocument, DocRef):
|
|||
return self
|
||||
|
||||
def check_if_locked(self):
|
||||
if self.creation and self.is_locked:
|
||||
raise frappe.DocumentLockedError
|
||||
if not self.creation or not self.is_locked:
|
||||
return
|
||||
|
||||
# Allow unlocking if created more than 60 minutes ago
|
||||
primary_action = None
|
||||
if file_lock.lock_age(self.get_signature()) > DOCUMENT_LOCK_SOFT_EXPIRY:
|
||||
primary_action = {
|
||||
"label": "Force Unlock",
|
||||
"server_action": "frappe.model.document.unlock_document",
|
||||
"hide_on_success": True,
|
||||
"args": {
|
||||
"doctype": self.doctype,
|
||||
"name": self.name,
|
||||
},
|
||||
}
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"This document is currently locked and queued for execution. Please try again after some time."
|
||||
),
|
||||
title=_("Document Queued"),
|
||||
primary_action=primary_action,
|
||||
exc=frappe.DocumentLockedError,
|
||||
)
|
||||
|
||||
@read_only_guard
|
||||
def save(self, *args, **kwargs) -> "Self":
|
||||
|
|
@ -546,6 +568,7 @@ class Document(BaseDocument, DocRef):
|
|||
if getattr(self.meta, "is_virtual", False):
|
||||
# Virtual doctypes manage their own children
|
||||
return
|
||||
|
||||
for df in self.meta.get_table_fields():
|
||||
self.update_child_table(df.fieldname, df)
|
||||
|
||||
|
|
@ -703,11 +726,11 @@ class Document(BaseDocument, DocRef):
|
|||
frappe.flags.currently_saving.append((self.doctype, self.name))
|
||||
|
||||
def set_docstatus(self):
|
||||
if self.docstatus is None:
|
||||
self.docstatus = DocStatus.draft()
|
||||
# docstatus property automatically sets a docstatus if not set
|
||||
docstatus = self.docstatus
|
||||
|
||||
for d in self.get_all_children():
|
||||
d.docstatus = self.docstatus
|
||||
d.set("docstatus", docstatus)
|
||||
|
||||
def _validate(self):
|
||||
self._validate_mandatory()
|
||||
|
|
@ -972,10 +995,7 @@ class Document(BaseDocument, DocRef):
|
|||
- Submit (1) > Cancel (2)
|
||||
|
||||
"""
|
||||
if not self.docstatus:
|
||||
self.docstatus = DocStatus.draft()
|
||||
|
||||
if to_docstatus == DocStatus.draft():
|
||||
if to_docstatus == DocStatus.DRAFT:
|
||||
if self.docstatus.is_draft():
|
||||
self._action = "save"
|
||||
elif self.docstatus.is_submitted():
|
||||
|
|
@ -988,7 +1008,7 @@ class Document(BaseDocument, DocRef):
|
|||
else:
|
||||
raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus)
|
||||
|
||||
elif to_docstatus == DocStatus.submitted():
|
||||
elif to_docstatus == DocStatus.SUMBITTED:
|
||||
if self.docstatus.is_submitted():
|
||||
self._action = "update_after_submit"
|
||||
self.check_permission("submit")
|
||||
|
|
@ -1002,7 +1022,7 @@ class Document(BaseDocument, DocRef):
|
|||
else:
|
||||
raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus)
|
||||
|
||||
elif to_docstatus == DocStatus.cancelled():
|
||||
elif to_docstatus == DocStatus.CANCELLED:
|
||||
raise frappe.ValidationError(_("Cannot edit cancelled document"))
|
||||
|
||||
def set_parent_in_children(self):
|
||||
|
|
@ -1078,11 +1098,11 @@ class Document(BaseDocument, DocRef):
|
|||
|
||||
children = []
|
||||
|
||||
for df in self.meta.get_table_fields():
|
||||
if parenttype and df.options != parenttype:
|
||||
for fieldname, child_doctype in self._table_fieldnames.items():
|
||||
if parenttype and child_doctype != parenttype:
|
||||
continue
|
||||
|
||||
if value := self.get(df.fieldname):
|
||||
if value := self.get(fieldname):
|
||||
children.extend(value)
|
||||
|
||||
return children
|
||||
|
|
@ -1168,12 +1188,12 @@ class Document(BaseDocument, DocRef):
|
|||
|
||||
def _submit(self):
|
||||
"""Submit the document. Sets `docstatus` = 1, then saves."""
|
||||
self.docstatus = DocStatus.submitted()
|
||||
self.docstatus = DocStatus.SUMBITTED
|
||||
return self.save()
|
||||
|
||||
def _cancel(self):
|
||||
"""Cancel the document. Sets `docstatus` = 2, then saves."""
|
||||
self.docstatus = DocStatus.cancelled()
|
||||
self.docstatus = DocStatus.CANCELLED
|
||||
return self.save()
|
||||
|
||||
def _rename(self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True):
|
||||
|
|
@ -1204,13 +1224,13 @@ class Document(BaseDocument, DocRef):
|
|||
self.set_user_and_timestamp()
|
||||
self.check_if_latest()
|
||||
|
||||
if not self.docstatus == DocStatus.draft():
|
||||
if not self.docstatus.is_draft():
|
||||
raise frappe.ValidationError(_("Only draft documents can be discarded"), self.docstatus)
|
||||
|
||||
self.check_permission("write")
|
||||
|
||||
self.run_method("before_discard")
|
||||
self.db_set("docstatus", DocStatus.cancelled())
|
||||
self.db_set("docstatus", DocStatus.CANCELLED)
|
||||
delattr(self, "_action")
|
||||
self.run_method("on_discard")
|
||||
|
||||
|
|
@ -1314,8 +1334,6 @@ class Document(BaseDocument, DocRef):
|
|||
|
||||
def clear_cache(self):
|
||||
frappe.clear_document_cache(self.doctype, self.name)
|
||||
frappe.db.after_commit.add(lambda: frappe.clear_document_cache(self.doctype, self.name))
|
||||
frappe.db.after_rollback.add(lambda: frappe.clear_document_cache(self.doctype, self.name))
|
||||
|
||||
def reset_seen(self):
|
||||
"""Clear _seen property and set current user as seen"""
|
||||
|
|
@ -1697,29 +1715,8 @@ class Document(BaseDocument, DocRef):
|
|||
if hasattr(self, f"_{action}"):
|
||||
action = f"_{action}"
|
||||
|
||||
try:
|
||||
self.lock()
|
||||
except frappe.DocumentLockedError:
|
||||
# Allow unlocking if created more than 60 minutes ago
|
||||
primary_action = None
|
||||
if file_lock.lock_age(self.get_signature()) > DOCUMENT_LOCK_SOFT_EXPIRY:
|
||||
primary_action = {
|
||||
"label": "Force Unlock",
|
||||
"server_action": "frappe.model.document.unlock_document",
|
||||
"hide_on_success": True,
|
||||
"args": {
|
||||
"doctype": self.doctype,
|
||||
"name": self.name,
|
||||
},
|
||||
}
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"This document is currently locked and queued for execution. Please try again after some time."
|
||||
),
|
||||
title=_("Document Queued"),
|
||||
primary_action=primary_action,
|
||||
)
|
||||
self.check_if_locked()
|
||||
self.lock()
|
||||
|
||||
enqueue_after_commit = kwargs.pop("enqueue_after_commit", None)
|
||||
if enqueue_after_commit is None:
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ def get_meta(doctype: str | dict | DocRef, cached=True) -> "_Meta":
|
|||
def clear_meta_cache(doctype: str = "*"):
|
||||
key = f"doctype_meta::{doctype}"
|
||||
if doctype == "*":
|
||||
frappe.cache.delete_keys(key)
|
||||
frappe.client_cache.delete_keys(key)
|
||||
else:
|
||||
frappe.client_cache.delete_value(key)
|
||||
|
||||
|
|
@ -273,10 +273,10 @@ class Meta(Document):
|
|||
return fields
|
||||
|
||||
def get_valid_columns(self) -> list[str]:
|
||||
return self._valid_columns
|
||||
return self._valid_columns_
|
||||
|
||||
@cached_property
|
||||
def _valid_columns(self):
|
||||
def _valid_columns_(self):
|
||||
table_exists = frappe.db.table_exists(self.name)
|
||||
if self.name in self.special_doctypes and table_exists:
|
||||
valid_columns = get_table_columns(self.name)
|
||||
|
|
@ -307,9 +307,6 @@ class Meta(Document):
|
|||
|
||||
return valid_fields
|
||||
|
||||
def get_table_field_doctype(self, fieldname):
|
||||
return TABLE_DOCTYPES_FOR_DOCTYPE.get(fieldname)
|
||||
|
||||
def get_field(self, fieldname):
|
||||
"""Return docfield from meta."""
|
||||
|
||||
|
|
@ -535,6 +532,9 @@ class Meta(Document):
|
|||
else:
|
||||
self._table_fields = self.get("fields", {"fieldtype": ["in", table_fields]})
|
||||
|
||||
# table fieldname: doctype map
|
||||
self._table_doctypes = {field.fieldname: field.options for field in self._table_fields}
|
||||
|
||||
def sort_fields(self):
|
||||
"""
|
||||
Sort fields on the basis of following rules (priority descending):
|
||||
|
|
|
|||
|
|
@ -126,10 +126,10 @@ def apply_workflow(doc, action):
|
|||
if next_state.update_field:
|
||||
doc.set(next_state.update_field, next_state.update_value)
|
||||
|
||||
new_docstatus = cint(next_state.doc_status)
|
||||
if doc.docstatus.is_draft() and new_docstatus == DocStatus.draft():
|
||||
new_docstatus = DocStatus(next_state.doc_status or 0)
|
||||
if doc.docstatus.is_draft() and new_docstatus.is_draft():
|
||||
doc.save()
|
||||
elif doc.docstatus.is_draft() and new_docstatus == DocStatus.submitted():
|
||||
elif doc.docstatus.is_draft() and new_docstatus.is_submitted():
|
||||
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
|
||||
|
|
@ -138,9 +138,9 @@ def apply_workflow(doc, action):
|
|||
return
|
||||
|
||||
doc.submit()
|
||||
elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.submitted():
|
||||
elif doc.docstatus.is_submitted() and new_docstatus.is_submitted():
|
||||
doc.save()
|
||||
elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.cancelled():
|
||||
elif doc.docstatus.is_submitted() and new_docstatus.is_cancelled():
|
||||
doc.cancel()
|
||||
else:
|
||||
frappe.throw(_("Illegal Document Status for {0}").format(next_state.state))
|
||||
|
|
|
|||
|
|
@ -75,10 +75,9 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
|
|||
new_password: value || "",
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
let score = r.message.score;
|
||||
var indicators = ["red", "red", "orange", "blue", "green"];
|
||||
me.set_strength_indicator(indicators[score]);
|
||||
if (r.message.score !== undefined && r.message.score !== null) {
|
||||
const indicators = ["red", "red", "orange", "blue", "green"];
|
||||
me.set_strength_indicator(indicators[r.message.score]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export default class GridPagination {
|
|||
}
|
||||
|
||||
setup_pagination() {
|
||||
this.page_length = 50;
|
||||
this.page_length = this.grid.meta?.grid_page_length || 50;
|
||||
this.page_index = 1;
|
||||
this.total_pages = Math.ceil(this.grid.data.length / this.page_length);
|
||||
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ frappe.ui.form.AssignToDialog = class AssignToDialog {
|
|||
},
|
||||
{
|
||||
label: __("Comment"),
|
||||
fieldtype: "Small Text",
|
||||
fieldtype: "Text Editor",
|
||||
fieldname: "description",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -420,7 +420,8 @@ frappe.views.Calendar = class Calendar {
|
|||
prepare_colors(d) {
|
||||
let color, color_name;
|
||||
if (this.get_css_class) {
|
||||
color_name = this.color_map[this.get_css_class(d)] || "blue";
|
||||
color_name = this.get_css_class(d);
|
||||
color_name = this.color_map[color_name] || color_name || "blue";
|
||||
|
||||
if (color_name.startsWith("#")) {
|
||||
color_name = frappe.ui.color.validate_hex(color_name) ? color_name : "blue";
|
||||
|
|
|
|||
|
|
@ -115,8 +115,7 @@ def emit_via_redis(event, message, room):
|
|||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def has_permission(doctype: str, name: str) -> bool:
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.check_permission("read")
|
||||
frappe.has_permission(doctype, doc=name, throw=True)
|
||||
return True
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{%- if item.icon -%}
|
||||
<img src="{{ item.icon }}" alt="{{ item.label }}">
|
||||
{%- else -%}
|
||||
{{ item.label }}
|
||||
{{ _(item.label) }}
|
||||
{%- endif -%}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,22 @@ from frappe.tests import IntegrationTestCase
|
|||
|
||||
class TestDocStatus(IntegrationTestCase):
|
||||
def test_draft(self):
|
||||
self.assertEqual(DocStatus(0), DocStatus.draft())
|
||||
self.assertEqual(DocStatus(0), DocStatus.DRAFT)
|
||||
|
||||
self.assertTrue(DocStatus.draft().is_draft())
|
||||
self.assertFalse(DocStatus.draft().is_cancelled())
|
||||
self.assertFalse(DocStatus.draft().is_submitted())
|
||||
self.assertTrue(DocStatus.DRAFT.is_draft())
|
||||
self.assertFalse(DocStatus.DRAFT.is_cancelled())
|
||||
self.assertFalse(DocStatus.DRAFT.is_submitted())
|
||||
|
||||
def test_submitted(self):
|
||||
self.assertEqual(DocStatus(1), DocStatus.submitted())
|
||||
self.assertEqual(DocStatus(1), DocStatus.SUMBITTED)
|
||||
|
||||
self.assertFalse(DocStatus.submitted().is_draft())
|
||||
self.assertTrue(DocStatus.submitted().is_submitted())
|
||||
self.assertFalse(DocStatus.submitted().is_cancelled())
|
||||
self.assertFalse(DocStatus.SUMBITTED.is_draft())
|
||||
self.assertTrue(DocStatus.SUMBITTED.is_submitted())
|
||||
self.assertFalse(DocStatus.SUMBITTED.is_cancelled())
|
||||
|
||||
def test_cancelled(self):
|
||||
self.assertEqual(DocStatus(2), DocStatus.cancelled())
|
||||
self.assertEqual(DocStatus(2), DocStatus.CANCELLED)
|
||||
|
||||
self.assertFalse(DocStatus.cancelled().is_draft())
|
||||
self.assertFalse(DocStatus.cancelled().is_submitted())
|
||||
self.assertTrue(DocStatus.cancelled().is_cancelled())
|
||||
self.assertFalse(DocStatus.CANCELLED.is_draft())
|
||||
self.assertFalse(DocStatus.CANCELLED.is_submitted())
|
||||
self.assertTrue(DocStatus.CANCELLED.is_cancelled())
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import ScheduledJobType, sync_jobs
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import add_days, get_datetime
|
||||
from frappe.utils.data import now_datetime
|
||||
from frappe.utils.doctor import purge_pending_jobs
|
||||
from frappe.utils.scheduler import (
|
||||
DEFAULT_SCHEDULER_TICK,
|
||||
_get_last_creation_timestamp,
|
||||
enqueue_events,
|
||||
is_dormant,
|
||||
schedule_jobs_based_on_activity,
|
||||
|
|
@ -61,42 +58,39 @@ class TestScheduler(IntegrationTestCase):
|
|||
# 1st job is in the queue (or running), don't enqueue it again
|
||||
self.assertFalse(job.enqueue())
|
||||
|
||||
def test_is_dormant(self):
|
||||
@patch.object(frappe.utils.frappecloud, "on_frappecloud", return_value=True)
|
||||
@patch.dict(frappe.conf, {"developer_mode": 0})
|
||||
def test_is_dormant(self, _mock):
|
||||
last_activity = frappe.db.get_value(
|
||||
"User", filters={}, fieldname="last_active", order_by="last_active desc"
|
||||
)
|
||||
self.assertTrue(is_dormant(check_time=get_datetime("2100-01-01 00:00:00")))
|
||||
self.assertTrue(is_dormant(check_time=add_days(frappe.db.get_last_created("Activity Log"), 5)))
|
||||
self.assertFalse(is_dormant(check_time=frappe.db.get_last_created("Activity Log")))
|
||||
self.assertTrue(is_dormant(check_time=add_days(last_activity, 5)))
|
||||
self.assertFalse(is_dormant(check_time=last_activity))
|
||||
|
||||
def test_once_a_day_for_dormant(self):
|
||||
@patch.object(frappe.utils.frappecloud, "on_frappecloud", return_value=True)
|
||||
@patch.dict(frappe.conf, {"developer_mode": 0})
|
||||
def test_once_a_day_for_dormant(self, _mocks):
|
||||
last_activity = frappe.db.get_value(
|
||||
"User", filters={}, fieldname="last_active", order_by="last_active desc"
|
||||
)
|
||||
frappe.db.truncate("Scheduled Job Log")
|
||||
self.assertTrue(schedule_jobs_based_on_activity(check_time=get_datetime("2100-01-01 00:00:00")))
|
||||
self.assertTrue(
|
||||
schedule_jobs_based_on_activity(
|
||||
check_time=add_days(frappe.db.get_last_created("Activity Log"), 5)
|
||||
)
|
||||
)
|
||||
self.assertTrue(schedule_jobs_based_on_activity(check_time=add_days(last_activity, 5)))
|
||||
|
||||
# create a fake job executed 5 days from now
|
||||
job = get_test_job(method="frappe.tests.test_scheduler.test_method", frequency="Daily")
|
||||
job.execute()
|
||||
job_log = frappe.get_doc("Scheduled Job Log", dict(scheduled_job_type=job.name))
|
||||
job_log.db_set(
|
||||
"creation", add_days(_get_last_creation_timestamp("Activity Log"), 5), update_modified=False
|
||||
)
|
||||
job_log.db_set("creation", add_days(last_activity, 5), update_modified=False)
|
||||
schedule_jobs_based_on_activity.clear_cache()
|
||||
is_dormant.clear_cache()
|
||||
|
||||
# inactive site with recent job, don't run
|
||||
self.assertFalse(
|
||||
schedule_jobs_based_on_activity(
|
||||
check_time=add_days(_get_last_creation_timestamp("Activity Log"), 5)
|
||||
)
|
||||
)
|
||||
self.assertFalse(schedule_jobs_based_on_activity(check_time=add_days(last_activity, 5)))
|
||||
|
||||
# one more day has passed
|
||||
self.assertTrue(
|
||||
schedule_jobs_based_on_activity(
|
||||
check_time=add_days(_get_last_creation_timestamp("Activity Log"), 6)
|
||||
)
|
||||
)
|
||||
self.assertTrue(schedule_jobs_based_on_activity(check_time=add_days(last_activity, 6)))
|
||||
|
||||
def test_real_time_alignment(self):
|
||||
test_cases = {
|
||||
|
|
|
|||
|
|
@ -685,7 +685,6 @@ class TestDateUtils(IntegrationTestCase):
|
|||
def test_pretty_date(self):
|
||||
from frappe import _
|
||||
|
||||
# differnt cases
|
||||
now = get_datetime()
|
||||
|
||||
test_cases = {
|
||||
|
|
@ -707,6 +706,8 @@ class TestDateUtils(IntegrationTestCase):
|
|||
for dt, exp_message in test_cases.items():
|
||||
self.assertEqual(pretty_date(dt), exp_message)
|
||||
|
||||
self.assertEqual(pretty_date(add_to_date(now, days=-5), mini=True), "5d")
|
||||
|
||||
def test_date_from_timegrain(self):
|
||||
start_date = getdate("2021-01-01")
|
||||
|
||||
|
|
|
|||
|
|
@ -522,14 +522,6 @@ app_license = "{app_license}"
|
|||
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
||||
# }}
|
||||
|
||||
# DocType Class
|
||||
# ---------------
|
||||
# Override standard doctype classes
|
||||
|
||||
# override_doctype_class = {{
|
||||
# "ToDo": "custom_app.overrides.CustomToDo"
|
||||
# }}
|
||||
|
||||
# Document Events
|
||||
# ---------------
|
||||
# Hook on document methods and events
|
||||
|
|
|
|||
|
|
@ -1692,7 +1692,7 @@ def escape_html(text: str) -> str:
|
|||
return "".join(html_escape_table.get(c, c) for c in text)
|
||||
|
||||
|
||||
def pretty_date(iso_datetime: datetime.datetime | str) -> str:
|
||||
def pretty_date(iso_datetime: datetime.datetime | str, mini=False) -> str:
|
||||
"""Return a localized string representation of the delta to the current system time.
|
||||
|
||||
For example, "1 hour ago", "2 days ago", "in 5 seconds", etc.
|
||||
|
|
@ -1706,7 +1706,12 @@ def pretty_date(iso_datetime: datetime.datetime | str) -> str:
|
|||
iso_datetime = get_datetime(iso_datetime)
|
||||
now_dt = now_datetime()
|
||||
locale = frappe.local.lang.replace("-", "_") if frappe.local.lang else None
|
||||
return format_timedelta(iso_datetime - now_dt, add_direction=True, locale=locale)
|
||||
return format_timedelta(
|
||||
iso_datetime - now_dt,
|
||||
add_direction=not mini,
|
||||
locale=locale,
|
||||
format="long" if not mini else "narrow",
|
||||
)
|
||||
|
||||
|
||||
def comma_or(some_list: list | tuple, add_quotes=True) -> str:
|
||||
|
|
|
|||
|
|
@ -80,9 +80,15 @@ def get_pending_jobs(site=None):
|
|||
def any_job_pending(site: str) -> bool:
|
||||
for queue in get_queue_list():
|
||||
q = get_queue(queue)
|
||||
# pending jobs
|
||||
for job_id in q.get_job_ids():
|
||||
if job_id.startswith(site):
|
||||
return True
|
||||
|
||||
# already running jobs
|
||||
for job_id in q.started_job_registry.get_job_ids():
|
||||
if job_id.startswith(site):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import frappe
|
||||
|
||||
FRAPPE_CLOUD_DOMAINS = ("frappe.cloud", "erpnext.com", "frappehr.com")
|
||||
FRAPPE_CLOUD_DOMAINS = ("frappe.cloud", "erpnext.com", "frappehr.com", "frappe.dev")
|
||||
|
||||
|
||||
def on_frappecloud() -> bool:
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ def get_children_data(doctype, meta):
|
|||
child_records = frappe.get_all(
|
||||
child.options,
|
||||
fields=child_fieldnames,
|
||||
filters={"docstatus": ["!=", 1], "parenttype": doctype},
|
||||
filters={"docstatus": ["!=", 2], "parenttype": doctype},
|
||||
)
|
||||
|
||||
for record in child_records:
|
||||
|
|
|
|||
|
|
@ -219,15 +219,23 @@ def schedule_jobs_based_on_activity(check_time=None):
|
|||
return True
|
||||
|
||||
|
||||
@redis_cache(ttl=60 * 60)
|
||||
def is_dormant(check_time=None):
|
||||
# Assume never dormant if developer_mode is enabled
|
||||
if frappe.conf.developer_mode:
|
||||
from frappe.utils.frappecloud import on_frappecloud
|
||||
|
||||
if frappe.conf.developer_mode or not on_frappecloud():
|
||||
return False
|
||||
last_activity_log_timestamp = _get_last_creation_timestamp("Activity Log")
|
||||
since = (frappe.get_system_settings("dormant_days") or 4) * 86400
|
||||
if not last_activity_log_timestamp:
|
||||
threshold = cint(frappe.get_system_settings("dormant_days")) * 86400
|
||||
if not threshold:
|
||||
return False
|
||||
|
||||
last_activity = frappe.db.get_value(
|
||||
"User", filters={}, fieldname="last_active", order_by="last_active desc"
|
||||
)
|
||||
|
||||
if not last_activity:
|
||||
return True
|
||||
if ((check_time or now_datetime()) - last_activity_log_timestamp).total_seconds() >= since:
|
||||
if ((check_time or now_datetime()) - last_activity).total_seconds() >= threshold:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -626,7 +626,7 @@ def delete(web_form_name: str, docname: str | int):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_multiple(web_form_name: str, docnames: list[str | int]):
|
||||
def delete_multiple(web_form_name: str, docnames):
|
||||
web_form = frappe.get_doc("Web Form", web_form_name)
|
||||
|
||||
docnames = json.loads(docnames)
|
||||
|
|
@ -635,6 +635,8 @@ def delete_multiple(web_form_name: str, docnames: list[str | int]):
|
|||
restricted_docnames = []
|
||||
|
||||
for docname in docnames:
|
||||
assert isinstance(docname, str | int)
|
||||
|
||||
owner = frappe.db.get_value(web_form.doc_type, docname, "owner")
|
||||
if frappe.session.user == owner and web_form.allow_delete:
|
||||
allowed_docnames.append(docname)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
"fieldname": "path",
|
||||
"fieldtype": "Data",
|
||||
"label": "Path",
|
||||
"set_only_once": 1,
|
||||
"search_index": 1
|
||||
"search_index": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "referrer",
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
],
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:04:02.743377",
|
||||
"modified": "2025-02-20 19:20:47.267461",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Website",
|
||||
"name": "Web Page View",
|
||||
|
|
@ -115,6 +115,7 @@
|
|||
],
|
||||
"quick_entry": 1,
|
||||
"read_only": 1,
|
||||
"row_format": "Compressed",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -476,6 +476,9 @@ def get_email_template_from_workflow(doc):
|
|||
|
||||
if not template_name:
|
||||
return
|
||||
|
||||
if isinstance(doc, Document):
|
||||
doc = doc.as_dict()
|
||||
return get_email_template(template_name, doc)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -440,21 +440,23 @@ def get_print_format(doctype: str, print_format: "PrintFormat") -> str:
|
|||
|
||||
# server, find template
|
||||
module = print_format.module or frappe.db.get_value("DocType", doctype, "module")
|
||||
path = os.path.join(
|
||||
get_module_path(module, "Print Format", print_format.name),
|
||||
frappe.scrub(print_format.name) + ".html",
|
||||
)
|
||||
|
||||
if os.path.exists(path):
|
||||
with open(path) as pffile:
|
||||
return pffile.read()
|
||||
else:
|
||||
is_custom_module = frappe.get_cached_value("Module Def", module, "custom")
|
||||
if is_custom_module:
|
||||
if print_format.raw_printing:
|
||||
return print_format.raw_commands
|
||||
if print_format.html:
|
||||
return print_format.html
|
||||
|
||||
frappe.throw(_("No template found at path: {0}").format(path), frappe.TemplateNotFoundError)
|
||||
path = os.path.join(
|
||||
get_module_path(module, "Print Format", print_format.name),
|
||||
frappe.scrub(print_format.name) + ".html",
|
||||
)
|
||||
if os.path.exists(path):
|
||||
with open(path) as pffile:
|
||||
return pffile.read()
|
||||
|
||||
frappe.throw(_("No template found at path: {0}").format(path), frappe.TemplateNotFoundError)
|
||||
|
||||
|
||||
def make_layout(doc: "Document", meta: "Meta", format_data=None) -> list:
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ dependencies = [
|
|||
"openpyxl~=3.1.2",
|
||||
"passlib~=1.7.4",
|
||||
"pdfkit~=1.0.0",
|
||||
"phonenumbers==8.13.13",
|
||||
"phonenumbers==8.13.55",
|
||||
"premailer~=3.10.0",
|
||||
"psutil~=5.9.5",
|
||||
"psycopg2-binary~=2.9.1",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue