chore: release v14version-14
@@ -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' }} | |||
@@ -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, | |||
@@ -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(); | |||
@@ -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", | |||
@@ -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() | |||
@@ -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(); | |||
@@ -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(); | |||
@@ -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(); | |||
@@ -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"); | |||
@@ -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"); | |||
@@ -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,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"); | |||
@@ -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)) | |||
@@ -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", | |||
@@ -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 | |||
@@ -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 +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(); | |||
}); | |||
} | |||
); | |||
}); | |||
} | |||
}, | |||
}); |
@@ -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" | |||
} |
@@ -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() |
@@ -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); | |||
}, | |||
}; |
@@ -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 +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(); | |||
}, | |||
}); |
@@ -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" | |||
} | |||
] | |||
} |
@@ -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), | |||
) |
@@ -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) |
@@ -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 = [] | |||
@@ -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]); | |||
} | |||
} | |||
}, | |||
@@ -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 |
@@ -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]); | |||
} | |||
} | |||
}, | |||
@@ -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, | |||
@@ -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): | |||
@@ -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({ | |||
@@ -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); | |||
@@ -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); | |||
@@ -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; | |||
} | |||
@@ -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) | |||
@@ -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(): | |||
@@ -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() |
@@ -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( | |||
@@ -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""" | |||
@@ -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"} |
@@ -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, | |||
), | |||
) | |||