Przeglądaj źródła

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

chore: release v14
version-14
Ankush Menat 2 lat temu
committed by GitHub
rodzic
commit
89e248e0a6
Nie znaleziono w bazie danych klucza dla tego podpisu ID klucza GPG: 4AEE18F83AFDEB23
44 zmienionych plików z 990 dodań i 154 usunięć
  1. +4
    -1
      .github/workflows/ui-tests.yml
  2. +1
    -0
      cypress.config.js
  3. +5
    -5
      cypress/integration/control_link.js
  4. +6
    -3
      cypress/integration/control_markdown_editor.js
  5. +6
    -9
      cypress/integration/dashboard_links.js
  6. +10
    -6
      cypress/integration/file_uploader.js
  7. +2
    -2
      cypress/integration/login.js
  8. +6
    -9
      cypress/integration/sidebar.js
  9. +3
    -1
      cypress/integration/table_multiselect.js
  10. +2
    -2
      cypress/integration/timeline.js
  11. +11
    -5
      cypress/integration/web_form.js
  12. +1
    -2
      cypress/support/commands.js
  13. +33
    -23
      frappe/app.py
  14. +0
    -3
      frappe/commands/utils.py
  15. +3
    -0
      frappe/core/doctype/doctype/doctype.py
  16. +2
    -0
      frappe/core/doctype/document_naming_settings/document_naming_settings.py
  17. +0
    -0
      frappe/core/doctype/rq_job/__init__.py
  18. +30
    -0
      frappe/core/doctype/rq_job/rq_job.js
  19. +162
    -0
      frappe/core/doctype/rq_job/rq_job.json
  20. +186
    -0
      frappe/core/doctype/rq_job/rq_job.py
  21. +32
    -0
      frappe/core/doctype/rq_job/rq_job_list.js
  22. +88
    -0
      frappe/core/doctype/rq_job/test_rq_job.py
  23. +0
    -0
      frappe/core/doctype/rq_worker/__init__.py
  24. +9
    -0
      frappe/core/doctype/rq_worker/rq_worker.js
  25. +138
    -0
      frappe/core/doctype/rq_worker/rq_worker.json
  26. +67
    -0
      frappe/core/doctype/rq_worker/rq_worker.py
  27. +17
    -0
      frappe/core/doctype/rq_worker/test_rq_worker.py
  28. +13
    -2
      frappe/database/database.py
  29. +13
    -17
      frappe/desk/doctype/dashboard_chart/dashboard_chart.js
  30. +22
    -0
      frappe/desk/doctype/dashboard_chart/dashboard_chart.py
  31. +13
    -17
      frappe/desk/doctype/number_card/number_card.js
  32. +2
    -0
      frappe/desk/form/load.py
  33. +4
    -1
      frappe/model/db_query.py
  34. +0
    -11
      frappe/public/js/frappe/desk.js
  35. +10
    -14
      frappe/public/js/frappe/form/controls/link.js
  36. +4
    -9
      frappe/public/js/frappe/model/sync.js
  37. +2
    -2
      frappe/public/scss/common/grid.scss
  38. +7
    -2
      frappe/tests/test_cors.py
  39. +4
    -0
      frappe/tests/test_db_query.py
  40. +35
    -0
      frappe/tests/ui_test_helpers.py
  41. +14
    -1
      frappe/utils/background_jobs.py
  42. +3
    -0
      frappe/utils/response.py
  43. +7
    -0
      frappe/utils/scheduler.py
  44. +13
    -7
      frappe/workflow/doctype/workflow/test_workflow.py

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

@@ -137,7 +137,10 @@ jobs:

- name: Site Setup
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard
run: |
cd ~/frappe-bench/
bench --site test_site execute frappe.utils.install.complete_setup_wizard
bench --site test_site execute frappe.tests.ui_test_helpers.create_test_user

- name: UI Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}


+ 1
- 0
cypress.config.js Wyświetl plik

@@ -3,6 +3,7 @@ const { defineConfig } = require("cypress");
module.exports = defineConfig({
projectId: "92odwv",
adminPassword: "admin",
testUser: "frappe@example.com",
defaultCommandTimeout: 20000,
pageLoadTimeout: 15000,
video: true,


+ 5
- 5
cypress/integration/control_link.js Wyświetl plik

@@ -177,14 +177,14 @@ context("Control Link", () => {
cy.intercept("POST", "/api/method/frappe.client.validate_link").as("validate_link");

cy.get(".frappe-control[data-fieldname=assigned_by] input").focus().as("input");
cy.get("@input").type("Administrator", { delay: 100 }).blur();
cy.get("@input").type(cy.config("testUser"), { delay: 100 }).blur();
cy.wait("@validate_link");
cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
"contain",
"Administrator"
"Frappe"
);

cy.window().its("cur_frm.doc.assigned_by").should("eq", "Administrator");
cy.window().its("cur_frm.doc.assigned_by").should("eq", cy.config("testUser"));

// invalid input
cy.get("@input").clear().type("invalid input", { delay: 100 }).blur();
@@ -198,10 +198,10 @@ context("Control Link", () => {
// set valid value again
cy.get("@input").clear().focus();
cy.wait("@search_link");
cy.get("@input").type("Administrator", { delay: 100 }).blur();
cy.get("@input").type(cy.config("testUser"), { delay: 100 }).blur();
cy.wait("@validate_link");

cy.window().its("cur_frm.doc.assigned_by").should("eq", "Administrator");
cy.window().its("cur_frm.doc.assigned_by").should("eq", cy.config("testUser"));

// clear input
cy.get("@input").clear().blur();


+ 6
- 3
cypress/integration/control_markdown_editor.js Wyświetl plik

@@ -7,9 +7,12 @@ context("Control Markdown Editor", () => {
it("should allow inserting images by drag and drop", () => {
cy.visit("/app/web-page/new");
cy.fill_field("content_type", "Markdown", "Select");
cy.get_field("main_section_md", "Markdown Editor").attachFile("sample_image.jpg", {
subjectType: "drag-n-drop",
});
cy.get_field("main_section_md", "Markdown Editor").selectFile(
"cypress/fixtures/sample_image.jpg",
{
action: "drag-drop",
}
);
cy.click_modal_primary_button("Upload");
cy.get_field("main_section_md", "Markdown Editor").should(
"contain",


+ 6
- 9
cypress/integration/dashboard_links.js Wyświetl plik

@@ -8,7 +8,7 @@ const child_table_doctype_name = child_table_doctype.name;
context("Dashboard links", () => {
before(() => {
cy.visit("/login");
cy.login();
cy.login("Administrator");
cy.insert_doc("DocType", child_table_doctype, true);
cy.insert_doc("DocType", child_table_doctype_1, true);
cy.insert_doc("DocType", doctype_with_child_table, true);
@@ -27,8 +27,7 @@ context("Dashboard links", () => {
cy.visit("/app/contact");
cy.clear_filters();

cy.visit("/app/user");
cy.get(".list-row-col > .level-item > .ellipsis").eq(0).click({ force: true });
cy.visit(`/app/user/${cy.config("testUser")}`);

//To check if initially the dashboard contains only the "Contact" link and there is no counter
cy.get('[data-doctype="Contact"]').should("contain", "Contact");
@@ -40,11 +39,10 @@ context("Dashboard links", () => {
cy.findByRole("button", { name: "Add Contact" }).click();
cy.get('[data-doctype="Contact"][data-fieldname="first_name"]').type("Admin");
cy.findByRole("button", { name: "Save" }).click();
cy.visit("/app/user");
cy.get(".list-row-col > .level-item > .ellipsis").eq(0).click({ force: true });
cy.visit(`/app/user/${cy.config("testUser")}`);

//To check if the counter for contact doc is "1" after adding the contact
cy.get('[data-doctype="Contact"] > .count').should("contain", "1");
//To check if the counter for contact doc is "2" after adding additional contact
cy.get('[data-doctype="Contact"] > .count').should("contain", "2");
cy.get('[data-doctype="Contact"]').contains("Contact").click();

//Deleting the newly created contact
@@ -62,8 +60,7 @@ context("Dashboard links", () => {
});

it("Report link in dashboard", () => {
cy.visit("/app/user");
cy.visit("/app/user/Administrator");
cy.visit(`/app/user/${cy.config("testUser")}`);
cy.get('[data-doctype="Contact"]').should("contain", "Contact");
cy.findByText("Connections");
cy.window()


+ 10
- 6
cypress/integration/file_uploader.js Wyświetl plik

@@ -21,9 +21,11 @@ context("FileUploader", () => {
it("should accept dropped files", () => {
open_upload_dialog();

cy.get_open_dialog().find(".file-upload-area").attachFile("example.json", {
subjectType: "drag-n-drop",
});
cy.get_open_dialog()
.find(".file-upload-area")
.selectFile("cypress/fixtures/example.json", {
action: "drag-drop",
});

cy.get_open_dialog().find(".file-name").should("contain", "example.json");
cy.intercept("POST", "/api/method/upload_file").as("upload_file");
@@ -64,9 +66,11 @@ context("FileUploader", () => {
it("should allow cropping and optimization for valid images", () => {
open_upload_dialog();

cy.get_open_dialog().find(".file-upload-area").attachFile("sample_image.jpg", {
subjectType: "drag-n-drop",
});
cy.get_open_dialog()
.find(".file-upload-area")
.selectFile("cypress/fixtures/sample_image.jpg", {
action: "drag-drop",
});

cy.get_open_dialog().findAllByText("sample_image.jpg").should("exist");
cy.get_open_dialog().find(".btn-crop").first().click();


+ 2
- 2
cypress/integration/login.js Wyświetl plik

@@ -32,7 +32,7 @@ context("Login", () => {

it("logs in using correct credentials", () => {
cy.get("#login_email").type("Administrator");
cy.get("#login_password").type(Cypress.config("adminPassword"));
cy.get("#login_password").type(Cypress.env("adminPassword"));

cy.findByRole("button", { name: "Login" }).click();
cy.location("pathname").should("eq", "/app");
@@ -56,7 +56,7 @@ context("Login", () => {
);

cy.get("#login_email").type("Administrator");
cy.get("#login_password").type(Cypress.config("adminPassword"));
cy.get("#login_password").type(Cypress.env("adminPassword"));

cy.findByRole("button", { name: "Login" }).click();



+ 6
- 9
cypress/integration/sidebar.js Wyświetl plik

@@ -4,9 +4,11 @@ const verify_attachment_visibility = (document, is_private) => {
const assertion = is_private ? "be.checked" : "not.be.checked";
cy.findByRole("button", { name: "Attach File" }).click();

cy.get_open_dialog().find(".file-upload-area").attachFile("sample_image.jpg", {
subjectType: "drag-n-drop",
});
cy.get_open_dialog()
.find(".file-upload-area")
.selectFile("cypress/fixtures/sample_image.jpg", {
action: "drag-drop",
});

cy.get_open_dialog().findByRole("checkbox", { name: "Private" }).should(assertion);
};
@@ -36,11 +38,6 @@ context("Sidebar", () => {
//To check if no filter is available in "Assigned To" dropdown
cy.get(".empty-state").should("contain", "No filters found");

cy.click_sidebar_button("Created By");

//To check if "Created By" dropdown contains filter
cy.get(".group-by-item > .dropdown-item").should("contain", "Me");

//Assigning a doctype to a user
cy.visit("/app/doctype/ToDo");
cy.get(".form-assignments > .flex > .text-muted").click();
@@ -70,7 +67,7 @@ context("Sidebar", () => {
cy.get(".condition").should("have.value", "like");
cy.get(".filter-field > .form-group > .input-with-feedback").should(
"have.value",
"%Administrator%"
`%${cy.config("testUser")}%`
);
cy.click_filter_button();



+ 3
- 1
cypress/integration/table_multiselect.js Wyświetl plik

@@ -13,7 +13,9 @@ context("Table MultiSelect", () => {
cy.fill_field("assign_condition", 'status=="Open"', "Code");
cy.get('input[data-fieldname="users"]').focus().as("input");
cy.get('input[data-fieldname="users"] + ul').should("be.visible");
cy.get("@input").type("test{enter}", { delay: 100 });
cy.get("@input").type("test@erpnext", { delay: 100 });
cy.wait(500);
cy.get("@input").type("{enter}");
cy.get(
'.frappe-control[data-fieldname="users"] .form-control .tb-selected-value .btn-link-to-form'
).as("selected-value");


+ 2
- 2
cypress/integration/timeline.js Wyświetl plik

@@ -72,14 +72,14 @@ context("Timeline", () => {
cy.click_listview_row_item(0);

//To check if the submission of the documemt is visible in the timeline content
cy.get(".timeline-content").should("contain", "Administrator submitted this document");
cy.get(".timeline-content").should("contain", "Frappe submitted this document");
cy.get('[id="page-Custom Submittable DocType"] .page-actions')
.findByRole("button", { name: "Cancel" })
.click();
cy.get_open_dialog().findByRole("button", { name: "Yes" }).click();

//To check if the cancellation of the documemt is visible in the timeline content
cy.get(".timeline-content").should("contain", "Administrator cancelled this document");
cy.get(".timeline-content").should("contain", "Frappe cancelled this document");

//Deleting the document
cy.visit("/app/custom-submittable-doctype");


+ 11
- 5
cypress/integration/web_form.js Wyświetl plik

@@ -1,6 +1,13 @@
context("Web Form", () => {
before(() => {
cy.login();
cy.login("Administrator");
cy.visit("/app/");
return cy
.window()
.its("frappe")
.then((frappe) => {
return frappe.xcall("frappe.tests.ui_test_helpers.clear_notes");
});
});

it("Create Web Form", () => {
@@ -42,7 +49,7 @@ context("Web Form", () => {
});

it("Login Required", () => {
cy.login();
cy.login("Administrator");
cy.visit("/app/web-form/note");

cy.findByRole("tab", { name: "Settings" }).click();
@@ -51,7 +58,6 @@ context("Web Form", () => {
cy.save();

cy.visit("/note");
cy.url().should("include", "/note/Note%201");

cy.call("logout");

@@ -62,7 +68,7 @@ context("Web Form", () => {
});

it("Show List", () => {
cy.login();
cy.login("Administrator");
cy.visit("/app/web-form/note");

cy.findByRole("tab", { name: "Settings" }).click();
@@ -156,7 +162,7 @@ context("Web Form", () => {
});

it("Read Only", () => {
cy.login();
cy.login("Administrator");
cy.visit("/note");
cy.url().should("include", "/note/list");



+ 1
- 2
cypress/support/commands.js Wyświetl plik

@@ -1,4 +1,3 @@
import "cypress-file-upload";
import "@testing-library/cypress/add-commands";
import "@4tw/cypress-drag-drop";
import "cypress-real-events/support";
@@ -30,7 +29,7 @@ import "cypress-real-events/support";

Cypress.Commands.add("login", (email, password) => {
if (!email) {
email = "Administrator";
email = Cypress.config("testUser") || "Administrator";
}
if (!password) {
password = Cypress.env("adminPassword");


+ 33
- 23
frappe/app.py Wyświetl plik

@@ -83,9 +83,6 @@ def application(request: Request):
except HTTPException as e:
return e

except frappe.SessionStopped as e:
response = frappe.utils.response.handle_session_stopped()

except Exception as e:
response = handle_exception(e)

@@ -160,35 +157,45 @@ def process_response(response):
response.headers.extend(frappe.local.rate_limiter.headers())

# CORS headers
if hasattr(frappe.local, "conf") and frappe.conf.allow_cors:
if hasattr(frappe.local, "conf"):
set_cors_headers(response)


def set_cors_headers(response):
origin = frappe.request.headers.get("Origin")
allow_cors = frappe.conf.allow_cors
if not (origin and allow_cors):
if not (
(allowed_origins := frappe.conf.allow_cors)
and (request := frappe.local.request)
and (origin := request.headers.get("Origin"))
):
return

if allow_cors != "*":
if not isinstance(allow_cors, list):
allow_cors = [allow_cors]
if allowed_origins != "*":
if not isinstance(allowed_origins, list):
allowed_origins = [allowed_origins]

if origin not in allow_cors:
if origin not in allowed_origins:
return

response.headers.extend(
{
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": (
"Authorization,DNT,X-Mx-ReqToken,"
"Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,"
"Cache-Control,Content-Type"
),
}
)
cors_headers = {
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Origin": origin,
"Vary": "Origin",
}

# only required for preflight requests
if request.method == "OPTIONS":
cors_headers["Access-Control-Allow-Methods"] = request.headers.get(
"Access-Control-Request-Method"
)

if allowed_headers := request.headers.get("Access-Control-Request-Headers"):
cors_headers["Access-Control-Allow-Headers"] = allowed_headers

# allow browsers to cache preflight requests for upto a day
if not frappe.conf.developer_mode:
cors_headers["Access-Control-Max-Age"] = "86400"

response.headers.extend(cors_headers)


def make_form_dict(request):
@@ -228,6 +235,9 @@ def handle_exception(e):
# if the request is ajax, send back the trace or error message
response = frappe.utils.response.report_error(http_status_code)

elif isinstance(e, frappe.SessionStopped):
response = frappe.utils.response.handle_session_stopped()

elif (
http_status_code == 500
and (frappe.db and isinstance(e, frappe.db.InternalError))


+ 0
- 3
frappe/commands/utils.py Wyświetl plik

@@ -860,7 +860,6 @@ def run_ui_tests(

node_bin = subprocess.getoutput("npm bin")
cypress_path = f"{node_bin}/cypress"
plugin_path = f"{node_bin}/../cypress-file-upload"
drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop"
real_events_plugin_path = f"{node_bin}/../cypress-real-events"
testing_library_path = f"{node_bin}/../@testing-library"
@@ -869,7 +868,6 @@ def run_ui_tests(
# check if cypress in path...if not, install it.
if not (
os.path.exists(cypress_path)
and os.path.exists(plugin_path)
and os.path.exists(drag_drop_plugin_path)
and os.path.exists(real_events_plugin_path)
and os.path.exists(testing_library_path)
@@ -880,7 +878,6 @@ def run_ui_tests(
packages = " ".join(
[
"cypress@^10",
"cypress-file-upload@^5",
"@4tw/cypress-drag-drop@^2",
"cypress-real-events",
"@testing-library/cypress@^8",


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

@@ -1198,6 +1198,9 @@ def validate_fields(meta):
frappe.throw(_("Precision should be between 1 and 6"))

def check_unique_and_text(docname, d):
if meta.is_virtual:
return

if meta.issingle:
d.unique = 0
d.search_index = 0


+ 2
- 0
frappe/core/doctype/document_naming_settings/document_naming_settings.py Wyświetl plik

@@ -113,6 +113,8 @@ class DocumentNamingSettings(Document):

option_string = "\n".join(options)

# Erase default first, it might not be in new options.
self.update_naming_series_property_setter(doctype, "default", "")
self.update_naming_series_property_setter(doctype, "options", option_string)
self.update_naming_series_property_setter(doctype, "default", default)



+ 0
- 0
frappe/core/doctype/rq_job/__init__.py Wyświetl plik


+ 30
- 0
frappe/core/doctype/rq_job/rq_job.js Wyświetl plik

@@ -0,0 +1,30 @@
// Copyright (c) 2022, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on("RQ Job", {
refresh: function (frm) {
// Nothing in this form is supposed to be editable.
frm.disable_form();
frm.dashboard.set_headline_alert(
"This is a virtual doctype and data is cleared periodically."
);

if (["started", "queued"].includes(frm.doc.status)) {
frm.add_custom_button(__("Force Stop job"), () => {
frappe.confirm(
"This will terminate the job immediately and might be dangerous, are you sure? ",
() => {
frappe
.xcall("frappe.core.doctype.rq_job.rq_job.stop_job", {
job_id: frm.doc.name,
})
.then((r) => {
frappe.show_alert("Job Stopped Succefully");
frm.reload_doc();
});
}
);
});
}
},
});

+ 162
- 0
frappe/core/doctype/rq_job/rq_job.json Wyświetl plik

@@ -0,0 +1,162 @@
{
"actions": [],
"allow_copy": 1,
"autoname": "field:job_id",
"creation": "2022-09-10 16:19:37.934903",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"job_info_section",
"job_id",
"job_name",
"queue",
"timeout",
"column_break_5",
"arguments",
"job_status_section",
"status",
"time_taken",
"column_break_11",
"started_at",
"ended_at",
"exception_section",
"exc_info"
],
"fields": [
{
"fieldname": "queue",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Queue",
"options": "default\nshort\nlong"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "queued\nstarted\nfinished\nfailed\ndeferred\nscheduled\ncanceled"
},
{
"fieldname": "job_id",
"fieldtype": "Data",
"label": "Job ID",
"unique": 1
},
{
"fieldname": "exc_info",
"fieldtype": "Code",
"label": "Exception"
},
{
"fieldname": "job_name",
"fieldtype": "Data",
"label": "Job Name"
},
{
"fieldname": "arguments",
"fieldtype": "Code",
"label": "Arguments"
},
{
"fieldname": "timeout",
"fieldtype": "Duration",
"label": "Timeout"
},
{
"fieldname": "time_taken",
"fieldtype": "Duration",
"label": "Time Taken"
},
{
"fieldname": "started_at",
"fieldtype": "Datetime",
"label": "Started At"
},
{
"fieldname": "ended_at",
"fieldtype": "Datetime",
"label": "Ended At"
},
{
"fieldname": "job_info_section",
"fieldtype": "Section Break",
"label": "Job Info"
},
{
"fieldname": "job_status_section",
"fieldtype": "Section Break",
"label": "Job Status"
},
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "exception_section",
"fieldtype": "Section Break"
}
],
"in_create": 1,
"is_virtual": 1,
"links": [],
"modified": "2022-09-11 05:27:50.878534",
"modified_by": "Administrator",
"module": "Core",
"name": "RQ Job",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1
},
{
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [
{
"color": "Yellow",
"title": "queued"
},
{
"color": "Blue",
"title": "started"
},
{
"color": "Red",
"title": "failed"
},
{
"color": "Green",
"title": "finished"
},
{
"color": "Orange",
"title": "cancelled"
}
],
"title_field": "job_name"
}

+ 186
- 0
frappe/core/doctype/rq_job/rq_job.py Wyświetl plik

@@ -0,0 +1,186 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt

import functools

from rq.command import send_stop_job_command
from rq.job import Job
from rq.queue import Queue

import frappe
from frappe.model.document import Document
from frappe.utils import (
cint,
compare,
convert_utc_to_user_timezone,
create_batch,
make_filter_dict,
)
from frappe.utils.background_jobs import get_queues, get_redis_conn

QUEUES = ["default", "long", "short"]
JOB_STATUSES = ["queued", "started", "failed", "finished", "deferred", "scheduled", "canceled"]


def check_permissions(method):
@functools.wraps(method)
def wrapper(*args, **kwargs):
frappe.only_for("System Manager")
job = args[0].job
if not for_current_site(job):
raise frappe.PermissionError

return method(*args, **kwargs)

return wrapper


class RQJob(Document):
def load_from_db(self):
job = Job.fetch(self.name, connection=get_redis_conn())
if not for_current_site(job):
raise frappe.PermissionError
super(Document, self).__init__(serialize_job(job))
self._job_obj = job

@property
def job(self):
return self._job_obj

@staticmethod
def get_list(args):

start = cint(args.get("start")) or 0
page_length = cint(args.get("page_length")) or 20

order_desc = "desc" in args.get("order_by", "")

matched_job_ids = RQJob.get_matching_job_ids(args)

jobs = []
for job_ids in create_batch(matched_job_ids, 100):
jobs.extend(
serialize_job(job)
for job in Job.fetch_many(job_ids=job_ids, connection=get_redis_conn())
if job and for_current_site(job)
)
if len(jobs) > start + page_length:
# we have fetched enough. This is inefficient but because of site filtering TINA
break

return sorted(jobs, key=lambda j: j.modified, reverse=order_desc)[start : start + page_length]

@staticmethod
def get_matching_job_ids(args):
filters = make_filter_dict(args.get("filters"))

queues = _eval_filters(filters.get("queue"), QUEUES)
statuses = _eval_filters(filters.get("status"), JOB_STATUSES)

matched_job_ids = []
for queue in get_queues():
if not queue.name.endswith(tuple(queues)):
continue
for status in statuses:
matched_job_ids.extend(fetch_job_ids(queue, status))

return matched_job_ids

@check_permissions
def delete(self):
self.job.delete()

@check_permissions
def stop_job(self):
send_stop_job_command(connection=get_redis_conn(), job_id=self.job_id)

@staticmethod
def get_count(args) -> int:
# Can not be implemented efficiently due to site filtering hence ignored.
return 0

# None of these methods apply to virtual job doctype, overriden for sanity.
@staticmethod
def get_stats(args):
return {}

def db_insert(self, *args, **kwargs):
pass

def db_update(self, *args, **kwargs):
pass


def serialize_job(job: Job) -> frappe._dict:
modified = job.last_heartbeat or job.ended_at or job.started_at or job.created_at

return frappe._dict(
name=job.id,
job_id=job.id,
queue=job.origin.rsplit(":", 1)[1],
job_name=job.kwargs.get("kwargs", {}).get("job_type") or str(job.kwargs.get("job_name")),
status=job.get_status(),
started_at=convert_utc_to_user_timezone(job.started_at) if job.started_at else "",
ended_at=convert_utc_to_user_timezone(job.ended_at) if job.ended_at else "",
time_taken=(job.ended_at - job.started_at).total_seconds() if job.ended_at else "",
exc_info=job.exc_info,
arguments=frappe.as_json(job.kwargs),
timeout=job.timeout,
creation=convert_utc_to_user_timezone(job.created_at),
modified=convert_utc_to_user_timezone(modified),
_comment_count=0,
)


def for_current_site(job: Job) -> bool:
return job.kwargs.get("site") == frappe.local.site


def _eval_filters(filter, values: list[str]) -> list[str]:
if filter:
operator, operand = filter
return [val for val in values if compare(val, operator, operand)]
return values


def fetch_job_ids(queue: Queue, status: str) -> list[str]:
registry_map = {
"queued": queue, # self
"started": queue.started_job_registry,
"finished": queue.finished_job_registry,
"failed": queue.failed_job_registry,
"deferred": queue.deferred_job_registry,
"scheduled": queue.scheduled_job_registry,
"canceled": queue.canceled_job_registry,
}

registry = registry_map.get(status)
if registry is not None:
job_ids = registry.get_job_ids()
return [j for j in job_ids if j]

return []


@frappe.whitelist()
def remove_failed_jobs():
frappe.only_for("System Manager")
for queue in get_queues():
fail_registry = queue.failed_job_registry
for job_ids in create_batch(fail_registry.get_job_ids(), 100):
for job in Job.fetch_many(job_ids=job_ids, connection=get_redis_conn()):
if job and for_current_site(job):
fail_registry.remove(job, delete_job=True)


def get_all_queued_jobs():
jobs = []
for q in get_queues():
jobs.extend(q.get_jobs())

return [job for job in jobs if for_current_site(job)]


@frappe.whitelist()
def stop_job(job_id):
frappe.get_doc("RQ Job", job_id).stop_job()

+ 32
- 0
frappe/core/doctype/rq_job/rq_job_list.js Wyświetl plik

@@ -0,0 +1,32 @@
frappe.listview_settings["RQ Job"] = {
hide_name_column: true,

onload(listview) {
if (!has_common(frappe.user_roles, ["Administrator", "System Manager"])) return;

listview.page.add_inner_button(__("Remove Failed Jobs"), () => {
frappe.confirm(__("Are you sure you want to remove all failed jobs?"), () => {
frappe.xcall("frappe.core.doctype.rq_job.rq_job.remove_failed_jobs");
});
});

if (listview.list_view_settings) {
listview.list_view_settings.disable_count = 1;
listview.list_view_settings.disable_sidebar_stats = 1;
}

frappe.xcall("frappe.utils.scheduler.get_scheduler_status").then(({ status }) => {
if (status === "active") {
listview.page.set_indicator(__("Scheduler: Active"), "green");
} else {
listview.page.set_indicator(__("Scheduler: Inactive"), "red");
}
});

setInterval(() => {
if (!listview.list_view_settings.disable_auto_refresh) {
listview.refresh();
}
}, 5000);
},
};

+ 88
- 0
frappe/core/doctype/rq_job/test_rq_job.py Wyświetl plik

@@ -0,0 +1,88 @@
# Copyright (c) 2022, Frappe Technologies and Contributors

# See license.txt

import time

from rq import exceptions as rq_exc
from rq.job import Job

import frappe
from frappe.core.doctype.rq_job.rq_job import RQJob, remove_failed_jobs, stop_job
from frappe.tests.utils import FrappeTestCase, timeout


class TestRQJob(FrappeTestCase):

BG_JOB = "frappe.core.doctype.rq_job.test_rq_job.test_func"

@timeout(seconds=20)
def check_status(self, job: Job, status, wait=True):
if wait:
while True:
if job.is_queued or job.is_started:
time.sleep(0.2)
else:
break
self.assertEqual(frappe.get_doc("RQ Job", job.id).status, status)

def test_serialization(self):

job = frappe.enqueue(method=self.BG_JOB, queue="short")

rq_job = frappe.get_doc("RQ Job", job.id)

self.assertEqual(job, rq_job.job)

self.assertDocumentEqual(
{
"name": job.id,
"queue": "short",
"job_name": self.BG_JOB,
"status": "queued",
"exc_info": None,
},
rq_job,
)
self.check_status(job, "finished")

def test_get_list_filtering(self):

# Check failed job clearning and filtering
remove_failed_jobs()
jobs = RQJob.get_list({"filters": [["RQ Job", "status", "=", "failed"]]})
self.assertEqual(jobs, [])

# Fail a job
job = frappe.enqueue(method=self.BG_JOB, queue="short", fail=True)
self.check_status(job, "failed")
jobs = RQJob.get_list({"filters": [["RQ Job", "status", "=", "failed"]]})
self.assertEqual(len(jobs), 1)
self.assertTrue(jobs[0].exc_info)

# Assert that non-failed job still exists
non_failed_jobs = RQJob.get_list({"filters": [["RQ Job", "status", "!=", "failed"]]})
self.assertGreaterEqual(len(non_failed_jobs), 1)

# Create a slow job and check if it's stuck in "Started"
job = frappe.enqueue(method=self.BG_JOB, queue="short", sleep=1000)
time.sleep(3)
self.check_status(job, "started", wait=False)
stop_job(job_id=job.id)
self.check_status(job, "stopped")

def test_delete_doc(self):
job = frappe.enqueue(method=self.BG_JOB, queue="short")
frappe.get_doc("RQ Job", job.id).delete()

with self.assertRaises(rq_exc.NoSuchJobError):
job.refresh()


def test_func(fail=False, sleep=0):
if fail:
42 / 0
if sleep:
time.sleep(sleep)

return True

+ 0
- 0
frappe/core/doctype/rq_worker/__init__.py Wyświetl plik


+ 9
- 0
frappe/core/doctype/rq_worker/rq_worker.js Wyświetl plik

@@ -0,0 +1,9 @@
// Copyright (c) 2022, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on("RQ Worker", {
refresh: function (frm) {
// Nothing in this form is supposed to be editable.
frm.disable_form();
},
});

+ 138
- 0
frappe/core/doctype/rq_worker/rq_worker.json Wyświetl plik

@@ -0,0 +1,138 @@
{
"actions": [],
"allow_copy": 1,
"creation": "2022-09-10 14:54:57.342170",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"worker_information_section",
"queue",
"queue_type",
"column_break_4",
"worker_name",
"statistics_section",
"status",
"pid",
"current_job_id",
"successful_job_count",
"failed_job_count",
"column_break_12",
"birth_date",
"last_heartbeat",
"total_working_time"
],
"fields": [
{
"fieldname": "worker_name",
"fieldtype": "Data",
"label": "Worker Name",
"unique": 1
},
{
"fieldname": "status",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Status"
},
{
"fieldname": "current_job_id",
"fieldtype": "Link",
"label": "Current Job ID",
"options": "RQ Job"
},
{
"fieldname": "pid",
"fieldtype": "Data",
"label": "PID"
},
{
"fieldname": "last_heartbeat",
"fieldtype": "Datetime",
"label": "Last Heartbeat"
},
{
"fieldname": "birth_date",
"fieldtype": "Datetime",
"label": "Start Time"
},
{
"fieldname": "successful_job_count",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Successful Job Count"
},
{
"fieldname": "failed_job_count",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Failed Job Count"
},
{
"fieldname": "total_working_time",
"fieldtype": "Duration",
"label": "Total Working Time"
},
{
"fieldname": "queue",
"fieldtype": "Data",
"label": "Queue"
},
{
"fieldname": "queue_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Queue Type",
"options": "default\nlong\nshort"
},
{
"fieldname": "worker_information_section",
"fieldtype": "Section Break",
"label": "Worker Information"
},
{
"fieldname": "statistics_section",
"fieldtype": "Section Break",
"label": "Statistics"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
}
],
"in_create": 1,
"is_virtual": 1,
"links": [],
"modified": "2022-09-11 05:02:53.981705",
"modified_by": "Administrator",
"module": "Core",
"name": "RQ Worker",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [
{
"color": "Blue",
"title": "idle"
},
{
"color": "Yellow",
"title": "busy"
}
]
}

+ 67
- 0
frappe/core/doctype/rq_worker/rq_worker.py Wyświetl plik

@@ -0,0 +1,67 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt

from rq import Worker

import frappe
from frappe.model.document import Document
from frappe.utils import cint, convert_utc_to_user_timezone
from frappe.utils.background_jobs import get_workers


class RQWorker(Document):
def load_from_db(self):

all_workers = get_workers()
worker = [w for w in all_workers if w.pid == cint(self.name)][0]
d = serialize_worker(worker)

super(Document, self).__init__(d)

@staticmethod
def get_list(args):
start = cint(args.get("start")) or 0
page_length = cint(args.get("page_length")) or 20

workers = get_workers()[start : start + page_length]
return [serialize_worker(worker) for worker in workers]

@staticmethod
def get_count(args) -> int:
return len(get_workers())

# None of these methods apply to virtual workers, overriden for sanity.
@staticmethod
def get_stats(args):
return {}

def db_insert(self, *args, **kwargs):
pass

def db_update(self, *args, **kwargs):
pass

def delete(self):
pass


def serialize_worker(worker: Worker) -> frappe._dict:
queue = ", ".join(worker.queue_names())

return frappe._dict(
name=worker.pid,
queue=queue,
queue_type=queue.rsplit(":", 1)[1],
worker_name=worker.name,
status=worker.get_state(),
pid=worker.pid,
current_job_id=worker.get_current_job_id(),
last_heartbeat=convert_utc_to_user_timezone(worker.last_heartbeat),
birth_date=convert_utc_to_user_timezone(worker.birth_date),
successful_job_count=worker.successful_job_count,
failed_job_count=worker.failed_job_count,
total_working_time=worker.total_working_time,
_comment_count=0,
modified=convert_utc_to_user_timezone(worker.last_heartbeat),
creation=convert_utc_to_user_timezone(worker.birth_date),
)

+ 17
- 0
frappe/core/doctype/rq_worker/test_rq_worker.py Wyświetl plik

@@ -0,0 +1,17 @@
# Copyright (c) 2022, Frappe Technologies and Contributors
# See license.txt

import frappe
from frappe.core.doctype.rq_worker.rq_worker import RQWorker
from frappe.tests.utils import FrappeTestCase


class TestRQWorker(FrappeTestCase):
def test_get_worker_list(self):
workers = RQWorker.get_list({})
self.assertGreaterEqual(len(workers), 1)
self.assertTrue(any(w.queue_type == "short" for w in workers))

def test_worker_serialization(self):
workers = RQWorker.get_list({})
frappe.get_doc("RQ Worker", workers[0].pid)

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

@@ -1300,12 +1300,23 @@ class Database:


def enqueue_jobs_after_commit():
from frappe.utils.background_jobs import execute_job, get_queue
from frappe.utils.background_jobs import (
RQ_JOB_FAILURE_TTL,
RQ_RESULTS_TTL,
execute_job,
get_queue,
)

if frappe.flags.enqueue_after_commit and len(frappe.flags.enqueue_after_commit) > 0:
for job in frappe.flags.enqueue_after_commit:
q = get_queue(job.get("queue"), is_async=job.get("is_async"))
q.enqueue_call(execute_job, timeout=job.get("timeout"), kwargs=job.get("queue_args"))
q.enqueue_call(
execute_job,
timeout=job.get("timeout"),
kwargs=job.get("queue_args"),
failure_ttl=RQ_JOB_FAILURE_TTL,
result_ttl=RQ_RESULTS_TTL,
)
frappe.flags.enqueue_after_commit = []




+ 13
- 17
frappe/desk/doctype/dashboard_chart/dashboard_chart.js Wyświetl plik

@@ -532,25 +532,21 @@ frappe.ui.form.on("Dashboard Chart", {
frm.set_df_property("parent_document_type", "hidden", !doc_is_table);

if (document_type && doc_is_table) {
let parent = await frappe.db.get_list("DocField", {
filters: {
fieldtype: "Table",
options: document_type,
},
fields: ["parent"],
});
let parents = await frappe.xcall(
"frappe.desk.doctype.dashboard_chart.dashboard_chart.get_parent_doctypes",
{ child_type: document_type }
);

parent &&
frm.set_query("parent_document_type", function () {
return {
filters: {
name: ["in", parent.map(({ parent }) => parent)],
},
};
});
frm.set_query("parent_document_type", function () {
return {
filters: {
name: ["in", parents],
},
};
});

if (parent.length === 1) {
frm.set_value("parent_document_type", parent[0].parent);
if (parents.length === 1) {
frm.set_value("parent_document_type", parents[0]);
}
}
},


+ 22
- 0
frappe/desk/doctype/dashboard_chart/dashboard_chart.py Wyświetl plik

@@ -392,3 +392,25 @@ class DashboardChart(Document):
json.loads(self.custom_options)
except ValueError as error:
frappe.throw(_("Invalid json added in the custom options: {0}").format(error))


@frappe.whitelist()
def get_parent_doctypes(child_type: str) -> list[str]:
"""Get all parent doctypes that have the child doctype."""
assert isinstance(child_type, str)

standard = frappe.get_all(
"DocField",
fields="parent",
filters={"fieldtype": "Table", "options": child_type},
pluck="parent",
)

custom = frappe.get_all(
"Custom Field",
fields="dt",
filters={"fieldtype": "Table", "options": child_type},
pluck="dt",
)

return standard + custom

+ 13
- 17
frappe/desk/doctype/number_card/number_card.js Wyświetl plik

@@ -472,25 +472,21 @@ frappe.ui.form.on("Number Card", {
frm.set_df_property("parent_document_type", "hidden", !doc_is_table);

if (document_type && doc_is_table) {
let parent = await frappe.db.get_list("DocField", {
filters: {
fieldtype: "Table",
options: document_type,
},
fields: ["parent"],
});
let parents = await frappe.xcall(
"frappe.desk.doctype.dashboard_chart.dashboard_chart.get_parent_doctypes",
{ child_type: document_type }
);

parent &&
frm.set_query("parent_document_type", function () {
return {
filters: {
name: ["in", parent.map(({ parent }) => parent)],
},
};
});
frm.set_query("parent_document_type", function () {
return {
filters: {
name: ["in", parents],
},
};
});

if (parent.length === 1) {
frm.set_value("parent_document_type", parent[0].parent);
if (parents.length === 1) {
frm.set_value("parent_document_type", parents[0]);
}
}
},


+ 2
- 0
frappe/desk/form/load.py Wyświetl plik

@@ -110,6 +110,8 @@ def get_docinfo(doc=None, doctype=None, name=None):

docinfo.update(
{
"doctype": doc.doctype,
"name": doc.name,
"attachments": get_attachments(doc.doctype, doc.name),
"communications": communications_except_auto_messages,
"automated_messages": automated_messages,


+ 4
- 1
frappe/model/db_query.py Wyświetl plik

@@ -613,7 +613,10 @@ class DatabaseQuery:

elif f.operator.lower() in ("in", "not in"):
# if values contain '' or falsy values then only coalesce column
can_be_null = not f.value or any(v is None or v == "" for v in f.value)
# for `in` query this is only required if values contain '' or values are empty.
# for `not in` queries we can't be sure as column values might contain null.
if f.operator.lower() == "in":
can_be_null = not f.value or any(v is None or v == "" for v in f.value)

values = f.value or ""
if isinstance(values, str):


+ 0
- 11
frappe/public/js/frappe/desk.js Wyświetl plik

@@ -147,17 +147,6 @@ frappe.Application = class Application {
this.link_preview = new frappe.ui.LinkPreview();

if (!frappe.boot.developer_mode) {
setInterval(function () {
frappe.call({
method: "frappe.core.page.background_jobs.background_jobs.get_scheduler_status",
callback: function (r) {
if (r.message[0] == __("Inactive")) {
frappe.call("frappe.utils.scheduler.activate_scheduler");
}
},
});
}, 300000); // check every 5 minutes

if (frappe.user.has_role("System Manager")) {
setInterval(function () {
frappe.call({


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

@@ -89,23 +89,19 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
is_translatable() {
return in_list(frappe.boot?.translated_doctypes || [], this.get_options());
}
set_link_title(value) {
let doctype = this.get_options();

if (!doctype) return;
async set_link_title(value) {
const doctype = this.get_options();

if (in_list(frappe.boot.link_title_doctypes, doctype)) {
let link_title = frappe.utils.get_link_title(doctype, value);
if (!link_title) {
link_title = frappe.utils.fetch_link_title(doctype, value).then((link_title) => {
this.translate_and_set_input_value(link_title, value);
});
} else {
this.translate_and_set_input_value(link_title, value);
}
} else {
if (!doctype || !in_list(frappe.boot.link_title_doctypes, doctype)) {
this.translate_and_set_input_value(value, value);
return;
}

const link_title =
frappe.utils.get_link_title(doctype, value) ||
(await frappe.utils.fetch_link_title(doctype, value));

this.translate_and_set_input_value(link_title, value);
}
translate_and_set_input_value(link_title, value) {
let translated_link_text = this.get_translated(link_title);


+ 4
- 9
frappe/public/js/frappe/model/sync.js Wyświetl plik

@@ -56,16 +56,11 @@ Object.assign(frappe.model, {
sync_docinfo: (r) => {
// set docinfo (comments, assign, attachments)
if (r.docinfo) {
var doc;
if (r.docs) {
doc = r.docs[0];
} else {
if (cur_frm) doc = cur_frm.doc;
}
if (doc) {
if (!frappe.model.docinfo[doc.doctype]) frappe.model.docinfo[doc.doctype] = {};
frappe.model.docinfo[doc.doctype][doc.name] = r.docinfo;
const { doctype, name } = r.docinfo;
if (!frappe.model.docinfo[doctype]) {
frappe.model.docinfo[doctype] = {};
}
frappe.model.docinfo[doctype][name] = r.docinfo;

// copy values to frappe.boot.user_info
Object.assign(frappe.boot.user_info, r.docinfo.user_info);


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

@@ -45,7 +45,7 @@

// hide row index in 6 column child tables
.form-column.col-sm-6 .form-grid {
.row-index > span {
.row-index {
display: none;
}

@@ -56,7 +56,7 @@
}
}

.modal .form-grid .row-index > span {
.modal .form-grid .row-index {
display: none;
}



+ 7
- 2
frappe/tests/test_cors.py Wyświetl plik

@@ -11,6 +11,7 @@ HEADERS = (
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Vary",
)


@@ -20,9 +21,13 @@ class TestCORS(FrappeTestCase):

headers = {}
if origin:
headers = {"Origin": origin}
headers = {
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "X-Test-Header",
}

frappe.utils.set_request(headers=headers)
frappe.utils.set_request(method="OPTIONS", headers=headers)

self.response = Response()
process_response(self.response)


+ 4
- 0
frappe/tests/test_db_query.py Wyświetl plik

@@ -749,6 +749,10 @@ class TestReportview(FrappeTestCase):
self.assertNotIn("ifnull", frappe.get_all("User", {"name": ("in", ["a", "b"])}, run=0))
self.assertIn("ifnull", frappe.get_all("User", {"name": ("in", ["a", None])}, run=0))
self.assertIn("ifnull", frappe.get_all("User", {"name": ("in", ["a", ""])}, run=0))
self.assertIn("ifnull", frappe.get_all("User", {"name": ("in", [])}, run=0))
self.assertIn("ifnull", frappe.get_all("User", {"name": ("not in", ["a"])}, run=0))
self.assertIn("ifnull", frappe.get_all("User", {"name": ("not in", [])}, run=0))
self.assertIn("ifnull", frappe.get_all("User", {"name": ("not in", [""])}, run=0))


def add_child_table_to_blog_post():


+ 35
- 0
frappe/tests/ui_test_helpers.py Wyświetl plik

@@ -2,6 +2,8 @@ import frappe
from frappe import _
from frappe.utils import add_to_date, now

UI_TEST_USER = "frappe@example.com"


@frappe.whitelist()
def create_if_not_exists(doc):
@@ -54,6 +56,15 @@ def create_todo_records():
).insert()


@frappe.whitelist()
def clear_notes():
if not frappe.local.dev_server:
frappe.throw(_("Not allowed"), frappe.PermissionError)

for note in frappe.get_all("Note", pluck="name"):
frappe.delete_doc("Note", note, force=True)


@frappe.whitelist()
def create_communication_record():
doc = frappe.get_doc(
@@ -396,3 +407,27 @@ def create_blog_post():
).insert(ignore_if_duplicate=True)

return doc


def create_test_user():
if frappe.db.exists("User", UI_TEST_USER):
return

user = frappe.new_doc("User")
user.email = UI_TEST_USER
user.first_name = "Frappe"
user.new_password = frappe.local.conf.admin_password
user.send_welcome_email = 0
user.time_zone = "Asia/Kolkata"
user.flags.ignore_password_policy = True
user.insert(ignore_if_duplicate=True)

user.reload()

blocked_roles = {"Administrator", "Guest", "All"}
all_roles = set(frappe.get_all("Role", pluck="name"))

for role in all_roles - blocked_roles:
user.append("roles", {"role": role})

user.save()

+ 14
- 1
frappe/utils/background_jobs.py Wyświetl plik

@@ -9,6 +9,7 @@ from uuid import uuid4
import redis
from redis.exceptions import BusyLoadingError, ConnectionError
from rq import Connection, Queue, Worker
from rq.command import send_stop_job_command
from rq.logutils import setup_loghandlers
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed

@@ -23,6 +24,11 @@ if TYPE_CHECKING:
from rq.job import Job


# TTL to keep RQ job logs in redis for.
RQ_JOB_FAILURE_TTL = 7 * 24 * 60 * 60 # 7 days instead of 1 year (default)
RQ_RESULTS_TTL = 10 * 60


@lru_cache
def get_queues_timeout():
common_site_config = frappe.get_conf()
@@ -103,7 +109,14 @@ def enqueue(
)
return frappe.flags.enqueue_after_commit

return q.enqueue_call(execute_job, timeout=timeout, kwargs=queue_args, at_front=at_front)
return q.enqueue_call(
execute_job,
timeout=timeout,
kwargs=queue_args,
at_front=at_front,
failure_ttl=RQ_JOB_FAILURE_TTL,
result_ttl=RQ_RESULTS_TTL,
)


def enqueue_doc(


+ 3
- 0
frappe/utils/response.py Wyświetl plik

@@ -182,6 +182,9 @@ def json_handler(obj):
elif type(obj) == type or isinstance(obj, Exception):
return repr(obj)

elif callable(obj):
return repr(obj)

else:
raise TypeError(
f"""Object of type {type(obj)} with value of {repr(obj)} is not JSON serializable"""


+ 7
- 0
frappe/utils/scheduler.py Wyświetl plik

@@ -177,3 +177,10 @@ def activate_scheduler():
enable_scheduler()
if frappe.conf.pause_scheduler:
update_site_config("pause_scheduler", 0)


@frappe.whitelist()
def get_scheduler_status():
if is_scheduler_inactive():
return {"status": "inactive"}
return {"status": "active"}

+ 13
- 7
frappe/workflow/doctype/workflow/test_workflow.py Wyświetl plik

@@ -2,7 +2,6 @@
# License: MIT. See LICENSE
import frappe
from frappe.model.workflow import (
WorkflowPermissionError,
WorkflowTransitionError,
apply_workflow,
get_common_transition_actions,
@@ -191,11 +190,18 @@ class TestWorkflow(FrappeTestCase):


def create_todo_workflow():
from frappe.tests.ui_test_helpers import UI_TEST_USER

if frappe.db.exists("Workflow", "Test ToDo"):
frappe.delete_doc("Workflow", "Test ToDo")

if not frappe.db.exists("Role", "Test Approver"):
frappe.get_doc(dict(doctype="Role", role_name="Test Approver")).insert(ignore_if_duplicate=True)
TEST_ROLE = "Test Approver"

if not frappe.db.exists("Role", TEST_ROLE):
frappe.get_doc(dict(doctype="Role", role_name=TEST_ROLE)).insert(ignore_if_duplicate=True)
if frappe.db.exists("User", UI_TEST_USER):
frappe.get_doc("User", UI_TEST_USER).add_roles(TEST_ROLE)

workflow = frappe.new_doc("Workflow")
workflow.workflow_name = "Test ToDo"
workflow.document_type = "ToDo"
@@ -205,16 +211,16 @@ def create_todo_workflow():
workflow.append("states", dict(state="Pending", allow_edit="All"))
workflow.append(
"states",
dict(state="Approved", allow_edit="Test Approver", update_field="status", update_value="Closed"),
dict(state="Approved", allow_edit=TEST_ROLE, update_field="status", update_value="Closed"),
)
workflow.append("states", dict(state="Rejected", allow_edit="Test Approver"))
workflow.append("states", dict(state="Rejected", allow_edit=TEST_ROLE))
workflow.append(
"transitions",
dict(
state="Pending",
action="Approve",
next_state="Approved",
allowed="Test Approver",
allowed=TEST_ROLE,
allow_self_approval=1,
),
)
@@ -224,7 +230,7 @@ def create_todo_workflow():
state="Pending",
action="Reject",
next_state="Rejected",
allowed="Test Approver",
allowed=TEST_ROLE,
allow_self_approval=1,
),
)


Ładowanie…
Anuluj
Zapisz