Merge branch 'develop' into get-all-mod

This commit is contained in:
Suraj Shetty 2022-08-16 17:07:47 +05:30 committed by GitHub
commit ae61df8273
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 739 additions and 251 deletions

103
.github/helper/ci.py vendored Normal file
View file

@ -0,0 +1,103 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
import os
from pathlib import Path
STANDARD_INCLUSIONS = ["*.py"]
STANDARD_EXCLUSIONS = [
"*.js",
"*.xml",
"*.pyc",
"*.css",
"*.less",
"*.scss",
"*.vue",
"*.html",
"*/test_*",
"*/node_modules/*",
"*/doctype/*/*_dashboard.py",
"*/patches/*",
".github/*",
]
# tested via commands' test suite
TESTED_VIA_CLI = [
"*/frappe/installer.py",
"*/frappe/build.py",
"*/frappe/database/__init__.py",
"*/frappe/database/db_manager.py",
"*/frappe/database/**/setup_db.py",
]
FRAPPE_EXCLUSIONS = [
"*/tests/*",
"*/commands/*",
"*/frappe/change_log/*",
"*/frappe/exceptions*",
"*/frappe/coverage.py",
"*frappe/setup.py",
"*/doctype/*/*_dashboard.py",
"*/patches/*",
] + TESTED_VIA_CLI
def get_bench_path():
return Path(__file__).resolve().parents[4]
class CodeCoverage:
def __init__(self, with_coverage, app):
self.with_coverage = with_coverage
self.app = app or "frappe"
def __enter__(self):
if self.with_coverage:
import os
from coverage import Coverage
# Generate coverage report only for app that is being tested
source_path = os.path.join(get_bench_path(), "apps", self.app)
print(f"Source path: {source_path}")
omit = STANDARD_EXCLUSIONS[:]
if self.app == "frappe":
omit.extend(FRAPPE_EXCLUSIONS)
self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
self.coverage.start()
def __exit__(self, exc_type, exc_value, traceback):
if self.with_coverage:
self.coverage.stop()
self.coverage.save()
self.coverage.xml_report()
if __name__ == "__main__":
app = "frappe"
site = os.environ.get("SITE") or "test_site"
use_orchestrator = bool(os.environ.get("ORCHESTRATOR_URL"))
build_number = 1
total_builds = 1
try:
build_number = int(os.environ.get("BUILD_NUMBER"))
except Exception:
pass
try:
total_builds = int(os.environ.get("TOTAL_BUILDS"))
except Exception:
pass
with CodeCoverage(with_coverage=True, app=app):
if use_orchestrator:
from frappe.parallel_test_runner import ParallelTestWithOrchestrator
ParallelTestWithOrchestrator(app, site=site)
else:
from frappe.parallel_test_runner import ParallelTestRunner
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)

View file

@ -30,9 +30,8 @@ jobs:
- name: Clone
uses: actions/checkout@v3
- name: Check for valid Python & Merge Conflicts
- name: Check for Merge Conflicts
run: |
python -m compileall -f "${GITHUB_WORKSPACE}"
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1

View file

@ -20,6 +20,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
build: ${{ steps.check-build.outputs.build }}
strategy:
fail-fast: false
matrix:
@ -122,8 +125,9 @@ jobs:
- name: Run Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage
run: cd ~/frappe-bench/sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
@ -143,9 +147,11 @@ jobs:
uses: actions/checkout@v3
- name: Download artifacts
if: ${{ needs.test.outputs.build == 'strawberry' }}
uses: actions/download-artifact@v3
- name: Upload coverage data
if: ${{ needs.test.outputs.build == 'strawberry' }}
uses: codecov/codecov-action@v3
with:
name: MariaDB

View file

@ -19,6 +19,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
build: ${{ steps.check-build.outputs.build }}
strategy:
fail-fast: false
matrix:
@ -125,8 +128,9 @@ jobs:
- name: Run Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage
run: cd ~/frappe-bench/sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
@ -146,9 +150,11 @@ jobs:
uses: actions/checkout@v3
- name: Download artifacts
if: ${{ needs.test.outputs.build == 'strawberry' }}
uses: actions/download-artifact@v3
- name: Upload coverage data
if: ${{ needs.test.outputs.build == 'strawberry' }}
uses: codecov/codecov-action@v3
with:
name: Postgres

View file

@ -17,6 +17,8 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
build: ${{ steps.check-build.outputs.build }}
strategy:
fail-fast: false
@ -184,18 +186,21 @@ jobs:
uses: actions/checkout@v2
- name: Download artifacts
if: ${{ needs.test.outputs.build == 'strawberry' }}
uses: actions/download-artifact@v3
- name: Upload python coverage data
if: ${{ needs.test.outputs.build == 'strawberry' }}
uses: codecov/codecov-action@v3
with:
name: MariaDB
name: UIBackend
fail_ci_if_error: true
verbose: true
files: ./coverage-py-1/coverage.xml,./coverage-py-2/coverage.xml,./coverage-py-3/coverage.xml
flags: server-ui
- name: Upload JS coverage data
if: ${{ needs.test.outputs.build == 'strawberry' }}
uses: codecov/codecov-action@v3
with:
name: Cypress

View file

@ -14,25 +14,28 @@
</div>
<div align="center">
<a target="_blank" href="#LICENSE" title="License: MIT">
<img src="https://img.shields.io/badge/License-MIT-success.svg">
</a>
<a target="_blank" href="https://www.python.org/downloads/" title="Python version">
<img src="https://img.shields.io/badge/python-%3E=_3.10-success.svg">
</a>
<a href="https://frappeframework.com/docs">
<img src="https://img.shields.io/badge/docs-%F0%9F%93%96-success.svg"/>
</a>
<a href="https://github.com/frappe/frappe/actions/workflows/server-mariadb-tests.yml">
<img src="https://github.com/frappe/frappe/actions/workflows/server-mariadb-tests.yml/badge.svg">
</a>
<a href="https://github.com/frappe/frappe/actions/workflows/ui-tests.yml">
<img src="https://github.com/frappe/frappe/actions/workflows/ui-tests.yml/badge.svg?branch=develop">
</a>
<a href='https://frappeframework.com/docs'>
<img src='https://img.shields.io/badge/docs-📖-7575FF.svg?style=flat-square'/>
</a>
<a href='https://www.codetriage.com/frappe/frappe'>
<img src='https://www.codetriage.com/frappe/frappe/badges/users.svg'>
</a>
<a href="https://codecov.io/gh/frappe/frappe">
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/>
</a>
</div>
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com)
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com).
<div align="center" style="max-height: 40px;">
<a href="https://frappecloud.com/frappe/signup">

View file

@ -41,7 +41,7 @@ context("Data Control", () => {
it("check custom formatters", () => {
cy.visit(`/app/doctype/User`);
cy.get(
'[data-fieldname="fields"] .grid-row[data-idx="2"] [data-fieldname="fieldtype"] .static-area'
'[data-fieldname="fields"] .grid-row[data-idx="3"] [data-fieldname="fieldtype"] .static-area'
).should("have.text", "Section Break");
});

View file

@ -31,6 +31,7 @@ context("Dashboard links", () => {
cy.get(".list-row-col > .level-item > .ellipsis").eq(0).click({ force: true });
//To check if initially the dashboard contains only the "Contact" link and there is no counter
cy.select_form_tab("Connections");
cy.get('[data-doctype="Contact"]').should("contain", "Contact");
//Adding a new contact
@ -44,6 +45,7 @@ context("Dashboard links", () => {
cy.get(".list-row-col > .level-item > .ellipsis").eq(0).click({ force: true });
//To check if the counter for contact doc is "1" after adding the contact
cy.select_form_tab("Connections");
cy.get('[data-doctype="Contact"] > .count').should("contain", "1");
cy.get('[data-doctype="Contact"]').contains("Contact").click();
@ -64,8 +66,8 @@ context("Dashboard links", () => {
it("Report link in dashboard", () => {
cy.visit("/app/user");
cy.visit("/app/user/Administrator");
cy.get('[data-doctype="Contact"]').should("contain", "Contact");
cy.findByText("Connections");
cy.select_form_tab("Connections");
cy.get('.document-link[data-doctype="Contact"]').contains("Contact");
cy.window()
.its("cur_frm")
.then((cur_frm) => {
@ -76,8 +78,9 @@ context("Dashboard links", () => {
},
];
cur_frm.dashboard.render_report_links();
cy.get('[data-report="Website Analytics"]').contains("Website Analytics").click();
cy.findByText("Website Analytics");
cy.get('.document-link[data-report="Website Analytics"]')
.contains("Website Analytics")
.click();
});
});

View file

@ -240,6 +240,10 @@ Cypress.Commands.add("new_form", (doctype) => {
cy.get("body").should("have.attr", "data-ajax-state", "complete");
});
Cypress.Commands.add("select_form_tab", (label) => {
cy.get(".form-tabs-list [data-toggle='tab']").contains(label).click().wait(500);
});
Cypress.Commands.add("go_to_list", (doctype) => {
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}`);

View file

@ -2283,14 +2283,22 @@ def safe_eval(code, eval_globals=None, eval_locals=None):
def get_website_settings(key):
if not hasattr(local, "website_settings"):
local.website_settings = db.get_singles_dict("Website Settings", cast=True)
try:
local.website_settings = get_cached_doc("Website Settings")
except DoesNotExistError:
clear_last_message()
return
return local.website_settings.get(key)
def get_system_settings(key):
if not hasattr(local, "system_settings"):
local.system_settings = db.get_singles_dict("System Settings", cast=True)
try:
local.system_settings = get_cached_doc("System Settings")
except DoesNotExistError: # possible during new install
clear_last_message()
return
return local.system_settings.get(key)

View file

@ -1740,3 +1740,24 @@ def get_field(doc, fieldname):
for field in doc.fields:
if field.fieldname == fieldname:
return field
@frappe.whitelist()
def set_field_order(doctype, field_order):
"""Update field order in doctype"""
frappe.only_for("System Manager")
field_order = json.loads(field_order)
idx = 1
for fieldname in field_order:
docfield = frappe.qb.DocType("DocField")
frappe.qb.update(docfield).set(docfield.idx, idx).where(
(docfield.fieldname == fieldname) & (docfield.parent == doctype)
).run()
idx += 1
# save to update
frappe.get_doc("DocType", doctype).save()
frappe.clear_cache(doctype=doctype)

View file

@ -51,7 +51,7 @@
"icon": "fa fa-globe",
"in_create": 1,
"links": [],
"modified": "2021-10-18 14:02:06.818219",
"modified": "2022-08-14 18:54:03.490836",
"modified_by": "Administrator",
"module": "Core",
"name": "Language",
@ -76,8 +76,10 @@
}
],
"search_fields": "language_name",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "language_name",
"track_changes": 1
}

View file

@ -8,6 +8,7 @@ from unittest.mock import patch
import frappe
import frappe.exceptions
from frappe.core.doctype.user.user import (
handle_password_test_fail,
reset_password,
sign_up,
test_password_strength,
@ -191,6 +192,12 @@ class TestUser(unittest.TestCase):
# Score 1; should now fail
result = test_password_strength("bee2ve")
self.assertEqual(result["feedback"]["password_policy_validation_passed"], False)
self.assertRaises(
frappe.exceptions.ValidationError, handle_password_test_fail, result["feedback"]
)
self.assertRaises(
frappe.exceptions.ValidationError, handle_password_test_fail, result
) # test backwards compatibility
# Score 4; should pass
result = test_password_strength("Eastern_43A1W")
@ -200,7 +207,7 @@ class TestUser(unittest.TestCase):
user = frappe.get_doc("User", "test@example.com")
frappe.flags.in_test = False
user.new_password = "password"
self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid Password", user.save)
self.assertRaises(frappe.exceptions.ValidationError, user.save)
user.reload()
user.new_password = "Eastern_43A1W"
user.save()

View file

@ -7,6 +7,7 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"user_details_tab",
"enabled",
"section_break_3",
"email",
@ -22,23 +23,31 @@
"send_welcome_email",
"unsubscribed",
"user_image",
"roles_permissions_tab",
"sb1",
"role_profile_name",
"roles_html",
"roles",
"sb_allow_modules",
"module_profile",
"modules_html",
"block_modules",
"home_settings",
"short_bio",
"gender",
"birth_date",
"interest",
"banner_image",
"desk_theme",
"column_break_26",
"phone",
"location",
"bio",
"mute_sounds",
"column_break_22",
"mobile_no",
"settings_tab",
"desk_settings_section",
"mute_sounds",
"desk_theme",
"banner_image",
"change_password",
"new_password",
"logout_all_sessions",
@ -61,11 +70,6 @@
"send_me_a_copy",
"allowed_in_mentions",
"user_emails",
"sb_allow_modules",
"module_profile",
"modules_html",
"block_modules",
"home_settings",
"sb2",
"defaults",
"sb3",
@ -87,7 +91,8 @@
"api_key",
"generate_keys",
"column_break_65",
"api_secret"
"api_secret",
"connections_tab"
],
"fields": [
{
@ -232,7 +237,7 @@
"collapsible": 1,
"depends_on": "enabled",
"fieldname": "short_bio",
"fieldtype": "Section Break",
"fieldtype": "Tab Break",
"label": "More Information"
},
{
@ -398,7 +403,6 @@
"permlevel": 1
},
{
"collapsible": 1,
"depends_on": "eval:in_list(['System User'], doc.user_type)",
"fieldname": "sb_allow_modules",
"fieldtype": "Section Break",
@ -615,13 +619,13 @@
"options": "Module Profile"
},
{
"description": "Stores the datetime when the last reset password key was generated.",
"fieldname": "last_reset_password_key_generated_on",
"fieldtype": "Datetime",
"hidden": 1,
"label": "Last Reset Password Key Generated On",
"read_only": 1
},
"description": "Stores the datetime when the last reset password key was generated.",
"fieldname": "last_reset_password_key_generated_on",
"fieldtype": "Datetime",
"hidden": 1,
"label": "Last Reset Password Key Generated On",
"read_only": 1
},
{
"fieldname": "column_break_75",
"fieldtype": "Column Break"
@ -648,18 +652,45 @@
"label": "Auto follow documents that you Like"
},
{
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_shared_documents",
"fieldtype": "Check",
"label": "Auto follow documents that are shared with you"
},
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_shared_documents",
"fieldtype": "Check",
"label": "Auto follow documents that are shared with you"
},
{
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_assigned_documents",
"fieldtype": "Check",
"label": "Auto follow documents that are assigned to you"
},
{
"fieldname": "user_details_tab",
"fieldtype": "Tab Break",
"label": "User Details"
},
{
"fieldname": "roles_permissions_tab",
"fieldtype": "Tab Break",
"label": "Roles & Permissions"
},
{
"fieldname": "settings_tab",
"fieldtype": "Tab Break",
"label": "Settings"
},
{
"fieldname": "connections_tab",
"fieldtype": "Tab Break",
"label": "Connections",
"show_dashboard": 1
},
{
"collapsible": 1,
"fieldname": "desk_settings_section",
"fieldtype": "Section Break",
"label": "Desk Settings"
}
],
"icon": "fa fa-user",
@ -722,7 +753,7 @@
"link_fieldname": "user"
}
],
"modified": "2022-05-25 01:00:51.345319",
"modified": "2022-08-11 14:47:04.100892",
"modified_by": "Administrator",
"module": "Core",
"name": "User",
@ -762,4 +793,4 @@
"states": [],
"title_field": "full_name",
"track_changes": 1
}
}

View file

@ -540,7 +540,7 @@ class User(Document):
feedback = result.get("feedback", None)
if feedback and not feedback.get("password_policy_validation_passed", False):
handle_password_test_fail(result)
handle_password_test_fail(feedback)
def suggest_username(self):
def _check_suggestion(suggestion):
@ -686,7 +686,7 @@ def update_password(new_password, logout_all_sessions=0, key=None, old_password=
feedback = result.get("feedback", None)
if feedback and not feedback.get("password_policy_validation_passed", False):
handle_password_test_fail(result)
handle_password_test_fail(feedback)
res = _get_user_for_update_password(key, old_password)
if res.get("message"):
@ -1042,13 +1042,15 @@ def notify_admin_access_to_system_manager(login_manager=None):
)
def handle_password_test_fail(result):
suggestions = result["feedback"]["suggestions"][0] if result["feedback"]["suggestions"] else ""
warning = result["feedback"]["warning"] if "warning" in result["feedback"] else ""
suggestions += (
"<br>" + _("Hint: Include symbols, numbers and capital letters in the password") + "<br>"
)
frappe.throw(" ".join([_("Invalid Password:"), warning, suggestions]))
def handle_password_test_fail(feedback: dict):
# Backward compatibility
if "feedback" in feedback:
feedback = feedback["feedback"]
suggestions = feedback.get("suggestions", [])
warning = feedback.get("warning", "")
frappe.throw(msg=" ".join([warning] + suggestions), title=_("Invalid Password"))
def update_gravatar(name):

View file

@ -3,9 +3,8 @@
import frappe
from frappe import _, msgprint
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from frappe.utils import cint, get_url, now_datetime
from frappe.utils.data import getdate
from frappe.utils.verified_command import get_signed_params, verify_request
@ -16,26 +15,17 @@ def get_emails_sent_this_month(email_account=None):
if email_account=None, email account filter is not applied while counting
"""
q = """
SELECT
COUNT(*)
FROM
`tabEmail Queue`
WHERE
`status`='Sent'
AND
EXTRACT(YEAR_MONTH FROM `creation`) = EXTRACT(YEAR_MONTH FROM NOW())
"""
today = getdate()
month_start = today.replace(day=1)
q_args = {}
if email_account is not None:
if email_account:
q += " AND email_account = %(email_account)s"
q_args["email_account"] = email_account
else:
q += " AND (email_account is null OR email_account='')"
filters = {
"status": "Sent",
"creation": [">=", str(month_start)],
}
if email_account:
filters["email_account"] = email_account
return frappe.db.sql(q, q_args)[0][0]
return frappe.db.count("Email Queue", filters=filters)
def get_emails_sent_today(email_account=None):

View file

@ -4,22 +4,22 @@ export default class Column {
this.df = df;
this.section = section;
this.section.columns.push(this);
this.make();
this.resize_all_columns();
}
make() {
this.wrapper = $(`
<div class="form-column">
<div class="form-column" data-fieldname="${this.df.fieldname}">
<form>
</form>
</div>
`)
.appendTo(this.section.body)
.find("form")
.on("submit", function () {
return false;
});
`).appendTo(this.section.body);
this.form = this.wrapper.find("form").on("submit", function () {
return false;
});
if (this.df.label) {
$(`
@ -41,7 +41,15 @@ export default class Column {
.addClass("col-sm-" + colspan);
}
add_field() {}
refresh() {
this.section.refresh();
}
make_sortable() {
this.sortable = new Sortable(this.form.get(0), {
group: this.section.layout.frm.doctype,
});
}
}

View file

@ -13,6 +13,7 @@ import "./script_helpers";
import "./sidebar/form_sidebar";
import "./footer/footer";
import "./form_tour";
import "./form_editor";
import { UndoManager } from "./undo_manager";
frappe.ui.form.Controller = class FormController {
@ -263,10 +264,19 @@ frappe.ui.form.Form = class FrappeForm {
frm: this,
});
this.form_editor = new frappe.ui.form.FormEditor({
frm: this,
});
//this.form_editor.setup();
// workflow state
this.states = new frappe.ui.form.States({
frm: this,
});
this.form_editor = new frappe.ui.form.FormEditor({
frm: this,
});
}
watch_model_updates() {
@ -1900,7 +1910,7 @@ frappe.ui.form.Form = class FrappeForm {
}
// uncollapse section
if (field.section.is_collapsed()) {
if (field.section?.is_collapsed()) {
field.section.collapse(false);
}
@ -1909,7 +1919,9 @@ frappe.ui.form.Form = class FrappeForm {
// focus if text field
if (focus) {
$el.find("input, select, textarea").focus();
setTimeout(() => {
$el.find("input, select, textarea").focus();
}, 500);
}
// highlight control inside field

View file

@ -0,0 +1,81 @@
frappe.ui.form.FormEditor = class FormEditor {
constructor({ frm }) {
this.frm = frm;
}
setup() {
this.setup_sortable();
this.setup_switch_tabs_on_hover();
}
setup_sortable() {
// setup sortable in all column
for (let section of this.frm.layout.sections) {
for (let column of section.columns) {
column.make_sortable();
}
}
// sortable for moving tabs
if (this.frm.layout.tab_link_container) {
this.tab_sortable = new Sortable(this.frm.layout.tab_link_container.get(0));
}
}
setup_switch_tabs_on_hover() {
for (let tab of this.frm.layout.tabs) {
tab.setup_switch_on_hover();
}
}
save() {
this.field_order = [];
if (this.frm.layout.is_tabbed_layout()) {
for (let tab of this.frm.layout.tab_link_container.find(".nav-link")) {
this.add_field_to_field_order(tab);
const tab_id = tab.getAttribute("href").slice(1);
this.add_sections(document.getElementById(tab_id));
}
} else {
this.add_sections(this.frm.layout.page);
}
frappe
.call("frappe.core.doctype.doctype.doctype.set_field_order", {
doctype: this.frm.doctype,
field_order: this.field_order,
})
.then(() => frappe.toast("Field order updated"));
}
add_sections(container) {
for (let section of $(container).find(".form-section")) {
this.add_field_to_field_order(section);
for (let column of $(section).find(".form-column")) {
this.add_field_to_field_order(column);
for (let control of $(column).find(".frappe-control")) {
this.add_field_to_field_order(control);
}
}
}
}
rebuid_fields_list() {
// rebuild the .fields_list and .fields_dict property of sections and columns
// refresh is based on the these properties
for (let section of this.frm.layout.sections) {
section.rebuild_fields_list_from_dom();
}
}
add_field_to_field_order(element) {
const fieldname = element.getAttribute("data-fieldname");
const fieldobj = this.frm.fields_dict[fieldname];
const is_custom_field = fieldobj ? fieldobj.df && fieldobj.df.is_custom_field : false;
if (fieldname && !is_custom_field && fieldname.substr(0, 2) !== "__") {
this.field_order.push(fieldname);
}
}
};

View file

@ -9,8 +9,11 @@ frappe.ui.form.Layout = class Layout {
this.tabs = [];
this.sections = [];
this.page_breaks = [];
this.sections_dict = {};
this.fields_list = [];
this.fields_dict = {};
this.section_count = 0;
this.column_count = 0;
$.extend(this, opts);
}
@ -41,7 +44,7 @@ frappe.ui.form.Layout = class Layout {
<ul class="nav form-tabs" id="form-tabs" role="tablist"></ul>
</div>
`).appendTo(this.page);
this.tabs_list = this.page.find(".form-tabs");
this.tab_link_container = this.page.find(".form-tabs");
this.tabs_content = $(`<div class="form-tab-content tab-content"></div>`).appendTo(
this.page
);
@ -211,14 +214,11 @@ frappe.ui.form.Layout = class Layout {
fieldobj.perm = this.frm.perm;
}
this.section.fields_list.push(fieldobj);
this.section.fields_dict[df.fieldname] = fieldobj;
fieldobj.section = this.section;
this.section.add_field(fieldobj);
this.column.add_field(fieldobj);
if (this.current_tab) {
fieldobj.tab = this.current_tab;
this.current_tab.fields_list.push(fieldobj);
this.current_tab.fields_dict[df.fieldname] = fieldobj;
this.current_tab.add_field(fieldobj);
}
}
@ -226,7 +226,7 @@ frappe.ui.form.Layout = class Layout {
const fieldobj = frappe.ui.form.make_control({
df: df,
doctype: this.doctype,
parent: this.column.wrapper.get(0),
parent: this.column.form.get(0),
frm: this.frm,
render_input: render,
doc: this.doc,
@ -276,13 +276,18 @@ frappe.ui.form.Layout = class Layout {
this.fold_btn.trigger("click");
}
make_section(df) {
make_section(df = {}) {
this.section_count++;
if (!df.fieldname) df.fieldname = `__section_${this.section_count}`;
this.section = new Section(
this.current_tab ? this.current_tab.wrapper : this.page,
df,
this.card_layout,
this
);
this.sections.push(this.section);
this.sections_dict[df.fieldname] = this.section;
// append to layout fields
if (df) {
@ -293,7 +298,10 @@ frappe.ui.form.Layout = class Layout {
this.column = null;
}
make_column(df) {
make_column(df = {}) {
this.column_count++;
if (!df.fieldname) df.fieldname = `__column_${this.section_count}`;
this.column = new Column(this.section, df);
if (df && df.fieldname) {
this.fields_list.push(this.column);
@ -302,7 +310,7 @@ frappe.ui.form.Layout = class Layout {
make_tab(df) {
this.section = null;
let tab = new Tab(this, df, this.frm, this.tabs_list, this.tabs_content);
let tab = new Tab(this, df, this.frm, this.tab_link_container, this.tabs_content);
this.current_tab = tab;
this.make_section({ fieldtype: "Section Break" });
this.tabs.push(tab);
@ -370,11 +378,23 @@ frappe.ui.form.Layout = class Layout {
const visible_tabs = this.tabs.filter((tab) => !tab.hidden);
if (visible_tabs && visible_tabs.length == 1) {
visible_tabs[0].parent.toggleClass("hide show");
visible_tabs[0].tab_link.toggleClass("hide show");
}
this.set_tab_as_active();
}
select_tab(label_or_fieldname) {
for (let tab of this.tabs) {
if (
tab.label.toLowerCase() === label_or_fieldname.toLowerCase() ||
tab.df.fieldname?.toLowerCase() === label_or_fieldname.toLowerCase()
) {
tab.set_active();
return;
}
}
}
set_tab_as_active() {
let frm_active_tab = this?.frm.get_active_tab?.();
if (frm_active_tab) {
@ -456,7 +476,7 @@ frappe.ui.form.Layout = class Layout {
}
setup_events() {
this.tabs_list.off("click").on("click", ".nav-link", (e) => {
this.tab_link_container.off("click").on("click", ".nav-link", (e) => {
e.preventDefault();
e.stopImmediatePropagation();
$(e.currentTarget).tab("show");

View file

@ -4,6 +4,7 @@ export default class Section {
this.card_layout = card_layout;
this.parent = parent;
this.df = df || {};
this.columns = [];
this.fields_list = [];
this.fields_dict = {};
@ -28,9 +29,8 @@ export default class Section {
let make_card = this.card_layout;
this.wrapper = $(`<div class="row
${this.df.is_dashboard_section ? "form-dashboard-section" : "form-section"}
${make_card ? "card-section" : ""}">
${make_card ? "card-section" : ""}" data-fieldname="${this.df.fieldname}">
`).appendTo(this.parent);
this.layout && this.layout.sections.push(this);
if (this.df) {
if (this.df.label) {
@ -82,6 +82,12 @@ export default class Section {
}
}
add_field(fieldobj) {
this.fields_list.push(fieldobj);
this.fields_dict[fieldobj.fieldname] = fieldobj;
fieldobj.section = this.section;
}
refresh(hide) {
if (!this.df) return;
// hide if explicitly hidden

View file

@ -1,14 +1,12 @@
export default class Tab {
constructor(parent, df, frm, tabs_list, tabs_content) {
this.parent = parent;
constructor(layout, df, frm, tab_link_container, tabs_content) {
this.layout = layout;
this.df = df || {};
this.frm = frm;
this.doctype = this.frm.doctype;
this.label = this.df && this.df.label;
this.tabs_list = tabs_list;
this.tab_link_container = tab_link_container;
this.tabs_content = tabs_content;
this.fields_list = [];
this.fields_dict = {};
this.make();
this.setup_listeners();
this.refresh();
@ -16,17 +14,18 @@ export default class Tab {
make() {
const id = `${frappe.scrub(this.doctype, "-")}-${this.df.fieldname}`;
this.parent = $(`
this.tab_link = $(`
<li class="nav-item">
<a class="nav-link ${this.df.active ? "active" : ""}" id="${id}-tab"
data-toggle="tab"
data-fieldname="${this.df.fieldname}"
href="#${id}"
role="tab"
aria-controls="${this.label}">
${__(this.label)}
</a>
</li>
`).appendTo(this.tabs_list);
`).appendTo(this.tab_link_container);
this.wrapper = $(`<div class="tab-pane fade show ${this.df.active ? "active" : ""}"
id="${id}" role="tabpanel" aria-labelledby="${id}-tab">`).appendTo(this.tabs_content);
@ -38,11 +37,6 @@ export default class Tab {
// hide if explicitly hidden
let hide = this.df.hidden || this.df.hidden_due_to_dependency;
// hide if dashboard and not saved
if (!hide && this.df.show_dashboard && this.frm.is_new() && !this.fields_list.length) {
hide = true;
}
// hide if no read permission
if (!hide && this.frm && !this.frm.get_perm(this.df.permlevel || 0, "read")) {
hide = true;
@ -60,29 +54,38 @@ export default class Tab {
}
}
// hide if dashboard and not saved
if (!hide && this.df.show_dashboard && this.frm.is_new()) {
hide = true;
}
this.toggle(!hide);
}
toggle(show) {
this.parent.toggleClass("hide", !show);
this.tab_link.toggleClass("hide", !show);
this.wrapper.toggleClass("hide", !show);
this.parent.toggleClass("show", show);
this.tab_link.toggleClass("show", show);
this.wrapper.toggleClass("show", show);
this.hidden = !show;
}
show() {
this.parent.show();
this.tab_link.show();
}
hide() {
this.parent.hide();
this.tab_link.hide();
}
add_field(fieldobj) {
fieldobj.tab = this;
}
set_active() {
this.parent.find(".nav-link").tab("show");
this.wrapper.addClass("active");
this.frm?.set_active_tab?.(this);
this.tab_link.find(".nav-link").tab("show");
this.wrapper.addClass("show");
this.frm.active_tab = this;
}
is_active() {
@ -90,12 +93,26 @@ export default class Tab {
}
is_hidden() {
return this.wrapper.hasClass("hide");
return this.wrapper.hasClass("hide") && this.tab_link.hasClass("hide");
}
setup_listeners() {
this.parent.find(".nav-link").on("shown.bs.tab", () => {
this.tab_link.find(".nav-link").on("shown.bs.tab", () => {
this?.frm.set_active_tab?.(this);
});
}
setup_switch_on_hover() {
this.tab_link.on("dragenter", () => {
this.action = setTimeout(() => {
this.set_active();
}, 2000);
});
this.tab_link.on("dragout", () => {
if (this.action) {
clearTimeout(this.action);
this.action = null;
}
});
}
}

View file

@ -143,7 +143,6 @@ def set_test_email_config():
"mail_server": "smtp.example.com",
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",
}
)

View file

@ -2,31 +2,19 @@
# License: MIT. See LICENSE
import base64
import unittest
import requests
import frappe
from frappe.core.doctype.user.user import generate_keys
from frappe.frappeclient import AuthError, FrappeClient, FrappeException
from frappe.frappeclient import FrappeClient, FrappeException
from frappe.tests.utils import FrappeTestCase
from frappe.utils.data import get_url
class TestFrappeClient(unittest.TestCase):
class TestFrappeClient(FrappeTestCase):
PASSWORD = frappe.conf.admin_password or "admin"
@classmethod
def setUpClass(cls) -> None:
site_url = get_url()
try:
FrappeClient(site_url, "Administrator", cls.PASSWORD, verify=False)
except AuthError:
raise unittest.SkipTest(
f"AuthError raised for {site_url} [usr=Administrator, pwd={cls.PASSWORD}]"
)
return super().setUpClass()
def test_insert_many(self):
server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False)
frappe.db.delete("Note", {"title": ("in", ("Sing", "a", "song", "of", "sixpence"))})

View file

@ -19,30 +19,48 @@ from PIL import Image
import frappe
from frappe.installer import parse_app_name
from frappe.model.document import Document
from frappe.tests.utils import FrappeTestCase
from frappe.utils import (
ceil,
dict_to_str,
evaluate_filters,
execute_in_shell,
floor,
format_timedelta,
get_bench_path,
get_file_timestamp,
get_gravatar,
get_site_info,
get_sites,
get_url,
money_in_words,
parse_timedelta,
random_string,
remove_blanks,
safe_json_loads,
scrub_urls,
validate_email_address,
validate_name,
validate_phone_number_with_country_code,
validate_url,
)
from frappe.utils.data import (
add_to_date,
add_years,
cast,
cstr,
duration_to_seconds,
get_datetime,
get_first_day_of_week,
get_time,
get_timedelta,
get_timespan_date_range,
get_year_ending,
getdate,
now_datetime,
nowtime,
pretty_date,
to_timedelta,
validate_python_code,
)
from frappe.utils.dateutils import get_dates_from_timegrain
@ -322,11 +340,36 @@ class TestValidationUtils(unittest.TestCase):
self.assertFalse(validate_email_address("someone"))
self.assertFalse(validate_email_address("someone@----.com"))
self.assertFalse(
validate_email_address("test@example.com test2@example.com,undisclosed-recipient")
)
# Invalid with throw
self.assertRaises(
frappe.InvalidEmailAddressError, validate_email_address, "someone.com", throw=True
)
def test_valid_phone(self):
valid_phones = ["+91 1234567890", ""]
for phone in valid_phones:
validate_phone_number_with_country_code(phone, "field")
self.assertRaises(
frappe.InvalidPhoneNumberError,
validate_phone_number_with_country_code,
"+420 1234567890",
"field",
)
def test_validate_name(self):
valid_names = ["", "abc", "asd a13", "asd-asd"]
for name in valid_names:
validate_name(name, True)
invalid_names = ["asd$wat", "asasd/ads"]
for name in invalid_names:
self.assertRaises(frappe.InvalidNameError, validate_name, name, True)
class TestImage(unittest.TestCase):
def test_strip_exif_data(self):
@ -476,6 +519,79 @@ class TestDateUtils(unittest.TestCase):
self.assertIsInstance(get_timedelta(str(timedelta_input)), timedelta)
self.assertIsInstance(get_timedelta(str(time_input)), timedelta)
def test_to_timedelta(self):
self.assertEqual(to_timedelta("00:00:01"), timedelta(seconds=1))
self.assertEqual(to_timedelta("10:00:01"), timedelta(seconds=1, hours=10))
self.assertEqual(to_timedelta(time(hour=2)), timedelta(hours=2))
def test_add_date_utils(self):
self.assertEqual(add_years(datetime(2020, 1, 1), 1), datetime(2021, 1, 1))
def test_duration_to_sec(self):
self.assertEqual(duration_to_seconds("3h 34m 45s"), 12885)
self.assertEqual(duration_to_seconds("1h"), 3600)
self.assertEqual(duration_to_seconds("110m"), 110 * 60)
self.assertEqual(duration_to_seconds("110m"), 110 * 60)
def test_get_timespan_date_range(self):
supported_timespans = [
"last week",
"last month",
"last quarter",
"last 6 months",
"last year",
"yesterday",
"today",
"tomorrow",
"this week",
"this month",
"this quarter",
"this year",
"next week",
"next month",
"next quarter",
"next 6 months",
"next year",
]
for ts in supported_timespans:
res = get_timespan_date_range(ts)
self.assertEqual(len(res), 2)
# Manual type checking eh?
self.assertIsInstance(res[0], date)
self.assertIsInstance(res[1], date)
def test_timesmap_utils(self):
self.assertEqual(get_year_ending(date(2021, 1, 1)), date(2021, 12, 31))
self.assertEqual(get_year_ending(date(2021, 1, 31)), date(2021, 12, 31))
def test_pretty_date(self):
from frappe import _
# differnt cases
now = get_datetime()
test_cases = {
now: _("just now"),
add_to_date(now, minutes=-1): _("1 minute ago"),
add_to_date(now, minutes=-3): _("3 minutes ago"),
add_to_date(now, hours=-1): _("1 hour ago"),
add_to_date(now, hours=-2): _("2 hours ago"),
add_to_date(now, days=-1): _("Yesterday"),
add_to_date(now, days=-5): _("5 days ago"),
add_to_date(now, days=-8): _("1 week ago"),
add_to_date(now, days=-14): _("2 weeks ago"),
add_to_date(now, days=-32): _("1 month ago"),
add_to_date(now, days=-32 * 2): _("2 months ago"),
add_to_date(now, years=-1, days=-5): _("1 year ago"),
add_to_date(now, years=-2, days=-10): _("2 years ago"),
}
for dt, exp_message in test_cases.items():
self.assertEqual(pretty_date(dt), exp_message)
def test_date_from_timegrain(self):
start_date = getdate("2021-01-01")
@ -724,7 +840,7 @@ class TestLazyLoader(unittest.TestCase):
self.assertEqual(["Module `frappe.tests.data.load_sleep` loaded"], output)
class TestIdenticon(unittest.TestCase):
class TestIdenticon(FrappeTestCase):
def test_get_gravatar(self):
# developers@frappe.io has a gravatar linked so str URL will be returned
frappe.flags.in_test = False
@ -747,3 +863,38 @@ class TestIdenticon(unittest.TestCase):
identicon_bs64 = identicon.base64()
self.assertIsInstance(identicon_bs64, str)
self.assertTrue(identicon_bs64.startswith("data:image/png;base64,"))
class TestContainerUtils(FrappeTestCase):
def test_dict_to_str(self):
self.assertEqual(dict_to_str({"a": "b"}), "a=b")
def test_remove_blanks(self):
a = {"asd": "", "b": None, "c": "d"}
remove_blanks(a)
self.assertEqual(len(a), 1)
self.assertEqual(a["c"], "d")
class TestMiscUtils(FrappeTestCase):
def test_get_file_timestamp(self):
self.assertIsInstance(get_file_timestamp(__file__), str)
def test_execute_in_shell(self):
err, out = execute_in_shell("ls")
self.assertIn("apps", cstr(out))
def test_get_all_sites(self):
self.assertIn(frappe.local.site, get_sites())
def test_get_site_info(self):
info = get_site_info()
installed_apps = [app["app_name"] for app in info["installed_apps"]]
self.assertIn("frappe", installed_apps)
self.assertGreaterEqual(len(info["users"]), 1)
def test_safe_json_load(self):
self.assertEqual(safe_json_loads("{}"), {})
self.assertEqual(safe_json_loads("{ /}"), "{ /}")
self.assertEqual(safe_json_loads("12"), 12) # this is a quirk

View file

@ -9,10 +9,18 @@ import os
import re
import sys
import traceback
from collections.abc import Generator, Iterable, MutableMapping, MutableSequence, Sequence
from collections.abc import (
Container,
Generator,
Iterable,
MutableMapping,
MutableSequence,
Sequence,
)
from email.header import decode_header, make_header
from email.utils import formataddr, parseaddr
from gzip import GzipFile
from typing import Any, Literal
from urllib.parse import quote, urlparse
from redis.exceptions import ConnectionError
@ -85,13 +93,10 @@ def get_formatted_email(user, mail=None):
def extract_email_id(email):
"""fetch only the email part of the Email Address"""
email_id = parse_addr(email)[1]
if email_id and isinstance(email_id, str) and not isinstance(email_id, str):
email_id = email_id.decode("utf-8", "ignore")
return email_id
return cstr(parse_addr(email)[1])
def validate_phone_number_with_country_code(phone_number, fieldname):
def validate_phone_number_with_country_code(phone_number: str, fieldname: str) -> None:
from phonenumbers import NumberParseException, is_valid_number, parse
from frappe import _
@ -138,6 +143,8 @@ def validate_name(name, throw=False):
"""Returns True if the name is valid
valid names may have unicode and ascii characters, dash, quotes, numbers
anything else is considered invalid
Note: "Name" here is name of a person, not the primary key in Frappe doctypes.
"""
if not name:
return False
@ -218,7 +225,11 @@ def split_emails(txt):
return email_list
def validate_url(txt, throw=False, valid_schemes=None):
def validate_url(
txt: str,
throw: bool = False,
valid_schemes: str | Container[str] | None = None,
) -> bool:
"""
Checks whether `txt` has a valid URL string
@ -244,7 +255,7 @@ def validate_url(txt, throw=False, valid_schemes=None):
return is_valid
def random_string(length):
def random_string(length: int) -> str:
"""generate a random string"""
import string
from random import choice
@ -252,7 +263,7 @@ def random_string(length):
return "".join(choice(string.ascii_letters + string.digits) for i in range(length))
def has_gravatar(email):
def has_gravatar(email: str) -> str:
"""Returns gravatar url if user has set an avatar at gravatar.com"""
import requests
@ -261,9 +272,7 @@ def has_gravatar(email):
# since querying gravatar for every item will be slow
return ""
hexdigest = hashlib.md5(frappe.as_unicode(email).encode("utf-8")).hexdigest()
gravatar_url = f"https://secure.gravatar.com/avatar/{hexdigest}?d=404&s=200"
gravatar_url = get_gravatar_url(email, "404")
try:
res = requests.get(gravatar_url)
if res.status_code == 200:
@ -274,13 +283,12 @@ def has_gravatar(email):
return ""
def get_gravatar_url(email):
return "https://secure.gravatar.com/avatar/{hash}?d=mm&s=200".format(
hash=hashlib.md5(email.encode("utf-8")).hexdigest()
)
def get_gravatar_url(email: str, default: Literal["mm", "404"] = "mm") -> str:
hexdigest = hashlib.md5(frappe.as_unicode(email).encode("utf-8")).hexdigest()
return f"https://secure.gravatar.com/avatar/{hexdigest}?d={default}&s=200"
def get_gravatar(email):
def get_gravatar(email: str) -> str:
from frappe.utils.identicon import Identicon
return has_gravatar(email) or Identicon(email).base64()
@ -310,7 +318,7 @@ def log(event, details):
frappe.logger(event).info(details)
def dict_to_str(args, sep="&"):
def dict_to_str(args: dict[str, Any], sep: str = "&") -> str:
"""
Converts a dictionary to URL
"""
@ -346,18 +354,13 @@ def set_default(key, val):
return frappe.db.set_default(key, val)
def remove_blanks(d):
def remove_blanks(d: dict) -> dict:
"""
Returns d with empty ('' or None) values stripped
Returns d with empty ('' or None) values stripped. Mutates inplace.
"""
empty_keys = []
for key in d:
if d[key] == "" or d[key] is None:
# del d[key] raises runtime exception, using a workaround
empty_keys.append(key)
for key in empty_keys:
del d[key]
for k, v in tuple(d.items()):
if v == "" or v == None:
del d[k]
return d
@ -417,21 +420,20 @@ def execute_in_shell(cmd, verbose=0, low_priority=False):
import tempfile
from subprocess import Popen
with tempfile.TemporaryFile() as stdout:
with tempfile.TemporaryFile() as stderr:
kwargs = {"shell": True, "stdout": stdout, "stderr": stderr}
with (tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr):
kwargs = {"shell": True, "stdout": stdout, "stderr": stderr}
if low_priority:
kwargs["preexec_fn"] = lambda: os.nice(10)
if low_priority:
kwargs["preexec_fn"] = lambda: os.nice(10)
p = Popen(cmd, **kwargs)
p.wait()
p = Popen(cmd, **kwargs)
p.wait()
stdout.seek(0)
out = stdout.read()
stdout.seek(0)
out = stdout.read()
stderr.seek(0)
err = stderr.read()
stderr.seek(0)
err = stderr.read()
if verbose:
if err:
@ -563,7 +565,7 @@ def update_progress_bar(txt, i, l, absolute=False):
sys.stdout.flush()
return
if not getattr(frappe.local, "request", None) or is_cli():
if not getattr(frappe.local, "request", None) or is_cli(): # pragma: no cover
lt = len(txt)
try:
col = 40 if os.get_terminal_size().columns > 80 else 20
@ -746,7 +748,7 @@ def get_site_info():
kwargs = {
"fields": ["user", "creation", "full_name"],
"filters": {"Operation": "Login", "Status": "Success"},
"filters": {"operation": "Login", "status": "Success"},
"limit": "10",
}

View file

@ -483,13 +483,11 @@ def get_quarter_ending(date):
return date
def get_year_ending(date):
def get_year_ending(date) -> datetime.date:
"""returns year ending of the given date"""
date = getdate(date)
# first day of next year (note year starts from 1)
date = add_to_date(f"{date.year}-01-01", months=12)
# last day of this month
return add_to_date(date, days=-1)
next_year_start = datetime.date(date.year + 1, 1, 1)
return add_to_date(next_year_start, days=-1)
def get_time(time_str: str) -> datetime.time:
@ -724,60 +722,77 @@ def get_weekday(datetime: datetime.datetime | None = None) -> str:
return weekdays[datetime.weekday()]
def get_timespan_date_range(timespan: str) -> tuple[datetime.datetime, datetime.datetime]:
today = nowdate()
date_range_map = {
"last week": lambda: (
get_first_day_of_week(add_to_date(today, days=-7)),
get_last_day_of_week(add_to_date(today, days=-7)),
),
"last month": lambda: (
get_first_day(add_to_date(today, months=-1)),
get_last_day(add_to_date(today, months=-1)),
),
"last quarter": lambda: (
get_quarter_start(add_to_date(today, months=-3)),
get_quarter_ending(add_to_date(today, months=-3)),
),
"last 6 months": lambda: (
get_quarter_start(add_to_date(today, months=-6)),
get_quarter_ending(add_to_date(today, months=-3)),
),
"last year": lambda: (
get_year_start(add_to_date(today, years=-1)),
get_year_ending(add_to_date(today, years=-1)),
),
"yesterday": lambda: (add_to_date(today, days=-1),) * 2,
"today": lambda: (today, today),
"tomorrow": lambda: (add_to_date(today, days=1),) * 2,
"this week": lambda: (get_first_day_of_week(today), get_last_day_of_week(today)),
"this month": lambda: (get_first_day(today), get_last_day(today)),
"this quarter": lambda: (get_quarter_start(today), get_quarter_ending(today)),
"this year": lambda: (get_year_start(today), get_year_ending(today)),
"next week": lambda: (
get_first_day_of_week(add_to_date(today, days=7)),
get_last_day_of_week(add_to_date(today, days=7)),
),
"next month": lambda: (
get_first_day(add_to_date(today, months=1)),
get_last_day(add_to_date(today, months=1)),
),
"next quarter": lambda: (
get_quarter_start(add_to_date(today, months=3)),
get_quarter_ending(add_to_date(today, months=3)),
),
"next 6 months": lambda: (
get_quarter_start(add_to_date(today, months=3)),
get_quarter_ending(add_to_date(today, months=6)),
),
"next year": lambda: (
get_year_start(add_to_date(today, years=1)),
get_year_ending(add_to_date(today, years=1)),
),
}
def get_timespan_date_range(timespan: str) -> tuple[datetime.datetime, datetime.datetime] | None:
today = getdate()
if timespan in date_range_map:
return date_range_map[timespan]()
match timespan:
case "last week":
return (
get_first_day_of_week(add_to_date(today, days=-7)),
get_last_day_of_week(add_to_date(today, days=-7)),
)
case "last month":
return (
get_first_day(add_to_date(today, months=-1)),
get_last_day(add_to_date(today, months=-1)),
)
case "last quarter":
return (
get_quarter_start(add_to_date(today, months=-3)),
get_quarter_ending(add_to_date(today, months=-3)),
)
case "last 6 months":
return (
get_quarter_start(add_to_date(today, months=-6)),
get_quarter_ending(add_to_date(today, months=-3)),
)
case "last year":
return (
get_year_start(add_to_date(today, years=-1)),
get_year_ending(add_to_date(today, years=-1)),
)
case "yesterday":
return (add_to_date(today, days=-1),) * 2
case "today":
return (today, today)
case "tomorrow":
return (add_to_date(today, days=1),) * 2
case "this week":
return (get_first_day_of_week(today), get_last_day_of_week(today))
case "this month":
return (get_first_day(today), get_last_day(today))
case "this quarter":
return (get_quarter_start(today), get_quarter_ending(today))
case "this year":
return (get_year_start(today), get_year_ending(today))
case "next week":
return (
get_first_day_of_week(add_to_date(today, days=7)),
get_last_day_of_week(add_to_date(today, days=7)),
)
case "next month":
return (
get_first_day(add_to_date(today, months=1)),
get_last_day(add_to_date(today, months=1)),
)
case "next quarter":
return (
get_quarter_start(add_to_date(today, months=3)),
get_quarter_ending(add_to_date(today, months=3)),
)
case "next 6 months":
return (
get_quarter_start(add_to_date(today, months=3)),
get_quarter_ending(add_to_date(today, months=6)),
)
case "next year":
return (
get_year_start(add_to_date(today, years=1)),
get_year_ending(add_to_date(today, years=1)),
)
case _:
return
def global_date_format(date, format="long"):
@ -1460,15 +1475,15 @@ def pretty_date(iso_datetime: datetime.datetime | str) -> str:
elif dt_diff_days < 12:
return _("1 week ago")
elif dt_diff_days < 31.0:
return _("{0} weeks ago").format(cint(math.ceil(dt_diff_days / 7.0)))
return _("{0} weeks ago").format(dt_diff_days // 7)
elif dt_diff_days < 46:
return _("1 month ago")
elif dt_diff_days < 365.0:
return _("{0} months ago").format(cint(math.ceil(dt_diff_days / 30.0)))
return _("{0} months ago").format(dt_diff_days // 30)
elif dt_diff_days < 550.0:
return _("1 year ago")
else:
return f"{cint(math.floor(dt_diff_days / 365.0))} years ago"
return _("{0} years ago").format(dt_diff_days // 365)
def comma_or(some_list, add_quotes=True):
@ -1658,14 +1673,14 @@ operator_map = {
"in": lambda a, b: operator.contains(b, a),
"not in": lambda a, b: not operator.contains(b, a),
# comparison operators
"=": lambda a, b: operator.eq(a, b),
"!=": lambda a, b: operator.ne(a, b),
">": lambda a, b: operator.gt(a, b),
"<": lambda a, b: operator.lt(a, b),
">=": lambda a, b: operator.ge(a, b),
"<=": lambda a, b: operator.le(a, b),
"not None": lambda a, b: a and True or False,
"None": lambda a, b: (not a) and True or False,
"=": operator.eq,
"!=": operator.ne,
">": operator.gt,
"<": operator.lt,
">=": operator.ge,
"<=": operator.le,
"not None": lambda a, b: a is not None,
"None": lambda a, b: a is None,
}
@ -1687,13 +1702,12 @@ def evaluate_filters(doc, filters: dict | list | tuple):
def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None):
ret = False
if fieldtype:
val2 = cast(fieldtype, val2)
if condition in operator_map:
ret = operator_map[condition](val1, val2)
return operator_map[condition](val1, val2)
return ret
return False
def get_filter(doctype: str, f: dict | list | tuple, filters_config=None) -> "frappe._dict":