Merge branch 'develop' into get-all-mod
This commit is contained in:
commit
ae61df8273
27 changed files with 739 additions and 251 deletions
103
.github/helper/ci.py
vendored
Normal file
103
.github/helper/ci.py
vendored
Normal 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)
|
||||
3
.github/workflows/patch-mariadb-tests.yml
vendored
3
.github/workflows/patch-mariadb-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/server-mariadb-tests.yml
vendored
8
.github/workflows/server-mariadb-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/server-postgres-tests.yml
vendored
8
.github/workflows/server-postgres-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/ui-tests.yml
vendored
7
.github/workflows/ui-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
81
frappe/public/js/frappe/form/form_editor.js
Normal file
81
frappe/public/js/frappe/form/form_editor.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue