Merge branch 'develop' into comm-send-after

This commit is contained in:
Ankush Menat 2023-11-27 20:27:59 +05:30
commit ded94bf050
17 changed files with 292 additions and 130 deletions

View file

@ -2,7 +2,11 @@ context("Awesome Bar", () => {
before(() => {
cy.visit("/login");
cy.login();
cy.visit("/app/website");
cy.visit("/app/todo"); // Make sure ToDo filters are cleared.
cy.clear_filters();
cy.visit("/app/blog-post"); // Make sure Blog Post filters are cleared.
cy.clear_filters();
cy.visit("/app/website"); // Go to some other page.
});
beforeEach(() => {
@ -11,36 +15,61 @@ context("Awesome Bar", () => {
cy.get("@awesome_bar").type("{selectall}");
});
after(() => {
cy.visit("/app/todo"); // Make sure we're not bleeding any filters to the next spec.
cy.clear_filters();
});
it("navigates to doctype list", () => {
cy.get("@awesome_bar").type("todo");
cy.wait(100);
cy.wait(100); // Wait a bit before hitting enter.
cy.get(".awesomplete").findByRole("listbox").should("be.visible");
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "To Do");
cy.location("pathname").should("eq", "/app/todo");
});
it("find text in doctype list", () => {
it("finds text in doctype list", () => {
cy.get("@awesome_bar").type("test in todo");
cy.wait(100);
cy.wait(150); // Wait a bit before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "To Do");
cy.wait(200);
const name_filter = cy.get('[data-original-title="ID"] > input');
name_filter.should("have.value", "%test%");
cy.clear_filters();
cy.wait(200); // Wait a bit longer before checking the filter.
cy.get('[data-original-title="ID"] > input').should("have.value", "%test%");
});
it("filter preserved, now finds something else", () => {
cy.visit("/app/todo");
cy.get(".title-text").should("contain", "To Do");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.get('[data-original-title="ID"] > input').as("filter");
cy.get("@filter").should("have.value", "%test%");
cy.get("@awesome_bar").type("anothertest in todo");
cy.wait(200); // Wait a bit longer before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.get("@filter").should("have.value", "%anothertest%");
});
it("navigates to another doctype, filter not bleeding", () => {
cy.get("@awesome_bar").type("blog post");
cy.wait(150); // Wait a bit before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "Blog Post");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.location("search").should("be.empty");
});
it("navigates to new form", () => {
cy.get("@awesome_bar").type("new blog post");
cy.wait(100);
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text:visible").should("have.text", "New Blog Post");
});
it("calculates math expressions", () => {
cy.get("@awesome_bar").type("55 + 32");
cy.wait(100);
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{downarrow}{enter}");
cy.get(".modal-title").should("contain", "Result");
cy.get(".msgprint").should("contain", "55 + 32 = 87");

View file

@ -202,7 +202,7 @@ def start_scheduler():
def start_worker(
queue, quiet=False, rq_username=None, rq_password=None, burst=False, strategy=None
):
"""Start a backgrond worker"""
"""Start a background worker"""
from frappe.utils.background_jobs import start_worker
start_worker(
@ -225,7 +225,7 @@ def start_worker(
@click.option("--quiet", is_flag=True, default=False, help="Hide Log Outputs")
@click.option("--burst", is_flag=True, default=False, help="Run Worker in Burst mode.")
def start_worker_pool(queue, quiet=False, num_workers=2, burst=False):
"""Start a backgrond worker"""
"""Start a pool of background workers"""
from frappe.utils.background_jobs import start_worker_pool
start_worker_pool(

View file

@ -23,38 +23,23 @@
"float_precision",
"currency_precision",
"rounding_method",
"sec_backup_limit",
"backup_limit",
"encrypt_backup",
"background_workers",
"enable_scheduler",
"dormant_days",
"permissions",
"apply_strict_user_permissions",
"column_break_21",
"allow_guests_to_upload_files",
"force_web_capture_mode_for_uploads",
"allow_older_web_view_links",
"security_tab",
"security",
"session_expiry",
"document_share_key_expiry",
"column_break_13",
"column_break_txqh",
"deny_multiple_sessions",
"disable_user_pass_login",
"login_methods_section",
"allow_login_using_mobile_number",
"allow_login_using_user_name",
"disable_user_pass_login",
"column_break_uhqk",
"login_with_email_link",
"login_with_email_link_expiry",
"allow_error_traceback",
"strip_exif_metadata_from_uploaded_images",
"allow_older_web_view_links",
"password_settings",
"logout_on_password_reset",
"force_user_to_reset_password",
"reset_password_link_expiry_duration",
"password_reset_limit",
"column_break_31",
"enable_password_policy",
"minimum_password_score",
"brute_force_security",
"allow_consecutive_login_attempts",
"column_break_34",
@ -66,6 +51,16 @@
"two_factor_method",
"lifespan_qrcode_image",
"otp_issuer_name",
"password_tab",
"password_settings",
"logout_on_password_reset",
"force_user_to_reset_password",
"reset_password_link_expiry_duration",
"password_reset_limit",
"column_break_31",
"enable_password_policy",
"minimum_password_score",
"email_tab",
"email",
"email_footer_address",
"email_retry_limit",
@ -75,17 +70,31 @@
"attach_view_link",
"welcome_email_template",
"reset_password_template",
"prepared_report_section",
"max_auto_email_report_per_user",
"files_tab",
"files_section",
"max_file_size",
"allow_guests_to_upload_files",
"force_web_capture_mode_for_uploads",
"strip_exif_metadata_from_uploaded_images",
"column_break_uqma",
"allowed_file_extensions",
"updates_tab",
"system_updates_section",
"disable_system_update_notification",
"disable_change_log_notification",
"backups_tab",
"sec_backup_limit",
"backup_limit",
"encrypt_backup",
"advanced_tab",
"prepared_report_section",
"max_auto_email_report_per_user",
"background_workers",
"enable_scheduler",
"dormant_days",
"telemetry_section",
"enable_telemetry",
"files_section",
"max_file_size",
"column_break_uqma",
"allowed_file_extensions"
"allow_error_traceback",
"enable_telemetry"
],
"fields": [
{
@ -126,7 +135,6 @@
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "date_and_number_format",
"fieldtype": "Section Break",
"label": "Date and Number Format"
@ -171,10 +179,8 @@
"options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9"
},
{
"collapsible": 1,
"fieldname": "sec_backup_limit",
"fieldtype": "Section Break",
"label": "Backups"
"fieldtype": "Section Break"
},
{
"default": "3",
@ -184,7 +190,6 @@
"label": "Number of Backups"
},
{
"collapsible": 1,
"fieldname": "background_workers",
"fieldtype": "Section Break",
"label": "Background Workers"
@ -198,7 +203,6 @@
"label": "Enable Scheduled Jobs"
},
{
"collapsible": 1,
"fieldname": "permissions",
"fieldtype": "Section Break",
"label": "Permissions"
@ -211,10 +215,8 @@
"label": "Apply Strict User Permissions"
},
{
"collapsible": 1,
"fieldname": "security",
"fieldtype": "Section Break",
"label": "Security"
"fieldtype": "Section Break"
},
{
"default": "170:00",
@ -223,10 +225,6 @@
"fieldtype": "Data",
"label": "Session Expiry (idle timeout)"
},
{
"fieldname": "column_break_13",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Note: Multiple sessions will be allowed in case of mobile device",
@ -255,7 +253,6 @@
"label": "Show Full Error and Allow Reporting of Issues to the Developer"
},
{
"collapsible": 1,
"fieldname": "password_settings",
"fieldtype": "Section Break",
"label": "Password"
@ -286,7 +283,6 @@
"options": "2\n3\n4"
},
{
"collapsible": 1,
"fieldname": "brute_force_security",
"fieldtype": "Section Break",
"label": "Brute Force Security"
@ -309,7 +305,6 @@
"label": "Allow Login After Fail"
},
{
"collapsible": 1,
"fieldname": "two_factor_authentication",
"fieldtype": "Section Break",
"label": "Two Factor Authentication"
@ -338,6 +333,7 @@
},
{
"default": "OTP App",
"depends_on": "enable_two_factor_auth",
"description": "Choose authentication method to be used by all users",
"fieldname": "two_factor_method",
"fieldtype": "Select",
@ -345,7 +341,7 @@
"options": "OTP App\nSMS\nEmail"
},
{
"depends_on": "eval:doc.two_factor_method == \"OTP App\"",
"depends_on": "eval:doc.enable_two_factor_auth && doc.two_factor_method == \"OTP App\"",
"description": "Time in seconds to retain QR code image on server. Min:<strong>240</strong>",
"fieldname": "lifespan_qrcode_image",
"fieldtype": "Int",
@ -359,10 +355,8 @@
"label": "OTP Issuer Name"
},
{
"collapsible": 1,
"fieldname": "email",
"fieldtype": "Section Break",
"label": "Email"
"fieldtype": "Section Break"
},
{
"description": "Your organization name and address for the email footer.",
@ -430,7 +424,6 @@
"label": "Include Web View Link in Email"
},
{
"collapsible": 1,
"fieldname": "prepared_report_section",
"fieldtype": "Section Break",
"label": "Reports"
@ -456,10 +449,8 @@
"label": "Encrypt Backups"
},
{
"collapsible": 1,
"fieldname": "system_updates_section",
"fieldtype": "Section Break",
"label": "System Updates"
"fieldtype": "Section Break"
},
{
"default": "0",
@ -547,7 +538,6 @@
"label": "Disable Document Sharing"
},
{
"collapsible": 1,
"fieldname": "telemetry_section",
"fieldtype": "Section Break",
"label": "Telemetry"
@ -578,10 +568,8 @@
"label": "Force Web Capture Mode for Uploads"
},
{
"collapsible": 1,
"fieldname": "files_section",
"fieldtype": "Section Break",
"label": "Files"
"fieldtype": "Section Break"
},
{
"fieldname": "max_file_size",
@ -598,12 +586,60 @@
"fieldname": "allowed_file_extensions",
"fieldtype": "Small Text",
"label": "Allowed File Extensions"
},
{
"fieldname": "security_tab",
"fieldtype": "Tab Break",
"label": "Login"
},
{
"fieldname": "email_tab",
"fieldtype": "Tab Break",
"label": "Email"
},
{
"fieldname": "files_tab",
"fieldtype": "Tab Break",
"label": "Files"
},
{
"fieldname": "updates_tab",
"fieldtype": "Tab Break",
"label": "Updates"
},
{
"fieldname": "backups_tab",
"fieldtype": "Tab Break",
"label": "Backups"
},
{
"fieldname": "advanced_tab",
"fieldtype": "Tab Break",
"label": "Advanced"
},
{
"fieldname": "password_tab",
"fieldtype": "Tab Break",
"label": "Password"
},
{
"fieldname": "column_break_txqh",
"fieldtype": "Column Break"
},
{
"fieldname": "login_methods_section",
"fieldtype": "Section Break",
"label": "Login Methods"
},
{
"fieldname": "column_break_uhqk",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2023-10-17 16:12:28.145496",
"modified": "2023-11-27 14:08:01.927794",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -93,7 +93,6 @@ class SystemSettings(Document):
time_zone: DF.Literal
two_factor_method: DF.Literal["OTP App", "SMS", "Email"]
welcome_email_template: DF.Link | None
# end: auto-generated types
def validate(self):
from frappe.twofactor import toggle_two_factor_auth

View file

@ -1227,27 +1227,31 @@ def create_contact(user, ignore_links=False, ignore_mandatory=False):
contact_name = get_contact_name(user.email)
if not contact_name:
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": user.first_name,
"last_name": user.last_name,
"user": user.name,
"gender": user.gender,
}
)
try:
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": user.first_name,
"last_name": user.last_name,
"user": user.name,
"gender": user.gender,
}
)
if user.email:
contact.add_email(user.email, is_primary=True)
if user.email:
contact.add_email(user.email, is_primary=True)
if user.phone:
contact.add_phone(user.phone, is_primary_phone=True)
if user.phone:
contact.add_phone(user.phone, is_primary_phone=True)
if user.mobile_no:
contact.add_phone(user.mobile_no, is_primary_mobile_no=True)
contact.insert(
ignore_permissions=True, ignore_links=ignore_links, ignore_mandatory=ignore_mandatory
)
if user.mobile_no:
contact.add_phone(user.mobile_no, is_primary_mobile_no=True)
contact.insert(
ignore_permissions=True, ignore_links=ignore_links, ignore_mandatory=ignore_mandatory
)
except frappe.DuplicateEntryError:
pass
else:
contact = frappe.get_doc("Contact", contact_name)
contact.first_name = user.first_name

View file

@ -46,8 +46,29 @@ class BulkUpdate(Document):
@frappe.whitelist()
def submit_cancel_or_update_docs(doctype, docnames, action="submit", data=None):
docnames = frappe.parse_json(docnames)
if isinstance(docnames, str):
docnames = frappe.parse_json(docnames)
if len(docnames) < 20:
return _bulk_action(doctype, docnames, action, data)
elif len(docnames) <= 500:
frappe.msgprint(_("Bulk operation is enqueued in background."), alert=True)
frappe.enqueue(
_bulk_action,
doctype=doctype,
docnames=docnames,
action=action,
data=data,
queue="short",
timeout=1000,
)
else:
frappe.throw(
_("Bulk operations only support up to 500 documents."), title=_("Too Many Documents")
)
def _bulk_action(doctype, docnames, action, data):
if data:
data = frappe.parse_json(data)
@ -85,5 +106,4 @@ def submit_cancel_or_update_docs(doctype, docnames, action="submit", data=None):
def show_progress(docnames, message, i, description):
n = len(docnames)
if n >= 10:
frappe.publish_progress(float(i) * 100 / n, title=message, description=description)
frappe.publish_progress(float(i) * 100 / n, title=message, description=description)

View file

@ -0,0 +1,48 @@
# Copyright (c) 2023, Frappe Technologies and Contributors
# See LICENSE
import time
import frappe
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.desk.doctype.bulk_update.bulk_update import submit_cancel_or_update_docs
from frappe.tests.utils import FrappeTestCase, timeout
class TestBulkUpdate(FrappeTestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.doctype = new_doctype(is_submittable=1, custom=1).insert().name
frappe.db.commit()
for _ in range(50):
frappe.new_doc(cls.doctype, some_fieldname=frappe.mock("name")).insert()
@timeout()
def wait_for_assertion(self, assertion):
"""Wait till an assertion becomes True"""
while True:
if assertion():
break
time.sleep(0.2)
def test_bulk_submit_in_background(self):
unsubmitted = frappe.get_all(self.doctype, {"docstatus": 0}, limit=5, pluck="name")
failed = submit_cancel_or_update_docs(self.doctype, unsubmitted, action="submit")
self.assertEqual(failed, [])
def check_docstatus(docs, status):
frappe.db.rollback()
matching_docs = frappe.get_all(
self.doctype, {"docstatus": status, "name": ("in", docs)}, pluck="name"
)
return set(matching_docs) == set(docs)
unsubmitted = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name")
submit_cancel_or_update_docs(self.doctype, unsubmitted, action="submit")
self.wait_for_assertion(lambda: check_docstatus(unsubmitted, 1))
submitted = frappe.get_all(self.doctype, {"docstatus": 1}, limit=20, pluck="name")
submit_cancel_or_update_docs(self.doctype, submitted, action="cancel")
self.wait_for_assertion(lambda: check_docstatus(submitted, 2))

View file

@ -102,8 +102,7 @@
"fieldname": "url",
"fieldtype": "Data",
"in_list_view": 1,
"label": "URL",
"options": "URL"
"label": "URL"
},
{
"depends_on": "eval:doc.doc_view == \"Kanban\"",
@ -116,7 +115,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-07-18 16:12:53.546430",
"modified": "2023-11-27 14:13:38.489737",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Shortcut",

View file

@ -196,7 +196,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
this.abort_setup(r.message.fail);
}
},
error: () => this.abort_setup("Error in setup"),
error: () => this.abort_setup(),
});
}
@ -213,7 +213,11 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
abort_setup(fail_msg) {
this.$working_state.find(".state-icon-container").html("");
fail_msg = fail_msg ? fail_msg : __("Failed to complete setup");
fail_msg = fail_msg
? fail_msg
: frappe.last_response.setup_wizard_failure_message
? frappe.last_response.setup_wizard_failure_message
: __("Failed to complete setup");
this.update_setup_message("Could not start up: " + fail_msg);
@ -463,7 +467,7 @@ frappe.setup.slides_settings = [
fieldtype: "Data",
options: "Email",
},
{ fieldname: "password", label: __("Password"), fieldtype: "Password" },
{ fieldname: "password", label: __("Password"), fieldtype: "Password", length: 512 },
],
onload: function (slide) {

View file

@ -83,11 +83,14 @@ def process_setup_stages(stages, user_input, is_background_task=False):
task.get("fn")(task.get("args"))
except Exception:
handle_setup_exception(user_input)
message = current_task.get("fail_msg") if current_task else "Failed to complete setup"
frappe.log_error(title=f"Setup failed: {message}")
if not is_background_task:
return {"status": "fail", "fail": current_task.get("fail_msg")}
frappe.response["setup_wizard_failure_message"] = message
raise
frappe.publish_realtime(
"setup_task",
{"status": "fail", "fail_msg": current_task.get("fail_msg")},
{"status": "fail", "fail_msg": message},
user=frappe.session.user,
)
else:

View file

@ -163,6 +163,7 @@ def get_mapped_doc(
if postprocess:
postprocess(source_doc, target_doc)
ret_doc.run_method("after_mapping", source_doc)
ret_doc.set_onload("load_after_mapping", True)
if (

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import json
from collections import defaultdict
from typing import TYPE_CHECKING, Union
import frappe
@ -233,17 +234,30 @@ def get_workflow_field_value(workflow_name, field):
@frappe.whitelist()
def bulk_workflow_approval(docnames, doctype, action):
from collections import defaultdict
docnames = json.loads(docnames)
if len(docnames) < 20:
_bulk_workflow_action(docnames, doctype, action)
elif len(docnames) <= 500:
frappe.msgprint(_("Bulk {0} is enqueued in background.").format(action), alert=True)
frappe.enqueue(
_bulk_workflow_action,
docnames=docnames,
doctype=doctype,
action=action,
queue="short",
timeout=1000,
)
else:
frappe.throw(_("Bulk approval only support up to 500 documents."), title=_("Too Many Documents"))
def _bulk_workflow_action(docnames, doctype, action):
# dictionaries for logging
failed_transactions = defaultdict(list)
successful_transactions = defaultdict(list)
# WARN: message log is cleared
print("Clearing frappe.message_log...")
frappe.clear_messages()
docnames = json.loads(docnames)
for (idx, docname) in enumerate(docnames, 1):
message_dict = {}
try:
@ -308,7 +322,9 @@ def print_workflow_log(messages, title, doctype, indicator):
html = f"<div>{doc}</div>"
msg += html
frappe.msgprint(msg, title=_("Workflow Status"), indicator=indicator, is_minimizable=True)
frappe.msgprint(
msg, title=_("Workflow Status"), indicator=indicator, is_minimizable=True, realtime=True
)
@frappe.whitelist()

View file

@ -11,7 +11,7 @@ from oauthlib.openid import RequestValidator
import frappe
from frappe.auth import LoginManager
from frappe.utils.data import get_system_timezone
from frappe.utils.data import get_system_timezone, now_datetime
class OAuthWebRequestValidator(RequestValidator):
@ -240,13 +240,7 @@ class OAuthWebRequestValidator(RequestValidator):
def validate_bearer_token(self, token, scopes, request):
# Remember to check expiration and scope membership
otoken = frappe.get_doc("OAuth Bearer Token", token)
token_expiration_local = otoken.expiration_time.replace(
tzinfo=pytz.timezone(get_system_timezone())
)
token_expiration_utc = token_expiration_local.astimezone(pytz.utc)
is_token_valid = (
datetime.datetime.now(pytz.UTC) < token_expiration_utc
) and otoken.status != "Revoked"
is_token_valid = (now_datetime() < otoken.expiration_time) and otoken.status != "Revoked"
client_scopes = frappe.db.get_value("OAuth Client", otoken.client, "scopes").split(
get_url_delimiter()
)

View file

@ -368,7 +368,11 @@ frappe.router = {
window.open(sub_path, "_blank");
frappe.open_in_new_tab = false;
} else {
this.push_state(sub_path);
const route_options = frappe.route_options || {};
const query_params = Object.entries(route_options)
.map(([key, value]) => `${key}=` + encodeURIComponent(JSON.stringify(value)))
.join("&");
this.push_state(sub_path, query_params ? `?${query_params}` : "");
}
setTimeout(() => {
frappe.after_ajax &&
@ -469,12 +473,19 @@ frappe.router = {
return "/app/" + (path_string || default_page);
},
push_state(url) {
// change the URL and call the router
if (window.location.pathname !== url) {
/**
* Changes the URL and calls the router.
*
* @param {string} path - The desired URI path to replace or push,
* without query string. Example: "/app/todo"
* @param {string} query_params - The desired query parameter string.
* @returns {void}
*/
push_state(path, query_params = "") {
if (window.location.pathname !== path || window.location.search !== query_params) {
// push/replace state so the browser looks fine
const method = frappe.route_flags.replace_route ? "replaceState" : "pushState";
history[method](null, null, url);
history[method](null, null, path);
// now process the route
this.route();

View file

@ -147,10 +147,13 @@ frappe.ui.GroupBy = class {
doctype_fields.forEach((field) => {
// pick numeric fields for sum / avg
if (frappe.model.is_numeric_field(field.fieldtype)) {
let field_label = field.label
? field.label
: frappe.model.unscrub(field.fieldname);
let option_text =
doctype == this.doctype
? field.label
: `${field.label} (${__(doctype)})`;
? field_label
: `${__(field_label)} (${__(doctype)})`;
this.aggregate_on_html += `<option data-doctype="${doctype}"
value="${field.fieldname}">${__(option_text)}</option>`;
}

View file

@ -419,7 +419,6 @@ class ShortcutDialog extends WidgetDialog {
fieldtype: "Data",
fieldname: "url",
label: "URL",
options: "URL",
default: "",
depends_on: (s) => s.type == "URL",
mandatory_depends_on: (s) => s.type == "URL",
@ -547,7 +546,11 @@ class ShortcutDialog extends WidgetDialog {
data.label = data.label ? data.label : frappe.model.unscrub(data.link_to);
if (data.url) {
!validate_url(data.url) &&
let _url = data.url;
if (data.url.startsWith("/")) {
_url = frappe.urllib.get_base_url() + data.url;
}
!validate_url(_url) &&
frappe.throw({
message: __("<b>{0}</b> is not a valid URL", [data.url]),
title: __("Invalid URL"),

View file

@ -1,8 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from frappe.tests.utils import FrappeTestCase
class TestBot(FrappeTestCase):
pass