瀏覽代碼

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 # imports - standard imports
import os import os
import sys
import shutil import shutil
import sys


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


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


def use(site, sites_path='.'): def use(site, sites_path='.'):
if os.path.exists(os.path.join(sites_path, site)): 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) sitefile.write(site)
print("Current Site set to {}".format(site)) print("Current Site set to {}".format(site))
else: 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): def set_user_password(site, user, password, logout_all_sessions=False):
import getpass import getpass

from frappe.utils.password import update_password from frappe.utils.password import update_password


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


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


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


port = frappe.conf.http_port or frappe.conf.webserver_port 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(f'Public URL: {tunnel.public_url}')
print('Inspect logs at http://localhost:4040') 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.utils.user import is_system_user
from frappe.contacts.doctype.contact.contact import get_contact_name 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 frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule
from parse import compile


exclude_from_linked_with = True exclude_from_linked_with = True


@@ -114,6 +115,44 @@ class Communication(Document, CommunicationEmailMixin):
frappe.publish_realtime('new_message', self.as_dict(), frappe.publish_realtime('new_message', self.as_dict(),
user=self.reference_name, after_commit=True) 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): def on_update(self):
# add to _comment property of the doctype, so it shows up in # add to _comment property of the doctype, so it shows up in
# comments count for the list view # comments count for the list view


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

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




@frappe.whitelist() @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 doctype: Reference DocType.
:param name: Reference Document name. :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 send_me_a_copy: Send a copy to the sender (default **False**).
:param email_template: Template which is used to compose mail . :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 recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients
cc = list_to_str(cc) if isinstance(cc, list) else cc cc = list_to_str(cc) if isinstance(cc, list) else cc
bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc 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, "read_receipt":read_receipt,
"has_attachment": 1 if attachments else 0, "has_attachment": 1 if attachments else 0,
"communication_type": communication_type, "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 not committed, delayed task doesn't find the communication
if attachments: if attachments:
@@ -87,17 +161,21 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =


if cint(send_email): if cint(send_email):
if not comm.get_outgoing_email_account(): 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) 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: def validate_email(doc: "Communication") -> None:
"""Validate Email Addresses of Recipients and CC""" """Validate Email Addresses of Recipients and CC"""


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

@@ -668,8 +668,7 @@
"link_fieldname": "user" "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", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "User", "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 # License: MIT. See LICENSE

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


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




@frappe.whitelist() @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): if isinstance(linkinfo, str):
# additional fields are added in linkinfo # additional fields are added in linkinfo
linkinfo = json.loads(linkinfo) linkinfo = json.loads(linkinfo)
@@ -377,23 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
if not linkinfo: if not linkinfo:
return results 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(): for dt, link in linkinfo.items():
filters = [] filters = []
link["doctype"] = dt 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] linkmeta = link_meta_bundle[0]

if not linkmeta.has_permission():
continue

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


return results 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() @frappe.whitelist()
def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
"""add list of doctypes this doctype is 'linked' with. """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: else:
return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype))



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


return ret return ret



def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):


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


return ret return ret



def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False): def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
ret = {} 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: for column in data.columns:
if column.get("hidden"): if column.get("hidden"):
continue continue
result[0].append(column.get("label"))
result[0].append(_(column.get("label")))
column_width = cint(column.get('width', 0)) column_width = cint(column.get('width', 0))
# to convert into scale accepted by openpyxl # to convert into scale accepted by openpyxl
column_width /= 10 column_width /= 10


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

@@ -236,8 +236,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_published_field": "published", "is_published_field": "published",
"links": [], "links": [],
"max_attachments": 3,
"modified": "2021-12-06 20:09:37.963141",
"modified": "2022-03-09 01:48:16.741603",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Newsletter", "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): def send_an_email(self, doc, context):
from email.utils import formataddr 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 subject = self.subject
if "{" in subject: if "{" in subject:
subject = frappe.render_template(self.subject, context) subject = frappe.render_template(self.subject, context)
@@ -216,7 +216,8 @@ def get_context(context):
# Add mail notification to communication list # Add mail notification to communication list
# No need to add if it is already a communication. # No need to add if it is already a communication.
if doc.doctype != 'Communication': if doc.doctype != 'Communication':
make_communication(doctype=doc.doctype,
make_communication(
doctype=doc.doctype,
name=doc.name, name=doc.name,
content=message, content=message,
subject=subject, subject=subject,
@@ -228,7 +229,7 @@ def get_context(context):
cc=cc, cc=cc,
bcc=bcc, bcc=bcc,
communication_type='Automated Message', communication_type='Automated Message',
ignore_permissions=True)
)


def send_a_slack_msg(self, doc, context): def send_a_slack_msg(self, doc, context):
send_slack_message( 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) 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({ rendered_email = frappe.get_template("templates/emails/standard.html").render({
"brand_logo": get_brand_logo(email_account) if with_container or header else None, "brand_logo": get_brand_logo(email_account) if with_container or header else None,
"with_container": with_container, "with_container": with_container,
"site_url": get_url(), "site_url": get_url(),
"header": get_header(header), "header": get_header(header),
"content": message, "content": message,
"signature": signature,
"footer": get_footer(email_account, footer), "footer": get_footer(email_account, footer),
"title": subject, "title": subject,
"print_html": print_html, "print_html": print_html,
@@ -281,8 +276,7 @@ def get_formatted_html(subject, message, footer=None, print_html=None,
if unsubscribe_link: if unsubscribe_link:
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html) 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() @frappe.whitelist()
def get_email_html(template, args, subject, header=None, with_container=False): 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.deferred_insert.save_to_db",
"frappe.desk.form.document_follow.send_hourly_updates", "frappe.desk.form.document_follow.send_hourly_updates",
"frappe.integrations.doctype.google_calendar.google_calendar.sync", "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": [ "daily": [
"frappe.email.queue.set_expiry_for_email_queue", "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.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed",
"frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", "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.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": [ "daily_long": [
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", "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** - `on_cancel` for **Cancel**
- `update_after_submit` for **Update after Submit**""" - `update_after_submit` for **Update after Submit**"""


doc_before_save = self.get_doc_before_save()

if self._action=="save": if self._action=="save":
self.run_method("on_update") self.run_method("on_update")
elif self._action=="submit": 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_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: if name_updated:
docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge) 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.update_github_endpoints #08-11-2021
frappe.patches.v14_0.remove_db_aggregation frappe.patches.v14_0.remove_db_aggregation
frappe.patches.v14_0.update_color_names_in_kanban_board_column 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 { frappe.ui.form.LinkedWith = class LinkedWith {

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


make_dialog() { make_dialog() {

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


this.dialog.on_page_show = () => { 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() { make_html() {
const linked_docs = this.frm.__linked_docs;

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


if (linked_doctypes.length === 0) { if (linked_doctypes.length === 0) {
@@ -63,88 +56,6 @@ frappe.ui.form.LinkedWith = class LinkedWith {
$(this.dialog.body).html(html); $(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) { make_doc_head(heading) {
return ` return `
<header class="level list-row list-row-head text-muted small"> <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') { if (file_format === 'CSV') {
const column_row = this.columns.reduce((acc, col) => { const column_row = this.columns.reduce((acc, col) => {
if (!col.hidden) { if (!col.hidden) {
acc.push(col.label);
acc.push(__(col.label));
} }
return acc; return acc;
}, []); }, []);


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

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


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

@@ -213,8 +213,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_published_field": "published", "is_published_field": "published",
"links": [], "links": [],
"max_attachments": 5,
"modified": "2021-11-23 10:42:01.759723",
"modified": "2022-03-09 01:48:25.227295",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Website", "module": "Website",
"name": "Blog Post", "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 import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document 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.user import get_system_managers
from frappe.utils.verified_command import get_signed_params, verify_request from frappe.utils.verified_command import get_signed_params, verify_request
import json import json
@@ -353,8 +353,8 @@ def process_data_deletion_request():


for request in requests: for request in requests:
doc = frappe.get_doc("Personal Data Deletion Request", request) 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() doc.trigger_data_deletion()


def remove_unverified_record(): 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 frappe
import unittest import unittest
from frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request import ( 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 ( 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 from datetime import datetime, timedelta


@@ -58,3 +58,15 @@ class TestPersonalDataDeletionRequest(unittest.TestCase):
self.assertFalse( self.assertFalse(
frappe.db.exists("Personal Data Deletion Request", self.delete_request.name) 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, "index_web_pages_for_search": 1,
"is_published_field": "published", "is_published_field": "published",
"links": [], "links": [],
"max_attachments": 20,
"modified": "2022-01-03 13:01:48.182645",
"modified": "2022-03-09 01:45:28.548671",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Website", "module": "Website",
"name": "Web Page", "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" "label": "Show Account Deletion Link in My Account Page"
}, },
{ {
"default": "3",
"default": "72",
"fieldname": "auto_account_deletion", "fieldname": "auto_account_deletion",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Auto Account Deletion within (Days)"
"label": "Auto Account Deletion within (Hours)"
}, },
{ {
"fieldname": "footer_powered", "fieldname": "footer_powered",
@@ -420,8 +420,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"max_attachments": 10,
"modified": "2022-02-28 23:05:42.493192",
"modified": "2022-03-09 01:47:31.094462",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Website", "module": "Website",
"name": "Website Settings", "name": "Website Settings",
@@ -446,4 +445,4 @@
"sort_order": "ASC", "sort_order": "ASC",
"states": [], "states": [],
"track_changes": 1 "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) => { callback: (data) => {
if (data.message) { if (data.message) {
const intro_wrapper = $('#introduction .ql-editor.read-mode'); 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>`; const sla_description_wrapper = `<br><b>${sla_description}</b>`;
intro_wrapper.html(intro_wrapper.html() + sla_description_wrapper); 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.utils import random_string
from frappe.model.workflow import apply_workflow, WorkflowTransitionError, WorkflowPermissionError, get_common_transition_actions from frappe.model.workflow import apply_workflow, WorkflowTransitionError, WorkflowPermissionError, get_common_transition_actions
from frappe.test_runner import make_test_records from frappe.test_runner import make_test_records
from frappe.query_builder import DocType




class TestWorkflow(unittest.TestCase): class TestWorkflow(unittest.TestCase):
@@ -15,9 +16,31 @@ class TestWorkflow(unittest.TestCase):
def setUp(self): def setUp(self):
self.workflow = create_todo_workflow() self.workflow = create_todo_workflow()
frappe.set_user('Administrator') 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): def tearDown(self):
frappe.delete_doc('Workflow', 'Test ToDo') 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): def test_default_condition(self):
'''test default condition is set''' '''test default condition is set'''
@@ -75,7 +98,7 @@ class TestWorkflow(unittest.TestCase):
actions = get_common_transition_actions([todo1, todo2], 'ToDo') actions = get_common_transition_actions([todo1, todo2], 'ToDo')
self.assertListEqual(actions, ['Review']) 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") frappe.db.delete("Workflow Action")
user = frappe.get_doc('User', 'test2@example.com') user = frappe.get_doc('User', 'test2@example.com')
user.add_roles('Test Approver', 'System Manager') user.add_roles('Test Approver', 'System Manager')
@@ -93,6 +116,32 @@ class TestWorkflow(unittest.TestCase):
self.assertEqual(workflow_actions[0].status, 'Completed') self.assertEqual(workflow_actions[0].status, 'Completed')
frappe.set_user('Administrator') 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): def test_update_docstatus(self):
todo = create_new_todo() todo = create_new_todo()
apply_workflow(todo, 'Approve') apply_workflow(todo, 'Approve')


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

@@ -8,9 +8,12 @@
"status", "status",
"reference_name", "reference_name",
"reference_doctype", "reference_doctype",
"user",
"workflow_state", "workflow_state",
"completed_by"
"column_break_5",
"completed_by_role",
"completed_by",
"permitted_roles",
"user"
], ],
"fields": [ "fields": [
{ {
@@ -24,16 +27,14 @@
"fieldname": "reference_name", "fieldname": "reference_name",
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"label": "Reference Name", "label": "Reference Name",
"options": "reference_doctype",
"search_index": 1
"options": "reference_doctype"
}, },
{ {
"fieldname": "reference_doctype", "fieldname": "reference_doctype",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Reference Document Type", "label": "Reference Document Type",
"options": "DocType",
"search_index": 1
"options": "DocType"
}, },
{ {
"fieldname": "user", "fieldname": "user",
@@ -47,18 +48,38 @@
"fieldname": "workflow_state", "fieldname": "workflow_state",
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1, "hidden": 1,
"label": "Workflow State",
"search_index": 1
"label": "Workflow State"
}, },
{ {
"depends_on": "eval: doc.completed_by",
"fieldname": "completed_by", "fieldname": "completed_by",
"fieldtype": "Link", "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": [], "links": [],
"modified": "2021-07-01 09:07:52.848618",
"modified": "2022-02-23 21:06:45.122258",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Workflow", "module": "Workflow",
"name": "Workflow Action", "name": "Workflow Action",
@@ -72,6 +93,7 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"title_field": "reference_name", "title_field": "reference_name",
"track_changes": 1 "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.desk.notifications import clear_doctype_notifications
from frappe.utils.user import get_users_with_role from frappe.utils.user import get_users_with_role
from frappe.utils.data import get_link_to_form from frappe.utils.data import get_link_to_form
from frappe.query_builder import DocType


class WorkflowAction(Document): class WorkflowAction(Document):
pass pass




def on_doctype_update(): 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): def get_permission_query_conditions(user):
if not user: user = frappe.session.user if not user: user = frappe.session.user


if user == "Administrator": return "" 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): 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): def process_workflow_actions(doc, state):
workflow = get_workflow_name(doc.get('doctype')) 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 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') clear_doctype_notifications('Workflow Action')


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


if not next_possible_transitions: return 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 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): if send_email_alert(workflow):
enqueue(send_workflow_action_email, queue='short', users_data=list(user_data_map.values()), doc=doc) 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')) frappe.bold(frappe.get_value('User', doc.get("modified_by"), 'full_name'))
), indicator_color='blue') ), 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 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 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): def get_next_possible_transitions(workflow_name, state, doc=None):
transitions = frappe.get_all('Workflow Transition', transitions = frappe.get_all('Workflow Transition',
@@ -167,8 +253,10 @@ def get_next_possible_transitions(workflow_name, state, doc=None):
return transitions_to_return return transitions_to_return


def get_users_next_action_data(transitions, doc): def get_users_next_action_data(transitions, doc):
roles = set()
user_data_map = {} user_data_map = {}
for transition in transitions: for transition in transitions:
roles.add(transition.allowed)
users = get_users_with_role(transition.allowed) users = get_users_with_role(transition.allowed)
filtered_users = filter_allowed_users(users, doc, transition) filtered_users = filter_allowed_users(users, doc, transition)
for user in filtered_users: for user in filtered_users:
@@ -182,19 +270,24 @@ def get_users_next_action_data(transitions, doc):
'action_name': transition.action, 'action_name': transition.action,
'action_link': get_workflow_action_url(transition.action, doc, user) '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): def send_workflow_action_email(users_data, doc):
common_args = get_common_email_args(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): def is_workflow_action_already_created(doc):
return frappe.db.exists({ return frappe.db.exists({
'doctype': 'Workflow Action', 'doctype': 'Workflow Action',
'reference_doctype': doc.get('doctype'),
'reference_name': doc.get('name'), '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): def clear_workflow_actions(doctype, name):
if not (doctype and name): if not (doctype and name):
return 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): def get_doc_workflow_state(doc):
workflow_name = get_workflow_name(doc.get('doctype')) workflow_name = get_workflow_name(doc.get('doctype'))
workflow_state_field = get_workflow_state_field(workflow_name) 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 num2words~=0.5.10
oauthlib~=3.1.0 oauthlib~=3.1.0
openpyxl~=3.0.7 openpyxl~=3.0.7
parse~=1.19.0
passlib~=1.7.4 passlib~=1.7.4
paytmchecksum~=1.7.0 paytmchecksum~=1.7.0
pdfkit~=0.6.1 pdfkit~=0.6.1


Loading…
取消
儲存