浏览代码

Merge branch 'develop' of https://github.com/frappe/frappe into camera_upload

version-14
hrwx 3 年前
父节点
当前提交
1868610054
共有 30 个文件被更改,包括 482 次插入230 次删除
  1. +9
    -7
      frappe/commands/site.py
  2. +39
    -0
      frappe/core/doctype/communication/communication.py
  3. +100
    -22
      frappe/core/doctype/communication/email.py
  4. +1
    -2
      frappe/core/doctype/user/user.json
  5. +25
    -16
      frappe/desk/form/linked_with.py
  6. +1
    -1
      frappe/desk/query_report.py
  7. +1
    -2
      frappe/email/doctype/newsletter/newsletter.json
  8. +4
    -3
      frappe/email/doctype/notification/notification.py
  9. +1
    -7
      frappe/email/email_body.py
  10. +3
    -3
      frappe/hooks.py
  11. +0
    -2
      frappe/model/document.py
  12. +2
    -2
      frappe/model/rename_doc.py
  13. +1
    -0
      frappe/patches.txt
  14. +5
    -0
      frappe/patches/v14_0/update_auto_account_deletion_duration.py
  15. +9
    -98
      frappe/public/js/frappe/form/linked_with.js
  16. +1
    -1
      frappe/public/js/frappe/views/reports/query_report.js
  17. +0
    -1
      frappe/templates/emails/standard.html
  18. +1
    -2
      frappe/website/doctype/blog_post/blog_post.json
  19. +3
    -3
      frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py
  20. +14
    -2
      frappe/website/doctype/personal_data_deletion_request/test_personal_data_deletion_request.py
  21. +1
    -2
      frappe/website/doctype/web_page/web_page.json
  22. +4
    -5
      frappe/website/doctype/website_settings/website_settings.json
  23. +1
    -1
      frappe/website/web_form/request_to_delete_data/request_to_delete_data.js
  24. +50
    -1
      frappe/workflow/doctype/workflow/test_workflow.py
  25. +33
    -11
      frappe/workflow/doctype/workflow_action/workflow_action.json
  26. +131
    -36
      frappe/workflow/doctype/workflow_action/workflow_action.py
  27. +0
    -0
      frappe/workflow/doctype/workflow_action_permitted_role/__init__.py
  28. +33
    -0
      frappe/workflow/doctype/workflow_action_permitted_role/workflow_action_permitted_role.json
  29. +8
    -0
      frappe/workflow/doctype/workflow_action_permitted_role/workflow_action_permitted_role.py
  30. +1
    -0
      requirements.txt

+ 9
- 7
frappe/commands/site.py 查看文件

@@ -1,7 +1,7 @@
# imports - standard imports
import os
import sys
import shutil
import sys

# imports - third party imports
import click
@@ -65,11 +65,11 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None,
"Restore site database from an sql file"
from frappe.installer import (
_new_site,
extract_sql_from_archive,
extract_files,
extract_sql_from_archive,
is_downgrade,
is_partial,
validate_database_sql
validate_database_sql,
)
from frappe.utils.backups import Backup
if not os.path.exists(sql_file_path):
@@ -207,7 +207,7 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None,
@click.option('--encryption-key', help='Backup encryption key')
@pass_context
def partial_restore(context, sql_file_path, verbose, encryption_key=None):
from frappe.installer import partial_restore, extract_sql_from_archive
from frappe.installer import extract_sql_from_archive, partial_restore
from frappe.utils.backups import Backup

if not os.path.exists(sql_file_path):
@@ -545,7 +545,7 @@ def _use(site, sites_path='.'):

def use(site, sites_path='.'):
if os.path.exists(os.path.join(sites_path, site)):
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
sitefile.write(site)
print("Current Site set to {}".format(site))
else:
@@ -751,6 +751,7 @@ def set_admin_password(context, admin_password=None, logout_all_sessions=False):

def set_user_password(site, user, password, logout_all_sessions=False):
import getpass

from frappe.utils.password import update_password

try:
@@ -881,15 +882,16 @@ def stop_recording(context):
raise SiteNotSpecifiedError

@click.command('ngrok')
@click.option('--bind-tls', is_flag=True, default=False, help='Returns a reference to the https tunnel.')
@pass_context
def start_ngrok(context):
def start_ngrok(context, bind_tls):
from pyngrok import ngrok

site = get_site(context)
frappe.init(site=site)

port = frappe.conf.http_port or frappe.conf.webserver_port
tunnel = ngrok.connect(addr=str(port), host_header=site)
tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls)
print(f'Public URL: {tunnel.public_url}')
print('Inspect logs at http://localhost:4040')



+ 39
- 0
frappe/core/doctype/communication/communication.py 查看文件

@@ -18,6 +18,7 @@ from urllib.parse import unquote
from frappe.utils.user import is_system_user
from frappe.contacts.doctype.contact.contact import get_contact_name
from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule
from parse import compile

exclude_from_linked_with = True

@@ -114,6 +115,44 @@ class Communication(Document, CommunicationEmailMixin):
frappe.publish_realtime('new_message', self.as_dict(),
user=self.reference_name, after_commit=True)

def set_signature_in_email_content(self):
"""Set sender's User.email_signature or default outgoing's EmailAccount.signature to the email
"""
if not self.content:
return

quill_parser = compile('<div class="ql-editor read-mode">{}</div>')
email_body = quill_parser.parse(self.content)

if not email_body:
return

email_body = email_body[0]

user_email_signature = frappe.db.get_value(
"User",
self.sender,
"email_signature",
) if self.sender else None

signature = user_email_signature or frappe.db.get_value(
"Email Account",
{"default_outgoing": 1, "add_signature": 1},
"signature",
)

if not signature:
return

_signature = quill_parser.parse(signature)[0] if "ql-editor" in signature else None

if (_signature or signature) not in self.content:
self.content = f'{self.content}</p><br><p class="signature">{signature}'

def before_save(self):
if not self.flags.skip_add_signature:
self.set_signature_in_email_content()

def on_update(self):
# add to _comment property of the doctype, so it shows up in
# comments count for the list view


+ 100
- 22
frappe/core/doctype/communication/email.py 查看文件

@@ -22,12 +22,30 @@ OUTGOING_EMAIL_ACCOUNT_MISSING = _("""


@frappe.whitelist()
def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent",
sender=None, sender_full_name=None, recipients=None, communication_medium="Email", send_email=False,
print_html=None, print_format=None, attachments='[]', send_me_a_copy=False, cc=None, bcc=None,
flags=None, read_receipt=None, print_letterhead=True, email_template=None, communication_type=None,
ignore_permissions=False) -> Dict[str, str]:
"""Make a new communication.
def make(
doctype=None,
name=None,
content=None,
subject=None,
sent_or_received="Sent",
sender=None,
sender_full_name=None,
recipients=None,
communication_medium="Email",
send_email=False,
print_html=None,
print_format=None,
attachments="[]",
send_me_a_copy=False,
cc=None,
bcc=None,
read_receipt=None,
print_letterhead=True,
email_template=None,
communication_type=None,
**kwargs,
) -> Dict[str, str]:
"""Make a new communication. Checks for email permissions for specified Document.

:param doctype: Reference DocType.
:param name: Reference Document name.
@@ -44,17 +62,71 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =
:param send_me_a_copy: Send a copy to the sender (default **False**).
:param email_template: Template which is used to compose mail .
"""
is_error_report = (doctype=="User" and name==frappe.session.user and subject=="Error Report")
send_me_a_copy = cint(send_me_a_copy)
if kwargs:
from frappe.utils.commands import warn
warn(
f"Options {kwargs} used in frappe.core.doctype.communication.email.make "
"are deprecated or unsupported",
category=DeprecationWarning
)

if doctype and name and not frappe.has_permission(doctype=doctype, ptype="email", doc=name):
raise frappe.PermissionError(
f"You are not allowed to send emails related to: {doctype} {name}"
)

return _make(
doctype=doctype,
name=name,
content=content,
subject=subject,
sent_or_received=sent_or_received,
sender=sender,
sender_full_name=sender_full_name,
recipients=recipients,
communication_medium=communication_medium,
send_email=send_email,
print_html=print_html,
print_format=print_format,
attachments=attachments,
send_me_a_copy=cint(send_me_a_copy),
cc=cc,
bcc=bcc,
read_receipt=read_receipt,
print_letterhead=print_letterhead,
email_template=email_template,
communication_type=communication_type,
add_signature=False,
)

if not ignore_permissions:
if doctype and name and not is_error_report and not frappe.has_permission(doctype, "email", name) and not (flags or {}).get('ignore_doctype_permissions'):
raise frappe.PermissionError("You are not allowed to send emails related to: {doctype} {name}".format(
doctype=doctype, name=name))

if not sender:
sender = get_formatted_email(frappe.session.user)
def _make(
doctype=None,
name=None,
content=None,
subject=None,
sent_or_received="Sent",
sender=None,
sender_full_name=None,
recipients=None,
communication_medium="Email",
send_email=False,
print_html=None,
print_format=None,
attachments="[]",
send_me_a_copy=False,
cc=None,
bcc=None,
read_receipt=None,
print_letterhead=True,
email_template=None,
communication_type=None,
add_signature=True,
) -> Dict[str, str]:
"""Internal method to make a new communication that ignores Permission checks.
"""

sender = sender or get_formatted_email(frappe.session.user)
recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients
cc = list_to_str(cc) if isinstance(cc, list) else cc
bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc
@@ -77,7 +149,9 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =
"read_receipt":read_receipt,
"has_attachment": 1 if attachments else 0,
"communication_type": communication_type,
}).insert(ignore_permissions=True)
})
comm.flags.skip_add_signature = not add_signature
comm.insert(ignore_permissions=True)

# if not committed, delayed task doesn't find the communication
if attachments:
@@ -87,17 +161,21 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =

if cint(send_email):
if not comm.get_outgoing_email_account():
frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError)
frappe.throw(
msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError
)

comm.send_email(print_html=print_html, print_format=print_format,
send_me_a_copy=send_me_a_copy, print_letterhead=print_letterhead)
comm.send_email(
print_html=print_html,
print_format=print_format,
send_me_a_copy=send_me_a_copy,
print_letterhead=print_letterhead,
)

emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy)

return {
"name": comm.name,
"emails_not_sent_to": ", ".join(emails_not_sent_to)
}
return {"name": comm.name, "emails_not_sent_to": ", ".join(emails_not_sent_to)}


def validate_email(doc: "Communication") -> None:
"""Validate Email Addresses of Recipients and CC"""


+ 1
- 2
frappe/core/doctype/user/user.json 查看文件

@@ -668,8 +668,7 @@
"link_fieldname": "user"
}
],
"max_attachments": 5,
"modified": "2022-01-03 11:53:25.250822",
"modified": "2022-03-09 01:47:56.745069",
"modified_by": "Administrator",
"module": "Core",
"name": "User",


+ 25
- 16
frappe/desk/form/linked_with.py 查看文件

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

import json
from collections import defaultdict
import itertools
from typing import List
from typing import Dict, List, Optional

import frappe
import frappe.desk.form.load
@@ -367,7 +368,7 @@ def get_exempted_doctypes():


@frappe.whitelist()
def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
def get_linked_docs(doctype: str, name: str, linkinfo: Optional[Dict] = None) -> Dict[str, List]:
if isinstance(linkinfo, str):
# additional fields are added in linkinfo
linkinfo = json.loads(linkinfo)
@@ -377,23 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
if not linkinfo:
return results

if for_doctype:
links = frappe.get_doc(doctype, name).get_link_filters(for_doctype)

if links:
linkinfo = links

if for_doctype in linkinfo:
# only get linked with for this particular doctype
linkinfo = { for_doctype: linkinfo.get(for_doctype) }
else:
return results

for dt, link in linkinfo.items():
filters = []
link["doctype"] = dt
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
try:
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
except Exception as e:
if isinstance(e, frappe.DoesNotExistError):
if frappe.local.message_log:
frappe.local.message_log.pop()
continue
linkmeta = link_meta_bundle[0]

if not linkmeta.has_permission():
continue

if not linkmeta.get("issingle"):
fields = [d.fieldname for d in linkmeta.get("fields", {
"in_list_view": 1,
@@ -456,6 +455,13 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):

return results


@frappe.whitelist()
def get(doctype, docname):
linked_doctypes = get_linked_doctypes(doctype=doctype)
return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes)


@frappe.whitelist()
def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
"""add list of doctypes this doctype is 'linked' with.
@@ -470,6 +476,7 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
else:
return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype))


def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
ret = {}
# find fields where this doctype is linked
@@ -499,6 +506,7 @@ def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False)

return ret


def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):

filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]]
@@ -529,6 +537,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):

return ret


def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
ret = {}



+ 1
- 1
frappe/desk/query_report.py 查看文件

@@ -406,7 +406,7 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visi
for column in data.columns:
if column.get("hidden"):
continue
result[0].append(column.get("label"))
result[0].append(_(column.get("label")))
column_width = cint(column.get('width', 0))
# to convert into scale accepted by openpyxl
column_width /= 10


+ 1
- 2
frappe/email/doctype/newsletter/newsletter.json 查看文件

@@ -236,8 +236,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 3,
"modified": "2021-12-06 20:09:37.963141",
"modified": "2022-03-09 01:48:16.741603",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter",


+ 4
- 3
frappe/email/doctype/notification/notification.py 查看文件

@@ -186,7 +186,7 @@ def get_context(context):

def send_an_email(self, doc, context):
from email.utils import formataddr
from frappe.core.doctype.communication.email import make as make_communication
from frappe.core.doctype.communication.email import _make as make_communication
subject = self.subject
if "{" in subject:
subject = frappe.render_template(self.subject, context)
@@ -216,7 +216,8 @@ def get_context(context):
# Add mail notification to communication list
# No need to add if it is already a communication.
if doc.doctype != 'Communication':
make_communication(doctype=doc.doctype,
make_communication(
doctype=doc.doctype,
name=doc.name,
content=message,
subject=subject,
@@ -228,7 +229,7 @@ def get_context(context):
cc=cc,
bcc=bcc,
communication_type='Automated Message',
ignore_permissions=True)
)

def send_a_slack_msg(self, doc, context):
send_slack_message(


+ 1
- 7
frappe/email/email_body.py 查看文件

@@ -259,17 +259,12 @@ def get_formatted_html(subject, message, footer=None, print_html=None,

email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender)

signature = None
if "<!-- signature-included -->" not in message:
signature = get_signature(email_account)

rendered_email = frappe.get_template("templates/emails/standard.html").render({
"brand_logo": get_brand_logo(email_account) if with_container or header else None,
"with_container": with_container,
"site_url": get_url(),
"header": get_header(header),
"content": message,
"signature": signature,
"footer": get_footer(email_account, footer),
"title": subject,
"print_html": print_html,
@@ -281,8 +276,7 @@ def get_formatted_html(subject, message, footer=None, print_html=None,
if unsubscribe_link:
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html)

html = inline_style_in_html(html)
return html
return inline_style_in_html(html)

@frappe.whitelist()
def get_email_html(template, args, subject, header=None, with_container=False):


+ 3
- 3
frappe/hooks.py 查看文件

@@ -221,7 +221,8 @@ scheduler_events = {
"frappe.deferred_insert.save_to_db",
"frappe.desk.form.document_follow.send_hourly_updates",
"frappe.integrations.doctype.google_calendar.google_calendar.sync",
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email"
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email",
"frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request"
],
"daily": [
"frappe.email.queue.set_expiry_for_email_queue",
@@ -240,8 +241,7 @@ scheduler_events = {
"frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed",
"frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails",
"frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports",
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up",
"frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request"
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up"
],
"daily_long": [
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily",


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

@@ -1003,8 +1003,6 @@ class Document(BaseDocument):
- `on_cancel` for **Cancel**
- `update_after_submit` for **Update after Submit**"""

doc_before_save = self.get_doc_before_save()

if self._action=="save":
self.run_method("on_update")
elif self._action=="submit":


+ 2
- 2
frappe/model/rename_doc.py 查看文件

@@ -43,8 +43,8 @@ def update_document_title(

title_field = doc.meta.get_title_field()

title_updated = (title_field != "name") and (updated_title != doc.get(title_field))
name_updated = updated_name != doc.name
title_updated = updated_title and (title_field != "name") and (updated_title != doc.get(title_field))
name_updated = updated_name and (updated_name != doc.name)

if name_updated:
docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge)


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

@@ -197,3 +197,4 @@ 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_auto_account_deletion_duration

+ 5
- 0
frappe/patches/v14_0/update_auto_account_deletion_duration.py 查看文件

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

def execute():
days = frappe.db.get_single_value("Website Settings", "auto_account_deletion")
frappe.db.set_value("Website Settings", None, "auto_account_deletion", days * 24)

+ 9
- 98
frappe/public/js/frappe/form/linked_with.js 查看文件

@@ -1,9 +1,8 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See LICENSE


frappe.ui.form.LinkedWith = class LinkedWith {

constructor(opts) {
$.extend(this, opts);
}
@@ -21,29 +20,23 @@ frappe.ui.form.LinkedWith = class LinkedWith {
}

make_dialog() {

this.dialog = new frappe.ui.Dialog({
title: __("Linked With")
});

this.dialog.on_page_show = () => {
// execute ajax calls sequentially
// 1. get linked doctypes
// 2. load all doctypes
// 3. load linked docs
this.get_linked_doctypes()
.then(() => this.load_doctypes())
.then(() => this.links_not_permitted_or_missing())
.then(() => this.get_linked_docs())
.then(() => this.make_html());
frappe.xcall(
"frappe.desk.form.linked_with.get",
{"doctype": cur_frm.doctype, "docname": cur_frm.docname},
).then(r => {
this.frm.__linked_docs = r;
}).then(() => this.make_html());
};
}

make_html() {
const linked_docs = this.frm.__linked_docs;

let html = '';
const linked_docs = this.frm.__linked_docs;
const linked_doctypes = Object.keys(linked_docs);

if (linked_doctypes.length === 0) {
@@ -63,88 +56,6 @@ frappe.ui.form.LinkedWith = class LinkedWith {
$(this.dialog.body).html(html);
}

load_doctypes() {
const already_loaded = Object.keys(locals.DocType);
let doctypes_to_load = [];

if (this.frm.__linked_doctypes) {
doctypes_to_load =
Object.keys(this.frm.__linked_doctypes)
.filter(doctype => !already_loaded.includes(doctype));
}

// load all doctypes asynchronously using with_doctype
const promises = doctypes_to_load.map(dt => {
return frappe.model.with_doctype(dt, () => {
if(frappe.listview_settings[dt]) {
// add additional fields to __linked_doctypes
this.frm.__linked_doctypes[dt].add_fields =
frappe.listview_settings[dt].add_fields;
}
});
});

return Promise.all(promises);
}

links_not_permitted_or_missing() {
let links = null;

if (this.frm.__linked_doctypes) {
links =
Object.keys(this.frm.__linked_doctypes)
.filter(frappe.model.can_get_report);
}

let flag;
if(!links) {
$(this.dialog.body).html(`${this.frm.__linked_doctypes
? __("Not enough permission to see links")
: __("Not Linked to any record")}`);
flag = true;
}
flag = false;

// reject Promise if not_permitted or missing
return new Promise(
(resolve, reject) => flag ? reject() : resolve()
);
}

get_linked_doctypes() {
return new Promise((resolve) => {
if (this.frm.__linked_doctypes) {
resolve();
}

frappe.call({
method: "frappe.desk.form.linked_with.get_linked_doctypes",
args: {
doctype: this.frm.doctype
},
callback: (r) => {
this.frm.__linked_doctypes = r.message;
resolve();
}
});
});
}

get_linked_docs() {
return frappe.call({
method: "frappe.desk.form.linked_with.get_linked_docs",
args: {
doctype: this.frm.doctype,
name: this.frm.docname,
linkinfo: this.frm.__linked_doctypes,
for_doctype: this.for_doctype
},
callback: (r) => {
this.frm.__linked_docs = r.message || {};
}
});
}

make_doc_head(heading) {
return `
<header class="level list-row list-row-head text-muted small">


+ 1
- 1
frappe/public/js/frappe/views/reports/query_report.js 查看文件

@@ -1343,7 +1343,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
if (file_format === 'CSV') {
const column_row = this.columns.reduce((acc, col) => {
if (!col.hidden) {
acc.push(col.label);
acc.push(__(col.label));
}
return acc;
}, []);


+ 0
- 1
frappe/templates/emails/standard.html 查看文件

@@ -37,7 +37,6 @@
<tr>
<td valign="top">
<p>{{ content }}</p>
<p class="signature">{{ signature }}</p>
</td>
</tr>
</table>


+ 1
- 2
frappe/website/doctype/blog_post/blog_post.json 查看文件

@@ -213,8 +213,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 5,
"modified": "2021-11-23 10:42:01.759723",
"modified": "2022-03-09 01:48:25.227295",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",


+ 3
- 3
frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py 查看文件

@@ -7,7 +7,7 @@ import re
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import get_fullname, date_diff, get_datetime
from frappe.utils import get_fullname, time_diff_in_hours, get_datetime
from frappe.utils.user import get_system_managers
from frappe.utils.verified_command import get_signed_params, verify_request
import json
@@ -353,8 +353,8 @@ def process_data_deletion_request():

for request in requests:
doc = frappe.get_doc("Personal Data Deletion Request", request)
if date_diff(get_datetime(), doc.creation) >= auto_account_deletion:
doc.add_comment("Comment", _("The User record for this request has been auto-deleted due to inactivity."))
if time_diff_in_hours(get_datetime(), doc.creation) >= auto_account_deletion:
doc.add_comment("Comment", _("The User record for this request has been auto-deleted due to inactivity by system admins."))
doc.trigger_data_deletion()

def remove_unverified_record():


+ 14
- 2
frappe/website/doctype/personal_data_deletion_request/test_personal_data_deletion_request.py 查看文件

@@ -4,10 +4,10 @@
import frappe
import unittest
from frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request import (
remove_unverified_record,
remove_unverified_record, process_data_deletion_request
)
from frappe.website.doctype.personal_data_download_request.test_personal_data_download_request import (
create_user_if_not_exists,
create_user_if_not_exists
)
from datetime import datetime, timedelta

@@ -58,3 +58,15 @@ class TestPersonalDataDeletionRequest(unittest.TestCase):
self.assertFalse(
frappe.db.exists("Personal Data Deletion Request", self.delete_request.name)
)

def test_process_auto_request(self):
frappe.db.set_value("Website Settings", None, "auto_account_deletion", "1")
date_time_obj = datetime.strptime(
self.delete_request.creation, "%Y-%m-%d %H:%M:%S.%f"
) + timedelta(hours=-2)
self.delete_request.db_set("creation", date_time_obj)
self.delete_request.db_set("status", "Pending Approval")

process_data_deletion_request()
self.delete_request.reload()
self.assertEqual(self.delete_request.status, "Deleted")

+ 1
- 2
frappe/website/doctype/web_page/web_page.json 查看文件

@@ -338,8 +338,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 20,
"modified": "2022-01-03 13:01:48.182645",
"modified": "2022-03-09 01:45:28.548671",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Page",


+ 4
- 5
frappe/website/doctype/website_settings/website_settings.json 查看文件

@@ -404,10 +404,10 @@
"label": "Show Account Deletion Link in My Account Page"
},
{
"default": "3",
"default": "72",
"fieldname": "auto_account_deletion",
"fieldtype": "Int",
"label": "Auto Account Deletion within (Days)"
"label": "Auto Account Deletion within (Hours)"
},
{
"fieldname": "footer_powered",
@@ -420,8 +420,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"max_attachments": 10,
"modified": "2022-02-28 23:05:42.493192",
"modified": "2022-03-09 01:47:31.094462",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Settings",
@@ -446,4 +445,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

+ 1
- 1
frappe/website/web_form/request_to_delete_data/request_to_delete_data.js 查看文件

@@ -5,7 +5,7 @@ frappe.ready(function() {
callback: (data) => {
if (data.message) {
const intro_wrapper = $('#introduction .ql-editor.read-mode');
const sla_description = __("Note: Your request for account deletion will be fulfilled within {0} days.", [data.message]);
const sla_description = __("Note: Your request for account deletion will be fulfilled within {0} hours.", [data.message]);
const sla_description_wrapper = `<br><b>${sla_description}</b>`;
intro_wrapper.html(intro_wrapper.html() + sla_description_wrapper);
}


+ 50
- 1
frappe/workflow/doctype/workflow/test_workflow.py 查看文件

@@ -5,6 +5,7 @@ import unittest
from frappe.utils import random_string
from frappe.model.workflow import apply_workflow, WorkflowTransitionError, WorkflowPermissionError, get_common_transition_actions
from frappe.test_runner import make_test_records
from frappe.query_builder import DocType


class TestWorkflow(unittest.TestCase):
@@ -15,9 +16,31 @@ class TestWorkflow(unittest.TestCase):
def setUp(self):
self.workflow = create_todo_workflow()
frappe.set_user('Administrator')
if self._testMethodName == "test_if_workflow_actions_were_processed_using_user":
if not frappe.db.has_column('Workflow Action', 'user'):
# mariadb would raise this statement would create an implicit commit
# if we do not commit before alter statement
# nosemgrep
frappe.db.commit()
frappe.db.multisql({
'mariadb': 'ALTER TABLE `tabWorkflow Action` ADD COLUMN user varchar(140)',
'postgres': 'ALTER TABLE "tabWorkflow Action" ADD COLUMN "user" varchar(140)'
})
frappe.cache().delete_value('table_columns')

def tearDown(self):
frappe.delete_doc('Workflow', 'Test ToDo')
if self._testMethodName == "test_if_workflow_actions_were_processed_using_user":
if frappe.db.has_column('Workflow Action', 'user'):
# mariadb would raise this statement would create an implicit commit
# if we do not commit before alter statement
# nosemgrep
frappe.db.commit()
frappe.db.multisql({
'mariadb': 'ALTER TABLE `tabWorkflow Action` DROP COLUMN user',
'postgres': 'ALTER TABLE "tabWorkflow Action" DROP COLUMN "user"'
})
frappe.cache().delete_value('table_columns')

def test_default_condition(self):
'''test default condition is set'''
@@ -75,7 +98,7 @@ class TestWorkflow(unittest.TestCase):
actions = get_common_transition_actions([todo1, todo2], 'ToDo')
self.assertListEqual(actions, ['Review'])

def test_if_workflow_actions_were_processed(self):
def test_if_workflow_actions_were_processed_using_role(self):
frappe.db.delete("Workflow Action")
user = frappe.get_doc('User', 'test2@example.com')
user.add_roles('Test Approver', 'System Manager')
@@ -93,6 +116,32 @@ class TestWorkflow(unittest.TestCase):
self.assertEqual(workflow_actions[0].status, 'Completed')
frappe.set_user('Administrator')

def test_if_workflow_actions_were_processed_using_user(self):
frappe.db.delete("Workflow Action")

user = frappe.get_doc('User', 'test2@example.com')
user.add_roles('Test Approver', 'System Manager')
frappe.set_user('test2@example.com')

doc = self.test_default_condition()
workflow_actions = frappe.get_all('Workflow Action', fields=['*'])
self.assertEqual(len(workflow_actions), 1)

# test if status of workflow actions are updated on approval
WorkflowAction = DocType("Workflow Action")
WorkflowActionPermittedRole = DocType("Workflow Action Permitted Role")
frappe.qb.update(WorkflowAction).set(WorkflowAction.user, 'test2@example.com').run()
frappe.qb.update(WorkflowActionPermittedRole).set(WorkflowActionPermittedRole.role, '').run()

self.test_approve(doc)

user.remove_roles('Test Approver', 'System Manager')
workflow_actions = frappe.get_all('Workflow Action', fields=['status'])
self.assertEqual(len(workflow_actions), 1)
self.assertEqual(workflow_actions[0].status, 'Completed')
frappe.set_user('Administrator')


def test_update_docstatus(self):
todo = create_new_todo()
apply_workflow(todo, 'Approve')


+ 33
- 11
frappe/workflow/doctype/workflow_action/workflow_action.json 查看文件

@@ -8,9 +8,12 @@
"status",
"reference_name",
"reference_doctype",
"user",
"workflow_state",
"completed_by"
"column_break_5",
"completed_by_role",
"completed_by",
"permitted_roles",
"user"
],
"fields": [
{
@@ -24,16 +27,14 @@
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"label": "Reference Name",
"options": "reference_doctype",
"search_index": 1
"options": "reference_doctype"
},
{
"fieldname": "reference_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Reference Document Type",
"options": "DocType",
"search_index": 1
"options": "DocType"
},
{
"fieldname": "user",
@@ -47,18 +48,38 @@
"fieldname": "workflow_state",
"fieldtype": "Data",
"hidden": 1,
"label": "Workflow State",
"search_index": 1
"label": "Workflow State"
},
{
"depends_on": "eval: doc.completed_by",
"fieldname": "completed_by",
"fieldtype": "Link",
"label": "Completed By",
"options": "User"
"label": "Completed By User",
"options": "User",
"read_only": 1
},
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"depends_on": "eval: doc.completed_by_role",
"fieldname": "completed_by_role",
"fieldtype": "Link",
"label": "Completed By Role",
"options": "Role",
"read_only": 1
},
{
"fieldname": "permitted_roles",
"fieldtype": "Table MultiSelect",
"label": "Permitted Roles",
"options": "Workflow Action Permitted Role",
"read_only": 1
}
],
"links": [],
"modified": "2021-07-01 09:07:52.848618",
"modified": "2022-02-23 21:06:45.122258",
"modified_by": "Administrator",
"module": "Workflow",
"name": "Workflow Action",
@@ -72,6 +93,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "reference_name",
"track_changes": 1
}

+ 131
- 36
frappe/workflow/doctype/workflow_action/workflow_action.py 查看文件

@@ -13,24 +13,46 @@ from frappe.model.workflow import apply_workflow, get_workflow_name, has_approva
from frappe.desk.notifications import clear_doctype_notifications
from frappe.utils.user import get_users_with_role
from frappe.utils.data import get_link_to_form
from frappe.query_builder import DocType

class WorkflowAction(Document):
pass


def on_doctype_update():
frappe.db.add_index("Workflow Action", ["status", "user"])
# The search order in any use case is no ["reference_name", "reference_doctype", "status"]
# The index scan would happen from left to right
# so even if status is not in the where clause the index will be used
frappe.db.add_index("Workflow Action", ["reference_name", "reference_doctype", "status"])

def get_permission_query_conditions(user):
if not user: user = frappe.session.user

if user == "Administrator": return ""

return "(`tabWorkflow Action`.`user`='{user}')".format(user=user)
roles = frappe.get_roles(user)

WorkflowAction = DocType("Workflow Action")
WorkflowActionPermittedRole = DocType("Workflow Action Permitted Role")

permitted_workflow_actions = (frappe.qb.from_(WorkflowAction)
.join(WorkflowActionPermittedRole)
.on(WorkflowAction.name == WorkflowActionPermittedRole.parent)
.select(WorkflowAction.name)
.where(WorkflowActionPermittedRole.role.isin(roles))
).get_sql()

return f"""(`tabWorkflow Action`.`name` in ({permitted_workflow_actions})
or `tabWorkflow Action`.`user`='{user}')
and `tabWorkflow Action`.`status`='Open'"""

def has_permission(doc, user):
if user not in ['Administrator', doc.user]:
return False

user_roles = set(frappe.get_roles(user))

permitted_roles = {permitted_role.role for permitted_role in doc.permitted_roles}

return user == "Administrator" or user_roles.intersection(permitted_roles)

def process_workflow_actions(doc, state):
workflow = get_workflow_name(doc.get('doctype'))
@@ -42,19 +64,18 @@ def process_workflow_actions(doc, state):

if is_workflow_action_already_created(doc): return

clear_old_workflow_actions(doc)
update_completed_workflow_actions(doc)
update_completed_workflow_actions(doc, workflow=workflow, workflow_state=get_doc_workflow_state(doc))
clear_doctype_notifications('Workflow Action')

next_possible_transitions = get_next_possible_transitions(workflow, get_doc_workflow_state(doc), doc)

if not next_possible_transitions: return

user_data_map = get_users_next_action_data(next_possible_transitions, doc)
user_data_map, roles = get_users_next_action_data(next_possible_transitions, doc)

if not user_data_map: return

create_workflow_actions_for_users(user_data_map.keys(), doc)
create_workflow_actions_for_roles(roles, doc)

if send_email_alert(workflow):
enqueue(send_workflow_action_email, queue='short', users_data=list(user_data_map.values()), doc=doc)
@@ -132,20 +153,85 @@ def return_link_expired_page(doc, doc_workflow_state):
frappe.bold(frappe.get_value('User', doc.get("modified_by"), 'full_name'))
), indicator_color='blue')

def clear_old_workflow_actions(doc, user=None):

def update_completed_workflow_actions(doc, user=None, workflow=None, workflow_state=None):
allowed_roles = get_allowed_roles(user, workflow, workflow_state)
# There is no transaction leading upto this state
# so no older actions to complete
if not allowed_roles:
return
if workflow_action := get_workflow_action_by_role(doc, allowed_roles):
update_completed_workflow_actions_using_role(doc, user, allowed_roles, workflow_action)
else:
# backwards compatibility
# for workflow actions saved using user
clear_old_workflow_actions_using_user(doc, user)
update_completed_workflow_actions_using_user(doc, user)

def get_allowed_roles(user, workflow, workflow_state):
user = user if user else frappe.session.user
frappe.db.delete("Workflow Action", {
"reference_doctype": doc.get("doctype"),
"reference_name": doc.get("name"),
"user": ("!=", user),
"status": "Open"
})

def update_completed_workflow_actions(doc, user=None):
allowed_roles = frappe.get_all('Workflow Transition',
fields='allowed',
filters=[
['parent', '=', workflow],
['next_state', '=', workflow_state]
],
pluck = 'allowed')

user_roles = set(frappe.get_roles(user))
return set(allowed_roles).intersection(user_roles)

def get_workflow_action_by_role(doc, allowed_roles):
WorkflowAction = DocType("Workflow Action")
WorkflowActionPermittedRole = DocType("Workflow Action Permitted Role")
return (frappe.qb.from_(WorkflowAction).join(WorkflowActionPermittedRole)
.on(WorkflowAction.name == WorkflowActionPermittedRole.parent)
.select(WorkflowAction.name, WorkflowActionPermittedRole.role)
.where((WorkflowAction.reference_name == doc.get('name'))
& (WorkflowAction.reference_doctype == doc.get('doctype'))
& (WorkflowAction.status == 'Open')
& (WorkflowActionPermittedRole.role.isin(list(allowed_roles))))
.orderby(WorkflowActionPermittedRole.role).limit(1)).run(as_dict=True)

def update_completed_workflow_actions_using_role(doc, user=None, allowed_roles = set(), workflow_action=None):
user = user if user else frappe.session.user
frappe.db.sql("""UPDATE `tabWorkflow Action` SET `status`='Completed', `completed_by`=%s
WHERE `reference_doctype`=%s AND `reference_name`=%s AND `user`=%s AND `status`='Open'""",
(user, doc.get('doctype'), doc.get('name'), user))
WorkflowAction = DocType("Workflow Action")

if not workflow_action:
return

(frappe.qb.update(WorkflowAction)
.set(WorkflowAction.status, 'Completed')
.set(WorkflowAction.completed_by, user)
.set(WorkflowAction.completed_by_role, workflow_action[0].role)
.where(WorkflowAction.name == workflow_action[0].name)
).run()

def clear_old_workflow_actions_using_user(doc, user=None):
user = user if user else frappe.session.user

if frappe.db.has_column('Workflow Action', 'user'):
frappe.db.delete("Workflow Action", {
"reference_name": doc.get("name"),
"reference_doctype": doc.get("doctype"),
"status": "Open",
"user": ("!=", user)
})

def update_completed_workflow_actions_using_user(doc, user=None):
user = user or frappe.session.user

if frappe.db.has_column('Workflow Action', 'user'):
WorkflowAction = DocType("Workflow Action")
(frappe.qb.update(WorkflowAction)
.set(WorkflowAction.status, 'Completed')
.set(WorkflowAction.completed_by, user)
.where((WorkflowAction.reference_name == doc.get('name'))
& (WorkflowAction.reference_doctype == doc.get('doctype'))
& (WorkflowAction.status == 'Open')
& (WorkflowAction.user == user))
).run()

def get_next_possible_transitions(workflow_name, state, doc=None):
transitions = frappe.get_all('Workflow Transition',
@@ -167,8 +253,10 @@ def get_next_possible_transitions(workflow_name, state, doc=None):
return transitions_to_return

def get_users_next_action_data(transitions, doc):
roles = set()
user_data_map = {}
for transition in transitions:
roles.add(transition.allowed)
users = get_users_with_role(transition.allowed)
filtered_users = filter_allowed_users(users, doc, transition)
for user in filtered_users:
@@ -182,19 +270,24 @@ def get_users_next_action_data(transitions, doc):
'action_name': transition.action,
'action_link': get_workflow_action_url(transition.action, doc, user)
}))
return user_data_map
return user_data_map, roles


def create_workflow_actions_for_users(users, doc):
for user in users:
frappe.get_doc({
'doctype': 'Workflow Action',
'reference_doctype': doc.get('doctype'),
'reference_name': doc.get('name'),
'workflow_state': get_doc_workflow_state(doc),
'status': 'Open',
'user': user
}).insert(ignore_permissions=True)
def create_workflow_actions_for_roles(roles, doc):
workflow_action = frappe.get_doc({
'doctype': 'Workflow Action',
'reference_doctype': doc.get('doctype'),
'reference_name': doc.get('name'),
'workflow_state': get_doc_workflow_state(doc),
'status': 'Open',
})

for role in roles:
workflow_action.append('permitted_roles', {
'role': role
})

workflow_action.insert(ignore_permissions=True)

def send_workflow_action_email(users_data, doc):
common_args = get_common_email_args(doc)
@@ -249,18 +342,20 @@ def get_confirm_workflow_action_url(doc, action, user):
def is_workflow_action_already_created(doc):
return frappe.db.exists({
'doctype': 'Workflow Action',
'reference_doctype': doc.get('doctype'),
'reference_name': doc.get('name'),
'workflow_state': get_doc_workflow_state(doc)
'reference_doctype': doc.get('doctype'),
'workflow_state': get_doc_workflow_state(doc),
})

def clear_workflow_actions(doctype, name):
if not (doctype and name):
return
frappe.db.delete("Workflow Action", {
"reference_doctype": doctype,
"reference_name": name
})
frappe.db.delete("Workflow Action", filters = {
"reference_name": name,
"reference_doctype": doctype,
}
)

def get_doc_workflow_state(doc):
workflow_name = get_workflow_name(doc.get('doctype'))
workflow_state_field = get_workflow_state_field(workflow_name)


+ 0
- 0
frappe/workflow/doctype/workflow_action_permitted_role/__init__.py 查看文件


+ 33
- 0
frappe/workflow/doctype/workflow_action_permitted_role/workflow_action_permitted_role.json 查看文件

@@ -0,0 +1,33 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2022-02-21 20:28:05.662187",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"role"
],
"fields": [
{
"fieldname": "role",
"fieldtype": "Link",
"label": "Role",
"options": "Role"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-02-21 20:28:05.662187",
"modified_by": "Administrator",
"module": "Workflow",
"name": "Workflow Action Permitted Role",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

+ 8
- 0
frappe/workflow/doctype/workflow_action_permitted_role/workflow_action_permitted_role.py 查看文件

@@ -0,0 +1,8 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt

# import frappe
from frappe.model.document import Document

class WorkflowActionPermittedRole(Document):
pass

+ 1
- 0
requirements.txt 查看文件

@@ -29,6 +29,7 @@ maxminddb-geolite2==2018.703
num2words~=0.5.10
oauthlib~=3.1.0
openpyxl~=3.0.7
parse~=1.19.0
passlib~=1.7.4
paytmchecksum~=1.7.0
pdfkit~=0.6.1


正在加载...
取消
保存