Merge branch 'develop' into add_pdf_backend_hook

This commit is contained in:
Maharshi Patel 2025-02-28 10:15:32 +05:30
commit c8b40b1805
78 changed files with 5842 additions and 5647 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [],

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -234,7 +234,7 @@ frappe.ui.form.AssignToDialog = class AssignToDialog {
},
{
label: __("Comment"),
fieldtype: "Small Text",
fieldtype: "Text Editor",
fieldname: "description",
},
];

View file

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

View file

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

View file

@ -3,7 +3,7 @@
{%- if item.icon -%}
<img src="{{ item.icon }}" alt="{{ item.label }}">
{%- else -%}
{{ item.label }}
{{ _(item.label) }}
{%- endif -%}
</a>
{% endmacro %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [],

View file

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

View file

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

View file

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