瀏覽代碼

Merge branch 'develop' into customize-form-highlighted-rows-fix

version-14
Shariq Ansari 3 年之前
committed by GitHub
父節點
當前提交
eadf6acab9
沒有發現已知的金鑰在資料庫的簽署中 GPG 金鑰 ID: 4AEE18F83AFDEB23
共有 32 個檔案被更改,包括 591 行新增127 行删除
  1. +7
    -6
      frappe/__init__.py
  2. +7
    -8
      frappe/core/doctype/activity_log/activity_log.py
  3. +1
    -2
      frappe/core/doctype/log_settings/log_settings.py
  4. +103
    -4
      frappe/core/doctype/log_settings/test_log_settings.py
  5. +9
    -1
      frappe/custom/doctype/custom_field/custom_field.json
  6. +2
    -3
      frappe/custom/doctype/custom_field/custom_field.py
  7. +2
    -1
      frappe/custom/doctype/customize_form/customize_form.js
  8. +11
    -2
      frappe/custom/doctype/customize_form/customize_form.py
  9. +9
    -1
      frappe/custom/doctype/property_setter/property_setter.json
  10. +4
    -4
      frappe/database/database.py
  11. +2
    -1
      frappe/desk/doctype/todo/todo.py
  12. +1
    -0
      frappe/desk/form/load.py
  13. +4
    -4
      frappe/desk/search.py
  14. +0
    -1
      frappe/email/doctype/email_queue/email_queue.py
  15. +9
    -6
      frappe/email/queue.py
  16. +3
    -0
      frappe/exceptions.py
  17. +7
    -10
      frappe/handler.py
  18. +2
    -0
      frappe/hooks.py
  19. +84
    -3
      frappe/installer.py
  20. +1
    -0
      frappe/patches.txt
  21. +17
    -0
      frappe/patches/v14_0/update_is_system_generated_flag.py
  22. +7
    -5
      frappe/public/js/frappe/file_uploader/FileUploader.vue
  23. +4
    -4
      frappe/public/js/frappe/form/controls/attach.js
  24. +230
    -54
      frappe/public/js/frappe/ui/capture.js
  25. +7
    -2
      frappe/public/js/frappe/views/calendar/calendar.js
  26. +7
    -0
      frappe/public/scss/common/controls.scss
  27. +12
    -0
      frappe/tests/test_assign.py
  28. +7
    -1
      frappe/tests/test_db.py
  29. +13
    -1
      frappe/tests/test_utils.py
  30. +5
    -0
      frappe/utils/__init__.py
  31. +7
    -0
      frappe/utils/boilerplate.py
  32. +7
    -3
      frappe/utils/logger.py

+ 7
- 6
frappe/__init__.py 查看文件

@@ -978,8 +978,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa

def delete_doc_if_exists(doctype, name, force=0):
"""Delete document if exists."""
if db.exists(doctype, name):
delete_doc(doctype, name, force=force)
delete_doc(doctype, name, force=force, ignore_missing=True)

def reload_doctype(doctype, force=False, reset_permissions=False):
"""Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files."""
@@ -1252,9 +1251,10 @@ def get_newargs(fn, kwargs):
if hasattr(fn, 'fnargs'):
fnargs = fn.fnargs
else:
fnargs = inspect.getfullargspec(fn).args
fnargs.extend(inspect.getfullargspec(fn).kwonlyargs)
varkw = inspect.getfullargspec(fn).varkw
fullargspec = inspect.getfullargspec(fn)
fnargs = fullargspec.args
fnargs.extend(fullargspec.kwonlyargs)
varkw = fullargspec.varkw

newargs = {}
for a in kwargs:
@@ -1266,7 +1266,7 @@ def get_newargs(fn, kwargs):

return newargs

def make_property_setter(args, ignore_validate=False, validate_fields_for_doctype=True):
def make_property_setter(args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True):
"""Create a new **Property Setter** (for overriding DocType and DocField properties).

If doctype is not specified, it will create a property setter for all fields with the
@@ -1297,6 +1297,7 @@ def make_property_setter(args, ignore_validate=False, validate_fields_for_doctyp
'property': args.property,
'value': args.value,
'property_type': args.property_type or "Data",
'is_system_generated': is_system_generated,
'__islocal': 1
})
ps.flags.ignore_validate = ignore_validate


+ 7
- 8
frappe/core/doctype/activity_log/activity_log.py 查看文件

@@ -1,15 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
# Copyright (c) 2022, Frappe Technologies and contributors
# License: MIT. See LICENSE

import frappe
from frappe import _
from frappe.utils import get_fullname, now
from frappe.model.document import Document
from frappe.core.utils import set_timeline_doc
import frappe
from frappe.model.document import Document
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from pypika.terms import PseudoColumn
from frappe.utils import get_fullname, now


class ActivityLog(Document):
def before_insert(self):
@@ -49,5 +48,5 @@ def clear_activity_logs(days=None):
days = 90
doctype = DocType("Activity Log")
frappe.db.delete(doctype, filters=(
doctype.creation < PseudoColumn(f"({Now() - Interval(days=days)})")
))
doctype.creation < (Now() - Interval(days=days))
))

+ 1
- 2
frappe/core/doctype/log_settings/log_settings.py 查看文件

@@ -7,7 +7,6 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from pypika.terms import PseudoColumn


class LogSettings(Document):
@@ -19,7 +18,7 @@ class LogSettings(Document):
def clear_error_logs(self):
table = DocType("Error Log")
frappe.db.delete(table, filters=(
table.creation < PseudoColumn(f"({Now() - Interval(days=self.clear_error_log_after)})")
table.creation < (Now() - Interval(days=self.clear_error_log_after))
))

def clear_activity_logs(self):


+ 103
- 4
frappe/core/doctype/log_settings/test_log_settings.py 查看文件

@@ -1,8 +1,107 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
# Copyright (c) 2022, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe

from datetime import datetime
import unittest

import frappe
from frappe.utils import now_datetime, add_to_date
from frappe.core.doctype.log_settings.log_settings import run_log_clean_up


class TestLogSettings(unittest.TestCase):
pass
@classmethod
def setUpClass(cls):
cls.savepoint = "TestLogSettings"
# SAVEPOINT can only be used in transaction blocks and we don't wan't to take chances
frappe.db.begin()
frappe.db.savepoint(cls.savepoint)

frappe.db.set_single_value(
"Log Settings",
{
"clear_error_log_after": 1,
"clear_activity_log_after": 1,
"clear_email_queue_after": 1,
},
)

@classmethod
def tearDownClass(cls):
frappe.db.rollback(save_point=cls.savepoint)

def setUp(self) -> None:
if self._testMethodName == "test_delete_logs":
self.datetime = frappe._dict()
self.datetime.current = now_datetime()
self.datetime.past = add_to_date(self.datetime.current, days=-4)
setup_test_logs(self.datetime.past)

def tearDown(self) -> None:
if self._testMethodName == "test_delete_logs":
del self.datetime

def test_delete_logs(self):
# make sure test data is present
activity_log_count = frappe.db.count(
"Activity Log", {"creation": ("<=", self.datetime.past)}
)
error_log_count = frappe.db.count(
"Error Log", {"creation": ("<=", self.datetime.past)}
)
email_queue_count = frappe.db.count(
"Email Queue", {"creation": ("<=", self.datetime.past)}
)

self.assertNotEqual(activity_log_count, 0)
self.assertNotEqual(error_log_count, 0)
self.assertNotEqual(email_queue_count, 0)

# run clean up job
run_log_clean_up()

# test if logs are deleted
activity_log_count = frappe.db.count(
"Activity Log", {"creation": ("<", self.datetime.past)}
)
error_log_count = frappe.db.count(
"Error Log", {"creation": ("<", self.datetime.past)}
)
email_queue_count = frappe.db.count(
"Email Queue", {"creation": ("<", self.datetime.past)}
)

self.assertEqual(activity_log_count, 0)
self.assertEqual(error_log_count, 0)
self.assertEqual(email_queue_count, 0)


def setup_test_logs(past: datetime) -> None:
activity_log = frappe.get_doc(
{
"doctype": "Activity Log",
"subject": "Test subject",
"full_name": "test user2",
}
).insert(ignore_permissions=True)
activity_log.db_set("creation", past)

error_log = frappe.get_doc(
{
"doctype": "Error Log",
"method": "test_method",
"error": "traceback",
}
).insert(ignore_permissions=True)
error_log.db_set("creation", past)

doc1 = frappe.get_doc(
{
"doctype": "Email Queue",
"sender": "test1@example.com",
"message": "This is a test email1",
"priority": 1,
"expose_recipients": "test@receiver.com",
}
).insert(ignore_permissions=True)
doc1.db_set("creation", past)

+ 9
- 1
frappe/custom/doctype/custom_field/custom_field.json 查看文件

@@ -7,6 +7,7 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"is_system_generated",
"dt",
"module",
"label",
@@ -425,13 +426,20 @@
"fieldtype": "Link",
"label": "Module (for export)",
"options": "Module Def"
},
{
"default": "0",
"fieldname": "is_system_generated",
"fieldtype": "Check",
"label": "Is System Generated",
"read_only": 1
}
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-02-14 15:42:21.885999",
"modified": "2022-02-28 22:22:54.893269",
"modified_by": "Administrator",
"module": "Custom",
"name": "Custom Field",


+ 2
- 3
frappe/custom/doctype/custom_field/custom_field.py 查看文件

@@ -119,7 +119,7 @@ def create_custom_field_if_values_exist(doctype, df):
frappe.db.count(dt=doctype, filters=IfNull(df.fieldname, "") != ""):
create_custom_field(doctype, df)

def create_custom_field(doctype, df, ignore_validate=False):
def create_custom_field(doctype, df, ignore_validate=False, is_system_generated=True):
df = frappe._dict(df)
if not df.fieldname and df.label:
df.fieldname = frappe.scrub(df.label)
@@ -130,8 +130,7 @@ def create_custom_field(doctype, df, ignore_validate=False):
"permlevel": 0,
"fieldtype": 'Data',
"hidden": 0,
# Looks like we always use this programatically?
# "is_standard": 1
"is_system_generated": is_system_generated
})
custom_field.update(df)
custom_field.flags.ignore_validate = ignore_validate


+ 2
- 1
frappe/custom/doctype/customize_form/customize_form.js 查看文件

@@ -246,7 +246,8 @@ frappe.ui.form.on("Customize Form Field", {
},
fields_add: function(frm, cdt, cdn) {
var f = frappe.model.get_doc(cdt, cdn);
f.is_custom_field = 1;
f.is_system_generated = false;
f.is_custom_field = true;
}
});



+ 11
- 2
frappe/custom/doctype/customize_form/customize_form.py 查看文件

@@ -402,7 +402,7 @@ class CustomizeForm(Document):
"property": prop,
"value": value,
"property_type": property_type
})
}, is_system_generated=False)

def get_existing_property_value(self, property_name, fieldname=None):
# check if there is any need to make property setter!
@@ -487,12 +487,21 @@ def reset_customization(doctype):
setters = frappe.get_all("Property Setter", filters={
'doc_type': doctype,
'field_name': ['!=', 'naming_series'],
'property': ['!=', 'options']
'property': ['!=', 'options'],
'is_system_generated': False
}, pluck='name')

for setter in setters:
frappe.delete_doc("Property Setter", setter)

custom_fields = frappe.get_all("Custom Field", filters={
'dt': doctype,
'is_system_generated': False
}, pluck='name')

for field in custom_fields:
frappe.delete_doc("Custom Field", field)

frappe.clear_cache(doctype=doctype)

doctype_properties = {


+ 9
- 1
frappe/custom/doctype/property_setter/property_setter.json 查看文件

@@ -6,6 +6,7 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"is_system_generated",
"help",
"sb0",
"doctype_or_field",
@@ -103,13 +104,20 @@
{
"fieldname": "section_break_9",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "is_system_generated",
"fieldtype": "Check",
"label": "Is System Generated",
"read_only": 1
}
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-12-14 14:15:41.929071",
"modified": "2022-02-28 22:24:12.377693",
"modified_by": "Administrator",
"module": "Custom",
"name": "Property Setter",


+ 4
- 4
frappe/database/database.py 查看文件

@@ -10,7 +10,7 @@ import re
import string
from contextlib import contextmanager
from time import time
from typing import Dict, List, Tuple, Union
from typing import Dict, List, Optional, Tuple, Union

from pypika.terms import Criterion, NullValue, PseudoColumn

@@ -561,7 +561,7 @@ class Database(object):
def get_list(*args, **kwargs):
return frappe.get_list(*args, **kwargs)

def set_single_value(self, doctype, fieldname, value, *args, **kwargs):
def set_single_value(self, doctype: str, fieldname: Union[str, Dict], value: Optional[Union[str, int]] = None, *args, **kwargs):
"""Set field value of Single DocType.

:param doctype: DocType of the single object
@@ -919,8 +919,8 @@ class Database(object):
return dn

if isinstance(dt, dict):
_dt = dt.pop("doctype")
dt, dn = _dt, dt
dt = dt.copy() # don't modify the original dict
dt, dn = dt.pop("doctype"), dt

return self.get_value(dt, dn, ignore=True, cache=cache)



+ 2
- 1
frappe/desk/doctype/todo/todo.py 查看文件

@@ -72,7 +72,8 @@ class ToDo(Document):
assignments = frappe.get_all("ToDo", filters={
"reference_type": self.reference_type,
"reference_name": self.reference_name,
"status": ("!=", "Cancelled")
"status": ("!=", "Cancelled"),
"allocated_to": ("is", "set")
}, pluck="allocated_to")
assignments.reverse()



+ 1
- 0
frappe/desk/form/load.py 查看文件

@@ -312,6 +312,7 @@ def get_assignments(dt, dn):
'reference_type': dt,
'reference_name': dn,
'status': ('!=', 'Cancelled'),
'allocated_to': ("is", "set")
})

@frappe.whitelist()


+ 4
- 4
frappe/desk/search.py 查看文件

@@ -9,7 +9,6 @@ from frappe import _, is_whitelisted
import re
import wrapt

UNTRANSLATED_DOCTYPES = ["DocType", "Role"]

def sanitize_searchfield(searchfield):
blacklisted_keywords = ['select', 'delete', 'drop', 'update', 'case', 'and', 'or', 'like']
@@ -114,6 +113,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
or_filters = []


translated_search_doctypes = frappe.get_hooks("translated_search_doctypes")
# build from doctype
if txt:
search_fields = ["name"]
@@ -125,7 +125,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,

for f in search_fields:
fmeta = meta.get_field(f.strip())
if (doctype not in UNTRANSLATED_DOCTYPES) and (f == "name" or (fmeta and fmeta.fieldtype in ["Data", "Text", "Small Text", "Long Text",
if (doctype not in translated_search_doctypes) and (f == "name" or (fmeta and fmeta.fieldtype in ["Data", "Text", "Small Text", "Long Text",
"Link", "Select", "Read Only", "Text Editor"])):
or_filters.append([doctype, f.strip(), "like", "%{0}%".format(txt)])

@@ -160,7 +160,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read'
ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))

if doctype in UNTRANSLATED_DOCTYPES:
if doctype in translated_search_doctypes:
page_length = None

values = frappe.get_list(doctype,
@@ -175,7 +175,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
as_list=not as_dict,
strict=False)

if doctype in UNTRANSLATED_DOCTYPES:
if doctype in translated_search_doctypes:
# Filtering the values array so that query is included in very element
values = (
v for v in values


+ 0
- 1
frappe/email/doctype/email_queue/email_queue.py 查看文件

@@ -111,7 +111,6 @@ class EmailQueue(Document):
""" Send emails to recipients.
"""
if not self.can_send_now():
frappe.db.rollback()
return

with SendMailContext(self, is_background_task) as ctx:


+ 9
- 6
frappe/email/queue.py 查看文件

@@ -1,10 +1,12 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE

import frappe
from frappe import msgprint, _
from frappe.utils.verified_command import get_signed_params, verify_request
from frappe.utils import get_url, now_datetime, cint
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now

def get_emails_sent_this_month(email_account=None):
"""Get count of emails sent from a specific email account.
@@ -162,15 +164,16 @@ def get_queue():
by priority desc, creation asc
limit 500''', { 'now': now_datetime() }, as_dict=True)

def clear_outbox(days=None):
def clear_outbox(days: int = None) -> None:
"""Remove low priority older than 31 days in Outbox or configured in Log Settings.
Note: Used separate query to avoid deadlock
"""
if not days:
days=31
days = days or 31
email_queue = DocType("Email Queue")

email_queues = frappe.db.sql_list("""SELECT `name` FROM `tabEmail Queue`
WHERE `priority`=0 AND `modified` < (NOW() - INTERVAL '{0}' DAY)""".format(days))
email_queues = frappe.qb.from_(email_queue).select(email_queue.name).where(
email_queue.modified < (Now() - Interval(days=days))
).run(pluck=True)

if email_queues:
frappe.db.delete("Email Queue", {"name": ("in", email_queues)})


+ 3
- 0
frappe/exceptions.py 查看文件

@@ -110,3 +110,6 @@ class InvalidAuthorizationPrefix(CSRFTokenError): pass
class InvalidAuthorizationToken(CSRFTokenError): pass
class InvalidDatabaseFile(ValidationError): pass
class ExecutableNotFound(FileNotFoundError): pass

class InvalidRemoteException(Exception):
pass

+ 7
- 10
frappe/handler.py 查看文件

@@ -225,11 +225,10 @@ def ping():

def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
"""run a whitelisted controller method"""
import json
import inspect
from inspect import getfullargspec

if not args:
args = arg or ""
if not args and arg:
args = arg

if dt: # not called from a doctype (from a page)
if not dn:
@@ -237,9 +236,7 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
doc = frappe.get_doc(dt, dn)

else:
if isinstance(docs, str):
docs = json.loads(docs)

docs = frappe.parse_json(docs)
doc = frappe.get_doc(docs)
doc._original_modified = doc.modified
doc.check_if_latest()
@@ -248,16 +245,16 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
throw_permission_error()

try:
args = json.loads(args)
args = frappe.parse_json(args)
except ValueError:
args = args
pass

method_obj = getattr(doc, method)
fn = getattr(method_obj, '__func__', method_obj)
is_whitelisted(fn)
is_valid_http_method(fn)

fnargs = inspect.getfullargspec(method_obj).args
fnargs = getfullargspec(method_obj).args

if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"):
response = doc.run_method(method)


+ 2
- 0
frappe/hooks.py 查看文件

@@ -383,3 +383,5 @@ global_search_doctypes = {
{"doctype": "Web Form"}
]
}

translated_search_doctypes = ["DocType", "Role", "Country", "Gender", "Salutation"]

+ 84
- 3
frappe/installer.py 查看文件

@@ -5,10 +5,11 @@ import json
import os
import sys
from collections import OrderedDict
from typing import List, Dict
from typing import List, Dict, Tuple

import frappe
from frappe.defaults import _clear_cache
from frappe.utils import is_git_url


def _new_site(
@@ -34,7 +35,6 @@ def _new_site(
from frappe.commands.scheduler import _is_scheduler_enabled
from frappe.utils import get_site_path, scheduler, touch_file


if not force and os.path.exists(site):
print("Site {0} already exists".format(site))
sys.exit(1)
@@ -124,6 +124,86 @@ def install_db(root_login=None, root_password=None, db_name=None, source_sql=Non
frappe.flags.in_install_db = False


def find_org(org_repo: str) -> Tuple[str, str]:
""" find the org a repo is in

find_org()
ref -> https://github.com/frappe/bench/blob/develop/bench/utils/__init__.py#L390

:param org_repo:
:type org_repo: str

:raises InvalidRemoteException: if the org is not found

:return: organisation and repository
:rtype: Tuple[str, str]
"""
from frappe.exceptions import InvalidRemoteException
import requests

for org in ["frappe", "erpnext"]:
res = requests.head(f"https://api.github.com/repos/{org}/{org_repo}")
if res.ok:
return org, org_repo

raise InvalidRemoteException


def fetch_details_from_tag(_tag: str) -> Tuple[str, str, str]:
""" parse org, repo, tag from string

fetch_details_from_tag()
ref -> https://github.com/frappe/bench/blob/develop/bench/utils/__init__.py#L403

:param _tag: input string
:type _tag: str

:return: organisation, repostitory, tag
:rtype: Tuple[str, str, str]
"""
app_tag = _tag.split("@")
org_repo = app_tag[0].split("/")

try:
repo, tag = app_tag
except ValueError:
repo, tag = app_tag + [None]

try:
org, repo = org_repo
except Exception:
org, repo = find_org(org_repo[0])

return org, repo, tag


def parse_app_name(name: str) -> str:
"""parse repo name from name

__setup_details_from_git()
ref -> https://github.com/frappe/bench/blob/develop/bench/app.py#L114


:param name: git tag
:type name: str

:return: repository name
:rtype: str
"""
name = name.rstrip("/")
if os.path.exists(name):
repo = os.path.split(name)[-1]
elif is_git_url(name):
if name.startswith("git@") or name.startswith("ssh://"):
_repo = name.split(":")[1].rsplit("/", 1)[1]
else:
_repo = name.rsplit("/", 2)[2]
repo = _repo.split(".")[0]
else:
_, repo, _ = fetch_details_from_tag(name)
return repo


def install_app(name, verbose=False, set_as_patched=True):
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
from frappe.model.sync import sync_for
@@ -140,7 +220,8 @@ def install_app(name, verbose=False, set_as_patched=True):
# install pre-requisites
if app_hooks.required_apps:
for app in app_hooks.required_apps:
install_app(app, verbose=verbose)
name = parse_app_name(name)
install_app(name, verbose=verbose)

frappe.flags.in_install = name
frappe.clear_cache()


+ 1
- 0
frappe/patches.txt 查看文件

@@ -197,4 +197,5 @@ frappe.patches.v14_0.copy_mail_data #08.03.21
frappe.patches.v14_0.update_github_endpoints #08-11-2021
frappe.patches.v14_0.remove_db_aggregation
frappe.patches.v14_0.update_color_names_in_kanban_board_column
frappe.patches.v14_0.update_is_system_generated_flag
frappe.patches.v14_0.update_auto_account_deletion_duration

+ 17
- 0
frappe/patches/v14_0/update_is_system_generated_flag.py 查看文件

@@ -0,0 +1,17 @@
import frappe

def execute():
# assuming all customization generated by Admin is system generated customization
custom_field = frappe.qb.DocType("Custom Field")
(
frappe.qb.update(custom_field)
.set(custom_field.is_system_generated, True)
.where(custom_field.owner == 'Administrator').run()
)

property_setter = frappe.qb.DocType("Property Setter")
(
frappe.qb.update(property_setter)
.set(property_setter.is_system_generated, True)
.where(property_setter.owner == 'Administrator').run()
)

+ 7
- 5
frappe/public/js/frappe/file_uploader/FileUploader.vue 查看文件

@@ -526,11 +526,13 @@ export default {
error: true
});
capture.show();
capture.submit(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
this.url_to_file(data_url, filename, 'image/png').then((file) =>
this.add_files([file])
);
capture.submit(data_urls => {
data_urls.forEach(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
this.url_to_file(data_url, filename, 'image/png').then((file) =>
this.add_files([file])
);
});
});
},
show_google_drive_picker() {


+ 4
- 4
frappe/public/js/frappe/form/controls/attach.js 查看文件

@@ -37,8 +37,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
if(this.frm) {
me.parse_validate_and_set_in_model(null);
me.refresh();
me.frm.attachments.remove_attachment_by_filename(me.value, function() {
me.parse_validate_and_set_in_model(null);
me.frm.attachments.remove_attachment_by_filename(me.value, async () => {
await me.parse_validate_and_set_in_model(null);
me.refresh();
me.frm.doc.docstatus == 1 ? me.frm.save('Update') : me.frm.save();
});
@@ -110,9 +110,9 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
return this.value || null;
}

on_upload_complete(attachment) {
async on_upload_complete(attachment) {
if(this.frm) {
this.parse_validate_and_set_in_model(attachment.file_url);
await this.parse_validate_and_set_in_model(attachment.file_url);
this.frm.attachments.update_attachment(attachment);
this.frm.doc.docstatus == 1 ? this.frm.save('Update') : this.frm.save();
}


+ 230
- 54
frappe/public/js/frappe/ui/capture.js 查看文件

@@ -28,6 +28,24 @@ frappe._.get_data_uri = element => {
return data_uri;
};

function get_file_input() {
let input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.setAttribute("multiple", "");

return input;
}

function read(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}

/**
* @description Frappe's Capture object.
*
@@ -45,6 +63,9 @@ frappe.ui.Capture = class {
constructor(options = {}) {
this.options = frappe.ui.Capture.OPTIONS;
this.set_options(options);

this.facing_mode = "environment";
this.images = [];
}

set_options(options) {
@@ -53,74 +74,229 @@ frappe.ui.Capture = class {
return this;
}

render() {
return navigator.mediaDevices.getUserMedia({ video: true }).then(stream => {
this.stream = stream;
show() {
this.build_dialog();

this.dialog = new frappe.ui.Dialog({
title: this.options.title,
animate: this.options.animate,
on_hide: () => this.stop_media_stream()
});
if (frappe.is_mobile()) {
this.show_for_mobile();
} else {
this.show_for_desktop();
}
}

this.dialog.get_close_btn().on('click', () => {
this.hide();
});
build_dialog() {
let me = this;
me.dialog = new frappe.ui.Dialog({
title: this.options.title,
animate: this.options.animate,
fields: [
{
fieldtype: "HTML",
fieldname: "capture"
},
{
fieldtype: "HTML",
fieldname: "total_count"
}
],
on_hide: this.stop_media_stream()
});

const set_take_photo_action = () => {
this.dialog.set_primary_action(__('Take Photo'), () => {
const data_url = frappe._.get_data_uri(video);
$e.find('.fc-p').attr('src', data_url);
me.$template = $(frappe.ui.Capture.TEMPLATE);

$e.find('.fc-s').hide();
$e.find('.fc-p').show();
let field = me.dialog.get_field("capture");
$(field.wrapper).html(me.$template);

this.dialog.set_secondary_action_label(__('Retake'));
this.dialog.get_secondary_btn().show();
me.dialog.get_close_btn().on('click', () => {
me.hide();
});
}

this.dialog.set_primary_action(__('Submit'), () => {
this.hide();
if (this.callback) this.callback(data_url);
});
});
};
show_for_mobile() {
let me = this;
if (!me.input) {
me.input = get_file_input();
}

set_take_photo_action();
me.input.onchange = async () => {
for (let file of me.input.files) {
let f = await read(file);
me.images.push(f);
}

this.dialog.set_secondary_action(() => {
$e.find('.fc-p').hide();
$e.find('.fc-s').show();
me.render_preview();
me.dialog.show();
};
me.input.click();
}

show_for_desktop() {
let me = this;

this.dialog.get_secondary_btn().hide();
this.dialog.get_primary_btn().off('click');
set_take_photo_action();
this.render_stream()
.then(() => {
me.dialog.show();
})
.catch(err => {
if (me.options.error) {
frappe.show_alert(frappe.ui.Capture.ERR_MESSAGE, 3);
}

throw err;
});
}

render_stream() {
let me = this;
let constraints = {
video: {
facingMode: this.facing_mode
}
};

this.dialog.get_secondary_btn().hide();
return navigator.mediaDevices.getUserMedia(constraints).then(stream => {
me.stream = stream;
me.dialog.custom_actions.empty();
me.dialog.get_primary_btn().off('click');
me.setup_take_photo_action();
me.setup_preview_action();
me.setup_toggle_camera();

me.$template.find('.fc-stream-container').show();
me.$template.find('.fc-preview-container').hide();
me.video = me.$template.find('video')[0];
me.video.srcObject = me.stream;
me.video.load();
me.video.play();
});
}

const $e = $(frappe.ui.Capture.TEMPLATE);
render_preview() {
this.stop_media_stream();
this.$template.find('.fc-stream-container').hide();
this.$template.find('.fc-preview-container').show();
this.dialog.get_primary_btn().off('click');

const video = $e.find('video')[0];
video.srcObject = this.stream;
video.play();
const $container = $(this.dialog.body);
let images = ``;

$container.html($e);
this.images.forEach((image, idx) => {
images += `
<div class="mt-1 p-1 rounded col-md-3 col-sm-4 col-xs-4" data-idx="${idx}">
<span class="capture-remove-btn" data-idx="${idx}">
${frappe.utils.icon("close", "lg")}
</span>
<img class="rounded" src="${image}" data-idx="${idx}">
</div>
`;
});

this.$template.find('.fc-preview-container').empty();
$(this.$template.find('.fc-preview-container')).html(
`<div class="row">
${images}
</div>`
);

this.setup_capture_action();
this.setup_submit_action();
this.setup_remove_action();
this.update_count();
this.dialog.custom_actions.empty();
}

show() {
this.render()
.then(() => {
this.dialog.show();
})
.catch(err => {
if (this.options.error) {
frappe.show_alert(frappe.ui.Capture.ERR_MESSAGE, 3);
}
setup_take_photo_action() {
let me = this;

throw err;
this.dialog.set_primary_action(__('Take Photo'), () => {
const data_url = frappe._.get_data_uri(me.video);

me.images.push(data_url);
me.setup_preview_action();
me.update_count();
});
}

setup_preview_action() {
let me = this;

if (!this.images.length) {
return;
}

this.dialog.set_secondary_action_label(__("Preview"));
this.dialog.set_secondary_action(() => {
me.dialog.get_primary_btn().off('click');
me.render_preview();
});
}

setup_remove_action() {
let me = this;
let elements = this.$template[0].getElementsByClassName("capture-remove-btn");

elements.forEach(el => {
el.onclick = () => {
let idx = parseInt(el.getAttribute("data-idx"));

me.images.splice(idx, 1);
me.render_preview();
};
});
}

update_count() {
let field = this.dialog.get_field("total_count");
let msg = `${__("Total Images")}: <b>${this.images.length}`;

if (this.images.length === 0) {
msg = __("No Images");
}

$(field.wrapper).html(`
<div class="row mt-2">
<div class="offset-4 col-4 d-flex justify-content-center">${msg}</b></div>
</div>
`);
}

setup_toggle_camera() {
let me = this;

this.dialog.add_custom_action(__("Switch Camera"), () => {
me.facing_mode = me.facing_mode == "environment" ? "user" : "environment";

frappe.show_alert({
message: __("Switching Camera")
});

me.stop_media_stream();
me.render_stream();
}, "btn-switch");
}

setup_capture_action() {
let me = this;

this.dialog.set_secondary_action_label(__("Capture"));
this.dialog.set_secondary_action(() => {
if (frappe.is_mobile()) {
me.show_for_mobile();
} else {
me.render_stream();
}
});
}

setup_submit_action() {
let me = this;

this.dialog.set_primary_action(__('Submit'), () => {
me.hide();

if (me.callback) {
me.callback(me.images);
}
});
}

hide() {
@@ -148,11 +324,11 @@ frappe.ui.Capture.OPTIONS = {
frappe.ui.Capture.ERR_MESSAGE = __('Unable to load camera.');
frappe.ui.Capture.TEMPLATE = `
<div class="frappe-capture">
<div class="panel panel-default">
<div class="embed-responsive embed-responsive-16by9">
<img class="fc-p embed-responsive-item" style="object-fit: contain; display: none;"/>
<video class="fc-s embed-responsive-item">${frappe.ui.Capture.ERR_MESSAGE}</video>
</div>
<div class="embed-responsive embed-responsive-16by9 fc-stream-container">
<video class="fc-stream embed-responsive-item">${frappe.ui.Capture.ERR_MESSAGE}</video>
</div>
<div class="fc-preview-container px-2" style="display: none;">
</div>
</div>
`;

+ 7
- 2
frappe/public/js/frappe/views/calendar/calendar.js 查看文件

@@ -29,7 +29,7 @@ frappe.views.CalendarView = class CalendarView extends frappe.views.ListView {
.then(() => {
this.page_title = __('{0} Calendar', [this.page_title]);
this.calendar_settings = frappe.views.calendar[this.doctype] || {};
this.calendar_name = frappe.utils.to_title_case(frappe.get_route()[3] || '');
this.calendar_name = frappe.get_route()[3];
});
}

@@ -72,12 +72,17 @@ frappe.views.CalendarView = class CalendarView extends frappe.views.ListView {
const calendar_name = this.calendar_name;

return new Promise(resolve => {
if (calendar_name === 'Default') {
if (calendar_name === 'default') {
Object.assign(options, frappe.views.calendar[this.doctype]);
resolve(options);
} else {
frappe.model.with_doc('Calendar View', calendar_name, () => {
const doc = frappe.get_doc('Calendar View', calendar_name);
if (!doc) {
frappe.show_alert(__("{0} is not a valid Calendar. Redirecting to default Calendar.", [calendar_name.bold()]));
frappe.set_route("List", this.doctype, "Calendar", "default");
return;
}
Object.assign(options, {
field_map: {
id: "name",


+ 7
- 0
frappe/public/scss/common/controls.scss 查看文件

@@ -460,3 +460,10 @@ button.data-pill {
justify-content: space-between;
align-items: center;
}

.capture-remove-btn {
position: absolute;
top: 0;
right: 0;
cursor: pointer;
}

+ 12
- 0
frappe/tests/test_assign.py 查看文件

@@ -4,6 +4,7 @@ import frappe, unittest
import frappe.desk.form.assign_to
from frappe.desk.listview import get_group_by_count
from frappe.automation.doctype.assignment_rule.test_assignment_rule import make_note
from frappe.desk.form.load import get_assignments

class TestAssign(unittest.TestCase):
def test_assign(self):
@@ -55,6 +56,17 @@ class TestAssign(unittest.TestCase):

frappe.db.rollback()

def test_assignment_removal(self):
todo = frappe.get_doc({"doctype":"ToDo", "description": "test"}).insert()
if not frappe.db.exists("User", "test@example.com"):
frappe.get_doc({"doctype":"User", "email":"test@example.com", "first_name":"Test"}).insert()

new_todo = assign(todo, "test@example.com")

# remove assignment
frappe.db.set_value("ToDo", new_todo[0].name, "allocated_to", "")

self.assertFalse(get_assignments("ToDo", todo.name))

def assign(doc, user):
return frappe.desk.form.assign_to.add({


+ 7
- 1
frappe/tests/test_db.py 查看文件

@@ -327,7 +327,13 @@ class TestDB(unittest.TestCase):
self.assertEqual(frappe.db.exists(dt, dn, cache=True), dn)
self.assertEqual(frappe.db.exists(dt, dn), dn)
self.assertEqual(frappe.db.exists(dt, {"name": ("=", dn)}), dn)
self.assertEqual(frappe.db.exists({"doctype": dt, "name": ("like", "Admin%")}), dn)

filters = {"doctype": dt, "name": ("like", "Admin%")}
self.assertEqual(frappe.db.exists(filters), dn)
self.assertEqual(
filters["doctype"], dt
) # make sure that doctype was not removed from filters

self.assertEqual(frappe.db.exists(dt, [["name", "=", dn]]), dn)




+ 13
- 1
frappe/tests/test_utils.py 查看文件

@@ -2,6 +2,7 @@
# License: MIT. See LICENSE

import io
import os
import json
import unittest
from datetime import date, datetime, time, timedelta
@@ -14,13 +15,14 @@ import pytz
from PIL import Image

import frappe
from frappe.utils import ceil, evaluate_filters, floor, format_timedelta
from frappe.utils import ceil, evaluate_filters, floor, format_timedelta, get_bench_path
from frappe.utils import get_url, money_in_words, parse_timedelta, scrub_urls
from frappe.utils import validate_email_address, validate_url
from frappe.utils.data import cast, get_time, get_timedelta, nowtime, now_datetime, validate_python_code
from frappe.utils.diff import _get_value_from_version, get_version_diff, version_query
from frappe.utils.image import optimize_image, strip_exif_data
from frappe.utils.response import json_handler
from frappe.installer import parse_app_name


class TestFilters(unittest.TestCase):
@@ -510,3 +512,13 @@ class TestLinkTitle(unittest.TestCase):
todo.delete()
user.delete()
prop_setter.delete()

class TestAppParser(unittest.TestCase):
def test_app_name_parser(self):
bench_path = get_bench_path()
frappe_app = os.path.join(bench_path, "apps", "frappe")
self.assertEqual("frappe", parse_app_name(frappe_app))
self.assertEqual("healthcare", parse_app_name("healthcare"))
self.assertEqual("healthcare", parse_app_name("https://github.com/frappe/healthcare.git"))
self.assertEqual("healthcare", parse_app_name("git@github.com:frappe/healthcare.git"))
self.assertEqual("healthcare", parse_app_name("frappe/healthcare@develop"))

+ 5
- 0
frappe/utils/__init__.py 查看文件

@@ -918,3 +918,8 @@ def add_user_info(user, user_info):
email = info.email,
time_zone = info.time_zone
)

def is_git_url(url: str) -> bool:
# modified to allow without the tailing .git from https://github.com/jonschlinkert/is-git-url.git
pattern = r"(?:git|ssh|https?|\w*@[-\w.]+):(\/\/)?(.*?)(\.git)?(\/?|\#[-\d\w._]+?)$"
return bool(re.match(pattern, url))

+ 7
- 0
frappe/utils/boilerplate.py 查看文件

@@ -333,6 +333,13 @@ app_license = "{app_license}"
# "{app_name}.auth.validate"
# ]

# Translation
# --------------------------------

# Make link fields search translated document names for these DocTypes
# Recommended only for DocTypes which have limited documents with untranslated names
# For example: Role, Gender, etc.
# translated_search_doctypes = []
"""

desktop_template = """from frappe import _


+ 7
- 3
frappe/utils/logger.py 查看文件

@@ -13,7 +13,7 @@ from frappe.utils import get_sites
default_log_level = logging.DEBUG


def get_logger(module=None, with_more_info=False, allow_site=True, filter=None, max_size=100_000, file_count=20):
def get_logger(module=None, with_more_info=False, allow_site=True, filter=None, max_size=100_000, file_count=20, stream_only=False):
"""Application Logger for your given module

Args:
@@ -23,6 +23,7 @@ def get_logger(module=None, with_more_info=False, allow_site=True, filter=None,
filter (function, optional): Add a filter function for your logger. Defaults to None.
max_size (int, optional): Max file size of each log file in bytes. Defaults to 100_000.
file_count (int, optional): Max count of log files to be retained via Log Rotation. Defaults to 20.
stream_only (bool, optional): Whether to stream logs only to stderr (True) or use log files (False). Defaults to False.

Returns:
<class 'logging.Logger'>: Returns a Python logger object with Site and Bench level logging capabilities.
@@ -54,11 +55,14 @@ def get_logger(module=None, with_more_info=False, allow_site=True, filter=None,
logger.propagate = False

formatter = logging.Formatter("%(asctime)s %(levelname)s {0} %(message)s".format(module))
handler = RotatingFileHandler(log_filename, maxBytes=max_size, backupCount=file_count)
if stream_only:
handler = logging.StreamHandler()
else:
handler = RotatingFileHandler(log_filename, maxBytes=max_size, backupCount=file_count)
handler.setFormatter(formatter)
logger.addHandler(handler)

if site:
if site and not stream_only:
sitelog_filename = os.path.join(site, "logs", logfile)
site_handler = RotatingFileHandler(sitelog_filename, maxBytes=max_size, backupCount=file_count)
site_handler.setFormatter(formatter)


Loading…
取消
儲存