瀏覽代碼

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

version-14
Shariq Ansari 3 年之前
committed by GitHub
父節點
當前提交
8117655258
沒有發現已知的金鑰在資料庫的簽署中 GPG 金鑰 ID: 4AEE18F83AFDEB23
共有 91 個檔案被更改,包括 1660 行新增1124 行删除
  1. +1
    -0
      .mergify.yml
  2. +128
    -0
      cypress/integration/control_dynamic_link.js
  3. +4
    -6
      cypress/integration/web_form.js
  4. +3
    -0
      cypress/support/index.js
  5. +20
    -12
      frappe/__init__.py
  6. +2
    -2
      frappe/commands/site.py
  7. +3
    -3
      frappe/core/doctype/communication/communication.json
  8. +1
    -17
      frappe/core/doctype/communication/communication.py
  9. +3
    -10
      frappe/core/doctype/log_settings/test_log_settings.py
  10. +2
    -3
      frappe/core/doctype/report/report.py
  11. +30
    -2
      frappe/core/doctype/report/test_report.py
  12. +47
    -2
      frappe/core/doctype/user/user.json
  13. +139
    -113
      frappe/core/web_form/edit_profile/edit_profile.json
  14. +21
    -8
      frappe/database/database.py
  15. +2
    -1
      frappe/desk/form/assign_to.py
  16. +45
    -28
      frappe/desk/form/document_follow.py
  17. +2
    -2
      frappe/desk/form/utils.py
  18. +2
    -1
      frappe/desk/like.py
  19. +205
    -18
      frappe/email/doctype/document_follow/test_document_follow.py
  20. +0
    -8
      frappe/hooks.py
  21. +7
    -4
      frappe/model/base_document.py
  22. +4
    -2
      frappe/model/document.py
  23. +1
    -1
      frappe/modules/utils.py
  24. +1
    -1
      frappe/patches.txt
  25. +1
    -1
      frappe/printing/page/print/print.js
  26. +11
    -7
      frappe/public/js/frappe/form/form.js
  27. +1
    -1
      frappe/public/js/frappe/ui/theme_switcher.js
  28. +23
    -43
      frappe/public/js/frappe/web_form/web_form.js
  29. +3
    -1
      frappe/public/js/frappe/web_form/web_form_list.js
  30. +5
    -5
      frappe/public/js/frappe/web_form/webform_script.js
  31. +2
    -2
      frappe/public/scss/desk/avatar.scss
  32. +1
    -1
      frappe/public/scss/desk/sidebar.scss
  33. +58
    -13
      frappe/public/scss/website/base.scss
  34. +2
    -104
      frappe/public/scss/website/blog.scss
  35. +4
    -4
      frappe/public/scss/website/footer.scss
  36. +1
    -1
      frappe/public/scss/website/index.scss
  37. +9
    -103
      frappe/public/scss/website/markdown.scss
  38. +6
    -23
      frappe/public/scss/website/my_account.scss
  39. +87
    -31
      frappe/public/scss/website/page_builder.scss
  40. +1
    -1
      frappe/public/scss/website/variables.scss
  41. +60
    -19
      frappe/public/scss/website/web_form.scss
  42. +22
    -0
      frappe/query_builder/functions.py
  43. +5
    -2
      frappe/query_builder/utils.py
  44. +2
    -1
      frappe/share.py
  45. +8
    -8
      frappe/templates/includes/avatar_macro.html
  46. +4
    -3
      frappe/templates/includes/blog/blogger.html
  47. +109
    -5
      frappe/templates/includes/comments/comments.html
  48. +1
    -1
      frappe/templates/includes/login/login.js
  49. +6
    -1
      frappe/templates/includes/web_block.html
  50. +15
    -0
      frappe/tests/test_db.py
  51. +5
    -1
      frappe/tests/test_document.py
  52. +0
    -1
      frappe/tests/test_global_search.py
  53. +11
    -1
      frappe/tests/test_query_builder.py
  54. +23
    -9
      frappe/tests/test_translate.py
  55. +15
    -1
      frappe/tests/translation_test_file.txt
  56. +30
    -2
      frappe/translate.py
  57. +21
    -6
      frappe/translations/fr.csv
  58. +0
    -224
      frappe/utils/bot.py
  59. +14
    -11
      frappe/utils/data.py
  60. +1
    -1
      frappe/utils/install.py
  61. +2
    -1
      frappe/utils/jinja.py
  62. +0
    -151
      frappe/utils/reset_doc.py
  63. +24
    -2
      frappe/utils/safe_exec.py
  64. +4
    -2
      frappe/website/doctype/blog_post/blog_post.json
  65. +3
    -1
      frappe/website/doctype/blog_post/templates/blog_post_row.html
  66. +35
    -30
      frappe/website/doctype/web_form/templates/web_form.html
  67. +5
    -3
      frappe/website/doctype/web_form/web_form.json
  68. +1
    -1
      frappe/website/doctype/web_page/web_page.js
  69. +33
    -4
      frappe/website/doctype/web_page_block/web_page_block.json
  70. +1
    -1
      frappe/website/doctype/website_settings/website_settings.py
  71. +13
    -0
      frappe/website/js/website.js
  72. +0
    -0
      frappe/website/web_template/cover_image/__init__.py
  73. +5
    -0
      frappe/website/web_template/cover_image/cover_image.html
  74. +34
    -0
      frappe/website/web_template/cover_image/cover_image.json
  75. +3
    -1
      frappe/website/web_template/full_width_image/full_width_image.json
  76. +2
    -2
      frappe/website/web_template/hero/hero.html
  77. +2
    -1
      frappe/website/web_template/hero/hero.json
  78. +1
    -1
      frappe/website/web_template/section_with_collapsible_content/section_with_collapsible_content.html
  79. +6
    -2
      frappe/website/web_template/section_with_cta/section_with_cta.html
  80. +6
    -2
      frappe/website/web_template/section_with_small_cta/section_with_small_cta.html
  81. +0
    -0
      frappe/website/web_template/section_with_testimonials/__init__.py
  82. +31
    -0
      frappe/website/web_template/section_with_testimonials/section_with_testimonials.html
  83. +73
    -0
      frappe/website/web_template/section_with_testimonials/section_with_testimonials.json
  84. +0
    -0
      frappe/website/web_template/section_with_videos/__init__.py
  85. +24
    -0
      frappe/website/web_template/section_with_videos/section_with_videos.html
  86. +61
    -0
      frappe/website/web_template/section_with_videos/section_with_videos.json
  87. +3
    -15
      frappe/www/me.html
  88. +0
    -1
      frappe/www/me.py
  89. +41
    -17
      frappe/www/third_party_apps.html
  90. +1
    -2
      frappe/www/third_party_apps.py
  91. +6
    -2
      frappe/www/update-password.html

+ 1
- 0
.mergify.yml 查看文件

@@ -5,6 +5,7 @@ pull_request_rules:
- and:
- author!=surajshetty3416
- author!=gavindsouza
- author!=deepeshgarg007
- or:
- base=version-13
- base=version-12


+ 128
- 0
cypress/integration/control_dynamic_link.js 查看文件

@@ -0,0 +1,128 @@
context('Dynamic Link', () => {
before(() => {
cy.login();
cy.visit('/app/doctype');
return cy.window().its('frappe').then(frappe => {
return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', {
name: 'Test Dynamic Link',
fields: [
{
"label": "Document Type",
"fieldname": "doc_type",
"fieldtype": "Link",
"options": "DocType",
"in_list_view": 1,
"in_standard_filter": 1,
},
{
"label": "Document ID",
"fieldname": "doc_id",
"fieldtype": "Dynamic Link",
"options": "doc_type",
"in_list_view": 1,
"in_standard_filter": 1,
},
]
});
});
});


function get_dialog_with_dynamic_link() {
return cy.dialog({
title: 'Dynamic Link',
fields: [{
"label": "Document Type",
"fieldname": "doc_type",
"fieldtype": "Link",
"options": "DocType",
"in_list_view": 1,
},
{
"label": "Document ID",
"fieldname": "doc_id",
"fieldtype": "Dynamic Link",
"options": "doc_type",
"in_list_view": 1,
}]
});
}

function get_dialog_with_dynamic_link_option() {
return cy.dialog({
title: 'Dynamic Link',
fields: [{
"label": "Document Type",
"fieldname": "doc_type",
"fieldtype": "Link",
"options": "DocType",
"in_list_view": 1,
},
{
"label": "Document ID",
"fieldname": "doc_id",
"fieldtype": "Dynamic Link",
"get_options": () => {
return "User";
},
"in_list_view": 1,
}]
});
}

it('Creating a dynamic link by passing option as function and verifying it in a dialog', () => {
get_dialog_with_dynamic_link_option().as('dialog');
cy.get_field('doc_type').clear();
cy.fill_field('doc_type', 'User', 'Link');
cy.get_field('doc_id').click();

//Checking if the listbox have length greater than 0
cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
cy.get('.btn-modal-close').click({force: true});
});

it('Creating a dynamic link and verifying it in a dialog', () => {
get_dialog_with_dynamic_link().as('dialog');
cy.get_field('doc_type').clear();
cy.fill_field('doc_type', 'User', 'Link');
cy.get_field('doc_id').click();

//Checking if the listbox have length greater than 0
cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
cy.get('.btn-modal-close').click({force: true, multiple: true});
});

it('Creating a dynamic link and verifying it', () => {
cy.visit('/app/test-dynamic-link');

//Clicking on the Document ID field
cy.get_field('doc_type').clear();

//Entering User in the Doctype field
cy.fill_field('doc_type', 'User', 'Link', {delay: 500});
cy.get_field('doc_id').click();

//Checking if the listbox have length greater than 0
cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);

//Opening a new form for dynamic link doctype
cy.new_form('Test Dynamic Link');
cy.get_field('doc_type').clear();

//Entering User in the Doctype field
cy.fill_field('doc_type', 'User', 'Link', {delay: 500});
cy.get_field('doc_id').click();

//Checking if the listbox have length greater than 0
cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
cy.get_field('doc_type').clear();

//Entering System Settings in the Doctype field
cy.fill_field('doc_type', 'System Settings', 'Link', {delay: 500});
cy.get_field('doc_id').click();

//Checking if the system throws error
cy.get('.modal-title').should('have.text', 'Error');
cy.get('.msgprint').should('have.text', 'System Settings is not a valid DocType for Dynamic Link');
});
});

+ 4
- 6
cypress/integration/web_form.js 查看文件

@@ -7,8 +7,8 @@ context('Web Form', () => {
cy.visit('/update-profile');
cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
cy.get('.web-form-actions .btn-primary').click();
cy.wait(500);
cy.get('.modal.show > .modal-dialog').should('be.visible');
cy.wait(5000);
cy.url().should('include', '/me');
});

it('Navigate and Submit a MultiStep WebForm', () => {
@@ -16,14 +16,12 @@ context('Web Form', () => {
cy.visit('/update-profile-duplicate');
cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
cy.get('.btn-next').should('be.visible');
cy.get('.web-form-footer .btn-primary').should('not.be.visible');
cy.get('.btn-next').click();
cy.get('.btn-previous').should('be.visible');
cy.get('.btn-next').should('not.be.visible');
cy.get('.web-form-footer .btn-primary').should('be.visible');
cy.get('.web-form-actions .btn-primary').click();
cy.wait(500);
cy.get('.modal.show > .modal-dialog').should('be.visible');
cy.wait(5000);
cy.url().should('include', '/me');
});
});
});

+ 3
- 0
cypress/support/index.js 查看文件

@@ -17,6 +17,9 @@
import './commands';
import '@cypress/code-coverage/support';

Cypress.on('uncaught:exception', (err, runnable) => {
return false;
});

// Alternatively you can use CommonJS syntax:
// require('./commands')


+ 20
- 12
frappe/__init__.py 查看文件

@@ -1,4 +1,4 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
"""
Frappe - Low Code Open Source Framework in Python and JS
@@ -20,10 +20,10 @@ if _dev_server:
warnings.simplefilter('always', DeprecationWarning)
warnings.simplefilter('always', PendingDeprecationWarning)

from werkzeug.local import Local, release_local
import sys, importlib, inspect, json
import typing
import click
from werkzeug.local import Local, release_local
from typing import TYPE_CHECKING, Dict, List, Union

# Local application imports
from .exceptions import *
@@ -143,15 +143,14 @@ lang = local("lang")

# This if block is never executed when running the code. It is only used for
# telling static code analyzer where to find dynamically defined attributes.
if typing.TYPE_CHECKING:
from frappe.utils.redis_wrapper import RedisWrapper

if TYPE_CHECKING:
from frappe.database.mariadb.database import MariaDBDatabase
from frappe.database.postgres.database import PostgresDatabase
from frappe.query_builder.builder import MariaDB, Postgres
from frappe.utils.redis_wrapper import RedisWrapper

db: typing.Union[MariaDBDatabase, PostgresDatabase]
qb: typing.Union[MariaDB, Postgres]
db: Union[MariaDBDatabase, PostgresDatabase]
qb: Union[MariaDB, Postgres]


# end: static analysis hack
@@ -897,7 +896,12 @@ def clear_document_cache(doctype, name):
cache().hdel('document_cache', key)

def get_cached_value(doctype, name, fieldname, as_dict=False):
doc = get_cached_doc(doctype, name)
try:
doc = get_cached_doc(doctype, name)
except DoesNotExistError:
clear_last_message()
return

if isinstance(fieldname, str):
if as_dict:
throw('Cannot make dict for single fieldname')
@@ -1465,7 +1469,7 @@ def get_list(doctype, *args, **kwargs):
:param fields: List of fields or `*`.
:param filters: List of filters (see example).
:param order_by: Order By e.g. `modified desc`.
:param limit_page_start: Start results at record #. Default 0.
:param limit_start: Start results at record #. Default 0.
:param limit_page_length: No of records in the page. Default 20.

Example usage:
@@ -1523,12 +1527,16 @@ def get_value(*args, **kwargs):
"""
return db.get_value(*args, **kwargs)

def as_json(obj, indent=1):
def as_json(obj: Union[Dict, List], indent=1) -> str:
from frappe.utils.response import json_handler

try:
return json.dumps(obj, indent=indent, sort_keys=True, default=json_handler, separators=(',', ': '))
except TypeError:
return json.dumps(obj, indent=indent, default=json_handler, separators=(',', ': '))
# this would break in case the keys are not all os "str" type - as defined in the JSON
# adding this to ensure keys are sorted (expected behaviour)
sorted_obj = dict(sorted(obj.items(), key=lambda kv: str(kv[0])))
return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=(',', ': '))

def are_emails_muted():
from frappe.utils import cint


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

@@ -728,7 +728,7 @@ def move(dest_dir, site):
@click.command('set-password')
@click.argument('user')
@click.argument('password', required=False)
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False)
@pass_context
def set_password(context, user, password=None, logout_all_sessions=False):
"Set password for a user on a site"
@@ -741,7 +741,7 @@ def set_password(context, user, password=None, logout_all_sessions=False):

@click.command('set-admin-password')
@click.argument('admin-password', required=False)
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False)
@pass_context
def set_admin_password(context, admin_password=None, logout_all_sessions=False):
"Set Administrator password for a site"


+ 3
- 3
frappe/core/doctype/communication/communication.json 查看文件

@@ -153,7 +153,7 @@
"fieldname": "communication_type",
"fieldtype": "Select",
"label": "Communication Type",
"options": "Communication\nComment\nChat\nBot\nNotification\nFeedback\nAutomated Message",
"options": "Communication\nComment\nChat\nNotification\nFeedback\nAutomated Message",
"read_only": 1,
"reqd": 1
},
@@ -164,7 +164,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Comment Type",
"options": "\nComment\nLike\nInfo\nLabel\nWorkflow\nCreated\nSubmitted\nCancelled\nUpdated\nDeleted\nAssigned\nAssignment Completed\nAttachment\nAttachment Removed\nShared\nUnshared\nBot\nRelinked",
"options": "\nComment\nLike\nInfo\nLabel\nWorkflow\nCreated\nSubmitted\nCancelled\nUpdated\nDeleted\nAssigned\nAssignment Completed\nAttachment\nAttachment Removed\nShared\nUnshared\nRelinked",
"read_only": 1
},
{
@@ -395,7 +395,7 @@
"icon": "fa fa-comment",
"idx": 1,
"links": [],
"modified": "2021-11-30 09:03:25.728637",
"modified": "2022-03-30 11:24:25.728637",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",


+ 1
- 17
frappe/core/doctype/communication/communication.py 查看文件

@@ -10,7 +10,6 @@ from frappe.utils import validate_email_address, strip_html, cstr, time_diff_in_
from frappe.core.doctype.communication.email import validate_email
from frappe.core.doctype.communication.mixins import CommunicationEmailMixin
from frappe.core.utils import get_parent_doc
from frappe.utils.bot import BotReply
from frappe.utils import parse_addr, split_emails
from frappe.core.doctype.comment.comment import update_comment_in_doc
from email.utils import getaddresses
@@ -105,7 +104,7 @@ class Communication(Document, CommunicationEmailMixin):
if self.communication_type == "Communication":
self.notify_change('add')

elif self.communication_type in ("Chat", "Notification", "Bot"):
elif self.communication_type in ("Chat", "Notification"):
if self.reference_name == frappe.session.user:
message = self.as_dict()
message['broadcast'] = True
@@ -160,7 +159,6 @@ class Communication(Document, CommunicationEmailMixin):

if self.comment_type != 'Updated':
update_parent_document_on_communication(self)
self.bot_reply()

def on_trash(self):
if self.communication_type == "Communication":
@@ -278,20 +276,6 @@ class Communication(Document, CommunicationEmailMixin):
if not self.sender_full_name:
self.sender_full_name = sender_email

def bot_reply(self):
if self.comment_type == 'Bot' and self.communication_type == 'Chat':
reply = BotReply().get_reply(self.content)
if reply:
frappe.get_doc({
"doctype": "Communication",
"comment_type": "Bot",
"communication_type": "Bot",
"content": cstr(reply),
"reference_doctype": self.reference_doctype,
"reference_name": self.reference_name
}).insert()
frappe.local.flags.commit = True

def set_delivery_status(self, commit=False):
'''Look into the status of Email Queue linked to this Communication and set the Delivery Status of this Communication'''
delivery_status = None


+ 3
- 10
frappe/core/doctype/log_settings/test_log_settings.py 查看文件

@@ -2,20 +2,17 @@
# License: MIT. See LICENSE

from datetime import datetime
import unittest

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


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

frappe.db.set_single_value(
"Log Settings",
@@ -26,10 +23,6 @@ class TestLogSettings(unittest.TestCase):
},
)

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

def setUp(self) -> None:
if self._testMethodName == "test_delete_logs":
self.datetime = frappe._dict()


+ 2
- 3
frappe/core/doctype/report/report.py 查看文件

@@ -11,7 +11,7 @@ from frappe.modules import make_boilerplate
from frappe.core.doctype.page.page import delete_custom_role
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from frappe.desk.reportview import append_totals_row
from frappe.utils.safe_exec import safe_exec
from frappe.utils.safe_exec import safe_exec, check_safe_sql_query


class Report(Document):
@@ -110,8 +110,7 @@ class Report(Document):
if not self.query:
frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error'))

if not self.query.lower().startswith("select"):
frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error'))
check_safe_sql_query(self.query)

result = [list(t) for t in frappe.db.sql(self.query, filters)]
columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()]


+ 30
- 2
frappe/core/doctype/report/test_report.py 查看文件

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

import textwrap

import frappe, json, os
import unittest
from frappe.desk.query_report import run, save_report, add_total_row
from frappe.desk.reportview import delete_report, save_report as _save_report
from frappe.custom.doctype.customize_form.customize_form import reset_customization
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.tests.utils import FrappeTestCase

test_records = frappe.get_test_records('Report')
test_dependencies = ['User']

class TestReport(unittest.TestCase):
class TestReport(FrappeTestCase):
def test_report_builder(self):
if frappe.db.exists('Report', 'User Activity Report'):
frappe.delete_doc('Report', 'User Activity Report')
@@ -335,3 +337,29 @@ result = [
self.assertEqual(result[-1][0], "Total")
self.assertEqual(result[-1][1], 200)
self.assertEqual(result[-1][2], 150.50)

def test_cte_in_query_report(self):
cte_query = textwrap.dedent("""
with enabled_users as (
select name
from `tabUser`
where enabled = 1
)
select * from enabled_users;
""")

report = frappe.get_doc({
"doctype": "Report",
"ref_doctype": "User",
"report_name": "Enabled Users List",
"report_type": "Query Report",
"is_standard": "No",
"query": cte_query,
}).insert()

if frappe.db.db_type == "mariadb":
col, rows = report.execute_query_report(filters={})
self.assertEqual(col[0], "name")
self.assertGreaterEqual(len(rows), 1)
elif frappe.db.db_type == "postgres":
self.assertRaises(frappe.PermissionError, report.execute_query_report, filters={})

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

@@ -2,7 +2,7 @@
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2014-03-11 14:55:00",
"creation": "2022-01-10 17:29:51.672911",
"description": "Represents a User in the system.",
"doctype": "DocType",
"engine": "InnoDB",
@@ -48,6 +48,12 @@
"document_follow_notifications_section",
"document_follow_notify",
"document_follow_frequency",
"column_break_75",
"follow_created_documents",
"follow_commented_documents",
"follow_liked_documents",
"follow_assigned_documents",
"follow_shared_documents",
"email_settings",
"email_signature",
"thread_notify",
@@ -606,6 +612,45 @@
"fieldtype": "Link",
"label": "Module Profile",
"options": "Module Profile"
},
{
"fieldname": "column_break_75",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_created_documents",
"fieldtype": "Check",
"label": "Auto follow documents that you create"
},
{
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_commented_documents",
"fieldtype": "Check",
"label": "Auto follow documents that you comment on"
},
{
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_liked_documents",
"fieldtype": "Check",
"label": "Auto follow documents that you Like"
},
{
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_shared_documents",
"fieldtype": "Check",
"label": "Auto follow documents that are shared with you"
},
{
"default": "0",
"depends_on": "eval:(doc.document_follow_notify== 1)",
"fieldname": "follow_assigned_documents",
"fieldtype": "Check",
"label": "Auto follow documents that are assigned to you"
}
],
"icon": "fa fa-user",
@@ -704,4 +749,4 @@
"states": [],
"title_field": "full_name",
"track_changes": 1
}
}

+ 139
- 113
frappe/core/web_form/edit_profile/edit_profile.json 查看文件

@@ -1,133 +1,159 @@
{
"accept_payment": 0,
"allow_comments": 0,
"allow_delete": 0,
"allow_edit": 1,
"allow_incomplete": 0,
"allow_multiple": 0,
"allow_print": 0,
"amount": 0.0,
"amount_based_on_field": 0,
"breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]",
"creation": "2016-09-19 05:16:59.242754",
"doc_type": "User",
"docstatus": 0,
"doctype": "Web Form",
"idx": 0,
"introduction_text": "",
"is_standard": 1,
"login_required": 1,
"max_attachment_size": 0,
"modified": "2019-01-28 12:45:17.158069",
"modified_by": "Administrator",
"module": "Core",
"name": "edit-profile",
"owner": "Administrator",
"published": 1,
"route": "update-profile",
"show_in_grid": 0,
"show_sidebar": 1,
"sidebar_items": [],
"success_message": "Profile updated successfully.",
"success_url": "/me",
"title": "Update Profile",
"accept_payment": 0,
"allow_comments": 0,
"allow_delete": 0,
"allow_edit": 1,
"allow_incomplete": 0,
"allow_multiple": 0,
"allow_print": 0,
"amount": 0.0,
"amount_based_on_field": 0,
"apply_document_permissions": 0,
"breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]",
"creation": "2016-09-19 05:16:59.242754",
"doc_type": "User",
"docstatus": 0,
"doctype": "Web Form",
"idx": 0,
"introduction_text": "",
"is_multi_step_form": 0,
"is_standard": 1,
"login_required": 1,
"max_attachment_size": 0,
"modified": "2022-03-22 15:00:43.456738",
"modified_by": "Administrator",
"module": "Core",
"name": "edit-profile",
"owner": "Administrator",
"published": 1,
"route": "update-profile",
"route_to_success_link": 0,
"show_attachments": 0,
"show_in_grid": 0,
"show_sidebar": 0,
"sidebar_items": [],
"success_message": "Profile updated successfully.",
"success_url": "/me",
"title": "Update Profile",
"web_form_fields": [
{
"allow_read_on_all_link_options": 0,
"fieldname": "first_name",
"fieldtype": "Data",
"hidden": 0,
"label": "First Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 1,
"allow_read_on_all_link_options": 0,
"fieldname": "first_name",
"fieldtype": "Data",
"hidden": 0,
"label": "First Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 1,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "middle_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Middle Name (Optional)",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "middle_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Middle Name (Optional)",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "last_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Last Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "last_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Last Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "user_image",
"fieldtype": "Attach Image",
"hidden": 0,
"label": "User Image",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "",
"fieldtype": "Column Break",
"hidden": 0,
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldtype": "Section Break",
"hidden": 0,
"label": "More Information",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "user_image",
"fieldtype": "Attach Image",
"hidden": 0,
"label": "Profile Picture",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "phone",
"fieldtype": "Data",
"hidden": 0,
"label": "Phone",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldtype": "Section Break",
"hidden": 0,
"label": "More Information",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "mobile_no",
"fieldtype": "Data",
"hidden": 0,
"label": "Mobile Number",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "phone",
"fieldtype": "Data",
"hidden": 0,
"label": "Phone",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "language",
"fieldtype": "Link",
"hidden": 0,
"label": "Language",
"max_length": 0,
"max_value": 0,
"options": "Language",
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "mobile_no",
"fieldtype": "Data",
"hidden": 0,
"label": "Mobile Number",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "",
"fieldtype": "Column Break",
"hidden": 0,
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "language",
"fieldtype": "Link",
"hidden": 0,
"label": "Language",
"max_length": 0,
"max_value": 0,
"options": "Language",
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
}
]

+ 21
- 8
frappe/database/database.py 查看文件

@@ -119,8 +119,8 @@ class Database(object):
if not run:
return query

# remove \n \t from start and end of query
query = re.sub(r'^\s*|\s*$', '', query)
# remove whitespace / indentation from start and end of query
query = query.strip()

if re.search(r'ifnull\(', query, flags=re.IGNORECASE):
# replaces ifnull in query with coalesce
@@ -357,6 +357,7 @@ class Database(object):
order_by="KEEP_DEFAULT_ORDERING",
cache=False,
for_update=False,
*,
run=True,
pluck=False,
distinct=False,
@@ -386,17 +387,27 @@ class Database(object):
frappe.db.get_value("System Settings", None, "date_format")
"""

ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
result = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct, limit=1)

if not run:
return ret
return result

if not result:
return None

row = result[0]

if len(row) > 1 or as_dict:
return row
else:
# single field is requested, send it without wrapping in containers
return row[0]

return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None

def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False,
run=True, pluck=False, distinct=False, limit=None):
*, run=True, pluck=False, distinct=False, limit=None):
"""Returns multiple document properties.

:param doctype: DocType name.
@@ -487,6 +498,7 @@ class Database(object):
as_dict=False,
debug=False,
update=None,
*,
run=True,
pluck=False,
distinct=False,
@@ -621,7 +633,8 @@ class Database(object):
filters,
doctype,
as_dict,
debug,
*,
debug=False,
order_by=None,
update=None,
for_update=False,
@@ -661,7 +674,7 @@ class Database(object):
)
return r

def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False, limit=None):
def _get_value_for_many_names(self, doctype, names, field, order_by, *, debug=False, run=True, pluck=False, distinct=False, limit=None):
names = list(filter(None, names))
if names:
return self.get_all(


+ 2
- 1
frappe/desk/form/assign_to.py 查看文件

@@ -84,7 +84,8 @@ def add(args=None):
shared_with_users.append(assign_to)

# make this document followed by assigned user
follow_document(args['doctype'], args['name'], assign_to)
if frappe.get_cached_value("User", assign_to, "follow_assigned_documents"):
follow_document(args['doctype'], args['name'], assign_to)

# notify
notify_assignment(d.assigned_by, d.allocated_to, d.reference_type, d.reference_name, action='ASSIGN',


+ 45
- 28
frappe/desk/form/document_follow.py 查看文件

@@ -6,7 +6,7 @@ import frappe.utils
from frappe.utils import get_url_to_form
from frappe.model import log_types
from frappe import _
from itertools import groupby
from frappe.query_builder import DocType

@frappe.whitelist()
def update_follow(doctype, doc_name, following):
@@ -94,33 +94,50 @@ def send_document_follow_mails(frequency):
call method to send mail
'''

users = frappe.get_list("Document Follow",
fields=["*"])

sorted_users = sorted(users, key=lambda k: k['user'])

grouped_by_user = {}
for k, v in groupby(sorted_users, key=lambda k: k['user']):
grouped_by_user[k] = list(v)

for user in grouped_by_user:
user_frequency = frappe.db.get_value("User", user, "document_follow_frequency")
message = []
valid_document_follows = []
if user_frequency == frequency:
for d in grouped_by_user[user]:
content = get_message(d.ref_docname, d.ref_doctype, frequency, user)
if content:
message = message + content
valid_document_follows.append({
"reference_docname": d.ref_docname,
"reference_doctype": d.ref_doctype,
"reference_url": get_url_to_form(d.ref_doctype, d.ref_docname)
})

if message and frappe.db.get_value("User", user, "document_follow_notify", ignore=True):
send_email_alert(user, valid_document_follows, message)

user_list = get_user_list(frequency)

for user in user_list:
message, valid_document_follows = get_message_for_user(frequency, user)
if message:
send_email_alert(user, valid_document_follows, message)
# send an email if we have already spent resources creating the message
# nosemgrep
frappe.db.commit()

def get_user_list(frequency):
DocumentFollow = DocType('Document Follow')
User = DocType('User')
return (frappe.qb.from_(DocumentFollow).join(User)
.on(DocumentFollow.user == User.name)
.where(User.document_follow_notify == 1)
.where(User.document_follow_frequency == frequency)
.select(DocumentFollow.user)
.groupby(DocumentFollow.user)).run(pluck="user")

def get_message_for_user(frequency, user):
message = []
latest_document_follows = get_document_followed_by_user(user)
valid_document_follows = []

for document_follow in latest_document_follows:
content = get_message(document_follow.ref_docname, document_follow.ref_doctype, frequency, user)
if content:
message = message + content
valid_document_follows.append({
"reference_docname": document_follow.ref_docname,
"reference_doctype": document_follow.ref_doctype,
"reference_url": get_url_to_form(document_follow.ref_doctype, document_follow.ref_docname)
})
return message, valid_document_follows

def get_document_followed_by_user(user):
DocumentFollow = DocType('Document Follow')
# at max 20 documents are sent for each user
return (frappe.qb.from_(DocumentFollow)
.where(DocumentFollow.user == user)
.select(DocumentFollow.ref_doctype, DocumentFollow.ref_docname)
.orderby(DocumentFollow.modified)
.limit(20)).run(as_dict=True)

def get_version(doctype, doc_name, frequency, user):
timeline = []


+ 2
- 2
frappe/desk/form/utils.py 查看文件

@@ -31,8 +31,8 @@ def add_comment(reference_doctype, reference_name, content, comment_email, comme
reference_doc = frappe.get_doc(reference_doctype, reference_name)
doc.content = extract_images_from_html(reference_doc, content, is_private=True)
doc.insert(ignore_permissions=True)
follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user)
if frappe.get_cached_value("User", frappe.session.user, "follow_commented_documents"):
follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user)
return doc.as_dict()

@frappe.whitelist()


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

@@ -41,7 +41,8 @@ def _toggle_like(doctype, name, add, user=None):
if user not in liked_by:
liked_by.append(user)
add_comment(doctype, name)
follow_document(doctype, name, user)
if frappe.get_cached_value("User", user, "follow_liked_documents"):
follow_document(doctype, name, user)
else:
if user in liked_by:
liked_by.remove(user)


+ 205
- 18
frappe/email/doctype/document_follow/test_document_follow.py 查看文件

@@ -3,10 +3,18 @@
# License: MIT. See LICENSE
import frappe
import unittest
from dataclasses import dataclass
import frappe.desk.form.document_follow as document_follow
from frappe.query_builder import DocType
from frappe.desk.form.utils import add_comment
from frappe.desk.form.document_follow import get_document_followed_by_user
from frappe.desk.like import toggle_like
from frappe.desk.form.assign_to import add
from frappe.share import add as share
from frappe.query_builder.functions import Cast_

class TestDocumentFollow(unittest.TestCase):
def test_document_follow(self):
def test_document_follow_version(self):
user = get_user()
event_doc = get_event()

@@ -18,18 +26,173 @@ class TestDocumentFollow(unittest.TestCase):
self.assertEqual(doc.user, user.name)

document_follow.send_hourly_updates()
emails = get_emails(event_doc, '%This is a test description for sending mail%')
self.assertIsNotNone(emails)

email_queue_entry_name = frappe.get_all("Email Queue", limit=1)[0].name
email_queue_entry_doc = frappe.get_doc("Email Queue", email_queue_entry_name)

self.assertEqual((email_queue_entry_doc.recipients[0].recipient), user.name)
def test_document_follow_comment(self):
user = get_user()
event_doc = get_event()

add_comment(event_doc.doctype, event_doc.name, "This is a test comment", 'Administrator@example.com', 'Bosh')

document_follow.unfollow_document("Event", event_doc.name, user.name)
doc = document_follow.follow_document("Event", event_doc.name, user.name)
self.assertEqual(doc.user, user.name)

document_follow.send_hourly_updates()
emails = get_emails(event_doc, '%This is a test comment%')
self.assertIsNotNone(emails)


def test_follow_limit(self):
user = get_user()
for _ in range(25):
event_doc = get_event()
document_follow.unfollow_document("Event", event_doc.name, user.name)
doc = document_follow.follow_document("Event", event_doc.name, user.name)
self.assertEqual(doc.user, user.name)
self.assertEqual(len(get_document_followed_by_user(user.name)), 20)

def test_follow_on_create(self):
user = get_user(DocumentFollowConditions(1))
frappe.set_user(user.name)
event = get_event()

event.description = "This is a test description for sending mail"
event.save(ignore_version=False)

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertTrue(documents_followed)

def test_do_not_follow_on_create(self):
user = get_user()
frappe.set_user(user.name)

event = get_event()

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertFalse(documents_followed)

def test_do_not_follow_on_update(self):
user = get_user()
frappe.set_user(user.name)
event = get_event()

event.description = "This is a test description for sending mail"
event.save(ignore_version=False)

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertFalse(documents_followed)

def test_follow_on_comment(self):
user = get_user(DocumentFollowConditions(0, 1))
frappe.set_user(user.name)
event = get_event()

add_comment(event.doctype, event.name, "This is a test comment", 'Administrator@example.com', 'Bosh')

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertTrue(documents_followed)

def test_do_not_follow_on_comment(self):
user = get_user()
frappe.set_user(user.name)
event = get_event()

self.assertIn(event_doc.doctype, email_queue_entry_doc.message)
self.assertIn(event_doc.name, email_queue_entry_doc.message)
add_comment(event.doctype, event.name, "This is a test comment", 'Administrator@example.com', 'Bosh')

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertFalse(documents_followed)

def test_follow_on_like(self):
user = get_user(DocumentFollowConditions(0, 0, 1))
frappe.set_user(user.name)
event = get_event()

toggle_like(event.doctype, event.name, add="Yes")

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertTrue(documents_followed)

def test_do_not_follow_on_like(self):
user = get_user()
frappe.set_user(user.name)
event = get_event()

toggle_like(event.doctype, event.name)

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertFalse(documents_followed)

def test_follow_on_assign(self):
user = get_user(DocumentFollowConditions(0, 0, 0, 1))
event = get_event()

add({
'assign_to': [user.name],
'doctype': event.doctype,
'name': event.name
})

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertTrue(documents_followed)

def test_do_not_follow_on_assign(self):
user = get_user()
frappe.set_user(user.name)
event = get_event()

add({
'assign_to': [user.name],
'doctype': event.doctype,
'name': event.name
})

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertFalse(documents_followed)

def test_follow_on_share(self):
user = get_user(DocumentFollowConditions(0, 0, 0, 0, 1))
event = get_event()

share(
user= user.name,
doctype= event.doctype,
name= event.name
)

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertTrue(documents_followed)

def test_do_not_follow_on_share(self):
user = get_user()
event = get_event()

share(
user = user.name,
doctype = event.doctype,
name = event.name
)

documents_followed = get_events_followed_by_user(event.name, user.name)
self.assertFalse(documents_followed)


def tearDown(self):
frappe.db.rollback()
frappe.db.delete('Email Queue')
frappe.db.delete('Email Queue Recipient')
frappe.db.delete('Document Follow')
frappe.db.delete('Event')

def get_events_followed_by_user(event_name, user_name):
DocumentFollow = DocType('Document Follow')
return (frappe.qb.from_(DocumentFollow)
.where(DocumentFollow.ref_doctype == 'Event')
.where(DocumentFollow.ref_docname == event_name)
.where(DocumentFollow.user == user_name)
.select(DocumentFollow.name)).run()

def get_event():
doc = frappe.get_doc({
@@ -42,16 +205,40 @@ def get_event():
doc.insert()
return doc

def get_user():
def get_user(document_follow=None):
frappe.set_user("Administrator")
if frappe.db.exists('User', 'test@docsub.com'):
doc = frappe.get_doc('User', 'test@docsub.com')
else:
doc = frappe.new_doc("User")
doc.email = "test@docsub.com"
doc.first_name = "Test"
doc.last_name = "User"
doc.send_welcome_email = 0
doc.document_follow_notify = 1
doc.document_follow_frequency = "Hourly"
doc.insert()
return doc
doc = frappe.delete_doc('User', 'test@docsub.com')
doc = frappe.new_doc("User")
doc.email = "test@docsub.com"
doc.first_name = "Test"
doc.last_name = "User"
doc.send_welcome_email = 0
doc.document_follow_notify = 1
doc.document_follow_frequency = "Hourly"
doc.__dict__.update(document_follow.__dict__ if document_follow else {})
doc.insert()
doc.add_roles('System Manager')
return doc

def get_emails(event_doc, search_string):
EmailQueue = DocType('Email Queue')
EmailQueueRecipient = DocType('Email Queue Recipient')

return (frappe.qb.from_(EmailQueue)
.join(EmailQueueRecipient)
.on(EmailQueueRecipient.parent == Cast_(EmailQueue.name, "varchar"))
.where(EmailQueueRecipient.recipient == 'test@docsub.com',)
.where(EmailQueue.message.like(f'%{event_doc.doctype}%'))
.where(EmailQueue.message.like(f'%{event_doc.name}%'))
.where(EmailQueue.message.like(search_string))
.select(EmailQueue.message)
.limit(1)).run()

@dataclass
class DocumentFollowConditions:
follow_created_documents: int = 0
follow_commented_documents: int = 0
follow_liked_documents: int = 0
follow_assigned_documents: int = 0
follow_shared_documents: int = 0

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

@@ -282,14 +282,6 @@ sounds = [
# {"name": "chime", "src": "/assets/frappe/sounds/chime.mp3"},
]

bot_parsers = [
'frappe.utils.bot.ShowNotificationBot',
'frappe.utils.bot.GetOpenListBot',
'frappe.utils.bot.ListBot',
'frappe.utils.bot.FindBot',
'frappe.utils.bot.CountBot'
]

setup_wizard_exception = [
"frappe.desk.page.setup_wizard.setup_wizard.email_setup_wizard_exception",
"frappe.desk.page.setup_wizard.setup_wizard.log_setup_wizard_exception"


+ 7
- 4
frappe/model/base_document.py 查看文件

@@ -963,10 +963,13 @@ class BaseDocument(object):
from frappe.model.meta import get_default_df
df = get_default_df(fieldname)

if df.fieldtype == "Currency" and not currency:
currency = self.get(df.get("options"))
if not frappe.db.exists('Currency', currency, cache=True):
currency = None
if (
df.fieldtype == "Currency"
and not currency
and (currency_field := df.get("options"))
and (currency_value := self.get(currency_field))
):
currency = frappe.db.get_value('Currency', currency_value, cache=True)

val = self.get(fieldname)



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

@@ -276,7 +276,8 @@ class Document(BaseDocument):
delattr(self, "__unsaved")

if not (frappe.flags.in_migrate or frappe.local.flags.in_install or frappe.flags.in_setup_wizard):
follow_document(self.doctype, self.name, frappe.session.user)
if frappe.get_cached_value("User", frappe.session.user, "follow_created_documents"):
follow_document(self.doctype, self.name, frappe.session.user)
return self

def save(self, *args, **kwargs):
@@ -1125,7 +1126,8 @@ class Document(BaseDocument):
version.insert(ignore_permissions=True)
if not frappe.flags.in_migrate:
# follow since you made a change?
follow_document(self.doctype, self.name, frappe.session.user)
if frappe.get_cached_value("User", frappe.session.user, "follow_created_documents"):
follow_document(self.doctype, self.name, frappe.session.user)

@staticmethod
def hook(f):


+ 1
- 1
frappe/modules/utils.py 查看文件

@@ -272,7 +272,7 @@ def make_boilerplate(template, doc, opts=None):
frappe.utils.cstr(source.read()).format(
app_publisher=app_publisher,
year=frappe.utils.nowdate()[:4],
classname=doc.name.replace(" ", ""),
classname=doc.name.replace(" ", "").replace("-", ""),
base_class_import=base_class_import,
base_class=base_class,
doctype=doc.name, **opts,


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

@@ -146,7 +146,7 @@ frappe.patches.v13_0.update_duration_options
frappe.patches.v13_0.replace_old_data_import # 2020-06-24
frappe.patches.v13_0.create_custom_dashboards_cards_and_charts
frappe.patches.v13_0.rename_is_custom_field_in_dashboard_chart
frappe.patches.v13_0.add_standard_navbar_items # 2022-03-15
frappe.patches.v13_0.add_standard_navbar_items # 2020-12-15
frappe.patches.v13_0.generate_theme_files_in_public_folder
frappe.patches.v13_0.increase_password_length
frappe.patches.v12_0.fix_email_id_formatting


+ 1
- 1
frappe/printing/page/print/print.js 查看文件

@@ -37,7 +37,7 @@ frappe.ui.form.PrintView = class {
this.print_wrapper = this.page.main.empty().html(
`<div class="print-preview-wrapper"><div class="print-preview">
${frappe.render_template('print_skeleton_loading')}
<iframe class="print-format-container" width="100%" height="0" frameBorder="0" scrolling="no"">
<iframe class="print-format-container" width="100%" height="0" frameBorder="0" scrolling="no">
</iframe>
</div>
<div class="page-break-message text-muted text-center text-medium margin-top"></div>


+ 11
- 7
frappe/public/js/frappe/form/form.js 查看文件

@@ -1701,13 +1701,17 @@ frappe.ui.form.Form = class FrappeForm {
}

update_in_all_rows(table_fieldname, fieldname, value) {
// update the child value in all tables where it is missing
if(!value) return;
var cl = this.doc[table_fieldname] || [];
for(var i = 0; i < cl.length; i++){
if(!cl[i][fieldname]) cl[i][fieldname] = value;
}
refresh_field("items");
// Update the `value` of the field named `fieldname` in all rows of the
// child table named `table_fieldname`.
// Do not overwrite existing values.
if (value === undefined) return;

frappe.model
.get_children(this.doc, table_fieldname)
.filter(child => !frappe.model.has_value(child.doctype, child.name, fieldname))
.forEach(child =>
frappe.model.set_value(child.doctype, child.name, fieldname, value)
);
}

get_sum(table_fieldname, fieldname) {


+ 1
- 1
frappe/public/js/frappe/ui/theme_switcher.js 查看文件

@@ -124,7 +124,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
toggle_theme(theme) {
this.current_theme = theme.toLowerCase();
document.documentElement.setAttribute("data-theme-mode", this.current_theme);
frappe.show_alert("Theme Changed", 3);
frappe.show_alert(__("Theme Changed"), 3);

frappe.xcall("frappe.core.doctype.user.user.switch_theme", {
theme: toTitle(theme)


+ 23
- 43
frappe/public/js/frappe/web_form/web_form.js 查看文件

@@ -25,7 +25,6 @@ export default class WebForm extends frappe.ui.FieldGroup {
this.setup_listeners();
if (this.introduction_text) this.set_form_description(this.introduction_text);
if (this.allow_print && !this.is_new) this.setup_print_button();
if (this.allow_delete && !this.is_new) this.setup_delete_button();
if (this.is_new) this.setup_cancel_button();
this.setup_primary_action();
this.setup_previous_next_button();
@@ -79,9 +78,9 @@ export default class WebForm extends frappe.ui.FieldGroup {
}

$('.web-form-footer').after(`
<div id="form-step-footer" class="pull-right">
<button class="btn btn-primary btn-previous btn-sm ml-2">${__("Previous")}</button>
<button class="btn btn-primary btn-next btn-sm ml-2">${__("Next")}</button>
<div id="form-step-footer" class="text-right">
<button class="btn btn-default btn-previous btn-sm ml-2">${__("Previous")}</button>
<button class="btn btn-default btn-next btn-sm ml-2">${__("Next")}</button>
</div>
`);

@@ -141,6 +140,7 @@ export default class WebForm extends frappe.ui.FieldGroup {
set_form_description(intro) {
let intro_wrapper = document.getElementById('introduction');
intro_wrapper.innerHTML = intro;
intro_wrapper.classList.remove('hidden');
}

add_button(name, type, action, wrapper_class=".web-form-actions") {
@@ -164,25 +164,18 @@ export default class WebForm extends frappe.ui.FieldGroup {
this.save()
);

this.add_button_to_footer(this.button_label || __("Save", null, "Button in web form"), "primary", () =>
this.save()
);
if (!this.is_multi_step_form && $('.frappe-card').height() > 600) {
// add button on footer if page is long
this.add_button_to_footer(this.button_label || __("Save", null, "Button in web form"), "primary", () =>
this.save()
);
}
}

setup_cancel_button() {
this.add_button_to_header(__("Cancel", null, "Button in web form"), "light", () => this.cancel());
}

setup_delete_button() {
frappe.has_permission(this.doc_type, "", "delete", () => {
this.add_button_to_header(
frappe.utils.icon('delete'),
"danger",
() => this.delete()
);
});
}

setup_print_button() {
this.add_button_to_header(
frappe.utils.icon('print'),
@@ -359,17 +352,6 @@ export default class WebForm extends frappe.ui.FieldGroup {
return true;
}

delete() {
frappe.call({
type: "POST",
method: "frappe.website.doctype.web_form.web_form.delete",
args: {
web_form_name: this.name,
docname: this.doc.name
}
});
}

print() {
window.open(`/printview?
doctype=${this.doc_type}
@@ -386,21 +368,19 @@ export default class WebForm extends frappe.ui.FieldGroup {
window.location.href = data;
}

const success_dialog = new frappe.ui.Dialog({
title: __("Saved Successfully"),
secondary_action: () => {
if (this.success_url) {
window.location.href = this.success_url;
} else if(this.login_required) {
window.location.href =
window.location.pathname + "?name=" + data.name;
}
}
});

success_dialog.show();
const success_message =
this.success_message || __("Your information has been submitted");
success_dialog.set_message(success_message);
this.success_message || __("Submitted");

frappe.toast({message: success_message, indicator:'green'});

// redirect
setTimeout(() => {
if (this.success_url) {
window.location.href = this.success_url;
} else if(this.login_required) {
window.location.href =
window.location.pathname + "?name=" + data.name;
}
}, 2000);
}
}

+ 3
- 1
frappe/public/js/frappe/web_form/web_form_list.js 查看文件

@@ -6,7 +6,7 @@ export default class WebFormList {
constructor(opts) {
Object.assign(this, opts);
frappe.web_form_list = this;
this.wrapper = document.getElementById("datatable");
this.wrapper = document.getElementById("list-table");
this.make_actions();
this.make_filters();
$('.link-btn').remove();
@@ -320,6 +320,7 @@ frappe.ui.WebFormListRow = class WebFormListRow {
make_row() {
// Add Checkboxes
let cell = this.row.insertCell();
cell.classList.add('list-col-checkbox');

this.checkbox = document.createElement("input");
this.checkbox.type = "checkbox";
@@ -332,6 +333,7 @@ frappe.ui.WebFormListRow = class WebFormListRow {

// Add Serial Number
let serialNo = this.row.insertCell();
serialNo.classList.add('list-col-serial');
serialNo.innerText = this.serial_number;

this.columns.forEach(field => {


+ 5
- 5
frappe/public/js/frappe/web_form/webform_script.js 查看文件

@@ -52,11 +52,11 @@ frappe.ready(function() {
const data = setup_fields(r.message);
let web_form_doc = data.web_form;

if (web_form_doc.name && web_form_doc.allow_edit === 0) {
if (!window.location.href.includes("?new=1")) {
window.location.replace(window.location.pathname + "?new=1");
}
}
// if (web_form_doc.name && web_form_doc.allow_edit === 0) {
// if (!window.location.href.includes("?new=1")) {
// window.location.replace(window.location.pathname + "?new=1");
// }
// }
let doc = r.message.doc || build_doc(r.message);
web_form.prepare(web_form_doc, r.message.doc && web_form_doc.allow_edit === 1 ? r.message.doc : {});
web_form.make();


+ 2
- 2
frappe/public/scss/desk/avatar.scss 查看文件

@@ -111,8 +111,8 @@
}

.avatar-large {
width: 72px;
height: 72px;
width: 64px;
height: 64px;

.standard-image {
font-size: var(--text-2xl);


+ 1
- 1
frappe/public/scss/desk/sidebar.scss 查看文件

@@ -355,7 +355,7 @@ body[data-route^="Module"] .main-menu {
display: none;
}

input {
input:not([data-fieldtype='Check']) {
background: var(--control-bg-on-gray);
}



+ 58
- 13
frappe/public/scss/website/base.scss 查看文件

@@ -1,3 +1,13 @@
$font-size-xs: 0.7rem;
$font-size-sm: 0.85rem;
$font-size-lg: 1.12rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 2rem;
$font-size-4xl: 2.5rem;
$font-size-5xl: 3rem;
$font-size-6xl: 4rem;

html {
height: 100%;
}
@@ -14,45 +24,80 @@ img {
height: auto;
}

h1, h2, h3, h4 {
font-weight: 600;
}

h1 {
font-size: $font-size-3xl;
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.025em;
margin-bottom: 1rem;
margin-top: 3rem;
margin-bottom: 0.75rem;

@include media-breakpoint-up(sm) {
font-size: $font-size-5xl;
line-height: 2.5rem;
font-size: $font-size-4xl;
margin-top: 3.5rem;
margin-bottom: 1.25rem;
}
@include media-breakpoint-up(xl) {
font-size: $font-size-6xl;
line-height: 1;
font-size: $font-size-5xl;
margin-top: 4rem;
}
}

h2 {
font-size: $font-size-xl;
font-weight: 700;
font-size: $font-size-2xl;
margin-top: 2rem;
margin-bottom: 0.75rem;

@include media-breakpoint-up(sm) {
font-size: $font-size-2xl;
}
@include media-breakpoint-up(md) {
font-size: $font-size-3xl;
margin-top: 4rem;
margin-bottom: 1rem;
}
@include media-breakpoint-up(xl) {
font-size: $font-size-4xl;
margin-top: 4rem;
}
}

h3 {
font-size: $font-size-base;
font-weight: 600;
font-size: $font-size-xl;
margin-top: 1.5rem;
margin-bottom: 0.5rem;

@include media-breakpoint-up(sm) {
font-size: $font-size-lg;
font-size: $font-size-2xl;
margin-top: 2.5rem;
}
@include media-breakpoint-up(xl) {
font-size: $font-size-3xl;
margin-top: 3.5rem;
}
@include media-breakpoint-up(md) {
}

h4 {
font-size: $font-size-lg;
margin-top: 1rem;
margin-bottom: 0.5rem;

@include media-breakpoint-up(sm) {
font-size: $font-size-xl;
margin-top: 1.25rem;
}
@include media-breakpoint-up(xl) {
font-size: $font-size-2xl;
margin-top: 1.75rem;
}

a {
color: $body-color;
}
}

.btn.btn-lg {
font-size: $font-size-lg;
}

+ 2
- 104
frappe/public/scss/website/blog.scss 查看文件

@@ -57,12 +57,12 @@

.blog-card-footer {
display: flex;
align-items: center;
align-items: top;
margin-top: 0.5rem;

.avatar {
margin-top: 0.4rem;
margin-right: 0.5rem;
border-radius: 50%;
}
}
}
@@ -119,106 +119,4 @@
}
}
}

.add-comment-button {
margin-left: 35px;
}

.timeline-dot {
width: 16px;
height: 16px;
border-radius: 50%;
position: absolute;
top: 8px;
left: 22px;
background-color: var(--fg-color);
border: 1px solid var(--dark-border-color);

&:before {
content: ' ';
background: var(--gray-600);
position: absolute;
top: 5px;
left: 5px;
border-radius: 50%;
height: 4px;
width: 4px;
}
}

.blog-comments {
.comment-form-wrapper {
display: none;
}

.add-comment-section {
.login-required {
padding: var(--padding-sm);
border-radius: var(--border-radius-sm);
box-shadow: var(--card-shadow);
}

.new-comment {
display: flex;
padding: var(--padding-lg);
box-shadow: var(--card-shadow);
border-radius: var(--border-radius-md);

.new-comment-fields {
flex: 1;

.form-label {
font-weight: var(--text-bold);
}

.comment-text-area textarea {
resize: none;
}

@media (min-width: 576px) {
.comment-by {
padding-right: 0px !important;
padding-bottom: 0px !important;
}
}
}
}
}


#comment-list {
position: relative;
padding-left: var(--padding-xl);

&:before {
content: " ";
position: absolute;
top: var(--comment-timeline-top);
bottom: var(--comment-timeline-bottom);
border-left: 1px solid var(--dark-border-color);
}

.comment-row {
position: relative;

.comment-avatar {
position: absolute;
top: 10px;
left: -17px;
}

.comment-content {
box-shadow: var(--card-shadow);
border-radius: var(--border-radius-md);
padding: var(--padding-md);
margin-left: 35px;
flex: 1;

.content p{
margin-bottom: 0px;
}
}
}
}
}
}

+ 4
- 4
frappe/public/scss/website/footer.scss 查看文件

@@ -1,6 +1,8 @@
.web-footer {
padding: 5rem 0;
margin: 5rem 0;
min-height: 140px;
background-color: var(--fg-color);
border-top: 1px solid $border-color;
}

.footer-logo {
@@ -76,8 +78,6 @@
}

.footer-info {
margin-top: 1rem;
border-top: 1px solid $border-color;
color: $text-muted;
font-size: $font-size-sm;
}
@@ -98,4 +98,4 @@
font-size: $font-size-sm;
}
}
}
}

+ 1
- 1
frappe/public/scss/website/index.scss 查看文件

@@ -5,7 +5,6 @@
@import "../common/global";
@import "../common/icons";
@import "../common/alert";
@import 'base';
@import "../common/flex";
@import "../common/buttons";
@import "../common/modal";
@@ -14,6 +13,7 @@
@import "../common/indicator";
@import "../common/controls";
@import "../common/awesomeplete";
@import 'base';
@import 'multilevel_dropdown';
@import 'website_image';
@import 'website_avatar';


+ 9
- 103
frappe/public/scss/website/markdown.scss 查看文件

@@ -1,30 +1,12 @@
$font-sizes-desktop: (
"sm": 0.75rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.41rem,
"2xl": 1.6rem,
"3xl": 2rem
);

$font-sizes-mobile: (
"sm": 0.75rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.25rem,
"2xl": 1.5rem,
"3xl": 1.75rem
);

.section-markdown > .from-markdown {
max-width: 50rem;
margin: auto;
}

.from-markdown {
color: $gray-700;
line-height: 1.7;
letter-spacing: -0.011em;

> * + * {
margin-top: 0.75rem;
margin-bottom: 0;
}

> :first-child {
margin-top: 0;
@@ -47,6 +29,10 @@ $font-sizes-mobile: (
list-style: decimal;
}

p, li {
font-size: $font-size-lg;
}

li {
padding-top: 1px;
padding-bottom: 1px;
@@ -87,86 +73,6 @@ $font-sizes-mobile: (
font-weight: 600;
}

h1, h2, h3, h4, h5, h6 {
color: $gray-900;
}

h2, h3, h4, h5, h6 {
font-weight: 600;
}

h1 {
font-size: map-get($font-sizes-mobile, '3xl');
line-height: 1.5;
letter-spacing: -0.021em;
font-weight: 700;

@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, '3xl');
letter-spacing: -0.024em;
}

// for byline
& + p {
margin-top: 1.5rem;
font-size: map-get($font-sizes-mobile, 'xl');
letter-spacing: -0.014em;
line-height: 1.4;

@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, 'xl');
letter-spacing: -0.0175em;
}
}
}

h2 {
font-size: map-get($font-sizes-mobile, '2xl');
line-height: 1.56;
letter-spacing: -0.015em;
margin-top: 4rem;

@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, '2xl');
letter-spacing: -0.0195em;
}
}

h3 {
font-size: map-get($font-sizes-mobile, 'xl');
line-height: 1.56;
letter-spacing: -0.014em;
margin-top: 2.25rem;

@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, 'xl');
letter-spacing: -0.0175em;
}
}

h4 {
font-size: map-get($font-sizes-mobile, 'lg');
line-height: 1.56;
letter-spacing: -0.014em;
margin-top: 2.5rem;
}

h5 {
font-size: map-get($font-sizes-mobile, 'base');
line-height: 1.5;
letter-spacing: -0.011em;
font-weight: 600;
margin-top: 2rem;
}

h6 {
font-size: map-get($font-sizes-mobile, 'sm');
line-height: 1.35;
font-weight: 600;
text-transform: uppercase;
margin-top: 1.5rem;
}

tr > td,
tr > th {
font-size: $font-size-sm;


+ 6
- 23
frappe/public/scss/website/my_account.scss 查看文件

@@ -27,15 +27,16 @@
}
}

.my-account-container {
max-width: 800px;
margin: auto;
}

.account-info {
background-color: var(--fg-color);
box-shadow: var(--card-shadow);
border-radius: var(--border-radius-md);
padding: var(--padding-sm) 25px;
max-width: 850px;

@include media-breakpoint-up(sm) {
margin-left: 0;
}

@include media-breakpoint-down(sm) {
padding: 0;
@@ -97,21 +98,3 @@
border: 0;
}
}

//styles for third party apps page
//center wrt to outer most container and not immediate parent
.empty-apps-state {
position: relative;
padding-top: 10rem;
margin-left: -250px;
text-align: center;

@include media-breakpoint-down(sm) {
margin: auto;
padding-top: 5rem;
}

@include media-breakpoint-down(md) {
margin-left: 0;
}
}

+ 87
- 31
frappe/public/scss/website/page_builder.scss 查看文件

@@ -1,4 +1,7 @@
.hero-content {
margin-top: 3rem;
margin-bottom: 3rem;

.btn-primary {
margin-top: 1rem;
margin-right: 0.5rem;
@@ -15,16 +18,23 @@

.hero-title, .hero-subtitle {
max-width: 42rem;
margin-top: 0rem;
margin-bottom: 0.5rem;
}

.lead {
font-weight: normal;
font-size: 1.25rem;
margin-bottom: 1.5rem;
}

.hero-subtitle {
@extend .lead;
font-weight: 400;
color: $gray-600;
font-size: 1rem;
font-size: $font-size-lg;

@include media-breakpoint-up(sm) {
font-size: 1.25rem;
font-size: $font-size-xl;
}
}

@@ -42,10 +52,10 @@
.section-description {
max-width: 56rem;
margin-top: 0.5rem;
font-size: $font-size-base;
font-size: $font-size-lg;

@include media-breakpoint-up(lg) {
font-size: $font-size-lg;
@include media-breakpoint-up(media-breakpoint-up) {
font-size: $font-size-xl;
}
}

@@ -226,14 +236,10 @@
}
}

.section-markdown > .from-markdown {
max-width: 42rem;
}

.section-cta {
padding: 3rem 2rem;
text-align: center;
background-color: $primary-light;
background-color: $gray-200;
border-radius: 0.75rem;

@include media-breakpoint-up(sm) {
@@ -248,12 +254,7 @@
.title {
margin: 0 auto;
max-width: 36rem;
font-size: $font-size-2xl;
font-weight: 800;
line-height: 1.25;
@include media-breakpoint-up(md) {
font-size: $font-size-4xl;
}
}
.subtitle {
max-width: 36rem;
@@ -270,11 +271,15 @@
margin-top: 0.5rem;
font-size: $font-size-xs;
}
.action {
margin-top: 0;
margin-bottom: 0;
}
}

.section-small-cta {
padding: 1.8rem;
background-color: lighten($primary, 42%);
background-color: var(--gray-200);
border-radius: 0.75rem;
display: flex;
flex-direction: column;
@@ -294,26 +299,27 @@
}
}

.title {
max-width: 36rem;
font-size: $font-size-xl;
font-weight: 800;
line-height: 1.25;
@include media-breakpoint-up(md) {
font-size: $font-size-2xl;
}
.section-title {
line-height: 1;
margin-bottom: 0.25rem;
}

.subtitle {
max-width: 36rem;
font-size: $font-size-base;
color: $gray-900;
margin-bottom: 1.2rem;
margin-bottom: 0.5rem;

@include media-breakpoint-up(md) {
font-size: $font-size-lg;
margin-bottom: 0px;
}
}

.action {
margin-top: 0;
margin-bottom: 0;
}
}

.section-cta-container {
@@ -379,6 +385,20 @@
}
}

.testimonial-author {
margin-top: 1rem;
display: flex;
align-items: center;

.avatar {
margin-right: 0.5rem;
}

p {
margin-bottom: 0;
}
}

.split-section-content.align-top {
margin-top: 2rem;
}
@@ -459,6 +479,12 @@
align-items: center;
}

.collapsible-item-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-2xl);
}

.collapsible-item a {
text-decoration: none;
}
@@ -496,6 +522,7 @@
.section-description, .collapsible-items {
margin-left: auto;
margin-right: auto;
margin-top: 3rem;
}
}

@@ -514,12 +541,12 @@

@include media-breakpoint-up(md) {
grid-template-columns: repeat(2, 1fr);
gap: 6rem;
gap: 3rem 5rem;
}

.feature-title {
font-size: $font-size-xl;
font-weight: bold;
font-size: $font-size-lg;
font-weight: 600;

@include media-breakpoint-up(md) {
font-size: $font-size-2xl;
@@ -528,7 +555,7 @@

.feature-content {
font-size: $font-size-base;
margin-top: 1.75rem;
margin-top: 1.25rem;

@include media-breakpoint-up(xl) {
font-size: $font-size-lg;
@@ -630,9 +657,14 @@
}
}

.section-title {
margin-top: 0;
margin-bottom: 0.5rem;
}

.section-title + .section-features, .section-description + .section-features {
&[data-columns="2"] {
margin-top: 3.75rem;
margin-top: 3rem;
}

&[data-columns="3"] {
@@ -651,6 +683,14 @@
position: relative;
}

.feature-title {
margin-top: 0;
}

.feature-content {
line-height: 1.7;
}

.feature-title, .feature-content {
margin-bottom: 0;
}
@@ -666,3 +706,19 @@
.section-with-embed .embed-container {
margin-top: 2rem;
}

.section-video-wrapper {
margin-bottom: 1rem;
}

.section-video {
aspect-ratio: 16 / 9;
width: 100%;
cursor: pointer;
}

.video-thumbnail {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
}

+ 1
- 1
frappe/public/scss/website/variables.scss 查看文件

@@ -58,7 +58,7 @@ $font-size-lg: 1.125rem !default;
$font-size-xl: 1.25rem !default;
$font-size-2xl: 1.5rem !default;
$font-size-3xl: 1.875rem !default;
$font-size-4xl: 2.25rem !default;
$font-size-4xl: 2.5rem !default;
$font-size-5xl: 3rem !default;
$font-size-6xl: 4rem !default;



+ 60
- 19
frappe/public/scss/website/web_form.scss 查看文件

@@ -1,26 +1,45 @@
@import "../common/form";


[data-doctype="Web Form"] {
.page-content-wrapper {
.page_content {
max-width: 800px;
margin: auto;

.breadcrumb-container.container {
@include media-breakpoint-up(sm) {
padding-left: 0;
.frappe-card {
padding: 1rem;

h3 {
margin-top: 0;
margin-bottom: 0;
}

.web-form-head {
margin: 0 -1rem;
padding: 0 1rem 1rem 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
}

.container {
max-width: 800px;
#introduction {
margin-bottom: 2rem;
}

&.my-4 {
background-color: var(--fg-color);
#introduction p {
color: var(--text-muted);
}

@include media-breakpoint-up(sm) {
padding: 1.8rem;
border-radius: var(--border-radius-md);
box-shadow: var(--card-shadow);
}
.web-form-actions button {
margin-top: 0.1rem;
}
}

.frappe-card.list-card {
min-height: 400px;
}

.breadcrumb-container.container {
@include media-breakpoint-up(sm) {
padding-left: 0;
}
}
}
@@ -57,13 +76,21 @@
}
}

.web-form-wrapper~#datatable {
.list-table {
margin-left: -1rem;
margin-right: -1rem;

.table {
thead {
th {
border: 0;
font-size: 13px;
font-weight: normal;
color: var(--text-muted)
color: var(--text-muted);

input[type="checkbox"] {
margin-bottom: -2px;
}
}
}

@@ -71,8 +98,22 @@
color: var(--text-color);

td {
border-top: 1px solid var(--border-color);
font-size: 13px;
border-top: 1px solid var(--border-color);
}
}

input[type="checkbox"] {
margin-left: 0.5rem;
margin-top: 2px;
}

.list-col-checkbox {
width: 1rem;
}

.list-col-serial {
width: 1.5rem;
}
}
}
}

+ 22
- 0
frappe/query_builder/functions.py 查看文件

@@ -44,6 +44,28 @@ CombineDatetime = ImportMapper(
)


class Cast_(Function):
def __init__(self, value, as_type, alias=None):
if db_type_is.MARIADB and (
(hasattr(as_type, "get_sql") and as_type.get_sql().lower() == "varchar") or str(as_type).lower() == "varchar"
):
# mimics varchar cast in mariadb
# as mariadb doesn't have varchar data cast
# https://mariadb.com/kb/en/cast/#description

# ref: https://stackoverflow.com/a/32542095
super().__init__("CONCAT", value, "", alias=alias)
else:
# from source: https://pypika.readthedocs.io/en/latest/_modules/pypika/functions.html#Cast
super().__init__("CAST", value, alias=alias)
self.as_type = as_type

def get_special_params_sql(self, **kwargs):
if self.name.lower() == "cast":
type_sql = self.as_type.get_sql(**kwargs) if hasattr(self.as_type, "get_sql") else str(self.as_type).upper()
return "AS {type}".format(type=type_sql)


def _aggregate(function, dt, fieldname, filters, **kwargs):
return (
Query()


+ 5
- 2
frappe/query_builder/utils.py 查看文件

@@ -54,6 +54,9 @@ def patch_query_execute():
This excludes the use of `frappe.db.sql` method while
executing the query object
"""
from frappe.utils.safe_exec import check_safe_sql_query


def execute_query(query, *args, **kwargs):
query, params = prepare_query(query)
return frappe.db.sql(query, params, *args, **kwargs) # nosemgrep
@@ -63,7 +66,7 @@ def patch_query_execute():

param_collector = NamedParameterWrapper()
query = query.get_sql(param_wrapper=param_collector)
if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"):
if frappe.flags.in_safe_exec and not check_safe_sql_query(query, throw=False):
callstack = inspect.stack()
if len(callstack) >= 3 and ".py" in callstack[2].filename:
# ignore any query builder methods called from python files
@@ -77,7 +80,7 @@ def patch_query_execute():
#
# if frame2 is server script it wont have a filename and hence
# it shouldn't be allowed.
# ps. stack() returns `"<unknown>"` as filename.
# p.s. stack() returns `"<unknown>"` as filename if not a file.
pass
else:
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')


+ 2
- 1
frappe/share.py 查看文件

@@ -44,7 +44,8 @@ def add(doctype, name, user=None, read=1, write=0, submit=0, share=0, everyone=0
doc.save(ignore_permissions=True)
notify_assignment(user, doctype, name, everyone, notify=notify)

follow_document(doctype, name, user)
if frappe.get_cached_value("User", user, "follow_shared_documents"):
follow_document(doctype, name, user)

return doc



+ 8
- 8
frappe/templates/includes/avatar_macro.html 查看文件

@@ -1,18 +1,18 @@
{% macro avatar(user_id=None, css_style=None, size="avatar-small") %}
{% macro avatar(user_id=None, css_style=None, size="avatar-small", full_name=None, image=None) %}
{% set user_info = frappe.utils.get_user_info_for_avatar(user_id) %}
<span class="avatar {{ size }}" title="{{ user_info.name }}" style="{{ css_style or '' }}">
{% if user_info.image %}
<span class="avatar {{ size }}" title="{{ full_name or user_info.name }}" style="{{ css_style or '' }}">
{% if image or user_info.image %}
<img
class="avatar-frame standard-image"
src="{{ user_info.image }}"
title="{{ user_info.name }}">
src="{{ image or user_info.image }}"
title="{{ full_name or user_info.name }}">
</span>
{% else %}
<span
class="avatar-frame standard-image"
title="{{ user_info.name }}">
{{ frappe.utils.get_abbr(user_info.name).upper() }}
title="{{ full_name or user_info.name }}">
{{ frappe.utils.get_abbr(full_name or user_info.name).upper() }}
</span>
{% endif %}
</span>
{% endmacro %}
{% endmacro %}

+ 4
- 3
frappe/templates/includes/blog/blogger.html 查看文件

@@ -1,8 +1,9 @@
{% from "frappe/templates/includes/macros.html" import square_image_with_fallback %}
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}

<div class="media">
{{ square_image_with_fallback(src=blogger_info.avatar, size='small', alt=blogger_info.full_name, class='align-self-start mr-4 rounded') }}
<div class="media-body">
{{ avatar(full_name=blogger_info.full_name, image=blogger_info.avatar, size='avatar-large') }}

<div class="media-body ml-3">
<h5 class="mt-0">
<a href="/blog?blogger={{ blogger_info.name }}" class="text-dark">{{ blogger_info.full_name }}</a>
</h5>


+ 109
- 5
frappe/templates/includes/comments/comments.html 查看文件

@@ -62,11 +62,13 @@
let user_id = "";

let update_timeline_line_length = function(direction, size) {
if (direction == 'top') {
$('.blog-container')[0].style.setProperty('--comment-timeline-top', size);
} else {
let comment_timeline_bottom = $('.comment-list .comment-row:last-child').height() - 10;
$('.blog-container')[0].style.setProperty('--comment-timeline-bottom', comment_timeline_bottom +'px');
if ($('.blog-container').length) {
if (direction == 'top') {
$('.blog-container')[0].style.setProperty('--comment-timeline-top', size);
} else {
let comment_timeline_bottom = $('.comment-list .comment-row:last-child').height() - 10;
$('.blog-container')[0].style.setProperty('--comment-timeline-bottom', comment_timeline_bottom +'px');
}
}
}

@@ -194,3 +196,105 @@
});
});
</script>

<style>

.add-comment-button {
margin-left: 35px;
}

.timeline-dot {
width: 16px;
height: 16px;
border-radius: 50%;
position: absolute;
top: 8px;
left: 22px;
background-color: var(--fg-color);
border: 1px solid var(--dark-border-color);
}

.timeline-dot::before {
content: ' ';
background: var(--gray-600);
position: absolute;
top: 5px;
left: 5px;
border-radius: 50%;
height: 4px;
width: 4px;
}

.comment-form-wrapper {
display: none;
}

.login-required {
padding: var(--padding-sm);
border-radius: var(--border-radius-sm);
box-shadow: var(--card-shadow);
}

.new-comment {
display: flex;
padding: var(--padding-lg);
box-shadow: var(--card-shadow);
border-radius: var(--border-radius-md);
background-color: var(--fg-color);
}

.new-comment-fields {
flex: 1;
}

.new-comment .form-label {
font-weight: var(--text-bold);
}

.new-comment .comment-text-area textarea {
resize: none;
}

@media (min-width: 576px) {
.comment-by {
padding-right: 0px !important;
padding-bottom: 0px !important;
}
}

#comment-list {
position: relative;
padding-left: var(--padding-xl);
}

#comment-list::before {
content: " ";
position: absolute;
top: var(--comment-timeline-top);
bottom: var(--comment-timeline-bottom);
border-left: 1px solid var(--dark-border-color);
}

.comment-row {
position: relative;
}
.comment-avatar {
position: absolute;
top: 10px;
left: -17px;
}

.comment-content {
box-shadow: var(--card-shadow);
background-color: var(--fg-color);
border-radius: var(--border-radius-md);
padding: var(--padding-md);
margin-left: 35px;
flex: 1;
}

.comment-content .content p{
margin-bottom: 0px;
}

</style>

+ 1
- 1
frappe/templates/includes/login/login.js 查看文件

@@ -313,7 +313,7 @@ var continue_otp_app = function (setup, qrcode) {
var qrcode_div = $('<div class="text-muted" style="padding-bottom: 15px;"></div>');

if (setup) {
direction = $('<div>').attr('id', 'qr_info').text('{{ _("Enter Code displayed in OTP App.") }}');
direction = $('<div>').attr('id', 'qr_info').html('{{ _("Enter Code displayed in OTP App.") }}');
qrcode_div.append(direction);
$('#otp_div').prepend(qrcode_div);
} else {


+ 6
- 1
frappe/templates/includes/web_block.html 查看文件

@@ -3,6 +3,8 @@
'section-padding-top': web_block.add_top_padding,
'section-padding-bottom': web_block.add_bottom_padding,
'bg-light': web_block.add_shade,
'border-top': web_block.add_border_at_top,
'border-bottom': web_block.add_border_at_bottom,
},
web_block.css_class
]) -%}
@@ -10,7 +12,10 @@
{%- if web_template_type == 'Section' -%}
{%- if not web_block.hide_block -%}
<section class="section {{ classes }}" data-section-idx="{{ web_block.idx | e }}"
data-section-template="{{ web_block.web_template | e }}">
data-section-template="{{ web_block.web_template | e }}"
{% if web_block.add_background_image -%}
style="background: url({{ web_block.background_image}}) no-repeat center center; background-size: cover;"
{%- endif %}>
{%- if web_block.add_container -%}
<div class="container">
{%- endif -%}


+ 15
- 0
frappe/tests/test_db.py 查看文件

@@ -312,6 +312,21 @@ class TestDB(unittest.TestCase):

frappe.db.MAX_WRITES_PER_TRANSACTION = Database.MAX_WRITES_PER_TRANSACTION

def test_transaction_write_counting(self):
note = frappe.get_doc(doctype="Note", title="transaction counting").insert()

writes = frappe.db.transaction_writes
frappe.db.set_value("Note", note.name, "content", "abc")
self.assertEqual(1, frappe.db.transaction_writes - writes)
writes = frappe.db.transaction_writes

frappe.db.sql("""
update `tabNote`
set content = 'abc'
where name = %s
""", note.name)
self.assertEqual(1, frappe.db.transaction_writes - writes)

def test_pk_collision_ignoring(self):
# note has `name` generated from title
for _ in range(3):


+ 5
- 1
frappe/tests/test_document.py 查看文件

@@ -246,7 +246,7 @@ class TestDocument(unittest.TestCase):
'fields': [
{'label': 'Currency', 'fieldname': 'currency', 'reqd': 1, 'fieldtype': 'Currency'},
]
}).insert()
}).insert(ignore_if_duplicate=True)

frappe.delete_doc_if_exists("Currency", "INR", 1)

@@ -262,6 +262,10 @@ class TestDocument(unittest.TestCase):
})
self.assertEqual(d.get_formatted('currency', currency='INR', format="#,###.##"), '₹ 100,000.00')

# should work even if options aren't set in df
# and currency param is not passed
self.assertIn("0", d.get_formatted("currency"))

def test_limit_for_get(self):
doc = frappe.get_doc("DocType", "DocType")
# assuming DocType has more than 3 Data fields


+ 0
- 1
frappe/tests/test_global_search.py 查看文件

@@ -83,7 +83,6 @@ class TestGlobalSearch(unittest.TestCase):

def test_delete_doc(self):
self.insert_test_events()

event_name = frappe.get_all('Event')[0].name
event = frappe.get_doc('Event', event_name)
test_subject = event.subject


+ 11
- 1
frappe/tests/test_query_builder.py 查看文件

@@ -3,7 +3,7 @@ from typing import Callable

import frappe
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Coalesce, GroupConcat, Match, CombineDatetime
from frappe.query_builder.functions import Coalesce, GroupConcat, Match, CombineDatetime, Cast_
from frappe.query_builder.utils import db_type_is
from frappe.query_builder import Case

@@ -53,6 +53,11 @@ class TestCustomFunctionsMariaDB(unittest.TestCase):
select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp"))
self.assertIn("timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`) `timestamp`", str(select_query).lower())

def test_cast(self):
note = frappe.qb.DocType("Note")
self.assertEqual("CONCAT(`tabnote`.`name`, '')", Cast_(note.name, "varchar"))
self.assertEqual("CAST(`tabnote`.`name` AS INTEGER)", Cast_(note.name, "integer"))


@run_only_if(db_type_is.POSTGRES)
class TestCustomFunctionsPostgres(unittest.TestCase):
@@ -97,6 +102,11 @@ class TestCustomFunctionsPostgres(unittest.TestCase):
select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp"))
self.assertIn('"tabnote"."posting_date"+"tabnote"."posting_time" "timestamp"', str(select_query).lower())

def test_cast(self):
note = frappe.qb.DocType("Note")
self.assertEqual("CAST(`tabnote`.`name` AS VARCHAR)", Cast_(note.name, "varchar"))
self.assertEqual("CAST(`tabnote`.`name` AS INTEGER)", Cast_(note.name, "integer"))


class TestBuilderBase(object):
def test_adding_tabs(self):


+ 23
- 9
frappe/tests/test_translate.py 查看文件

@@ -36,7 +36,18 @@ class TestTranslate(unittest.TestCase):

def test_extract_message_from_file(self):
data = frappe.translate.get_messages_from_file(translation_string_file)
self.assertListEqual(data, expected_output)
exp_filename = "apps/frappe/frappe/tests/translation_test_file.txt"

self.assertEqual(len(data), len(expected_output),
msg=f"Mismatched output:\nExpected: {expected_output}\nFound: {data}")

for extracted, expected in zip(data, expected_output):
ext_filename, ext_message, ext_context, ext_line = extracted
exp_message, exp_context, exp_line = expected
self.assertEqual(ext_filename, exp_filename)
self.assertEqual(ext_message, exp_message)
self.assertEqual(ext_context, exp_context)
self.assertEqual(ext_line, exp_line)

def test_translation_with_context(self):
try:
@@ -107,13 +118,16 @@ class TestTranslate(unittest.TestCase):


expected_output = [
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', None, 4),
('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 6),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Submit', 'Some DocType', 8),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 15),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Submit', 'Some DocType', 17),
('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 19),
('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 21)
('Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2),
('Warning: Unable to find {0} in any table related to {1}', None, 4),
("You don't have any messages yet.", None, 6),
('Submit', 'Some DocType', 8),
('Warning: Unable to find {0} in any table related to {1}', 'This is some context', 15),
('Submit', 'Some DocType', 17),
("You don't have any messages yet.", None, 19),
("You don't have any messages yet.", None, 21),
("Long string that needs its own line because of black formatting.", None, 24),
("Long string with", "context", 28),
("Long string with", "context on newline", 32),
]


+ 15
- 1
frappe/tests/translation_test_file.txt 查看文件

@@ -18,4 +18,18 @@ _('Submit', context="Some DocType")

_("""You don't have any messages yet.""")

_('''You don't have any messages yet.''')
_('''You don't have any messages yet.''')

// allow newline in beginning
_(
"""Long string that needs its own line because of black formatting."""
).format("blah")

_(
"Long string with", context="context"
).format("blah")

_(
"Long string with",
context="context on newline"
).format("blah")

+ 30
- 2
frappe/translate.py 查看文件

@@ -23,6 +23,35 @@ from frappe.utils import get_bench_path, is_html, strip, strip_html_tags
from frappe.query_builder import Field, DocType
from pypika.terms import PseudoColumn

TRANSLATE_PATTERN = re.compile(
r"_\([\s\n]*" # starts with literal `_(`, ignore following whitespace/newlines

# BEGIN: message search
r"([\"']{,3})" # start of message string identifier - allows: ', ", """, '''; 1st capture group
r"(?P<message>((?!\1).)*)" # Keep matching until string closing identifier is met which is same as 1st capture group
r"\1" # match exact string closing identifier
# END: message search

# BEGIN: python context search
r"([\s\n]*,[\s\n]*context\s*=\s*" # capture `context=` with ignoring whitespace
r"([\"'])" # start of context string identifier; 5th capture group
r"(?P<py_context>((?!\5).)*)" # capture context string till closing id is found
r"\5" # match context string closure
r")?" # match 0 or 1 context strings
# END: python context search

# BEGIN: JS context search
r"(\s*,\s*(.)*?\s*(,\s*" # skip message format replacements: ["format", ...] | null | []
r"([\"'])" # start of context string; 11th capture group
r"(?P<js_context>((?!\11).)*)" # capture context string till closing id is found
r"\11" # match context string closure
r")*"
r")*" # match one or more context string
# END: JS context search

r"[\s\n]*\)" # Closing function call ignore leading whitespace/newlines
)


def get_language(lang_list: List = None) -> str:
"""Set `frappe.local.lang` from HTTP headers at beginning of request
@@ -651,9 +680,8 @@ def extract_messages_from_code(code):
frappe.clear_last_message()

messages = []
pattern = r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,\s*(.)*?\s*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)"

for m in re.compile(pattern).finditer(code):
for m in TRANSLATE_PATTERN.finditer(code):
message = m.group('message')
context = m.group('py_context') or m.group('js_context')
pos = m.start()


+ 21
- 6
frappe/translations/fr.csv 查看文件

@@ -218,7 +218,7 @@ Route,Route,
Sales Manager,Responsable des Ventes,
Sales Master Manager,Directeur des Ventes,
Sales User,Chargé de Ventes,
Salutation,Salutations,
Salutation,Civilité,
Sample,Échantillon,
Saturday,Samedi,
Saved,Enregistré,
@@ -246,7 +246,7 @@ Start Import,Démarrer l'import,
State,Etat,
Stopped,Arrêté,
Subject,Sujet,
Submit,Soumettre,
Submit,Valider,
Successful,Réussi,
Summary,Résumé,
Sunday,Dimanche,
@@ -786,6 +786,7 @@ Custom Sidebar Menu,Barre Latérale Personnalisée,
Custom Translations,Traductions Personnalisées,
Customization,Personnalisation,
Customizations Reset,Réinitialiser les Personnalisations,
Reset Customizations,Réinitialiser les Personnalisations,
Customizations for <b>{0}</b> exported to:<br>{1},Personnalisations pour <b>{0}</b> exportées vers: <br> {1},
Customize Form,Personnaliser le formulaire,
Customize Form Field,Personnaliser un Champ de Formulaire,
@@ -1508,7 +1509,7 @@ Login not allowed at this time,Connexion non autorisée pour le moment,
Login to comment,Connectez-vous pour commenter,
Login token required,Identifiants de Connexion Requis,
Login with LDAP,Se connecter avec LDAP,
Logout,Connectez - Out,
Logout,Déconnecté,
Long Text,Texte Long,
Looks like something is wrong with this site's Paypal configuration.,Il semble qu'il y ait une erreur avec la configuration Paypal de ce site.,
Looks like something is wrong with this site's payment gateway configuration. No payment has been made.,On dirait que quelque chose ne va pas dans la configuration de la passerelle de paiement de ce site. Aucun paiement n'a été effectué.,
@@ -2164,7 +2165,7 @@ Search for '{0}',Rechercher &#39;{0}&#39;,
Search for anything,Rechercher tout,
Search in a document type,Rechercher dans un type de document,
Search or Create a New Chat,Rechercher ou créer un nouveau chat,
Search or type a command,Rechercher ou taper une commande,
Search or type a command (Ctrl + G),Rechercher ou taper une commande (Ctrl + G),
Search...,Rechercher...,
Searching,Recherche,
Searching ...,Recherche ...,
@@ -4139,7 +4140,7 @@ Document is only editable by users with role,Le document n&#39;est modifiable qu
{0}: Other permission rules may also apply,{0}: d&#39;autres règles d&#39;autorisation peuvent également s&#39;appliquer,
{0} Page Views,{0} pages vues,
Expand,Développer,
Collapse,Effondrer,
Collapse,Réduire,
"Invalid Bearer token, please provide a valid access token with prefix 'Bearer'.","Jeton de porteur non valide, veuillez fournir un jeton d&#39;accès valide avec le préfixe «porteur».",
"Failed to decode token, please provide a valid base64-encoded token.","Échec du décodage du jeton, veuillez fournir un jeton encodé en base64 valide.",
"Invalid token, please provide a valid token with prefix 'Basic' or 'Token'.","Jeton non valide, veuillez fournir un jeton valide avec le préfixe «Basic» ou «Token».",
@@ -4215,7 +4216,7 @@ since yesterday,depuis hier,
since last week,depuis la semaine dernière,
since last month,depuis le mois dernier,
since last year,depuis l&#39;année dernière,
Show,Spectacle,
Show,Afficher,
New Number Card,Nouvelle carte de numéro,
Your Shortcuts,Vos raccourcis,
You haven't added any Dashboard Charts or Number Cards yet.,Vous n&#39;avez pas encore ajouté de tableaux de bord ou de cartes numériques.,
@@ -4700,3 +4701,17 @@ Value cannot be negative for {0}: {1},La valeur ne peut pas être négative pour
Negative Value,Valeur négative,
Authentication failed while receiving emails from Email Account: {0}.,L&#39;authentification a échoué lors de la réception des e-mails du compte de messagerie: {0}.,
Message from server: {0},Message du serveur: {0},
{0} edited this {1},{0} a édité {1},
{0} created this {1}, {0} a créé {1}
Report an Issue, Signaler une anomalie
About, A Propos
My Profile, Mon profil
My Settings, Mes paramètres
Toggle Full Width, Changer l&#39;affichage en pleine largeur
Toggle Theme, Basculer le thème
Theme Changed, Thème changé
Amend, Nouv. version
Document has been submitted, Document validé
Document has been cancelled, Document annulé
Document is in draft state, Document au statut brouillon
Copy to Clipboard,Copier vers le presse-papiers

+ 0
- 224
frappe/utils/bot.py 查看文件

@@ -1,224 +0,0 @@
# Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE

import frappe, re, frappe.utils
from frappe.desk.notifications import get_notifications
from frappe import _

@frappe.whitelist()
def get_bot_reply(question):
return BotReply().get_reply(question)

class BotParser(object):
'''Base class for bot parser'''
def __init__(self, reply, query):
self.query = query
self.reply = reply
self.tables = reply.tables
self.doctype_names = reply.doctype_names

def has(self, *words):
'''return True if any of the words is present int the query'''
for word in words:
if re.search(r'\b{0}\b'.format(word), self.query):
return True

def startswith(self, *words):
'''return True if the query starts with any of the given words'''
for w in words:
if self.query.startswith(w):
return True

def strip_words(self, query, *words):
'''Remove the given words from the query'''
for word in words:
query = re.sub(r'\b{0}\b'.format(word), '', query)

return query.strip()

def format_list(self, data):
'''Format list as markdown'''
return _('I found these:') + ' ' + ', '.join(' [{title}](/app/Form/{doctype}/{name})'.format(
title = d.title or d.name,
doctype=self.get_doctype(),
name=d.name) for d in data)

def get_doctype(self):
'''returns the doctype name from self.tables'''
return self.doctype_names[self.tables[0]]

class ShowNotificationBot(BotParser):
'''Show open notifications'''
def get_reply(self):
if self.has("whatsup", "what's up", "wassup", "whats up", 'notifications', 'open tasks'):
n = get_notifications()
open_items = sorted(n.get('open_count_doctype').items())

if open_items:
return ("Following items need your attention:\n\n"
+ "\n\n".join("{0} [{1}](/app/List/{1})".format(d[1], d[0])
for d in open_items if d[1] > 0))
else:
return 'Take it easy, nothing urgent needs your attention'

class GetOpenListBot(BotParser):
'''Get list of open items'''
def get_reply(self):
if self.startswith('open', 'show open', 'list open', 'get open'):
if self.tables:
doctype = self.get_doctype()
from frappe.desk.notifications import get_notification_config
filters = get_notification_config().get('for_doctype').get(doctype, None)
if filters:
if isinstance(filters, dict):
data = frappe.get_list(doctype, filters=filters)
else:
data = [{'name':d[0], 'title':d[1]} for d in frappe.get_attr(filters)(as_list=True)]

return ", ".join('[{title}](/app/Form/{doctype}/{name})'.format(doctype=doctype,
name=d.get('name'), title=d.get('title') or d.get('name')) for d in data)
else:
return _("Can't identify open {0}. Try something else.").format(doctype)

class ListBot(BotParser):
def get_reply(self):
if self.query.endswith(' ' + _('list')) and self.startswith(_('list')):
self.query = _('list') + ' ' + self.query.replace(' ' + _('list'), '')
if self.startswith(_('list'), _('show')):
like = None
if ' ' + _('like') + ' ' in self.query:
self.query, like = self.query.split(' ' + _('like') + ' ')

self.tables = self.reply.identify_tables(self.query.split(None, 1)[1])
if self.tables:
doctype = self.get_doctype()
meta = frappe.get_meta(doctype)
fields = ['name']
if meta.title_field:
fields.append('`{0}` as title'.format(meta.title_field))

filters = {}
if like:
filters={
meta.title_field or 'name': ('like', '%' + like + '%')
}
return self.format_list(frappe.get_list(self.get_doctype(), fields=fields, filters=filters))

class CountBot(BotParser):
def get_reply(self):
if self.startswith('how many'):
self.tables = self.reply.identify_tables(self.query.split(None, 1)[1])
if self.tables:
return str(frappe.db.sql('select count(*) from `tab{0}`'.format(self.get_doctype()))[0][0])

class FindBot(BotParser):
def get_reply(self):
if self.startswith('find', 'search'):
query = self.query.split(None, 1)[1]

if self.has('from'):
text, table = query.split('from')

if self.has('in'):
text, table = query.split('in')

if table:
text = text.strip()
self.tables = self.reply.identify_tables(table.strip())


if self.tables:
filters = {'name': ('like', '%{0}%'.format(text))}
or_filters = None

title_field = frappe.get_meta(self.get_doctype()).title_field
if title_field and title_field!='name':
or_filters = {'title': ('like', '%{0}%'.format(text))}

data = frappe.get_list(self.get_doctype(),
filters=filters, or_filters=or_filters)
if data:
return self.format_list(data)
else:
return _("Could not find {0} in {1}").format(text, self.get_doctype())

else:
self.out = _("Could not identify {0}").format(table)
else:
self.out = _("You can find things by asking 'find orange in customers'").format(table)

class BotReply(object):
'''Build a reply for the bot by calling all parsers'''
def __init__(self):
self.tables = []

def get_reply(self, query):
self.query = query.lower()
self.setup()
self.pre_process()

# basic replies
if self.query.split()[0] in ("hello", "hi"):
return _("Hello {0}").format(frappe.utils.get_fullname())

if self.query == "help":
return help_text.format(frappe.utils.get_fullname())

# build using parsers
replies = []
for parser in frappe.get_hooks('bot_parsers'):
reply = None
try:
reply = frappe.get_attr(parser)(self, query).get_reply()
except frappe.PermissionError:
reply = _("Oops, you are not allowed to know that")

if reply:
replies.append(reply)

if replies:
return '\n\n'.join(replies)

if not reply:
return _("Don't know, ask 'help'")

def setup(self):
self.setup_tables()
self.identify_tables()

def pre_process(self):
if self.query.endswith("?"):
self.query = self.query[:-1]

if self.query in ("todo", "to do"):
self.query = "open todo"

def setup_tables(self):
tables = frappe.get_all("DocType", {"istable": 0})
self.all_tables = [d.name.lower() for d in tables]
self.doctype_names = {d.name.lower():d.name for d in tables}

def identify_tables(self, query=None):
if not query:
query = self.query
self.tables = []
for t in self.all_tables:
if t in query or t[:-1] in query:
self.tables.append(t)

return self.tables



help_text = """Hello {0}, I am a K.I.S.S Bot, not AI, so be kind. I can try answering a few questions like,

- "todo": list my todos
- "show customers": list customers
- "show customers like giant": list customer containing giant
- "locate shirt": find where to find item "shirt"
- "open issues": find open issues, try "open sales orders"
- "how many users": count number of users
- "find asian in sales orders": find sales orders where name or title has "asian"

have fun!
"""

+ 14
- 11
frappe/utils/data.py 查看文件

@@ -1645,18 +1645,21 @@ def validate_json_string(string: str) -> None:
raise frappe.ValidationError

def get_user_info_for_avatar(user_id: str) -> Dict:
user_info = {
"email": user_id,
"image": "",
"name": user_id
}
try:
user_info["email"] = frappe.get_cached_value("User", user_id, "email")
user_info["name"] = frappe.get_cached_value("User", user_id, "full_name")
user_info["image"] = frappe.get_cached_value("User", user_id, "user_image")
except Exception:
frappe.local.message_log = []
return user_info
user = frappe.get_cached_doc("User", user_id)
return {
"email": user.email,
"image": user.user_image,
"name": user.full_name
}

except frappe.DoesNotExistError:
frappe.clear_last_message()
return {
"email": user_id,
"image": "",
"name": user_id
}


def validate_python_code(string: str, fieldname=None, is_expression: bool = True) -> None:


+ 1
- 1
frappe/utils/install.py 查看文件

@@ -236,7 +236,7 @@ def add_standard_navbar_items():
'is_standard': 1
},
{
'item_label': 'Logout',
'item_label': 'Log out',
'item_type': 'Action',
'action': 'frappe.app.logout()',
'is_standard': 1


+ 2
- 1
frappe/utils/jinja.py 查看文件

@@ -48,7 +48,8 @@ def validate_template(html):
"""Throws exception if there is a syntax error in the Jinja Template"""
import frappe
from jinja2 import TemplateSyntaxError

if not html:
return
jenv = get_jenv()
try:
jenv.from_string(html)


+ 0
- 151
frappe/utils/reset_doc.py 查看文件

@@ -1,151 +0,0 @@

import frappe
import json, os
from frappe.modules import scrub, get_module_path, utils
from frappe.custom.doctype.customize_form.customize_form import doctype_properties, docfield_properties
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.core.page.permission_manager.permission_manager import get_standard_permissions
from frappe.permissions import setup_custom_perms
from urllib.request import urlopen

branch = 'develop'

def reset_all():
for doctype in frappe.db.get_all('DocType', dict(custom=0)):
print(doctype.name)
reset_doc(doctype.name)

def reset_doc(doctype):
'''
doctype = name of the DocType that you want to reset
'''
# fetch module name
module = frappe.db.get_value('DocType', doctype, 'module')
app = utils.get_module_app(module)

# get path for doctype's json and its equivalent git url
doc_path = os.path.join(get_module_path(module), 'doctype', scrub(doctype), scrub(doctype)+'.json')
try:
git_link = '/'.join(['https://raw.githubusercontent.com/frappe',\
app, branch, doc_path.split('apps/'+app)[1]])
original_file = urlopen(git_link).read()
except:
print('Did not find {0} in {1}'.format(doctype, app))
return

# load local and original json objects
local_doc = json.loads(open(doc_path, 'r').read())
original_doc = json.loads(original_file)

remove_duplicate_fields(doctype)
set_property_setter(doctype, local_doc, original_doc)
make_custom_fields(doctype, local_doc, original_doc)

with open(doc_path, 'w+') as f:
f.write(original_file)
f.close()

setup_perms_for(doctype)

frappe.db.commit()

def remove_duplicate_fields(doctype):
for field in frappe.db.sql('''select fieldname, count(1) as cnt from tabDocField where parent=%s group by fieldname having cnt > 1''', doctype):
frappe.db.sql('delete from tabDocField where fieldname=%s and parent=%s limit 1', (field[0], doctype))
print('removed duplicate {0} in {1}'.format(field[0], doctype))

def set_property_setter(doctype, local_doc, original_doc):
''' compare doctype_properties and docfield_properties and create property_setter '''

# doctype_properties reset
for dp in doctype_properties:
# make property_setter to mimic changes made in local json
if dp in local_doc and dp not in original_doc:
make_property_setter(doctype, '', dp, local_doc[dp], doctype_properties[dp], for_doctype=True)

local_fields = get_fields_dict(local_doc)
original_fields = get_fields_dict(original_doc)

# iterate through field and properties of each of those field
for docfield in original_fields:
for prop in original_fields[docfield]:
# skip fields that are not in local_fields
if docfield not in local_fields: continue

if prop in docfield_properties and prop in local_fields[docfield]\
and original_fields[docfield][prop] != local_fields[docfield][prop]:
# make property_setter equivalent of local changes
make_property_setter(doctype, docfield, prop, local_fields[docfield][prop],\
docfield_properties[prop])

def make_custom_fields(doctype, local_doc, original_doc):
'''
check fields and create a custom field equivalent for non standard fields
'''
local_fields, original_fields = get_fields_dict(local_doc), get_fields_dict(original_doc)
local_fields = sorted(local_fields.items(), key=lambda x: x[1]['idx'])
doctype_doc = frappe.get_doc('DocType', doctype)

custom_docfield_properties, prev = get_custom_docfield_properties(), ""
for field, field_dict in local_fields:
df = {}
if field not in original_fields:
for prop in field_dict:
if prop in custom_docfield_properties:
df[prop] = field_dict[prop]

df['insert_after'] = prev if prev else ''

doctype_doc.fields = [d for d in doctype_doc.fields if d.fieldname != df['fieldname']]
doctype_doc.update_children()

create_custom_field(doctype, df)

# set current field as prev field for next field
prev = field

def get_fields_dict(doc):
fields, idx = {}, 0
for field in doc['fields']:
field['idx'] = idx
fields[field.get('fieldname')] = field
idx += 1

return fields

def get_custom_docfield_properties():
fields_meta = frappe.get_meta('Custom Field').fields
fields = {}
for d in fields_meta:
fields[d.fieldname] = d.fieldtype

return fields


def setup_perms_for(doctype):
perms = frappe.get_all('DocPerm', fields='*', filters=dict(parent=doctype), order_by='idx asc')
# get default perms
try:
standard_perms = get_standard_permissions(doctype)
except (IOError, KeyError):
# no json file, doctype no longer exists!
return

same = True
if len(standard_perms) != len(perms):
same = False

else:
for i, p in enumerate(perms):
standard = standard_perms[i]
for fieldname in frappe.get_meta('DocPerm').get_fieldnames_with_value():
if p.get(fieldname) != standard.get(fieldname):
same = False
break

if not same:
break

if not same:
setup_custom_perms(doctype)

+ 24
- 2
frappe/utils/safe_exec.py 查看文件

@@ -269,11 +269,33 @@ def get_hooks(hook=None, default=None, app_name=None):
def read_sql(query, *args, **kwargs):
'''a wrapper for frappe.db.sql to allow reads'''
query = str(query)
if frappe.flags.in_safe_exec and not query.strip().lower().startswith('select'):
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')
if frappe.flags.in_safe_exec:
check_safe_sql_query(query)
return frappe.db.sql(query, *args, **kwargs)


def check_safe_sql_query(query: str, throw: bool = True) -> bool:
""" Check if SQL query is safe for running in restricted context.

Safe queries:
1. Read only 'select' or 'explain' queries
2. CTE on mariadb where writes are not allowed.
"""

query = query.strip().lower()
whitelisted_statements = ("select", "explain")

if (query.startswith(whitelisted_statements)
or (query.startswith("with") and frappe.db.db_type == "mariadb")):
return True

if throw:
frappe.throw(_("Query must be of SELECT or read-only WITH type."),
title=_("Unsafe SQL query"), exc=frappe.PermissionError)

return False


def _getitem(obj, key):
# guard function for RestrictedPython
# allow any key to be accessed as long as it does not start with underscore


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

@@ -113,6 +113,7 @@
"depends_on": "eval:doc.content_type === 'Markdown'",
"fieldname": "content_md",
"fieldtype": "Markdown Editor",
"ignore_xss_filter": 1,
"label": "Content (Markdown)"
},
{
@@ -213,7 +214,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"modified": "2022-03-09 01:48:25.227295",
"modified": "2022-03-21 14:42:19.282612",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",
@@ -245,6 +246,7 @@
"route": "blog",
"sort_field": "modified",
"sort_order": "ASC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

+ 3
- 1
frappe/website/doctype/blog_post/templates/blog_post_row.html 查看文件

@@ -1,3 +1,5 @@
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}

{%- set post = doc -%}
<div class="blog-card col-sm-12 {{ 'col-md-8' if post.featured else 'col-md-4' }}">
<div class="card h-100">
@@ -26,7 +28,7 @@
<p class="post-description text-muted">{{ post.intro }}</p>
</div>
<div class="blog-card-footer">
<img class="avatar website-image-extra-small" src="{{ post.avatar }}">
{{ avatar(full_name=post.full_name, image=post.avatar, size='avatar-medium') }}
<div class="text-muted">
<a href="/blog?blogger={{ post.blogger }}">{{ post.full_name }}</a>
<div class="small">


+ 35
- 30
frappe/website/doctype/web_form/templates/web_form.html 查看文件

@@ -2,41 +2,42 @@

{% block title %}{{ _(title) }}{% endblock %}

{% block header %}
<h3>{{ _(title) }}</h3>
{% endblock %}

{% block breadcrumbs %}
{% if has_header and login_required %}
{% include "templates/includes/breadcrumbs.html" %}
{% endif %}
{% endblock %}

{% block header_actions %}
{% if is_list %}
<div class="list-view-actions"></div>
{% else %}
<div class="web-form-actions"></div>
{% endif %}
{% endblock %}
{% block breadcrumbs %}{% endblock %}

{% macro container_attributes() %}
data-web-form="{{ name }}" data-web-form-doctype="{{ doc_type }}" data-login-required="{{ frappe.utils.cint(login_required and frappe.session.user=='Guest') }}" data-is-list="{{ frappe.utils.cint(is_list) }}" data-allow-delete="{{ allow_delete }}"
{% endmacro %}

{% block page_content %}
<div>

{% if has_header and login_required and allow_multiple %}
<!-- breadcrumb -->
{% include "templates/includes/breadcrumbs.html" %}
{% else %}
<div style="height: 3rem"></div>
{% endif %}

<!-- main card -->
<div class="frappe-card {{ frappe.utils.cint(is_list) and 'list-card' or '' }}">
{% if is_list %}
{# web form list #}
<!-- list -->
<div class="d-flex justify-content-between">
<h3>{{ _(title) }}</h3>
<div class="list-view-actions"></div>
</div>

<div class="web-form-wrapper" {{ container_attributes() }}></div>
<div id="list-filters" class="row mt-4"></div>
<div id="datatable" class="pt-4 overflow-auto"></div>
<div id="list-table" class="list-table pt-4 overflow-auto"></div>
<div class="list-view-footer text-right"></div>
{% else %}
{# web form #}
<!-- web form -->
<div class="d-flex justify-content-between web-form-head">
<h3>{{ _(title) }}</h3>
<div class="web-form-actions"></div>
</div>
<div role="form">
<div id="introduction" class="text-muted"></div>
<hr>
<div id="introduction" class="text-muted hidden"></div>
<div class="web-form-wrapper" {{ container_attributes() }}></div>
<div class="web-form-footer text-right"></div>
</div>
@@ -61,15 +62,16 @@ data-web-form="{{ name }}" data-web-form-doctype="{{ doc_type }}" data-login-req
</div>
{% endif %} {# attachments #}

{% if allow_comments and not frappe.form_dict.new and not is_list -%}
<div class="comments mt-6">
<h3>{{ _("Comments") }}</h3>
{% include 'templates/includes/comments/comments.html' %}
</div>
{%- endif %} {# comments #}

{% endif %}
</div>

{% if allow_comments and not frappe.form_dict.new and not is_list -%}
<!-- comments -->
<div class="comments" style="margin-top: 3rem;">
{% include 'templates/includes/comments/comments.html' %}
</div>
{%- endif %} {# comments #}

{% endblock page_content %}

{% block script %}
@@ -132,6 +134,9 @@ frappe.init_client_script = () => {
{% endif %}

<style>
body {
background-color: var(--bg-color);
}
{% if style is defined %}
{{ style }}
{% endif %}


+ 5
- 3
frappe/website/doctype/web_form/web_form.json 查看文件

@@ -183,7 +183,8 @@
},
{
"fieldname": "introduction_text",
"fieldtype": "Text Editor",
"fieldtype": "Small Text",
"ignore_xss_filter": 1,
"label": "Introduction"
},
{
@@ -234,7 +235,7 @@
"label": "Success Message"
},
{
"description": "Go to this URL after completing the form (only for Guest users)",
"description": "Go to this URL after completing the form",
"fieldname": "success_url",
"fieldtype": "Data",
"label": "Success URL"
@@ -368,7 +369,7 @@
"icon": "icon-edit",
"is_published_field": "published",
"links": [],
"modified": "2021-11-15 14:12:44.624573",
"modified": "2022-03-23 15:44:41.385001",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Form",
@@ -386,6 +387,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}

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

@@ -18,7 +18,7 @@ frappe.ui.form.on('Web Page', {
frm.set_query('web_template', 'page_blocks', function() {
return {
filters: {
"type": 'Section'
"type": ['in', ['Section', 'Component']]
}
};
});


+ 33
- 4
frappe/website/doctype/web_page_block/web_page_block.json 查看文件

@@ -13,8 +13,12 @@
"add_container",
"add_top_padding",
"add_bottom_padding",
"add_border_at_top",
"add_border_at_bottom",
"add_shade",
"hide_block"
"hide_block",
"add_background_image",
"background_image"
],
"fields": [
{
@@ -68,18 +72,42 @@
"default": "1",
"fieldname": "add_top_padding",
"fieldtype": "Check",
"label": "Add Space on Top"
"label": "Add Space at Top"
},
{
"default": "1",
"fieldname": "add_bottom_padding",
"fieldtype": "Check",
"label": "Add Space on Bottom"
"label": "Add Space at Bottom"
},
{
"default": "0",
"fieldname": "add_border_at_top",
"fieldtype": "Check",
"label": "Add Border at Top"
},
{
"default": "0",
"fieldname": "add_border_at_bottom",
"fieldtype": "Check",
"label": "Add Border at Bottom"
},
{
"default": "0",
"fieldname": "add_background_image",
"fieldtype": "Check",
"label": "Add Background Image"
},
{
"depends_on": "add_background_image",
"fieldname": "background_image",
"fieldtype": "Attach Image",
"label": "Background Image"
}
],
"istable": 1,
"links": [],
"modified": "2020-05-11 15:21:54.247652",
"modified": "2022-03-21 14:23:32.665108",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Page Block",
@@ -88,5 +116,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

+ 1
- 1
frappe/website/doctype/website_settings/website_settings.py 查看文件

@@ -111,7 +111,7 @@ def get_website_settings(context=None):
'footer_items': get_items('footer_items'),
"post_login": [
{"label": _("My Account"), "url": "/me"},
{"label": _("Logout"), "url": "/?cmd=web_logout"}
{"label": _("Log out"), "url": "/?cmd=web_logout"}
]
})



+ 13
- 0
frappe/website/js/website.js 查看文件

@@ -423,6 +423,18 @@ $.extend(frappe, {
});
});
}
},
setup_videos: () => {
// converts video images into youtube embeds (via Page Builder)
$('.section-video-wrapper').on('click', (e) => {
let $video = $(e.currentTarget);
let id = $video.data('youtubeId');
console.log(id);
$video.find(".video-thumbnail").hide();
$video.append(`
<iframe allowfullscreen="" class="section-video" f;rameborder="0" src="//youtube.com/embed/${id}?autoplay=1"></iframe>
`);
});
}
});

@@ -647,5 +659,6 @@ $(document).on("page-change", function() {

frappe.ready(function() {
frappe.show_language_picker();
frappe.setup_videos();
frappe.socketio.init(window.socketio_port);
});

+ 0
- 0
frappe/website/web_template/cover_image/__init__.py 查看文件


+ 5
- 0
frappe/website/web_template/cover_image/cover_image.html 查看文件

@@ -0,0 +1,5 @@
{{ frappe.render_template('templates/includes/image_with_blur.html', {
"src": url,
"alt": description,
"class": "full-width-image"
}) }}

+ 34
- 0
frappe/website/web_template/cover_image/cover_image.json 查看文件

@@ -0,0 +1,34 @@
{
"__islocal": true,
"__unsaved": 1,
"creation": "2022-03-15 14:17:49.482939",
"docstatus": 0,
"doctype": "Web Template",
"fields": [
{
"__islocal": 1,
"__unsaved": 1,
"fieldname": "url",
"fieldtype": "Attach Image",
"label": "Image",
"reqd": 0
},
{
"__islocal": 1,
"__unsaved": 1,
"fieldname": "description",
"fieldtype": "Data",
"label": "Description",
"reqd": 0
}
],
"idx": 0,
"modified": "2022-03-15 14:17:49.482939",
"modified_by": "Administrator",
"module": "Website",
"name": "Cover Image",
"owner": "Administrator",
"standard": 1,
"template": "",
"type": "Component"
}

+ 3
- 1
frappe/website/web_template/full_width_image/full_width_image.json 查看文件

@@ -1,4 +1,5 @@
{
"__unsaved": 1,
"creation": "2020-04-17 16:03:35.676241",
"docstatus": 0,
"doctype": "Web Template",
@@ -17,8 +18,9 @@
}
],
"idx": 0,
"modified": "2020-09-11 15:52:40.656939",
"modified": "2022-03-15 14:17:17.563982",
"modified_by": "Administrator",
"module": "Website",
"name": "Full Width Image",
"owner": "Administrator",
"standard": 1,


+ 2
- 2
frappe/website/web_template/hero/hero.html 查看文件

@@ -9,12 +9,12 @@
{%- if primary_action or secondary_action -%}
<div class="hero-buttons">
{%- if primary_action -%}
<a class="btn btn-lg btn-primary" href="{{ primary_action }}">
<a class="btn btn-lg btn-dark" href="{{ primary_action }}">
{{ primary_action_label }}
</a>
{%- endif -%}
{%- if secondary_action -%}
<a class="btn btn-lg btn-primary-light" href="{{ secondary_action }}">
<a class="btn btn-lg btn-light ml-3" href="{{ secondary_action }}">
{{ secondary_action_label }}
</a>
{%- endif -%}


+ 2
- 1
frappe/website/web_template/hero/hero.json 查看文件

@@ -1,4 +1,5 @@
{
"__unsaved": 1,
"creation": "2020-04-19 15:26:23.140620",
"docstatus": 0,
"doctype": "Web Template",
@@ -49,7 +50,7 @@
}
],
"idx": 0,
"modified": "2020-10-26 17:39:56.959008",
"modified": "2022-03-21 14:30:14.405261",
"modified_by": "Administrator",
"module": "Website",
"name": "Hero",


+ 1
- 1
frappe/website/web_template/section_with_collapsible_content/section_with_collapsible_content.html 查看文件

@@ -10,7 +10,7 @@
{%- set collapse_id = 'id-' + frappe.utils.generate_hash('Collapse', 12) -%}
<a class="collapsible-title" data-toggle="collapse" href="#{{ collapse_id }}" role="button"
aria-expanded="false" aria-controls="{{ collapse_id }}">
<h3>{{ item.title }}</h3>
<div class="collapsible-item-title">{{ item.title }}</div>
<svg class="collapsible-icon" width="24" height="24" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path class="vertical" d="M8 4V12" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"
stroke-linecap="round"


+ 6
- 2
frappe/website/web_template/section_with_cta/section_with_cta.html 查看文件

@@ -4,8 +4,12 @@
{%- if subtitle -%}
<p class="subtitle">{{ subtitle }}</p>
{%- endif -%}
<div class="mt-6">
<a href="{{ cta_url }}" class="btn btn-lg btn-primary">{{ cta_label }}</a>
<div class="mt-3">
<h4 class="action">
<a href="{{ cta_url }}" class="no-decoration">{{ cta_label }}
<svg class="icon icon-md"><use xlink:href="#icon-right"></use></svg>
</a>
</h4>
</div>
{%- if cta_description -%}
<div class="description">


+ 6
- 2
frappe/website/web_template/section_with_small_cta/section_with_small_cta.html 查看文件

@@ -1,14 +1,18 @@
<div class="section-cta-container">
<div class="section-small-cta">
<div>
<h2 class="title">{{ title or '' }}</h2>
<h3 class="section-title">{{ title or '' }}</h3>
{%- if subtitle -%}
<p class="subtitle">{{ subtitle }}</p>
{%- endif -%}
</div>
<div>
{%- if cta_label and cta_url -%}
<a href="{{ cta_url }}" class="btn btn-lg btn-primary">{{ cta_label }}</a>
<h4 class="action">
<a href="{{ cta_url }}" class="no-decoration">{{ cta_label }}
<svg class="icon icon-md"><use xlink:href="#icon-right"></use></svg>
</a>
</h4>
{%- endif -%}
</div>
</div>


+ 0
- 0
frappe/website/web_template/section_with_testimonials/__init__.py 查看文件


+ 31
- 0
frappe/website/web_template/section_with_testimonials/section_with_testimonials.html 查看文件

@@ -0,0 +1,31 @@
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}

<div class="section-with-features">
{%- if title -%}
<h2 class="section-title">{{ title }}</h2>
{%- endif -%}
{%- if subtitle -%}
<p class="section-description">{{ subtitle }}</p>
{%- endif -%}

<div class="section-features" data-columns="{{ columns or 3 }}">
{%- for testimonial in testimonials -%}
<div class="section-feature">
<div>
{%- if testimonial.content -%}
<p class="feature-content">{{ testimonial.content }}</p>
{%- endif -%}
</div>
<div class="testimonial-author">
{{ avatar(full_name=testimonial.full_name, image=testimonial.image, size='avatar-medium') }}
<p>
{{ testimonial.full_name }}
{%- if testimonial.designation -%}
<br>{{ testimonial.designation }}
{%- endif -%}
</p>
</div>
</div>
{%- endfor -%}
</div>
</div>

+ 73
- 0
frappe/website/web_template/section_with_testimonials/section_with_testimonials.json 查看文件

@@ -0,0 +1,73 @@
{
"__unsaved": 1,
"creation": "2022-03-21 15:28:13.141783",
"docstatus": 0,
"doctype": "Web Template",
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"reqd": 0
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Subtitle",
"reqd": 0
},
{
"default": "3",
"fieldname": "columns",
"fieldtype": "Select",
"label": "Columns",
"options": "2\n3\n4",
"reqd": 0
},
{
"fieldname": "testimonials",
"fieldtype": "Table Break",
"label": "Testimonials",
"reqd": 0
},
{
"fieldname": "content",
"fieldtype": "Small Text",
"label": "Content",
"reqd": 0
},
{
"fieldname": "full_name",
"fieldtype": "Data",
"label": "Full Name",
"reqd": 0
},
{
"fieldname": "designation",
"fieldtype": "Data",
"label": "Designation",
"reqd": 0
},
{
"fieldname": "image",
"fieldtype": "Attach Image",
"label": "Image",
"reqd": 0
},
{
"fieldname": "url",
"fieldtype": "Data",
"label": "URL",
"reqd": 0
}
],
"idx": 0,
"modified": "2022-03-21 15:39:39.044104",
"modified_by": "Administrator",
"module": "Website",
"name": "Section with Testimonials",
"owner": "Administrator",
"standard": 1,
"template": "",
"type": "Section"
}

+ 0
- 0
frappe/website/web_template/section_with_videos/__init__.py 查看文件


+ 24
- 0
frappe/website/web_template/section_with_videos/section_with_videos.html 查看文件

@@ -0,0 +1,24 @@
<div class="section-with-features">
{%- if title -%}
<h2 class="section-title">{{ title }}</h2>
{%- endif -%}
{%- if subtitle -%}
<p class="section-description">{{ subtitle }}</p>
{%- endif -%}

<div class="section-features" data-columns="{{ columns or 3 }}">
{%- for video in videos -%}
<div class="section-feature">
<div class="section-video-wrapper" data-youtube-id="{{ video.youtube_id }}">
<img class="video-thumbnail" src="https://i.ytimg.com/vi/{{ video.youtube_id }}/sddefault.jpg">
</div>
{%- if video.title -%}
<h3 class="feature-title">{{ video.title }}</h3>
{%- endif -%}
{%- if video.content -%}
<p class="feature-content">{{ video.content }}</p>
{%- endif -%}
</div>
{%- endfor -%}
</div>
</div>

+ 61
- 0
frappe/website/web_template/section_with_videos/section_with_videos.json 查看文件

@@ -0,0 +1,61 @@
{
"__unsaved": 1,
"creation": "2022-03-21 15:59:18.432776",
"docstatus": 0,
"doctype": "Web Template",
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"reqd": 0
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Subtitle",
"reqd": 0
},
{
"default": "3",
"fieldname": "columns",
"fieldtype": "Select",
"label": "Columns",
"options": "2\n3\n4",
"reqd": 0
},
{
"fieldname": "videos",
"fieldtype": "Table Break",
"label": "Videos",
"reqd": 0
},
{
"fieldname": "youtube_id",
"fieldtype": "Data",
"label": "YouTube Video ID",
"reqd": 0
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"reqd": 0
},
{
"fieldname": "content",
"fieldtype": "Small Text",
"label": "Content",
"reqd": 0
}
],
"idx": 0,
"modified": "2022-03-21 16:03:46.339279",
"modified_by": "Administrator",
"module": "Website",
"name": "Section with Videos",
"owner": "Administrator",
"standard": 1,
"template": "",
"type": "Section"
}

+ 3
- 15
frappe/www/me.html 查看文件

@@ -3,10 +3,9 @@
{% block title %}
{{ _("My Account") }}
{% endblock %}
{% block header %}
<h3 class="my-account-header">{{_("My Account") }}</h3>
{% endblock %}
{% block page_content %}
<div class="my-account-container">
<h3 class="my-account-header">{{_("My Account") }}</h3>
<div class="row account-info d-flex flex-column">
<div class="col d-flex justify-content-between align-items-center">
<div>
@@ -79,16 +78,5 @@
</div>
{% endif %}
</div>
<div class="row d-block d-sm-none">
<div class="col-12 side-list">
<ul class="list-group">
{% for item in sidebar_items -%}
<a class="list-group-item" href="{{ item.route }}"
{% if item.target %}target="{{ item.target }}"{% endif %}>
{{ _(item.title or item.label) }}
</a>
{%- endfor %}
</ul>
</div>
</div>
{% endblock %}
{% endblock %}

+ 0
- 1
frappe/www/me.py 查看文件

@@ -12,4 +12,3 @@ def get_context(context):
frappe.throw(_("You need to be logged in to access this page"), frappe.PermissionError)

context.current_user = frappe.get_doc("User", frappe.session.user)
context.show_sidebar=True

+ 41
- 17
frappe/www/third_party_apps.html 查看文件

@@ -1,9 +1,6 @@
{% extends "templates/web.html" %}

{% block title %} {{ _("Third Party Apps") }} {% endblock %}
{% block header %}
<h3 class="my-account-header">{{ _("Third Party Apps") }}</h3>
{% endblock %}

{% block page_sidebar %}
{% include "templates/includes/web_sidebar.html" %}
@@ -13,25 +10,24 @@
{% endblock %}

{% block page_content %}

<div class='padding'></div>

<h3 class="my-account-header">{{ _("Third Party Apps") }}</h3>
<div class="third-party-wrapper">
{% if app %}
<h4>{{ app.app_name }}</h4>
<div class="web-list-item">
<div class="row">
<div class="col-xs-12">
<div class="well">
<div class="text-muted">{{ _("This will log out {0} from all other devices").format(app.app_name) }}</div>
<div class="padding"></div>
<div class="text-right">
<button class="btn btn-default" onclick="location.href = '/third_party_apps';">Cancel</button>
<button class="btn btn-danger btn-delete-app" data-client_id="{{ app.client_id }}">Revoke</button>
</div>
<div class="web-list-item">
<div class="row">
<div class="col-xs-12">
<div class="well">
<div class="text-muted">{{ _("This will log out {0} from all other devices").format(app.app_name) }}</div>
<div class="padding"></div>
<div class="text-right">
<button class="btn btn-default" onclick="location.href = '/third_party_apps';">Cancel</button>
<button class="btn btn-danger btn-delete-app" data-client_id="{{ app.client_id }}">Revoke</button>
</div>
</div>
</div>
</div>
</div>
{% elif apps|length > 0 %}
<h4>{{ _("Active Sessions") }}</h4>
{% for app in apps %}
@@ -62,9 +58,37 @@
</div>
</div>
{% endif %}
<div class="padding"></div>
</div>
<script>
{% include "templates/includes/integrations/third_party_apps.js" %}
</script>
<style>
body {
background-color: var(--bg-color);
}

.my-account-header, .third-party-wrapper {
max-width: 800px;
margin: auto;
}

.my-account-header {
margin-top: 3rem;
margin-bottom: 1rem;
}

.third-party-wrapper {
background-color: var(--fg-color);
border-radius: var(--border-radius-md);
box-shadow: var(--card-shadow);
}

.empty-apps-state {
margin: auto;
text-align: center;
padding-top: 6rem;
padding-bottom: 6rem;
}

</style>
{% endblock %}

+ 1
- 2
frappe/www/third_party_apps.py 查看文件

@@ -34,7 +34,6 @@ def get_context(context):
context.app = app

context.apps = client_apps
context.show_sidebar = True

def get_first_login(client):
login_date = frappe.get_all("OAuth Bearer Token",
@@ -49,4 +48,4 @@ def get_first_login(client):
def delete_client(client_id):
active_client_id_tokens = frappe.get_all("OAuth Bearer Token", filters=[["user", "=", frappe.session.user], ["client","=", client_id]])
for token in active_client_id_tokens:
frappe.delete_doc("OAuth Bearer Token", token.get("name"), ignore_permissions=True)
frappe.delete_doc("OAuth Bearer Token", token.get("name"), ignore_permissions=True)

+ 6
- 2
frappe/www/update-password.html 查看文件

@@ -12,11 +12,11 @@
<form id="reset-password">
<div class="form-group">
<input id="old_password" type="password"
class="form-control" placeholder="{{ _('Old Password') }}">
class="form-control mb-4" placeholder="{{ _('Old Password') }}">
</div>
<div class="form-group">
<input id="new_password" type="password"
class="form-control" placeholder="{{ _('New Password') }}">
class="form-control mb-4" placeholder="{{ _('New Password') }}">
<span class="password-strength-indicator indicator"></span>
</div>
<div class="form-group">
@@ -216,6 +216,10 @@ frappe.ready(function() {

{% block style %}
<style>
body {
background-color: var(--bg-color);
}

.password-strength-indicator {
float: right;
padding: 15px;


Loading…
取消
儲存