diff --git a/.mergify.yml b/.mergify.yml index 63fe1a0086..838ce75835 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -5,6 +5,7 @@ pull_request_rules: - and: - author!=surajshetty3416 - author!=gavindsouza + - author!=deepeshgarg007 - or: - base=version-13 - base=version-12 diff --git a/CODEOWNERS b/CODEOWNERS index f7d759c123..170334a4b4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,9 +6,7 @@ * @frappe/frappe-review-team templates/ @surajshetty3416 www/ @surajshetty3416 -integrations/ @leela patches/ @surajshetty3416 @gavindsouza -email/ @leela event_streaming/ @ruchamahabal data_import* @netchampfaris core/ @surajshetty3416 diff --git a/cypress/integration/control_attach.js b/cypress/integration/control_attach.js new file mode 100644 index 0000000000..0552780737 --- /dev/null +++ b/cypress/integration/control_attach.js @@ -0,0 +1,90 @@ +context('Attach Control', () => { + 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 Attach Control', + fields: [ + { + "label": "Attach File or Image", + "fieldname": "attach", + "fieldtype": "Attach", + "in_list_view": 1, + }, + ] + }); + }); + }); + it('Checking functionality for "Link" button in the "Attach" fieldtype', () => { + //Navigating to the new form for the newly created doctype + cy.new_form('Test Attach Control'); + + //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype + cy.findByRole('button', {name: 'Attach'}).click(); + + //Clicking on "Link" button to attach a file using the "Link" button + cy.findByRole('button', {name: 'Link'}).click(); + cy.findByPlaceholderText('Attach a web link').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg'); + + //Clicking on the Upload button to upload the file + cy.intercept("POST", "/api/method/upload_file").as("upload_image"); + cy.get('.modal-footer').findByRole("button", {name: "Upload"}).click({delay: 500}); + cy.wait("@upload_image"); + cy.findByRole('button', {name: 'Save'}).click(); + + //Checking if the URL of the attached image is getting displayed in the field of the newly created doctype + cy.get('.attached-file > .ellipsis > .attached-file-link') + .should('have.attr', 'href') + .and('equal', 'https://wallpaperplay.com/walls/full/8/2/b/72402.jpg'); + + //Clicking on the "Clear" button + cy.get('[data-action="clear_attachment"]').click(); + + //Checking if clicking on the clear button clears the field of the doctype form and again displays the attach button + cy.get('.control-input > .btn-sm').should('contain', 'Attach'); + + //Deleting the doc + cy.go_to_list('Test Attach Control'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.get('.actions-btn-group > .btn').contains('Actions').click(); + cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click(); + cy.click_modal_primary_button('Yes'); + }); + + it('Checking functionality for "Library" button in the "Attach" fieldtype', () => { + //Navigating to the new form for the newly created doctype + cy.new_form('Test Attach Control'); + + //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype + cy.findByRole('button', {name: 'Attach'}).click(); + + //Clicking on "Library" button to attach a file using the "Library" button + cy.findByRole('button', {name: 'Library'}).click(); + cy.contains('72402.jpg').click(); + + //Clicking on the Upload button to upload the file + cy.intercept("POST", "/api/method/upload_file").as("upload_image"); + cy.get('.modal-footer').findByRole("button", {name: "Upload"}).click({delay: 500}); + cy.wait("@upload_image"); + cy.findByRole('button', {name: 'Save'}).click(); + + //Checking if the URL of the attached image is getting displayed in the field of the newly created doctype + cy.get('.attached-file > .ellipsis > .attached-file-link') + .should('have.attr', 'href') + .and('equal', 'https://wallpaperplay.com/walls/full/8/2/b/72402.jpg'); + + //Clicking on the "Clear" button + cy.get('[data-action="clear_attachment"]').click(); + + //Checking if clicking on the clear button clears the field of the doctype form and again displays the attach button + cy.get('.control-input > .btn-sm').should('contain', 'Attach'); + + //Deleting the doc + cy.go_to_list('Test Attach Control'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.get('.actions-btn-group > .btn').contains('Actions').click(); + cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click(); + cy.click_modal_primary_button('Yes'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/control_date.js b/cypress/integration/control_date.js new file mode 100644 index 0000000000..35c585306c --- /dev/null +++ b/cypress/integration/control_date.js @@ -0,0 +1,71 @@ +context('Date Control', () => { + 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 Date Control', + fields: [ + { + "label": "Date", + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1 + }, + ] + }); + }); + }); + it('Selecting a date from the datepicker', () => { + cy.new_form('Test Date Control'); + cy.get_field('date', 'Date').click(); + cy.get('.datepicker--nav-title').click(); + cy.get('.datepicker--nav-title').click({force: true}); + + + //Inputing values in the date field + cy.get('.datepicker--years > .datepicker--cells > .datepicker--cell[data-year=2020]').click(); + cy.get('.datepicker--months > .datepicker--cells > .datepicker--cell[data-month=0]').click(); + cy.get('.datepicker--days > .datepicker--cells > .datepicker--cell[data-date=15]').click(); + + //Verifying if the selected date is displayed in the date field + cy.get_field('date', 'Date').should('have.value', '01-15-2020'); + }); + + it('Checking next and previous button', () => { + cy.get_field('date', 'Date').click(); + + //Clicking on the next button in the datepicker + cy.get('.datepicker--nav-action[data-action=next]').click(); + + //Selecting a date from the datepicker + cy.get('.datepicker--cell[data-date=15]').click({force: true}); + + //Verifying if the selected date has been displayed in the date field + cy.get_field('date', 'Date').should('have.value', '02-15-2020'); + cy.wait(500); + cy.get_field('date', 'Date').click(); + + //Clicking on the previous button in the datepicker + cy.get('.datepicker--nav-action[data-action=prev]').click(); + + //Selecting a date from the datepicker + cy.get('.datepicker--cell[data-date=15]').click({force: true}); + + //Verifying if the selected date has been displayed in the date field + cy.get_field('date', 'Date').should('have.value', '01-15-2020'); + }); + + it('Clicking on "Today" button gives todays date', () => { + cy.get_field('date', 'Date').click(); + + //Clicking on "Today" button + cy.get('.datepicker--button').click(); + + //Picking up the todays date + const todays_date = Cypress.moment().format('MM-DD-YYYY'); + + //Verifying if clicking on "Today" button matches today's date + cy.get_field('date', 'Date').should('have.value', todays_date); + }); +}); \ No newline at end of file diff --git a/cypress/integration/control_dynamic_link.js b/cypress/integration/control_dynamic_link.js new file mode 100644 index 0000000000..cc1eb0b695 --- /dev/null +++ b/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'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/discussions.js b/cypress/integration/discussions.js index a6e0ff9b56..caf7d6c3f9 100644 --- a/cypress/integration/discussions.js +++ b/cypress/integration/discussions.js @@ -37,24 +37,24 @@ context('Discussions', () => { }; const reply_through_comment_box = () => { - cy.get('.discussion-on-page:visible .comment-field') + cy.get('.discussion-form:visible .comment-field') .type('This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.') .should('have.value', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.'); - cy.get('.discussion-on-page:visible .submit-discussion').click(); + cy.get('.discussion-form:visible .submit-discussion').click(); cy.wait(3000); cy.get('.discussion-on-page:visible').should('have.class', 'show'); - cy.get('.discussion-on-page:visible').children(".reply-card").eq(1).children(".reply-text") + cy.get('.discussion-on-page:visible').children(".reply-card").eq(1).find(".reply-text") .should('have.text', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.\n'); }; const cancel_and_clear_comment_box = () => { - cy.get('.discussion-on-page:visible .comment-field') + cy.get('.discussion-form:visible .comment-field') .type('This is a discussion from the cypress ui tests.') .should('have.value', 'This is a discussion from the cypress ui tests.'); - cy.get('.discussion-on-page:visible .cancel-comment').click(); - cy.get('.discussion-on-page:visible .comment-field').should('have.value', ''); + cy.get('.discussion-form:visible .cancel-comment').click(); + cy.get('.discussion-form:visible .comment-field').should('have.value', ''); }; const single_thread_discussion = () => { @@ -62,13 +62,13 @@ context('Discussions', () => { cy.get('.discussions-sidebar').should('have.length', 0); cy.get('.reply').should('have.length', 0); - cy.get('.discussion-on-page .comment-field') + cy.get('.discussion-form:visible .comment-field') .type('This comment is being made on a single thread discussion.') .should('have.value', 'This comment is being made on a single thread discussion.'); - cy.get('.discussion-on-page .submit-discussion').click(); + cy.get('.discussion-form:visible .submit-discussion').click(); cy.wait(3000); - cy.get('.discussion-on-page').children(".reply-card").eq(-1).children(".reply-text") + cy.get('.discussion-on-page').children(".reply-card").eq(-1).find(".reply-text") .should('have.text', 'This comment is being made on a single thread discussion.\n'); }; diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 71cc6f4f0d..acaff9a191 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -29,10 +29,12 @@ context('Form', () => { cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur(); cy.click_listview_row_item(0); + cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist'); cy.get('.prev-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); + cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist'); cy.get('.next-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 4f273af21f..37134f0cbc 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -200,16 +200,15 @@ Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, va Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fieldtype = 'Data') => { let selector = `.frappe-control[data-fieldname="${tablefieldname}"]`; selector += ` [data-idx="${row_idx}"]`; - selector += ` .form-in-grid`; if (fieldtype === 'Text Editor') { selector += ` [data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`; } else if (fieldtype === 'Code') { selector += ` [data-fieldname="${fieldname}"] .ace_text-input`; } else { - selector += ` .form-control[data-fieldname="${fieldname}"]`; + selector += ` [data-fieldname="${fieldname}"]`; + return cy.get(selector).find('.form-control:visible, .static-area:visible').first(); } - return cy.get(selector); }); @@ -290,6 +289,7 @@ Cypress.Commands.add('add_filter', () => { }); Cypress.Commands.add('clear_filters', () => { + let has_filter = false; cy.intercept({ method: 'POST', url: 'api/method/frappe.model.utils.user_settings.save' @@ -297,12 +297,17 @@ Cypress.Commands.add('clear_filters', () => { cy.get('.filter-section .filter-button').click({force: true}); cy.wait(300); cy.get('.filter-popover').should('exist'); + cy.get('.filter-popover').then(popover => { + if (popover.find('input.input-with-feedback')[0].value != '') { + has_filter = true; + } + }); cy.get('.filter-popover').find('.clear-filters').click(); cy.get('.filter-section .filter-button').click(); cy.window().its('cur_list').then(cur_list => { cur_list && cur_list.filter_area && cur_list.filter_area.clear(); + has_filter && cy.wait('@filter-saved'); }); - cy.wait('@filter-saved'); }); Cypress.Commands.add('click_modal_primary_button', (btn_name) => { diff --git a/cypress/support/index.js b/cypress/support/index.js index 9cd770a31e..5980e96677 100644 --- a/cypress/support/index.js +++ b/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') diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index ff31aa4b74..4aa1ebc824 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -259,6 +259,7 @@ function get_build_options(files, outdir, plugins) { return { entryPoints: files, entryNames: "[dir]/[name].[hash]", + target: ['es2017'], outdir, sourcemap: true, bundle: true, diff --git a/frappe/__init__.py b/frappe/__init__.py index 0abaf932a7..f92f76c98c 100644 --- a/frappe/__init__.py +++ b/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 @@ -852,18 +851,25 @@ def set_value(doctype, docname, fieldname, value=None): return frappe.client.set_value(doctype, docname, fieldname, value) def get_cached_doc(*args, **kwargs): + allow_dict = kwargs.pop("_allow_dict", False) + + def _respond(doc, from_redis=False): + if not allow_dict and isinstance(doc, dict): + local.document_cache[key] = doc = get_doc(doc) + + elif from_redis: + local.document_cache[key] = doc + + return doc + if key := can_cache_doc(args): # local cache - doc = local.document_cache.get(key) - if doc: - return doc + if doc := local.document_cache.get(key): + return _respond(doc) # redis cache - doc = cache().hget('document_cache', key) - if doc: - doc = get_doc(doc) - local.document_cache[key] = doc - return doc + if doc := cache().hget('document_cache', key): + return _respond(doc, True) # database doc = get_doc(*args, **kwargs) @@ -896,8 +902,13 @@ def clear_document_cache(doctype, name): del local.document_cache[key] cache().hdel('document_cache', key) -def get_cached_value(doctype, name, fieldname, as_dict=False): - doc = get_cached_doc(doctype, name) +def get_cached_value(doctype, name, fieldname="name", as_dict=False): + try: + doc = get_cached_doc(doctype, name, _allow_dict=True) + except DoesNotExistError: + clear_last_message() + return + if isinstance(fieldname, str): if as_dict: throw('Cannot make dict for single fieldname') @@ -1523,12 +1534,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 diff --git a/frappe/commands/site.py b/frappe/commands/site.py index e788c7ec4d..6bee442054 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -780,9 +780,8 @@ def set_user_password(site, user, password, logout_all_sessions=False): @pass_context def set_last_active_for_user(context, user=None): "Set users last active date to current datetime" - from frappe.core.doctype.user.user import get_system_users - from frappe.utils.user import set_last_active_to_now + from frappe.utils import now_datetime site = get_site(context) @@ -795,9 +794,10 @@ def set_last_active_for_user(context, user=None): else: return - set_last_active_to_now(user) + frappe.db.set_value("User", user, "last_active", now_datetime()) frappe.db.commit() + @click.command('publish-realtime') @click.argument('event') @click.option('--message') diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json index 175c64b9eb..f9d15af483 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/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", diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 475762f39d..38fb0fd757 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/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 diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index f89eb31cc8..b10b84f048 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -244,6 +244,7 @@ class Importer: existing_doc = frappe.get_doc(self.doctype, doc.get(id_field.fieldname)) updated_doc = frappe.get_doc(self.doctype, doc.get(id_field.fieldname)) + updated_doc.update(doc) if get_diff(existing_doc, updated_doc): diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index 11077ca58b..489b8caa58 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -92,11 +92,18 @@ class TestImporter(unittest.TestCase): # update child table id in template date i.import_file.raw_data[1][4] = existing_doc.table_field_1[0].name - i.import_file.raw_data[1][0] = existing_doc.name + + # uppercase to check if autoname field isn't replaced in mariadb + if frappe.db.db_type == "mariadb": + i.import_file.raw_data[1][0] = existing_doc.name.upper() + else: + i.import_file.raw_data[1][0] = existing_doc.name + i.import_file.parse_data_from_template() i.import_data() updated_doc = frappe.get_doc(doctype_name, existing_doc.name) + self.assertEqual(existing_doc.title, updated_doc.title) self.assertEqual(updated_doc.description, 'test description') self.assertEqual(updated_doc.table_field_1[0].child_title, 'child title') self.assertEqual(updated_doc.table_field_1[0].name, existing_doc.table_field_1[0].name) diff --git a/frappe/core/page/background_jobs/background_jobs.css b/frappe/core/page/background_jobs/background_jobs.css index 0c77522cb3..7716519113 100644 --- a/frappe/core/page/background_jobs/background_jobs.css +++ b/frappe/core/page/background_jobs/background_jobs.css @@ -1,43 +1,32 @@ -.list-jobs { - font-size: var(--text-base); -} -.table { +.table-background-jobs { margin-bottom: 0px; margin-top: 0px; + font-size: var(--text-md); + table-layout: fixed; } -thead { - background-color: var(--control-bg); - border-radius: var(--border-radius-sm); +.table-background-jobs th { + font-weight: normal; + color: var(--text-muted); } -thead > tr { - border-radius: var(--border-radius-sm); +.table-background-jobs td { + color: var(--text-light); } -thead > tr > th:first-child { - border-radius: var(--border-radius-sm) 0 0 var(--border-radius-sm); -} -thead > tr > th:last-child { - border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0; +.table-background-jobs th, .table-background-jobs td { + padding: var(--padding-sm) var(--padding-md); } -.worker-name { - display: flex; - align-items: center; +.table-background-jobs tbody tr:hover { + background-color: var(--highlight-color); } .job-name { font-size: var(--text-md); - font-family: "Courier New", Courier, monospace; - /* background-color: var(--control-bg); */ - /* padding: var(--padding-xs) var(--padding-sm); */ - /* border-radius: var(--border-radius-md); */ -} - -.background-job-row:hover { - background-color: var(--bg-color); + font-family: var(--font-family-monospace); + word-break: break-word; } .no-background-jobs { @@ -54,7 +43,5 @@ thead > tr > th:last-child { } .footer { - align-items: flex-end; - margin-top: var(--margin-md); - font-size: var(--text-base); + padding: var(--padding-md); } diff --git a/frappe/core/page/background_jobs/background_jobs.html b/frappe/core/page/background_jobs/background_jobs.html index 1b00ec3106..e0c1a8f633 100644 --- a/frappe/core/page/background_jobs/background_jobs.html +++ b/frappe/core/page/background_jobs/background_jobs.html @@ -1,51 +1,58 @@ -
- {% if jobs.length %} - - - - - - - - - - {% for j in jobs %} - - - + + + + {% endfor %} + +
{{ __("Queue / Worker") }}{{ __("Job") }}{{ __("Created") }}
- - {{ j.queue.split(".").slice(-1)[0] }} - -
- - {{ frappe.utils.encode_tags(j.job_name) }} - -
- {% if j.exc_info %} +{% if jobs.length %} + + + + + + + + + + + {% for j in jobs %} + + + - - - {% endfor %} - -
{{ __("Queue") }}{{ __("Job") }}{{ __("Status") }}{{ __("Created") }}
+ {{ toTitle(j.queue.split(":").slice(-1)[0]) }} + +
+ + {{ frappe.utils.encode_tags(j.job_name) }} + +
+ {% if j.exc_info %} +
+ {{ __("Exception") }}
{{ frappe.utils.encode_tags(j.exc_info) }}
- {% endif %} -
{{ j.creation }}
- {% else %} -
- Empty State -

{{ __("No pending or current jobs for this site") }}

+ + {% endif %} +
+ + {{ toTitle(j.status) }} + + + {{ frappe.datetime.prettyDate(j.creation) }} +
+{% else %} +
+ Empty State +

{{ __("No jobs found on this site") }}

+
+{% endif %} + \ No newline at end of file +
diff --git a/frappe/core/page/background_jobs/background_jobs.js b/frappe/core/page/background_jobs/background_jobs.js index 0b4d6792dc..7334bfd5dd 100644 --- a/frappe/core/page/background_jobs/background_jobs.js +++ b/frappe/core/page/background_jobs/background_jobs.js @@ -1,7 +1,7 @@ -frappe.pages["background_jobs"].on_page_load = (wrapper) => { +frappe.pages["background_jobs"].on_page_load = wrapper => { const background_job = new BackgroundJobs(wrapper); - $(wrapper).bind('show', () => { + $(wrapper).bind("show", () => { background_job.show(); }); @@ -12,61 +12,135 @@ class BackgroundJobs { constructor(wrapper) { this.page = frappe.ui.make_app_page({ parent: wrapper, - title: __('Background Jobs'), + title: __("Background Jobs"), single_column: true }); - this.called = false; - this.show_failed = false; + this.page.add_inner_button(__("Remove Failed Jobs"), () => { + frappe.confirm( + __("Are you sure you want to remove all failed jobs?"), + () => { + frappe + .call( + "frappe.core.page.background_jobs.background_jobs.remove_failed_jobs" + ) + .then(() => this.refresh_jobs()); + } + ); + }); - this.show_failed_button = this.page.add_inner_button(__("Show Failed Jobs"), () => { - this.show_failed = !this.show_failed; - if (this.show_failed_button) { - this.show_failed_button.text( - this.show_failed ? __("Hide Failed Jobs") : __("Show Failed Jobs") - ); + this.page.main.addClass("frappe-card"); + this.page.body.append('
'); + this.$content = $(this.page.body).find(".table-area"); + + this.make_filters(); + this.refresh_jobs = frappe.utils.throttle( + this.refresh_jobs.bind(this), + 1000 + ); + } + + make_filters() { + this.view = this.page.add_field({ + label: __("View"), + fieldname: "view", + fieldtype: "Select", + options: ["Jobs", "Workers"], + default: "Jobs", + change: () => { + this.queue_timeout.toggle(this.view.get_value() === "Jobs"); + this.job_status.toggle(this.view.get_value() === "Jobs"); } }); - - // add a "Remove Failed Jobs button" - this.remove_failed_button = this.page.add_inner_button(__("Remove Failed Jobs"), () => { - frappe.call({ - method: 'frappe.core.page.background_jobs.background_jobs.remove_failed_jobs', - callback: () => { + this.queue_timeout = this.page.add_field({ + label: __("Queue"), + fieldname: "queue_timeout", + fieldtype: "Select", + options: [ + { label: "All Queues", value: "all" }, + { label: "Default", value: "default" }, + { label: "Short", value: "short" }, + { label: "Long", value: "long" } + ], + default: "all" + }); + this.job_status = this.page.add_field({ + label: __("Job Status"), + fieldname: "job_status", + fieldtype: "Select", + options: [ + { label: "All Jobs", value: "all" }, + { label: "Queued", value: "queued" }, + { label: "Deferred", value: "deferred" }, + { label: "Started", value: "started" }, + { label: "Finished", value: "finished" }, + { label: "Failed", value: "failed" } + ], + default: "all" + }); + this.auto_refresh = this.page.add_field({ + label: __("Auto Refresh"), + fieldname: "auto_refresh", + fieldtype: "Check", + default: 1, + change: () => { + if (this.auto_refresh.get_value()) { this.refresh_jobs(); } - }); + } }); - - $(frappe.render_template('background_jobs_outer')).appendTo(this.page.body); - this.content = $(this.page.body).find('.table-area'); } show() { this.refresh_jobs(); + this.update_scheduler_status(); + } + + update_scheduler_status() { frappe.call({ - method: 'frappe.core.page.background_jobs.background_jobs.get_scheduler_status', - callback: res => { - this.page.set_indicator(...res.message); + method: + "frappe.core.page.background_jobs.background_jobs.get_scheduler_status", + callback: r => { + let { status } = r.message; + if (status === "active") { + this.page.set_indicator(__("Scheduler: Active"), "green"); + } else { + this.page.set_indicator(__("Scheduler: Inactive"), "red"); + } } }); } refresh_jobs() { - if (this.called) return; - this.called = true; + let view = this.view.get_value(); + let args; + let { queue_timeout, job_status } = this.page.get_form_values(); + if (view === "Jobs") { + args = { view, queue_timeout, job_status }; + } else { + args = { view }; + } + this.page.add_inner_message(__("Refreshing...")); frappe.call({ - method: 'frappe.core.page.background_jobs.background_jobs.get_info', - args: { - show_failed: this.show_failed - }, - callback: (res) => { - this.called = false; - this.page.body.find('.list-jobs').remove(); - $(frappe.render_template('background_jobs', { jobs: res.message || [] })).appendTo(this.content); + method: "frappe.core.page.background_jobs.background_jobs.get_info", + args, + callback: res => { + this.page.add_inner_message(""); - if (frappe.get_route()[0] === 'background_jobs') { + let template = + view === "Jobs" ? "background_jobs" : "background_workers"; + this.$content.html( + frappe.render_template(template, { + jobs: res.message || [] + }) + ); + + let auto_refresh = this.auto_refresh.get_value(); + if ( + frappe.get_route()[0] === "background_jobs" && + auto_refresh + ) { setTimeout(() => this.refresh_jobs(), 2000); } } diff --git a/frappe/core/page/background_jobs/background_jobs.py b/frappe/core/page/background_jobs/background_jobs.py index 4d9deca526..960444c349 100644 --- a/frappe/core/page/background_jobs/background_jobs.py +++ b/frappe/core/page/background_jobs/background_jobs.py @@ -8,8 +8,8 @@ from rq import Worker import frappe from frappe import _ -from frappe.utils import convert_utc_to_user_timezone, format_datetime -from frappe.utils.background_jobs import get_redis_conn, get_queues +from frappe.utils import convert_utc_to_user_timezone +from frappe.utils.background_jobs import get_queues, get_workers from frappe.utils.scheduler import is_scheduler_inactive if TYPE_CHECKING: @@ -24,16 +24,15 @@ JOB_COLORS = { @frappe.whitelist() -def get_info(show_failed=False) -> List[Dict]: - if isinstance(show_failed, str): - show_failed = json.loads(show_failed) - - conn = get_redis_conn() - queues = get_queues() - workers = Worker.all(conn) +def get_info(view=None, queue_timeout=None, job_status=None) -> List[Dict]: jobs = [] def add_job(job: 'Job', name: str) -> None: + if job_status != "all" and job.get_status() != job_status: + return + if queue_timeout != "all" and not name.endswith(f':{queue_timeout}'): + return + if job.kwargs.get('site') == frappe.local.site: job_info = { 'job_name': job.kwargs.get('kwargs', {}).get('playbook_method') @@ -41,7 +40,7 @@ def get_info(show_failed=False) -> List[Dict]: or str(job.kwargs.get('job_name')), 'status': job.get_status(), 'queue': name, - 'creation': format_datetime(convert_utc_to_user_timezone(job.created_at)), + 'creation': convert_utc_to_user_timezone(job.created_at), 'color': JOB_COLORS[job.get_status()] } @@ -50,32 +49,31 @@ def get_info(show_failed=False) -> List[Dict]: jobs.append(job_info) - # show worker jobs - for worker in workers: - job = worker.get_current_job() - if job: - add_job(job, worker.name) - - for queue in queues: - # show active queued jobs - if queue.name != 'failed': + if view == 'Jobs': + queues = get_queues() + for queue in queues: for job in queue.jobs: add_job(job, queue.name) - # show failed jobs, if requested - if show_failed: - fail_registry = queue.failed_job_registry - for job_id in fail_registry.get_job_ids(): - job = queue.fetch_job(job_id) - if job: - add_job(job, queue.name) + elif view == 'Workers': + workers = get_workers() + for worker in workers: + current_job = worker.get_current_job() + if current_job and current_job.kwargs.get('site') == frappe.local.site: + add_job(current_job, job.origin) + else: + jobs.append({ + 'queue': worker.name, + 'job_name': 'idle', + 'status': '', + 'creation': '' + }) return jobs @frappe.whitelist() def remove_failed_jobs(): - conn = get_redis_conn() queues = get_queues() for queue in queues: fail_registry = queue.failed_job_registry @@ -87,5 +85,5 @@ def remove_failed_jobs(): @frappe.whitelist() def get_scheduler_status(): if is_scheduler_inactive(): - return [_("Inactive"), "red"] - return [_("Active"), "green"] + return {'status': 'inactive'} + return {'status': 'active'} diff --git a/frappe/core/page/background_jobs/background_jobs_outer.html b/frappe/core/page/background_jobs/background_jobs_outer.html deleted file mode 100644 index 4ca3a32906..0000000000 --- a/frappe/core/page/background_jobs/background_jobs_outer.html +++ /dev/null @@ -1,5 +0,0 @@ -
-
- -
-
\ No newline at end of file diff --git a/frappe/core/page/background_jobs/background_workers.html b/frappe/core/page/background_jobs/background_workers.html new file mode 100644 index 0000000000..1647cea4b4 --- /dev/null +++ b/frappe/core/page/background_jobs/background_workers.html @@ -0,0 +1,51 @@ +{% if jobs.length %} + + + + + + + + + + + {% for j in jobs %} + + + + + + + {% endfor %} + +
{{ __("Worker") }}{{ __("Current Job") }}{{ __("Status") }}{{ __("Created") }}
+ {{ j.queue }} + +
+ + {{ frappe.utils.encode_tags(j.job_name) }} + +
+ {% if j.exc_info %} +
+ {{ __("Exception") }} +
+
{{ frappe.utils.encode_tags(j.exc_info) }}
+
+
+ {% endif %} +
+ {{ toTitle(j.status) }} + {{ frappe.datetime.prettyDate(j.creation) }}
+{% else %} +
+ Empty State +

{{ __("No workers online on this site") }}

+
+{% endif %} + \ No newline at end of file diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index af10c6d76a..fdd7bf3134 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -33,29 +33,40 @@ class CustomField(Document): def before_insert(self): self.set_fieldname() - meta = frappe.get_meta(self.dt, cached=False) - fieldnames = [df.fieldname for df in meta.get("fields")] - - if self.fieldname in fieldnames: - frappe.throw(_("A field with the name '{}' already exists in doctype {}.").format(self.fieldname, self.dt)) def validate(self): + # these imports have been added to avoid cyclical import, should fix in future from frappe.custom.doctype.customize_form.customize_form import CustomizeForm + from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts - meta = frappe.get_meta(self.dt, cached=False) - fieldnames = [df.fieldname for df in meta.get("fields")] + # don't always get meta to improve performance + # setting idx is just an improvement, not a requirement + if self.is_new() or self.insert_after == "append": + meta = frappe.get_meta(self.dt, cached=False) + fieldnames = [df.fieldname for df in meta.get("fields")] - if self.insert_after=='append': - self.insert_after = fieldnames[-1] + if self.is_new() and self.fieldname in fieldnames: + frappe.throw( + _("A field with the name {0} already exists in {1}") + .format(frappe.bold(self.fieldname), self.dt) + ) - if self.insert_after and self.insert_after in fieldnames: - self.idx = fieldnames.index(self.insert_after) + 1 + if self.insert_after == "append": + self.insert_after = fieldnames[-1] - old_fieldtype = self.db_get('fieldtype') - is_fieldtype_changed = (not self.is_new()) and (old_fieldtype != self.fieldtype) + if self.insert_after and self.insert_after in fieldnames: + self.idx = fieldnames.index(self.insert_after) + 1 - if not self.is_virtual and is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype): - frappe.throw(_("Fieldtype cannot be changed from {0} to {1}").format(old_fieldtype, self.fieldtype)) + if ( + not self.is_virtual + and (doc_before_save := self.get_doc_before_save()) + and (old_fieldtype := doc_before_save.fieldtype) != self.fieldtype + and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype) + ): + frappe.throw( + _("Fieldtype cannot be changed from {0} to {1}") + .format(old_fieldtype, self.fieldtype) + ) if not self.fieldname: frappe.throw(_("Fieldname not set for Custom Field")) @@ -63,13 +74,12 @@ class CustomField(Document): if self.get('translatable', 0) and not supports_translation(self.fieldtype): self.translatable = 0 - if not self.flags.ignore_validate: - from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts - check_fieldname_conflicts(self) + check_fieldname_conflicts(self) def on_update(self): if not frappe.flags.in_setup_wizard: frappe.clear_cache(doctype=self.dt) + if not self.flags.ignore_validate: # validate field from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 7c2a7a2aff..4c2d207df9 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -49,6 +49,14 @@ frappe.ui.form.on("Customize Form", { if (grid_row.doc && grid_row.doc.fieldtype == "Section Break") { $(grid_row.row).css({ "font-weight": "bold" }); } + + grid_row.row.removeClass("highlight"); + + if (grid_row.doc.is_custom_field && + !grid_row.row.hasClass('highlight') && + !grid_row.doc.is_system_generated) { + grid_row.row.addClass("highlight"); + } }); $(frm.wrapper).on("grid-make-sortable", function(e, frm) { @@ -84,17 +92,11 @@ frappe.ui.form.on("Customize Form", { }, setup_sortable: function(frm) { - frm.page.body.find(".highlight").removeClass("highlight"); frm.doc.fields.forEach(function(f, i) { - var data_row = frm.page.body.find( - '[data-fieldname="fields"] [data-idx="' + f.idx + '"] .data-row' - ); - - if (f.is_custom_field) { - data_row.addClass("highlight"); - } else { + if (!f.is_custom_field) { f._sortable = false; } + if (f.fieldtype == "Table") { frm.add_custom_button( f.options, diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index b6bd7ba787..5ec5cae121 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -67,7 +67,12 @@ class CustomizeForm(Document): self.set(prop, meta.get(prop)) for d in meta.get("fields"): - new_d = {"fieldname": d.fieldname, "is_custom_field": d.get("is_custom_field"), "name": d.name} + new_d = { + "fieldname": d.fieldname, + "is_custom_field": d.get("is_custom_field"), + "is_system_generated": d.get("is_system_generated"), + "name": d.name + } for prop in docfield_properties: new_d[prop] = d.get(prop) self.append("fields", new_d) diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 1cc4c9f623..cc446e321e 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -7,6 +7,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "is_system_generated", "label_and_type", "label", "fieldtype", @@ -444,13 +445,21 @@ "fieldname": "no_copy", "fieldtype": "Check", "label": "No Copy" + }, + { + "default": "0", + "fieldname": "is_system_generated", + "fieldtype": "Check", + "hidden": 1, + "label": "Is System Generated", + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-02-25 16:01:12.616736", + "modified": "2022-03-31 12:05:11.799654", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/database/database.py b/frappe/database/database.py index 24dfdd32df..7551c5f628 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -115,12 +115,13 @@ class Database(object): {"name": "a%", "owner":"test@example.com"}) """ + debug = debug or getattr(self, "debug", False) query = str(query) 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 +358,7 @@ class Database(object): order_by="KEEP_DEFAULT_ORDERING", cache=False, for_update=False, + *, run=True, pluck=False, distinct=False, @@ -386,17 +388,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. @@ -435,6 +447,7 @@ class Database(object): pluck=pluck, distinct=distinct, limit=limit, + as_dict=as_dict, ) else: @@ -487,6 +500,7 @@ class Database(object): as_dict=False, debug=False, update=None, + *, run=True, pluck=False, distinct=False, @@ -537,7 +551,7 @@ class Database(object): return r and [[i[1] for i in r]] or [] - def get_singles_dict(self, doctype, debug = False): + def get_singles_dict(self, doctype, debug=False, *, for_update=False): """Get Single DocType as dict. :param doctype: DocType of the single object whose value is requested @@ -548,10 +562,13 @@ class Database(object): account_settings = frappe.db.get_singles_dict("Accounts Settings") """ result = self.query.get_sql( - "Singles", filters={"doctype": doctype}, fields=["field", "value"] + "Singles", + filters={"doctype": doctype}, + fields=["field", "value"], + for_update=for_update, ).run() - dict_ = frappe._dict(result) - return dict_ + + return frappe._dict(result) @staticmethod def get_all(*args, **kwargs): @@ -621,7 +638,8 @@ class Database(object): filters, doctype, as_dict, - debug, + *, + debug=False, order_by=None, update=None, for_update=False, @@ -661,7 +679,20 @@ 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, + as_dict=False + ): names = list(filter(None, names)) if names: return self.get_all( @@ -671,7 +702,7 @@ class Database(object): order_by=order_by, pluck=pluck, debug=debug, - as_list=1, + as_list=not as_dict, run=run, distinct=distinct, limit_page_length=limit diff --git a/frappe/database/query.py b/frappe/database/query.py index 15ab85ff56..641b584932 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -115,21 +115,23 @@ def change_orderby(order: str): OPERATOR_MAP = { - "+": operator.add, - "=": operator.eq, - "-": operator.sub, - "!=": operator.ne, - "<": operator.lt, - ">": operator.gt, - "<=": operator.le, - ">=": operator.ge, - "in": func_in, - "not in": func_not_in, - "like": like, - "not like": not_like, - "regex": func_regex, - "between": func_between - } + "+": operator.add, + "=": operator.eq, + "-": operator.sub, + "!=": operator.ne, + "<": operator.lt, + ">": operator.gt, + "<=": operator.le, + "=<": operator.le, + ">=": operator.ge, + "=>": operator.ge, + "in": func_in, + "not in": func_not_in, + "like": like, + "not like": not_like, + "regex": func_regex, + "between": func_between, +} class Query: @@ -211,8 +213,7 @@ class Query: _operator = OPERATOR_MAP[f[1]] conditions = conditions.where(_operator(Field(f[0]), f[2])) - conditions = self.add_conditions(conditions, **kwargs) - return conditions + return self.add_conditions(conditions, **kwargs) def dict_query(self, table: str, filters: Dict[str, Union[str, int]] = None, **kwargs) -> frappe.qb: """Build conditions using the given dictionary filters @@ -251,8 +252,7 @@ class Query: field = getattr(_table, key) conditions = conditions.where(field.isnull()) - conditions = self.add_conditions(conditions, **kwargs) - return conditions + return self.add_conditions(conditions, **kwargs) def build_conditions( self, diff --git a/frappe/desk/doctype/system_console/system_console.json b/frappe/desk/doctype/system_console/system_console.json index 657e9df89d..c92b2005ed 100644 --- a/frappe/desk/doctype/system_console/system_console.json +++ b/frappe/desk/doctype/system_console/system_console.json @@ -1,7 +1,7 @@ { "actions": [ { - "action": "#List/Console Log/List", + "action": "app/console-log", "action_type": "Route", "label": "Logs" }, @@ -86,7 +86,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-15 17:17:44.844767", + "modified": "2022-04-09 16:35:32.345542", "modified_by": "Administrator", "module": "Desk", "name": "System Console", @@ -104,5 +104,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index dd8623cae2..9003158a85 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -220,9 +220,9 @@ class SendMailContext: def message_placeholder(self, placeholder_key): map = { - 'tracker': '', - 'unsubscribe_url': '', - 'cc': '', + 'tracker': '', + 'unsubscribe_url': '', + 'cc': '', 'recipient': '', } return map.get(placeholder_key) diff --git a/frappe/email/queue.py b/frappe/email/queue.py index f6f52e79e2..07a9c6552d 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -66,25 +66,25 @@ def get_emails_sent_today(email_account=None): def get_unsubscribe_message(unsubscribe_message, expose_recipients): if unsubscribe_message: - unsubscribe_html = '''{0}'''.format(unsubscribe_message) else: - unsubscribe_link = '''{0}'''.format(_('Unsubscribe')) unsubscribe_html = _("{0} to stop receiving emails of this type").format(unsubscribe_link) html = """
- +
{0}
""".format(unsubscribe_html) if expose_recipients == "footer": - text = "\n" + text = "\n" else: text = "" - text += "\n\n{unsubscribe_message}: \n".format(unsubscribe_message=unsubscribe_message) + text += "\n\n{unsubscribe_message}: \n".format(unsubscribe_message=unsubscribe_message) return frappe._dict({ "html": html, diff --git a/frappe/hooks.py b/frappe/hooks.py index 78f4a2d801..b545c6a719 100644 --- a/frappe/hooks.py +++ b/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" diff --git a/frappe/installer.py b/frappe/installer.py index e28a942f01..c7dacc4ac1 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -142,8 +142,10 @@ def find_org(org_repo: str) -> Tuple[str, str]: import requests for org in ["frappe", "erpnext"]: - res = requests.head(f"https://api.github.com/repos/{org}/{org_repo}") - if res.ok: + response = requests.head(f"https://api.github.com/repos/{org}/{org_repo}") + if response.status_code == 400: + response = requests.head(f"https://github.com/{org}/{org_repo}") + if response.ok: return org, org_repo raise InvalidRemoteException @@ -220,7 +222,7 @@ def install_app(name, verbose=False, set_as_patched=True): # install pre-requisites if app_hooks.required_apps: for app in app_hooks.required_apps: - name = parse_app_name(name) + name = parse_app_name(app) install_app(name, verbose=verbose) frappe.flags.in_install = name diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 8fd64689fc..57591d01d5 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -132,32 +132,30 @@ class BaseDocument(object): def get_db_value(self, key): return frappe.db.get_value(self.doctype, self.name, key) - def get(self, key=None, filters=None, limit=None, default=None): - if key: - if isinstance(key, dict): - return _filter(self.get_all_children(), key, limit=limit) - if filters: - if isinstance(filters, dict): - value = _filter(self.__dict__.get(key, []), filters, limit=limit) - else: - default = filters - filters = None - value = self.__dict__.get(key, default) + def get(self, key, filters=None, limit=None, default=None): + if isinstance(key, dict): + return _filter(self.get_all_children(), key, limit=limit) + + if filters: + if isinstance(filters, dict): + value = _filter(self.__dict__.get(key, []), filters, limit=limit) else: + default = filters + filters = None value = self.__dict__.get(key, default) - - if value is None and key in ( - d.fieldname for d in self.meta.get_table_fields() - ): - value = [] - self.set(key, value) - - if limit and isinstance(value, (list, tuple)) and len(value) > limit: - value = value[:limit] - - return value else: - return self.__dict__ + value = self.__dict__.get(key, default) + + if value is None and key in ( + d.fieldname for d in self.meta.get_table_fields() + ): + value = [] + self.set(key, value) + + if limit and isinstance(value, (list, tuple)) and len(value) > limit: + value = value[:limit] + + return value def getone(self, key, filters=None): return self.get(key, filters=filters, limit=1)[0] @@ -817,6 +815,13 @@ class BaseDocument(object): elif language == "PythonExpression": frappe.utils.validate_python_code(code_string, fieldname=field.label) + def _sync_autoname_field(self): + """Keep autoname field in sync with `name`""" + autoname = self.meta.autoname or "" + _empty, _field_specifier, fieldname = autoname.partition("field:") + + if fieldname and self.name and self.name != self.get("fieldname"): + self.set(fieldname, self.name) def throw_length_exceeded_error(self, df, max_length, value): # check if parentfield exists (only applicable for child table doctype) diff --git a/frappe/model/document.py b/frappe/model/document.py index 3848fa8029..15e9c28d83 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -88,35 +88,27 @@ class Document(BaseDocument): If DocType name and document name are passed, the object will load all values (including child documents) from the database. """ - self.doctype = self.name = None - self._default_new_docs = {} + self.doctype = None + self.name = None self.flags = frappe._dict() - if args and args[0] and isinstance(args[0], str): - # first arugment is doctype - if len(args)==1: - # single - self.doctype = self.name = args[0] - else: + if args and args[0]: + if isinstance(args[0], str): + # first arugment is doctype self.doctype = args[0] - if isinstance(args[1], dict): - # filter - self.name = frappe.db.get_value(args[0], args[1], "name") - if self.name is None: - frappe.throw(_("{0} {1} not found").format(_(args[0]), args[1]), - frappe.DoesNotExistError) - else: - self.name = args[1] - if 'for_update' in kwargs: - self.flags.for_update = kwargs.get('for_update') + # doctype for singles, string value or filters for other documents + self.name = self.doctype if len(args) == 1 else args[1] - self.load_from_db() - return + # for_update is set in flags to avoid changing load_from_db signature + # since it is used in virtual doctypes and inherited in child classes + self.flags.for_update = kwargs.get("for_update") + self.load_from_db() + return - if args and args[0] and isinstance(args[0], dict): - # first argument is a dict - kwargs = args[0] + if isinstance(args[0], dict): + # first argument is a dict + kwargs = args[0] if kwargs: # init base document @@ -133,17 +125,15 @@ class Document(BaseDocument): frappe.whitelist()(fn) return fn - def reload(self): - """Reload document from database""" - self.load_from_db() - def load_from_db(self): """Load document and children from database and create properties from fields""" if not getattr(self, "_metaclass", False) and self.meta.issingle: - single_doc = frappe.db.get_singles_dict(self.doctype) + single_doc = frappe.db.get_singles_dict( + self.doctype, for_update=self.flags.for_update + ) if not single_doc: - single_doc = frappe.new_doc(self.doctype).as_dict() + single_doc = frappe.new_doc(self.doctype, as_dict=True) single_doc["name"] = self.doctype del single_doc["__islocal"] @@ -177,6 +167,8 @@ class Document(BaseDocument): if hasattr(self, "__setup__"): self.__setup__() + reload = load_from_db + def get_latest(self): if not getattr(self, "latest", None): self.latest = frappe.get_doc(self.doctype, self.name) @@ -500,6 +492,7 @@ class Document(BaseDocument): self._validate_non_negative() self._validate_length() self._validate_code_fields() + self._sync_autoname_field() self._extract_images_from_text_editor() self._sanitize_content() self._save_passwords() @@ -848,16 +841,19 @@ class Document(BaseDocument): frappe.CancelledLinkError) def get_all_children(self, parenttype=None): - """Returns all children documents from **Table** type field in a list.""" - ret = [] - for df in self.meta.get("fields", {"fieldtype": ['in', table_fields]}): - if parenttype: - if df.options==parenttype: - return self.get(df.fieldname) + """Returns all children documents from **Table** type fields in a list.""" + + children = [] + + for df in self.meta.get_table_fields(): + if parenttype and df.options != parenttype: + continue + value = self.get(df.fieldname) if isinstance(value, list): - ret.extend(value) - return ret + children.extend(value) + + return children def run_method(self, method, *args, **kwargs): """run standard triggers, plus those in hooks""" @@ -1375,11 +1371,9 @@ class Document(BaseDocument): doctype = self.__class__.__name__ docstatus = f" docstatus={self.docstatus}" if self.docstatus else "" - repr_str = f"<{doctype}: {name}{docstatus}" + parent = f" parent={self.parent}" if getattr(self, "parent", None) else "" - if not hasattr(self, "parent"): - return repr_str + ">" - return f"{repr_str} parent={self.parent}>" + return f"<{doctype}: {name}{docstatus}{parent}>" def __str__(self): name = self.name or "unsaved" diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 4768faff48..0383327b68 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -45,7 +45,7 @@ def export_customizations(module, doctype, sync_on_migrate=0, with_permissions=0 if not frappe.get_conf().developer_mode: raise Exception('Not developer mode') - custom = {'custom_fields': [], 'property_setters': [], 'custom_perms': [], + custom = {'custom_fields': [], 'property_setters': [], 'custom_perms': [],'links':[], 'doctype': doctype, 'sync_on_migrate': sync_on_migrate} def add(_doctype): @@ -53,6 +53,8 @@ def export_customizations(module, doctype, sync_on_migrate=0, with_permissions=0 fields='*', filters={'dt': _doctype}) custom['property_setters'] += frappe.get_all('Property Setter', fields='*', filters={'doc_type': _doctype}) + custom['links'] += frappe.get_all('DocType Link', + fields='*', filters={'parent': _doctype}) add(doctype) diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index 5d04fbe982..122aea9fa1 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -37,7 +37,7 @@ frappe.ui.form.PrintView = class { this.print_wrapper = this.page.main.empty().html( ` {{ _("Cancel") }} -
+
{{ _("Post") }}
diff --git a/frappe/templates/discussions/discussions.js b/frappe/templates/discussions/discussions.js index 19c0f89a49..c786583fa6 100644 --- a/frappe/templates/discussions/discussions.js +++ b/frappe/templates/discussions/discussions.js @@ -4,8 +4,6 @@ frappe.ready(() => { add_color_to_avatars(); - expand_first_discussion(); - $(".search-field").keyup((e) => { search_topic(e); }); @@ -14,11 +12,11 @@ frappe.ready(() => { show_new_topic_modal(e); }); - $("#login-from-discussion").click((e) => { + $(".login-from-discussion").click((e) => { login_from_discussion(e); }); - $(".sidebar-topic").click((e) => { + $(".sidebar-parent").click((e) => { if ($(e.currentTarget).attr("aria-expanded") == "true") { e.stopPropagation(); } @@ -31,17 +29,6 @@ frappe.ready(() => { } }); - $(document).on("input", ".discussion-on-page .comment-field", (e) => { - if ($(e.currentTarget).val()) { - $(e.currentTarget).css("height", "48px"); - $(".cancel-comment").removeClass("hide").addClass("show"); - $(e.currentTarget).css("height", $(e.currentTarget).prop("scrollHeight")); - } else { - $(".cancel-comment").removeClass("show").addClass("hide"); - $(e.currentTarget).css("height", "48px"); - } - }); - $(document).on("click", ".submit-discussion", (e) => { submit_discussion(e); }); @@ -50,16 +37,26 @@ frappe.ready(() => { clear_comment_box(); }); - if ($(document).width() <= 550) { - $(document).on("click", ".sidebar-parent", () => { - hide_sidebar(); - }); - } + $(document).on("click", ".sidebar-parent", () => { + hide_sidebar(); + }); - $(document).on("click", ".back", (e) => { + $(document).on("click", ".back-button", (e) => { back_to_sidebar(e); }); + $(document).on("click", ".dismiss-reply", (e) => { + dismiss_reply(e); + }); + + $(document).on("click", ".reply-card .dropdown-menu", (e) => { + perform_action(e); + }); + + $(document).on("input", ".discussion-on-page .comment-field", (e) => { + adjust_comment_box(e); + }); + }); const show_new_topic_modal = (e) => { @@ -79,10 +76,17 @@ const setup_socket_io = () => { if (window.dev_server) { frappe.boot.socketio_port = "9000"; } + frappe.socketio.init(9000); frappe.socketio.socket.on("publish_message", (data) => { publish_message(data); }); + frappe.socketio.socket.on("update_message", (data) => { + update_message(data); + }); + frappe.socketio.socket.on("delete_message", (data) => { + delete_message(data); + }); }); }; @@ -92,44 +96,47 @@ const publish_message = (data) => { const topic = data.topic_info; const single_thread = $(".is-single-thread").length; const first_topic = !$(".reply-card").length; - const document_match_found = doctype == topic.reference_doctype && docname == topic.reference_docname; + const document_match_found = (doctype == topic.reference_doctype) && (docname == topic.reference_docname); + + post_message_cleanup(); + data.template = hide_actions_on_conditions(data.template, data.reply_owner); + data.template = style_avatar_frame(data.template); + data.sidebar = style_avatar_frame(data.sidebar); + data.new_topic_template = style_avatar_frame(data.new_topic_template); if ($(`.discussion-on-page[data-topic=${topic.name}]`).length) { - post_message_cleanup(); - data.template = style_avatar_frame(data.template); - $('
' + data.template) - .insertBefore(`.discussion-on-page[data-topic=${topic.name}] .discussion-form`); + $(data.template).insertBefore(`.discussion-on-page[data-topic=${topic.name}] .discussion-form`); } else if (!first_topic && !single_thread && document_match_found) { - post_message_cleanup(); - data.new_topic_template = style_avatar_frame(data.new_topic_template); - - $(data.sidebar).insertAfter(`.discussions-sidebar .form-group`); + $(data.sidebar).insertBefore($(`.discussions-sidebar .sidebar-parent`).first()); $(`#discussion-group`).prepend(data.new_topic_template); - if (topic.owner == frappe.session.user) { $(".discussion-on-page") && $(".discussion-on-page").collapse(); - $(".sidebar-topic").first().click(); + $(".sidebar-parent").first().click(); } } else if (single_thread && document_match_found) { - post_message_cleanup(); - data.template = style_avatar_frame(data.template); $(data.template).insertBefore(`.discussion-form`); $(".discussion-on-page").attr("data-topic", topic.name); } else if (topic.owner == frappe.session.user && document_match_found) { - post_message_cleanup(); window.location.reload(); } update_reply_count(topic.name); }; +const update_message = (data) => { + const reply_card = $(`[data-reply=${data.reply_name}]`); + reply_card.find(".reply-body").removeClass("hide"); + reply_card.find(".reply-edit-card").addClass("hide"); + reply_card.find(".reply-text").html(data.reply); + reply_card.find(".reply-actions").addClass("hide"); +}; + const post_message_cleanup = () => { $(".topic-title").val(""); - $(".comment-field").val(""); - $(".discussion-on-page .comment-field").css("height", "48px"); + $(".discussion-form .comment-field").val(""); $("#discussion-modal").modal("hide"); $("#no-discussions").addClass("hide"); $(".cancel-comment").addClass("hide"); @@ -141,15 +148,6 @@ const update_reply_count = (topic) => { $(`[data-target='#t${topic}']`).find(".reply-count").text(reply_count); }; -const expand_first_discussion = () => { - if ($(document).width() > 550) { - $($(".discussions-parent .collapse")[0]).addClass("show"); - $($(".discussions-sidebar [data-toggle='collapse']")[0]).attr("aria-expanded", true); - } else { - $("#discussion-group").addClass("hide"); - } -}; - const search_topic = (e) => { let input = $(e.currentTarget).val(); @@ -160,7 +158,7 @@ const search_topic = (e) => { } topics.each((i, elem) => { - let topic_id = $(elem).parent().attr("data-target"); + let topic_id = $(elem).closest(".sidebar-parent").attr("data-target"); /* Check match in replies */ let match_in_reply = false; @@ -201,16 +199,20 @@ const submit_discussion = (e) => { e.preventDefault(); e.stopImmediatePropagation(); + const target = $(e.currentTarget); + const reply_name = target.closest(".reply-card").data("reply"); const title = $(".topic-title:visible").length ? $(".topic-title:visible").val().trim() : ""; - const reply = $(".comment-field:visible").val().trim(); + let reply = reply_name ? target.closest(".reply-card") : target.closest(".discussion-form"); + reply = reply.find(".comment-field").val().trim(); if (reply) { - let doctype = $(e.currentTarget).closest(".discussions-parent").attr("data-doctype"); + let doctype = target.closest(".discussions-parent").attr("data-doctype"); doctype = doctype ? decodeURIComponent(doctype) : doctype; - let docname = $(e.currentTarget).closest(".discussions-parent").attr("data-docname"); + let docname = target.closest(".discussions-parent").attr("data-docname"); docname = docname ? decodeURIComponent(docname) : docname; + frappe.call({ method: "frappe.website.doctype.discussion_topic.discussion_topic.submit_discussion", args: { @@ -218,7 +220,8 @@ const submit_discussion = (e) => { "docname": docname ? docname : "", "reply": reply, "title": title, - "topic_name": $(e.currentTarget).closest(".discussion-on-page").attr("data-topic") + "topic_name": target.closest(".discussion-on-page").attr("data-topic"), + "reply_name": reply_name } }); } @@ -252,18 +255,64 @@ const style_avatar_frame = (template) => { }; const clear_comment_box = () => { - $(".discussion-on-page .comment-field").val(""); + $(".discussion-form .comment-field").val(""); $(".cancel-comment").removeClass("show").addClass("hide"); - $(".discussion-on-page .comment-field").css("height", "48px"); }; const hide_sidebar = () => { $(".discussions-sidebar").addClass("hide"); $("#discussion-group").removeClass("hide"); + $(".search-field").addClass("hide"); + $(".reply").addClass("hide"); }; const back_to_sidebar = () => { $(".discussions-sidebar").removeClass("hide"); $("#discussion-group").addClass("hide"); $(".discussion-on-page").collapse("hide"); + $(".search-field").removeClass("hide"); + $(".reply").removeClass("hide"); +}; + +const perform_action = (e) => { + const action = $(e.target).data().action; + const reply_card = $(e.target).closest(".reply-card"); + + if (action === "edit") { + reply_card.find(".reply-edit-card").removeClass("hide"); + reply_card.find(".reply-body").addClass("hide"); + reply_card.find(".reply-actions").removeClass("hide"); + } else if (action === "delete") { + frappe.call({ + method: "frappe.website.doctype.discussion_reply.discussion_reply.delete_message", + args: { + "reply_name": $(e.target).closest(".reply-card").data("reply") + } + }); + } +}; + +const dismiss_reply = (e) => { + const reply_card = $(e.currentTarget).closest(".reply-card"); + reply_card.find(".reply-edit-card").addClass("hide"); + reply_card.find(".reply-body").removeClass("hide"); + reply_card.find(".reply-actions").addClass("hide"); +}; + +const adjust_comment_box = (e) => { + if ($(e.currentTarget).val()) { + $(".cancel-comment").removeClass("hide").addClass("show"); + } else { + $(".cancel-comment").removeClass("show").addClass("hide"); + } +}; + +const hide_actions_on_conditions = (template, owner) => { + let $template = $(template); + frappe.session.user != owner && $template.find(".dropdown").addClass("hide"); + return $template.prop("outerHTML"); +}; + +const delete_message = (data) => { + $(`[data-reply=${data.reply_name}]`).addClass("hide"); }; diff --git a/frappe/templates/discussions/discussions_section.html b/frappe/templates/discussions/discussions_section.html index 07c229595b..5db7cf86b1 100644 --- a/frappe/templates/discussions/discussions_section.html +++ b/frappe/templates/discussions/discussions_section.html @@ -9,25 +9,31 @@
{{ _(title) }} + {% if topics | length and not single_thread %} + {% include "frappe/templates/discussions/search.html" %} + {% endif %} {% if topics and not single_thread %} {% include "frappe/templates/discussions/button.html" %} {% endif %}
-
+
{% if topics and not single_thread %} -
- {% include "frappe/templates/discussions/search.html" %} +
{% for topic in topics %} {% set replies = frappe.get_all("Discussion Reply", {"topic": topic.name})%} {% include "frappe/templates/discussions/sidebar.html" %} + + {% if loop.index != topics | length %} +
+ {% endif %} + {% endfor %}
-
+
{% for topic in topics %} {% include "frappe/templates/discussions/reply_section.html" %} {% endfor %} @@ -38,19 +44,25 @@ {% include "frappe/templates/discussions/reply_section.html" %} {% else %} -
- -
{{ empty_state_title }}
-
{{ empty_state_subtitle }}
- {% if frappe.session.user == "Guest" %} -
{{ _("Login") }}
- {% elif condition is defined and not condition %} -
- {{ button_name }} +
+
+ +
+
+
{{ empty_state_title }}
+
{{ empty_state_subtitle }}
+
+
+ {% if frappe.session.user == "Guest" %} + + {% elif condition is defined and not condition %} + + {% else %} + {% include "frappe/templates/discussions/button.html" %} + {% endif %}
- {% else %} - {% include "frappe/templates/discussions/button.html" %} - {% endif %}
{% endif %}
diff --git a/frappe/templates/discussions/reply_card.html b/frappe/templates/discussions/reply_card.html index 5ff5261472..d9eb1da25c 100644 --- a/frappe/templates/discussions/reply_card.html +++ b/frappe/templates/discussions/reply_card.html @@ -1,14 +1,50 @@ {% from "frappe/templates/includes/avatar_macro.html" import avatar %} -
+
{% set member = frappe.db.get_value("User", reply.owner, ["name", "full_name", "username"], as_dict=True) %} -
- {% if loop.index == 1 or single_thread %} + +
{{ avatar(reply.owner) }} - {% endif %} - + {{ member.full_name }} -
{{ frappe.utils.pretty_date(reply.creation) }}
+
{{ frappe.utils.pretty_date(reply.creation) }}
+
+
{{ _("Post") }}
+
{{ _("Dismiss") }}
+
+
+ +
+ {% if frappe.session.user == reply.owner %} + + {% endif %} +
{{ frappe.utils.md_to_html(reply.reply) }}
+
+ +
+
+
+
+ +
+
+
-
{{ frappe.utils.md_to_html(reply.reply) }}
+ diff --git a/frappe/templates/discussions/reply_section.html b/frappe/templates/discussions/reply_section.html index b269883ba0..6d219030d7 100644 --- a/frappe/templates/discussions/reply_section.html +++ b/frappe/templates/discussions/reply_section.html @@ -1,43 +1,52 @@ {% if topic %} {% set replies = frappe.get_all("Discussion Reply", {"topic": topic.name}, -["reply", "owner", "creation"], order_by="creation")%} +["reply", "owner", "creation", "name"], order_by="creation")%} {% endif %} -
- {% if not single_thread %} -
- {{ _("Back") }} -
- {% endif %} +
+ {% if not single_thread %} +
+ + + +
+ {% endif %} - {% if topic and topic.title %} -
{{ topic.title }}
- {% endif %} + {% if topic and topic.title %} +
{{ topic.title }}
+ {% endif %} +
{% for reply in replies %} + {% set index = loop.index %} {% include "frappe/templates/discussions/reply_card.html" %} - - {% if loop.index != replies | length %} -
- {% endif %} {% endfor %} {% if frappe.session.user == "Guest" or (condition is defined and not condition) %} -
- {{ _("Want to join the discussion?") }} - {% if frappe.session.user == "Guest" %} -
{{ _("Login") }}
- {% elif not condition %} -
{{ button_name }} +
+
+ +
+
+
{{ _("Want to discuss?") }}
+
{{ _("Post it here, our mentors will help you out.") }}
+
+
+ {% if frappe.session.user == "Guest" %} + + {% elif condition is defined and not condition %} + + {% endif %}
- {% endif %}
{% else %} {% include "frappe/templates/discussions/comment_box.html" %} {% endif %} -
diff --git a/frappe/templates/discussions/search.html b/frappe/templates/discussions/search.html index 800f2962fd..371c5dce9c 100644 --- a/frappe/templates/discussions/search.html +++ b/frappe/templates/discussions/search.html @@ -1,9 +1,2 @@ -
-
-
- -
-
-
+ diff --git a/frappe/templates/discussions/sidebar.html b/frappe/templates/discussions/sidebar.html index 14d38c86a5..05125f2bc3 100644 --- a/frappe/templates/discussions/sidebar.html +++ b/frappe/templates/discussions/sidebar.html @@ -1,19 +1,24 @@ -