diff --git a/cypress/fixtures/custom_submittable_doctype.js b/cypress/fixtures/custom_submittable_doctype.js new file mode 100644 index 0000000000..c88d37b373 --- /dev/null +++ b/cypress/fixtures/custom_submittable_doctype.js @@ -0,0 +1,53 @@ +export default { + name: 'Custom Submittable DocType', + custom: 1, + actions: [], + is_submittable: 1, + creation: '2019-12-10 06:29:07.215072', + doctype: 'DocType', + editable_grid: 1, + engine: 'InnoDB', + fields: [ + { + fieldname: 'enabled', + fieldtype: 'Check', + label: 'Enabled', + allow_on_submit: 1, + reqd: 1 + }, + { + fieldname: 'title', + fieldtype: 'Data', + label: 'title', + reqd: 1 + }, + { + fieldname: 'description', + fieldtype: 'Text Editor', + label: 'Description' + } + ], + links: [], + modified: '2019-12-10 14:40:53.127615', + modified_by: 'Administrator', + module: 'Custom', + owner: 'Administrator', + permissions: [ + { + create: 1, + delete: 1, + email: 1, + print: 1, + read: 1, + role: 'System Manager', + share: 1, + write: 1, + submit: 1, + cancel: 1 + } + ], + quick_entry: 1, + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; \ No newline at end of file diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 5a8b85d19e..ed5ff21ff1 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -1,8 +1,4 @@ context('Form', () => { - beforeEach(() => { - cy.login(); - cy.visit('/desk'); - }); before(() => { cy.login(); cy.visit('/desk'); @@ -10,6 +6,9 @@ context('Form', () => { frappe.call("frappe.tests.ui_test_helpers.create_contact_records"); }); }); + beforeEach(() => { + cy.visit('/desk'); + }); it('create a new form', () => { cy.visit('/desk#Form/ToDo/New ToDo 1'); cy.fill_field('description', 'this is a test todo', 'Text Editor').blur(); @@ -27,10 +26,11 @@ context('Form', () => { cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); cy.get('.filter-box .btn:contains("Apply")').click({ force: true }); cy.visit('/desk#Form/Contact/Test Form Contact 3'); - cy.get('.prev-doc').click(); + cy.get('.prev-doc').click({ force: true }); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); - cy.get('.modal-backdrop').click(); - cy.get('.next-doc').click(); + cy.get('.btn-modal-close:visible').click(); + cy.get('.next-doc').click({ force: true }); + cy.wait(200); cy.contains('Test Form Contact 2').should('not.exist'); cy.get('.page-title .title-text').should('contain', 'Test Form Contact 1'); cy.visit('/desk#List/Contact'); diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index cf41e31ee6..67fdb8acf0 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -14,15 +14,15 @@ context('Grid Pagination', () => { cy.visit('/desk#Form/Contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.current-page-number').should('contain', '1'); - cy.get('@table').find('.total-page-number').should('contain', '50'); - cy.get('@table').find('.grid-body .grid-row').should('have.length', 20); + cy.get('@table').find('.total-page-number').should('contain', '20'); + cy.get('@table').find('.grid-body .grid-row').should('have.length', 50); }); it('goes to the next and previous page', () => { cy.visit('/desk#Form/Contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.next-page').click(); cy.get('@table').find('.current-page-number').should('contain', '2'); - cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '21'); + cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '51'); cy.get('@table').find('.prev-page').click(); cy.get('@table').find('.current-page-number').should('contain', '1'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1'); @@ -32,19 +32,20 @@ context('Grid Pagination', () => { cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('button.grid-add-row').click(); cy.get('@table').find('.grid-body .row-index').should('contain', 1001); - cy.get('@table').find('.current-page-number').should('contain', '51'); - cy.get('@table').find('.total-page-number').should('contain', '51'); + cy.get('@table').find('.current-page-number').should('contain', '21'); + cy.get('@table').find('.total-page-number').should('contain', '21'); cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({force: true}); cy.get('@table').find('button.grid-remove-rows').click(); cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000); - cy.get('@table').find('.current-page-number').should('contain', '50'); - cy.get('@table').find('.total-page-number').should('contain', '50'); + cy.get('@table').find('.current-page-number').should('contain', '20'); + cy.get('@table').find('.total-page-number').should('contain', '20'); }); it('deletes all rows', ()=> { cy.visit('/desk#Form/Contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true}); cy.get('@table').find('button.grid-remove-all-rows').click(); + cy.get('.modal-dialog .btn-primary').contains('Yes').click(); cy.get('@table').find('.grid-body .grid-row').should('have.length', 0); }); }); \ No newline at end of file diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js new file mode 100644 index 0000000000..c7aeaa92de --- /dev/null +++ b/cypress/integration/report_view.js @@ -0,0 +1,40 @@ +import custom_submittable_doctype from '../fixtures/custom_submittable_doctype'; +const doctype_name = custom_submittable_doctype.name; + +context('Report View', () => { + before(() => { + cy.login(); + cy.visit('/desk'); + cy.insert_doc('DocType', custom_submittable_doctype, true); + cy.clear_cache(); + cy.insert_doc(doctype_name, { + 'title': 'Doc 1', + 'description': 'Random Text', + 'enabled': 0, + // submit document + 'docstatus': 1 + }, true).as('doc'); + }); + it('Field with enabled allow_on_submit should be editable.', () => { + cy.server(); + cy.route('POST', 'api/method/frappe.client.set_value').as('value-update'); + cy.visit(`/desk#List/${doctype_name}/Report`); + let cell = cy.get('.dt-row-0 > .dt-cell--col-3'); + // select the cell + cell.dblclick(); + cell.find('input[data-fieldname="enabled"]').check({force: true}); + cy.get('.dt-row-0 > .dt-cell--col-4').click(); + cy.wait('@value-update'); + cy.get('@doc').then(doc => { + cy.call('frappe.client.get_value', { + doctype: doc.doctype, + filters: { + name: doc.name, + }, + fieldname: 'enabled' + }).then(r => { + expect(r.message.enabled).to.equals(1); + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 464cbbe1d5..41d9c16d7b 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -183,3 +183,31 @@ Cypress.Commands.add('hide_dialog', () => { cy.get_open_dialog().find('.btn-modal-close').click(); cy.get('.modal:visible').should('not.exist'); }); + +Cypress.Commands.add('insert_doc', (doctype, args, ignore_duplicate) => { + return cy + .window() + .its('frappe.csrf_token') + .then(csrf_token => { + return cy + .request({ + method: 'POST', + url: `/api/resource/${doctype}`, + body: args, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Frappe-CSRF-Token': csrf_token + }, + failOnStatusCode: !ignore_duplicate + }) + .then(res => { + let status_codes = [200]; + if (ignore_duplicate) { + status_codes.push(409); + } + expect(res.status).to.be.oneOf(status_codes); + return res.body.data; + }); + }); +}); \ No newline at end of file diff --git a/frappe/__init__.py b/frappe/__init__.py index cb1b6e5358..b383ae958e 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -23,7 +23,7 @@ if sys.version[0] == '2': reload(sys) sys.setdefaultencoding("utf-8") -__version__ = '12.0.20' +__version__ = '12.1.0' __title__ = "Frappe Framework" local = Local() diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index b431c7c473..55792b2648 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -31,12 +31,6 @@ class AssignmentRule(Document): return False - def apply_close(self, doc, assignments): - if (self.close_assignments and - self.name in [d.assignment_rule for d in assignments]): - return self.close_assignments(doc) - - return False def apply_assign(self, doc): if self.safe_eval('assign_condition', doc): @@ -157,16 +151,17 @@ def bulk_apply(doctype, docnames): apply(None, doctype=doctype, name=name) def reopen_closed_assignment(doc): - todo = frappe.db.exists('ToDo', dict( + todo_list = frappe.db.get_all('ToDo', filters = dict( reference_type = doc.doctype, reference_name = doc.name, status = 'Closed' )) - if not todo: + if not todo_list: return False - todo = frappe.get_doc("ToDo", todo) - todo.status = 'Open' - todo.save(ignore_permissions=True) + for todo in todo_list: + todo_doc = frappe.get_doc('ToDo', todo.name) + todo_doc.status = 'Open' + todo_doc.save(ignore_permissions=True) return True def apply(doc, method=None, doctype=None, name=None): @@ -225,13 +220,12 @@ def apply(doc, method=None, doctype=None, name=None): continue if not new_apply: - reopen = reopen_closed_assignment(doc) - if reopen: - break - close = assignment_rule.apply_close(doc, assignments) - if close: - break - + # only reopen if close condition is not satisfied + if not assignment_rule.safe_eval('close_condition', doc): + reopen = reopen_closed_assignment(doc) + if reopen: + break + assignment_rule.close_assignments(doc) def get_assignment_rules(): return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))] diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 27f17a1a62..e618c7d63e 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -117,8 +117,8 @@ class AutoRepeat(Document): start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day) if self.end_date: - start_date = start_date = get_next_schedule_date( - start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) + start_date = get_next_schedule_date( + start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, for_full_schedule=True) while (getdate(start_date) < getdate(end_date)): row = { "reference_document" : self.reference_document, @@ -126,10 +126,9 @@ class AutoRepeat(Document): "next_scheduled_date" : start_date } schedule_details.append(row) - start_date = start_date = get_next_schedule_date( + start_date = get_next_schedule_date( start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) - return schedule_details def create_documents(self): diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index 82fff31394..36c297cc26 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "Prompt", "creation": "2019-09-30 11:56:57.943241", "doctype": "DocType", @@ -43,7 +44,7 @@ "fieldname": "doctype_event", "fieldtype": "Select", "label": "DocType Event", - "options": "Before Insert\nBefore Save\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete" + "options": "Before Insert\nBefore Save\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)" }, { "depends_on": "eval:doc.script_type==='API'", @@ -73,7 +74,8 @@ "fieldtype": "Section Break" } ], - "modified": "2019-10-09 15:08:40.085059", + "links": [], + "modified": "2019-12-17 12:55:07.389775", "modified_by": "Administrator", "module": "Core", "name": "Server Script", diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index 878810f459..2e1a5ae8bb 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -14,6 +14,8 @@ EVENT_MAP = { 'on_cancel': 'After Cancel', 'on_trash': 'Before Delete', 'after_delete': 'After Delete', + 'before_update_after_submit': 'Before Save (Submitted Document)', + 'on_update_after_submit': 'After Save (Submitted Document)' } def run_server_script_api(method): diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index b142047059..3a8815ca71 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -182,6 +182,8 @@ def get_notification_info(): return out def get_notification_config(): + user = frappe.session.user or 'Guest' + def _get(): subscribed_documents = get_subscribed_documents() config = frappe._dict() @@ -205,7 +207,7 @@ def get_notification_config(): config[key].update(nc.get(key, {})) return config - return frappe.cache().hget("notification_config", frappe.session.user, _get) + return frappe.cache().hget("notification_config", user, _get) def get_filters_for(doctype): '''get open filters for doctype''' diff --git a/frappe/desk/page/setup_wizard/install_fixtures.py b/frappe/desk/page/setup_wizard/install_fixtures.py index bb598ab180..e7e147fb7d 100644 --- a/frappe/desk/page/setup_wizard/install_fixtures.py +++ b/frappe/desk/page/setup_wizard/install_fixtures.py @@ -10,6 +10,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import up def install(): update_genders_and_salutations() update_global_search_doctypes() + setup_email_linking() @frappe.whitelist() def update_genders_and_salutations(): @@ -20,13 +21,12 @@ def update_genders_and_salutations(): for record in records: doc = frappe.new_doc(record.get("doctype")) doc.update(record) + doc.insert(ignore_permissions=True, ignore_if_duplicate=True) - try: - doc.insert(ignore_permissions=True) - except frappe.DuplicateEntryError as e: - # pass DuplicateEntryError and continue - if e.args and e.args[0]==doc.doctype and e.args[1]==doc.name: - # make sure DuplicateEntryError is for the exact same doc and not a related doc - pass - else: - raise \ No newline at end of file +def setup_email_linking(): + doc = frappe.get_doc({ + "doctype": "Email Account", + "email_id": "email_linking@example.com", + }) + doc.insert(ignore_permissions=True, ignore_if_duplicate=True) + \ No newline at end of file diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 21a69f5111..7dc561193f 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -510,7 +510,7 @@ def has_match(row, linked_doctypes, doctype_match_filters, ref_doctype, if_owner cell_value = None if isinstance(row, dict): cell_value = row.get(idx) - elif isinstance(row, list): + elif isinstance(row, (list, tuple)): cell_value = row[idx] if dt in match_filters and cell_value not in match_filters.get(dt) and frappe.db.exists(dt, cell_value): diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 5db6ae18bf..9caf72d3bd 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -267,18 +267,14 @@ def get_sidebar_stats(stats, doctype, filters=[]): data = frappe._dict(frappe.local.form_dict) filters = json.loads(data["filters"]) - if not frappe.cache().hget("Tags", doctype): - tags = set([tag.tag for tag in frappe.get_list("Tag Link", filters={"document_type": doctype}, fields=["tag"])]) - frappe.cache().hset("Tags", doctype, tags) - - for tag in list(frappe.cache().hget("Tags", doctype)): + for tag in frappe.get_all("Tag Link", filters={"document_type": doctype}, fields=["tag"]): tag_filters = [] tag_filters.extend(filters) - tag_filters.extend([['Tag Link', 'tag', '=', tag]]) + tag_filters.extend([['Tag Link', 'tag', '=', tag.tag]]) count = frappe.get_all(doctype, filters=tag_filters, fields=["count(*)"]) if count[0].get("count(*)") > 0: - _user_tags.append([tag, count[0].get("count(*)")]) + _user_tags.append([tag.tag, count[0].get("count(*)")]) return {"stats": {"_user_tags": _user_tags}} diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index c05a0f3fe4..495644f652 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -322,16 +322,16 @@ class EmailAccount(Document): unhandled_email.insert(ignore_permissions=True) frappe.db.commit() - def insert_communication(self, msg, args={}): + def insert_communication(self, msg, args=None): if isinstance(msg, list): raw, uid, seen = msg else: raw = msg uid = -1 seen = 0 - - if args.get("uid", -1): uid = args.get("uid", -1) - if args.get("seen", 0): seen = args.get("seen", 0) + if isinstance(args, dict): + if args.get("uid", -1): uid = args.get("uid", -1) + if args.get("seen", 0): seen = args.get("seen", 0) email = Email(raw) @@ -355,7 +355,7 @@ class EmailAccount(Document): name = names[0].get("name") # email is already available update communication uid instead frappe.db.set_value("Communication", name, "uid", uid, update_modified=False) - return + return frappe.get_doc("Communication", name) if email.content_type == 'text/html': email.content = clean_email_html(email.content) diff --git a/frappe/email/queue.py b/frappe/email/queue.py index 792b47296a..1c9a2fd3de 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -3,22 +3,25 @@ from __future__ import unicode_literals import frappe +import sys from six.moves import html_parser as HTMLParser import smtplib, quopri, json -from frappe import msgprint, throw, _, safe_decode +from frappe import msgprint, _, safe_decode from frappe.email.smtp import SMTPServer, get_outgoing_email_account from frappe.email.email_body import get_email, get_formatted_html, add_attachment from frappe.utils.verified_command import get_signed_params, verify_request from html2text import html2text -from frappe.utils import get_url, nowdate, encode, now_datetime, add_days, split_emails, cstr, cint +from frappe.utils import get_url, nowdate, now_datetime, add_days, split_emails, cstr, cint from rq.timeouts import JobTimeoutException -from six import text_type, string_types +from six import text_type, string_types, PY3 +from email.parser import Parser + class EmailLimitCrossedError(frappe.ValidationError): pass def send(recipients=None, sender=None, subject=None, message=None, text_content=None, reference_doctype=None, reference_name=None, unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, - attachments=None, reply_to=None, cc=[], bcc=[], message_id=None, in_reply_to=None, send_after=None, + attachments=None, reply_to=None, cc=None, bcc=None, message_id=None, in_reply_to=None, send_after=None, expose_recipients=None, send_priority=1, communication=None, now=False, read_receipt=None, queue_separately=False, is_notification=False, add_unsubscribe_link=1, inline_images=None, header=None, print_letterhead=False): @@ -52,6 +55,11 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content= if not recipients and not cc: return + if not cc: + cc = [] + if not bcc: + bcc = [] + if isinstance(recipients, string_types): recipients = split_emails(recipients) @@ -68,7 +76,6 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content= if not sender or sender == "Administrator": sender = email_account.default_sender - if not text_content: try: text_content = html2text(message) @@ -404,7 +411,7 @@ def send_one(email, smtpserver=None, auto_commit=True, now=False, from_test=Fals message = prepare_message(email, recipient.recipient, recipients_list) if not frappe.flags.in_test: - smtpserver.sess.sendmail(email.sender, recipient.recipient, encode(message)) + smtpserver.sess.sendmail(email.sender, recipient.recipient, message) recipient.status = "Sent" frappe.db.sql("""update `tabEmail Queue Recipient` set status='Sent', modified=%s where name=%s""", @@ -509,37 +516,41 @@ def prepare_message(email, recipient, recipients_list): message = (message and message.encode('utf8')) or '' message = safe_decode(message) - if not email.attachments: - return message - # On-demand attachments - from email.parser import Parser + if PY3: + from email.policy import SMTPUTF8 + message = Parser(policy=SMTPUTF8).parsestr(message) + else: + message = Parser().parsestr(message) - msg_obj = Parser().parsestr(message) - attachments = json.loads(email.attachments) + if email.attachments: + # On-demand attachments - for attachment in attachments: - if attachment.get('fcontent'): continue + attachments = json.loads(email.attachments) - fid = attachment.get("fid") - if fid: - _file = frappe.get_doc("File", fid) - fcontent = _file.get_content() - attachment.update({ - 'fname': _file.file_name, - 'fcontent': fcontent, - 'parent': msg_obj - }) - attachment.pop("fid", None) - add_attachment(**attachment) + for attachment in attachments: + if attachment.get('fcontent'): + continue - elif attachment.get("print_format_attachment") == 1: - attachment.pop("print_format_attachment", None) - print_format_file = frappe.attach_print(**attachment) - print_format_file.update({"parent": msg_obj}) - add_attachment(**print_format_file) + fid = attachment.get("fid") + if fid: + _file = frappe.get_doc("File", fid) + fcontent = _file.get_content() + attachment.update({ + 'fname': _file.file_name, + 'fcontent': fcontent, + 'parent': message + }) + attachment.pop("fid", None) + add_attachment(**attachment) - return msg_obj.as_string() + elif attachment.get("print_format_attachment") == 1: + attachment.pop("print_format_attachment", None) + print_format_file = frappe.attach_print(**attachment) + print_format_file.update({"parent": message}) + add_attachment(**print_format_file) + + return message.as_string() def clear_outbox(): """Remove low priority older than 31 days in Outbox and expire mails not sent for 7 days. diff --git a/frappe/email/test_email_body.py b/frappe/email/test_email_body.py index feb8e80007..26c4e5ba5d 100644 --- a/frappe/email/test_email_body.py +++ b/frappe/email/test_email_body.py @@ -5,7 +5,10 @@ from __future__ import unicode_literals import unittest, os, base64 from frappe.email.receive import Email from frappe.email.email_body import (replace_filename_with_cid, - get_email, inline_style_in_html, get_header) + get_email, inline_style_in_html, get_header) +from frappe.email.queue import prepare_message, get_email_queue +from six import PY3 + class TestEmailBody(unittest.TestCase): def setUp(self): @@ -37,6 +40,53 @@ This is the text version of this email text_content=email_text ).as_string() + def test_prepare_message_returns_already_encoded_string(self): + + if PY3: + uni_chr1 = chr(40960) + uni_chr2 = chr(1972) + else: + uni_chr1 = unichr(40960) + uni_chr2 = unichr(1972) + + email = get_email_queue( + recipients=['test@example.com'], + sender='me@example.com', + subject='Test Subject', + content='
{{ frappe.markdown(version_info[1]) }}
{% } } %} +