From patchwork Wed Jul 28 15:24:15 2021 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Raxel Gutierrez X-Patchwork-Id: 1510898 Return-Path: X-Original-To: incoming@patchwork.ozlabs.org Delivered-To: patchwork-incoming@bilbo.ozlabs.org Authentication-Results: ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=lists.ozlabs.org (client-ip=2404:9400:2:0:216:3eff:fee1:b9f1; helo=lists.ozlabs.org; envelope-from=patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org; receiver=) Authentication-Results: ozlabs.org; dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=dv9rK+nA; dkim-atps=neutral Received: from lists.ozlabs.org (lists.ozlabs.org [IPv6:2404:9400:2:0:216:3eff:fee1:b9f1]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits) server-digest SHA256) (No client certificate requested) by ozlabs.org (Postfix) with ESMTPS id 4GZcsR38qPz9sT6 for ; Thu, 29 Jul 2021 01:24:43 +1000 (AEST) Received: from boromir.ozlabs.org (localhost [IPv6:::1]) by lists.ozlabs.org (Postfix) with ESMTP id 4GZcsP1zSGz30G6 for ; Thu, 29 Jul 2021 01:24:41 +1000 (AEST) Authentication-Results: lists.ozlabs.org; dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=dv9rK+nA; dkim-atps=neutral X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=flex--raxel.bounces.google.com (client-ip=2607:f8b0:4864:20::84a; helo=mail-qt1-x84a.google.com; envelope-from=3q3ybyqukcawdmjqxsaasxq.oaybmfotiadwxuefe.alxmne.ads@flex--raxel.bounces.google.com; receiver=) Authentication-Results: lists.ozlabs.org; dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=dv9rK+nA; dkim-atps=neutral Received: from mail-qt1-x84a.google.com (mail-qt1-x84a.google.com [IPv6:2607:f8b0:4864:20::84a]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 4GZcsH5MtSz304X for ; Thu, 29 Jul 2021 01:24:34 +1000 (AEST) Received: by mail-qt1-x84a.google.com with SMTP id i8-20020ac85c080000b029026ae3f4adc9so1197641qti.13 for ; Wed, 28 Jul 2021 08:24:33 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20161025; h=date:in-reply-to:message-id:mime-version:references:subject:from:to :cc:content-transfer-encoding; bh=a3x6H6GtDqBXKfr9aThx9bIiMbKmDxi3R2r0OPPL8yA=; b=dv9rK+nAc7JBlpK2XmvjJ8aJFTgeNC3JN8ruvqiYdS+C4R8VlC/hP6yj91XiLfxdZK 0oINbd/kzoGn8NF8u5+KmTSnR2zTpIqwjkcVXV4gEf0OfodNLXza4RQV+vNNDGPgyvfh x+8LqyWYl7HGFwRrEjUiBo1gDyrPLdQK8mcrtaX9QAh0/u/V2DPiWqYP27ahnGXD9MVR nINKFZ/lYw0gR6WR3ZhdOOMDgHM40pjXg7csNkCmLwoHCgEv9HlU+6p3anAbopWskNgt 71yg48kG/nlmuyhoWRzlVQ84xBemcMrLlVClX0PDSKOblh0KquCJoivy4ZAKUytFjQH3 1jCw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20161025; h=x-gm-message-state:date:in-reply-to:message-id:mime-version :references:subject:from:to:cc:content-transfer-encoding; bh=a3x6H6GtDqBXKfr9aThx9bIiMbKmDxi3R2r0OPPL8yA=; b=gYy4O3Y1/bZ6VQd/EMb9JOyJ2PNYdBFBWBRMsRpKZUlh4HEWiv519+/XJpMprMj5wT FShlSBnXfgdkM9TrQZizd0m1al6yyY4vHyPXHerwKG4+UeL4lA83ekR0lPWoHc2Ph3ax 74T7reGD/vfW7V9yygN5SUC7PgJKHpoD9vjZ27q8B8mVmvXQp3KxZnc8KPSwAOdC0Llb OeD6aOZ/B5y34GZDk7Lg/jzwgYUE9h+UO0R2CGz2dwpRFwKSKxs3PZ30vISOE6AQm+ms lIqrDqVE7ZJXFNszBBEO8n3JjkXcDcKRxTgmo+x03cOv6uok//rzOfSPYjeoeVanA0nm M1TA== X-Gm-Message-State: AOAM530TWn7ZbN8ceX5x+lVdWOsjz8QeaKjiUR8BJFFUkLiSpG4JqEpK jn80kZxRfRnGxe4GMgXaUh4z49eUVGO3khwAtcgiA8qkmLtRL4xFpIFG66NabnALeyq9/ZpMqYW LrUEWDB1OsbI21A36G/HxS23qHtJH25mWBVSxZScUgx4JS6tKaRFaDYK/ivML0VPB X-Google-Smtp-Source: ABdhPJwhTG2rfXNn6Z+zhq+ovQQhfwASRG7Q9zbQBEwLoig29BAZEP5ehfujGgOKSaRvKaEUwYUaJgss/w== X-Received: from raxel-pw.c.googlers.com ([fda3:e722:ac3:cc00:14:4d90:c0a8:2fda]) (user=raxel job=sendgmr) by 2002:a05:6214:1747:: with SMTP id dc7mr464487qvb.27.1627485867680; Wed, 28 Jul 2021 08:24:27 -0700 (PDT) Date: Wed, 28 Jul 2021 15:24:15 +0000 In-Reply-To: <20210728152419.3588812-1-raxel@google.com> Message-Id: <20210728152419.3588812-2-raxel@google.com> Mime-Version: 1.0 References: <20210728152419.3588812-1-raxel@google.com> X-Mailer: git-send-email 2.32.0.554.ge1b32706d8-goog Subject: [PATCH v3 1/5] static: add JS Cookie Library to get csrftoken for fetch requests From: Raxel Gutierrez To: patchwork@lists.ozlabs.org X-BeenThere: patchwork@lists.ozlabs.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: Patchwork development List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" Currently, requests are made only through form submission and the csrftoken is added to templates using {% csrf_token %}. Following Django docs [1], the library is useful to add csrftoken when making requests in JavaScript. [1] https://docs.djangoproject.com/en/3.2/ref/csrf/#ajax Signed-off-by: Raxel Gutierrez Acked-by: Daniel Axtens --- htdocs/README.rst | 9 +++++++++ htdocs/js/js.cookie.min.js | 2 ++ templates/base.html | 1 + 3 files changed, 12 insertions(+) create mode 100644 htdocs/js/js.cookie.min.js diff --git a/htdocs/README.rst b/htdocs/README.rst index 62f15c2..128dc7c 100644 --- a/htdocs/README.rst +++ b/htdocs/README.rst @@ -122,6 +122,15 @@ js :GitHub: jQuery plug-in to drag and drop rows in HTML tables :Version: ??? +``js.cookie.min.js`` + + Library used to handle cookies. + + This is used to get the ``csrftoken`` cookie for AJAX requests in JavaScript. + + :GitHub: https://github.com/js-cookie/js-cookie/ + :Version: 3.0.0 + ``selectize.min.js`` Selectize is the hybrid of a ``textbox`` and `` + +
+ {% include "patchwork/partials/patch-forms.html" %} + {% include "patchwork/partials/pagination.html" %} +
@@ -174,9 +157,9 @@ $(document).ready(function() { {% for patch in page.object_list %} - + {% if user.is_authenticated %} - {% endif %} @@ -188,24 +171,24 @@ $(document).ready(function() { {% endif %} - - - - - - - - + + + + + + {% empty %} @@ -217,86 +200,6 @@ $(document).ready(function() { {% if page.paginator.count %} {% include "patchwork/partials/pagination.html" %} - -
- -{% if patchform %} -
-

Properties

-
+ + {{ patch.name|default:"[no subject]"|truncatechars:100 }} + {% if patch.series %} {{ patch.series|truncatechars:100 }} {% endif %} {{ patch|patch_tags }}{{ patch|patch_checks }}{{ patch.date|date:"Y-m-d" }}{{ patch.submitter|personify:project }}{{ patch.delegate.username }}{{ patch.state }}{{ patch|patch_tags }}{{ patch|patch_checks }}{{ patch.date|date:"Y-m-d" }}{{ patch.submitter|personify:project }}{{ patch.delegate.username }}{{ patch.state }}
- - - - - - - - - - - - - - - - -
Change state: - {{ patchform.state }} - {{ patchform.state.errors }} -
Delegate to: - {{ patchform.delegate }} - {{ patchform.delegate.errors }} -
Archive: - {{ patchform.archived }} - {{ patchform.archived.errors }} -
- -
- - -{% endif %} - -{% if user.is_authenticated %} -
-

Bundling

- - - - - - {% if bundles %} - - - - - {% endif %} - {% if bundle %} - - - - - {% endif %} -
Create bundle: - - -
Add to bundle: - - -
Remove from bundle: - - -
-
-{% endif %} - -
-
- - {% endif %} diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html index 978559b..17255ee 100644 --- a/patchwork/templates/patchwork/submission.html +++ b/patchwork/templates/patchwork/submission.html @@ -27,6 +27,8 @@ function toggle_div(link_id, headers_id, label_show, label_hide) } +{% include "patchwork/partials/errors.html" %} +
{% include "patchwork/partials/download-buttons.html" %}

{{ submission.name }}

@@ -149,91 +151,10 @@ function toggle_div(link_id, headers_id, label_show, label_hide) {% endif %} -
-{% if patchform %} -
-

Patch Properties

-
- {% csrf_token %} - - - - - - - - - - - - - - - - - -
Change state: - {{ patchform.state }} - {{ patchform.state.errors }} -
Delegate to: - {{ patchform.delegate }} - {{ patchform.delegate.errors }} -
Archived: - {{ patchform.archived }} - {{ patchform.archived.errors }} -
- -
-
-
-{% endif %} - -{% if createbundleform %} -
-

Bundling

- - - - - -{% if bundles %} - - - - -{% endif %} -
Create bundle: - {% if createbundleform.non_field_errors %} -
{{createbundleform.non_field_errors}}
- {% endif %} -
- {% csrf_token %} - - {% if createbundleform.name.errors %} -
{{createbundleform.name.errors}}
- {% endif %} - {{ createbundleform.name }} - -
-
Add to bundle: -
- {% csrf_token %} - - - -
-
- -
-{% endif %} - -
-
-
+
+ {% csrf_token %} + {% include "patchwork/partials/patch-forms.html" %} +
{% if submission.pull_url %}

Pull-request

diff --git a/patchwork/tests/views/test_bundles.py b/patchwork/tests/views/test_bundles.py index 6a74409..2233c21 100644 --- a/patchwork/tests/views/test_bundles.py +++ b/patchwork/tests/views/test_bundles.py @@ -146,7 +146,7 @@ class BundleUpdateTest(BundleTestBase): data = { 'form': 'bundle', 'action': 'update', - 'name': newname, + 'bundle_name': newname, 'public': '', } response = self.client.post(bundle_url(self.bundle), data) @@ -159,7 +159,7 @@ class BundleUpdateTest(BundleTestBase): data = { 'form': 'bundle', 'action': 'update', - 'name': self.bundle.name, + 'bundle_name': self.bundle.name, 'public': 'on', } response = self.client.post(bundle_url(self.bundle), data) @@ -243,7 +243,7 @@ class BundlePublicModifyTest(BundleTestBase): data = { 'form': 'bundle', 'action': 'update', - 'name': newname, + 'bundle_name': newname, } self.bundle.name = oldname self.bundle.save() @@ -353,7 +353,7 @@ class BundleCreateFromListTest(BundleTestBase): def test_create_empty_bundle(self): newbundlename = 'testbundle-new' - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'bundle_name': newbundlename, 'action': 'Create', 'project': self.project.id} @@ -369,7 +369,7 @@ class BundleCreateFromListTest(BundleTestBase): newbundlename = 'testbundle-new' patch = self.patches[0] - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'bundle_name': newbundlename, 'action': 'Create', 'project': self.project.id, @@ -393,7 +393,7 @@ class BundleCreateFromListTest(BundleTestBase): n_bundles = Bundle.objects.count() - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'bundle_name': '', 'action': 'Create', 'project': self.project.id, @@ -414,7 +414,7 @@ class BundleCreateFromListTest(BundleTestBase): newbundlename = 'testbundle-dup' patch = self.patches[0] - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'bundle_name': newbundlename, 'action': 'Create', 'project': self.project.id, @@ -440,7 +440,9 @@ class BundleCreateFromListTest(BundleTestBase): params) self.assertNotContains(response, 'Bundle %s created' % newbundlename) - self.assertContains(response, 'You already have a bundle called') + self.assertContains( + response, + 'A bundle called %s already exists' % newbundlename) self.assertEqual(Bundle.objects.count(), n_bundles) self.assertEqual(bundle.patches.count(), 1) @@ -451,8 +453,8 @@ class BundleCreateFromPatchTest(BundleTestBase): newbundlename = 'testbundle-new' patch = self.patches[0] - params = {'name': newbundlename, - 'action': 'createbundle'} + params = {'bundle_name': newbundlename, + 'action': 'Create'} response = self.client.post( reverse('patch-detail', @@ -470,8 +472,8 @@ class BundleCreateFromPatchTest(BundleTestBase): newbundlename = self.bundle.name patch = self.patches[0] - params = {'name': newbundlename, - 'action': 'createbundle'} + params = {'bundle_name': newbundlename, + 'action': 'Create'} response = self.client.post( reverse('patch-detail', @@ -489,7 +491,7 @@ class BundleAddFromListTest(BundleTestBase): def test_add_to_empty_bundle(self): patch = self.patches[0] - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'action': 'Add', 'project': self.project.id, 'bundle_id': self.bundle.id, @@ -509,7 +511,7 @@ class BundleAddFromListTest(BundleTestBase): def test_add_to_non_empty_bundle(self): self.bundle.append_patch(self.patches[0]) patch = self.patches[1] - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'action': 'Add', 'project': self.project.id, 'bundle_id': self.bundle.id, @@ -538,7 +540,7 @@ class BundleAddFromListTest(BundleTestBase): count = self.bundle.patches.count() patch = self.patches[0] - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'action': 'Add', 'project': self.project.id, 'bundle_id': self.bundle.id, @@ -559,7 +561,7 @@ class BundleAddFromListTest(BundleTestBase): count = self.bundle.patches.count() patch = self.patches[0] - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'action': 'Add', 'project': self.project.id, 'bundle_id': self.bundle.id, @@ -584,7 +586,7 @@ class BundleAddFromPatchTest(BundleTestBase): def test_add_to_empty_bundle(self): patch = self.patches[0] - params = {'action': 'addtobundle', + params = {'action': 'Add', 'bundle_id': self.bundle.id} response = self.client.post( @@ -594,7 +596,7 @@ class BundleAddFromPatchTest(BundleTestBase): self.assertContains( response, - 'added to bundle "%s"' % self.bundle.name, + 'added to bundle %s' % self.bundle.name, count=1) self.assertEqual(self.bundle.patches.count(), 1) @@ -603,7 +605,7 @@ class BundleAddFromPatchTest(BundleTestBase): def test_add_to_non_empty_bundle(self): self.bundle.append_patch(self.patches[0]) patch = self.patches[1] - params = {'action': 'addtobundle', + params = {'action': 'Add', 'bundle_id': self.bundle.id} response = self.client.post( @@ -613,7 +615,7 @@ class BundleAddFromPatchTest(BundleTestBase): self.assertContains( response, - 'added to bundle "%s"' % self.bundle.name, + 'added to bundle %s' % self.bundle.name, count=1) self.assertEqual(self.bundle.patches.count(), 2) @@ -650,7 +652,7 @@ class BundleInitialOrderTest(BundleTestBase): newbundlename = 'testbundle-new' # need to define our querystring explicity to enforce ordering - params = {'form': 'patchlistform', + params = {'form': 'patch-list-form', 'bundle_name': newbundlename, 'action': 'Create', 'project': self.project.id, diff --git a/patchwork/tests/views/test_patch.py b/patchwork/tests/views/test_patch.py index 1a1243c..483ab99 100644 --- a/patchwork/tests/views/test_patch.py +++ b/patchwork/tests/views/test_patch.py @@ -304,7 +304,7 @@ class PatchViewTest(TestCase): class PatchUpdateTest(TestCase): - properties_form_id = 'patchform-properties' + properties_form_id = 'patch-form-properties' def setUp(self): self.project = create_project() @@ -318,7 +318,7 @@ class PatchUpdateTest(TestCase): self.base_data = { 'action': 'Update', 'project': str(self.project.id), - 'form': 'patchlistform', + 'form': 'patch-list-form', 'archived': '*', 'delegate': '*', 'state': '*' diff --git a/patchwork/views/__init__.py b/patchwork/views/__init__.py index 3efe90c..3ea2af4 100644 --- a/patchwork/views/__init__.py +++ b/patchwork/views/__init__.py @@ -3,11 +3,14 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +import json + from django.contrib import messages from django.shortcuts import get_object_or_404 from django.db.models import Prefetch from patchwork.filters import Filters +from patchwork.forms import CreateBundleForm from patchwork.forms import MultiplePatchForm from patchwork.models import Bundle from patchwork.models import BundlePatch @@ -108,46 +111,35 @@ class Order(object): # TODO(stephenfin): Refactor this to break it into multiple, testable functions -def set_bundle(request, project, action, data, patches, context): +def set_bundle(request, project, action, data, patches): # set up the bundle bundle = None user = request.user if action == 'create': bundle_name = data['bundle_name'].strip() - if '/' in bundle_name: - return ['Bundle names can\'t contain slashes'] - - if not bundle_name: - return ['No bundle name was specified'] - - if Bundle.objects.filter(owner=user, name=bundle_name).count() > 0: - return ['You already have a bundle called "%s"' % bundle_name] - bundle = Bundle(owner=user, project=project, name=bundle_name) - bundle.save() - messages.success(request, "Bundle %s created" % bundle.name) + create_bundle_form = CreateBundleForm(instance=bundle, + data=request.POST) + if create_bundle_form.is_valid(): + create_bundle_form.save() + addBundlePatches(request, patches, bundle) + bundle.save() + create_bundle_form = CreateBundleForm() + messages.success(request, 'Bundle %s created' % bundle.name) + else: + formErrors = json.loads(create_bundle_form.errors.as_json()) + errors = [e['message'] for e in formErrors['name']] + return errors elif action == 'add': + if not data['bundle_id']: + return ['No bundle was selected'] bundle = get_object_or_404(Bundle, id=data['bundle_id']) + addBundlePatches(request, patches, bundle) elif action == 'remove': bundle = get_object_or_404(Bundle, id=data['removed_bundle_id']) - - if not bundle: - return ['no such bundle'] - - for patch in patches: - if action in ['create', 'add']: - bundlepatch_count = BundlePatch.objects.filter(bundle=bundle, - patch=patch).count() - if bundlepatch_count == 0: - bundle.append_patch(patch) - messages.success(request, "Patch '%s' added to bundle %s" % - (patch.name, bundle.name)) - else: - messages.warning(request, "Patch '%s' already in bundle %s" % - (patch.name, bundle.name)) - elif action == 'remove': + for patch in patches: try: bp = BundlePatch.objects.get(bundle=bundle, patch=patch) bp.delete() @@ -158,10 +150,21 @@ def set_bundle(request, project, action, data, patches, context): request, "Patch '%s' removed from bundle %s\n" % (patch.name, bundle.name)) + return [] - bundle.save() - return [] +def addBundlePatches(request, patches, bundle): + for patch in patches: + bundlepatch_count = BundlePatch.objects.filter(bundle=bundle, + patch=patch).count() + if bundlepatch_count == 0: + bundle.append_patch(patch) + bundle.save() + messages.success(request, "Patch '%s' added to bundle %s" % + (patch.name, bundle.name)) + else: + messages.warning(request, "Patch '%s' already in bundle %s" % + (patch.name, bundle.name)) def generic_list(request, project, view, view_args=None, filter_settings=None, @@ -216,17 +219,20 @@ def generic_list(request, project, view, view_args=None, filter_settings=None, data = None user = request.user properties_form = None + create_bundle_form = None if user.is_authenticated: # we only pass the post data to the MultiplePatchForm if that was # the actual form submitted data_tmp = None - if data and data.get('form', '') == 'patchlistform': + if data and data.get('form', '') == 'patch-list-form': data_tmp = data properties_form = MultiplePatchForm(project, data=data_tmp) + if request.user.is_authenticated: + create_bundle_form = CreateBundleForm() - if request.method == 'POST' and data.get('form') == 'patchlistform': + if request.method == 'POST' and data.get('form') == 'patch-list-form': action = data.get('action', '').lower() # special case: the user may have hit enter in the 'create bundle' @@ -237,7 +243,7 @@ def generic_list(request, project, view, view_args=None, filter_settings=None, ps = Patch.objects.filter(id__in=get_patch_ids(data)) if action in bundle_actions: - errors = set_bundle(request, project, action, data, ps, context) + errors = set_bundle(request, project, action, data, ps) elif properties_form and action == properties_form.action: errors = process_multiplepatch_form(request, properties_form, @@ -288,6 +294,7 @@ def generic_list(request, project, view, view_args=None, filter_settings=None, context.update({ 'page': paginator.current_page, 'patchform': properties_form, + 'createbundleform': create_bundle_form, 'project': project, 'order': order, }) diff --git a/patchwork/views/patch.py b/patchwork/views/patch.py index 3e6874a..229e1dd 100644 --- a/patchwork/views/patch.py +++ b/patchwork/views/patch.py @@ -14,11 +14,11 @@ from django.urls import reverse from patchwork.forms import CreateBundleForm from patchwork.forms import PatchForm -from patchwork.models import Bundle from patchwork.models import Cover from patchwork.models import Patch from patchwork.models import Project from patchwork.views import generic_list +from patchwork.views import set_bundle from patchwork.views.utils import patch_to_mbox from patchwork.views.utils import series_patch_to_mbox @@ -60,6 +60,7 @@ def patch_detail(request, project_id, msgid): form = None createbundleform = None + errors = None if editable: form = PatchForm(instance=patch) @@ -71,34 +72,14 @@ def patch_detail(request, project_id, msgid): if action: action = action.lower() - if action == 'createbundle': - bundle = Bundle(owner=request.user, project=project) - createbundleform = CreateBundleForm(instance=bundle, - data=request.POST) - if createbundleform.is_valid(): - createbundleform.save() - bundle.append_patch(patch) - bundle.save() - createbundleform = CreateBundleForm() - messages.success(request, 'Bundle %s created' % bundle.name) - elif action == 'addtobundle': - bundle = get_object_or_404( - Bundle, id=request.POST.get('bundle_id')) - if bundle.append_patch(patch): - messages.success(request, - 'Patch "%s" added to bundle "%s"' % ( - patch.name, bundle.name)) - else: - messages.error(request, - 'Failed to add patch "%s" to bundle "%s": ' - 'patch is already in bundle' % ( - patch.name, bundle.name)) - - # all other actions require edit privs + if action in ['create', 'add']: + errors = set_bundle(request, project, action, + request.POST, [patch]) + elif not editable: return HttpResponseForbidden() - elif action is None: + elif action == 'update': form = PatchForm(data=request.POST, instance=patch) if form.is_valid(): form.save() @@ -133,6 +114,8 @@ def patch_detail(request, project_id, msgid): context['project'] = patch.project context['related_same_project'] = related_same_project context['related_different_project'] = related_different_project + if errors: + context['errors'] = errors return render(request, 'patchwork/submission.html', context) From patchwork Wed Jul 28 15:24:17 2021 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Raxel Gutierrez X-Patchwork-Id: 1510899 Return-Path: X-Original-To: incoming@patchwork.ozlabs.org Delivered-To: patchwork-incoming@bilbo.ozlabs.org Authentication-Results: ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=lists.ozlabs.org (client-ip=112.213.38.117; helo=lists.ozlabs.org; envelope-from=patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org; receiver=) Authentication-Results: ozlabs.org; dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=aZLBwC+z; dkim-atps=neutral Received: from lists.ozlabs.org (lists.ozlabs.org [112.213.38.117]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits) server-digest SHA256) (No client certificate requested) by ozlabs.org (Postfix) with ESMTPS id 4GZcsV1Sfzz9sT6 for ; Thu, 29 Jul 2021 01:24:46 +1000 (AEST) Received: from boromir.ozlabs.org (localhost [IPv6:::1]) by lists.ozlabs.org (Postfix) with ESMTP id 4GZcsT67Gyz3bY5 for ; Thu, 29 Jul 2021 01:24:45 +1000 (AEST) Authentication-Results: lists.ozlabs.org; dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=aZLBwC+z; dkim-atps=neutral X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=flex--raxel.bounces.google.com (client-ip=2607:f8b0:4864:20::84a; helo=mail-qt1-x84a.google.com; envelope-from=3rnybyqukca8gpmtavddvat.rdbepirwldgzaxhih.doapqh.dgv@flex--raxel.bounces.google.com; receiver=) Authentication-Results: lists.ozlabs.org; dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=aZLBwC+z; dkim-atps=neutral Received: from mail-qt1-x84a.google.com (mail-qt1-x84a.google.com [IPv6:2607:f8b0:4864:20::84a]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 4GZcsH5RdSz30CC for ; Thu, 29 Jul 2021 01:24:34 +1000 (AEST) Received: by mail-qt1-x84a.google.com with SMTP id f9-20020a05622a1a09b02902615523e725so1176995qtb.21 for ; Wed, 28 Jul 2021 08:24:34 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20161025; h=date:in-reply-to:message-id:mime-version:references:subject:from:to :cc; bh=eClI2olvcRDYOEsqTolgDe9oNDA7GOH+OS7vUROZxDA=; b=aZLBwC+zWBAS96FUY5LjHw1Uzj7IDhcK1FKwPb4jL6awtV9+Vg2uqlxEm8i9EWmbBw KWQjrm75+4EWkCFfDDbDyXF+C7nbL2WVr6HzcWP4ylK1SPoz8rUPgezsuW7xsQiLbQkY o+XyBn/t0zEqmqd0pSk8mv6JCkGe0zlxo3DO/Sb9vf7gBUfR7DzF2i2I2cAVKfzZwWZy N4NKzZb8CaTVhASn4OL8OCpOUyCmuKXRCfyi9G2/OdrqBjvdFFOLy3RDSEBA5Q5rvamk MvUukY6ektrFgxOLf5v1277FxF/2vThqcpnzKqUJF7fubRyqjAbIO3PpSkM/X6D2NyGU 7LPA== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20161025; h=x-gm-message-state:date:in-reply-to:message-id:mime-version :references:subject:from:to:cc; bh=eClI2olvcRDYOEsqTolgDe9oNDA7GOH+OS7vUROZxDA=; b=myHJ05tuLCibQzJrB3T+G0fPKUKJPDBvarysJMKsVpzna/Tu8V9FwQWvwj+5H1HUJV VW/vbt5EzUt5gSNziWcgddjvjcXBjng+E8E9Klz1GTXVK89AStzAxwmgAWLmIqjLirR8 5JeG9N07UaHj/y75GPUZ+cAGKiM3Z0rOsj77PC6XKaJGz2OFTQWV9mzodzu6hiqok9/e 49mdLcfbfQfZWP8vp/GwSjiA6Yn/PIqN0J+7/xJLP2jv4nRMrZr+nyyl/GMIPeyHN/ZX udcqsUweFx4qZpmqrccVaqWOx5A4r0WPICPC3XjMVAY+M0mq1Hl503wEyKjGCdTrO/Xc Lspw== X-Gm-Message-State: AOAM532PaLhmCBXUvIwZ9HGHaIaweaLYIsYEhqD/fk2zrWF1Wq8w0GXN kc7XONzhSOBwQnmtA0pUhwFZ1jjkm4aQPxFCuOnU5Qzt51d4Ka/UjWVWy20A9FCqcxwdcB7ztyq FBpL6gXx7O8xAzZlWcuuwcCBhkOmLYDb1v3sG6mX4/0QYU84tgrluc9msdN88oGFD X-Google-Smtp-Source: ABdhPJzVZ98isX0Iq1H2zGC7mggFvfx6HfQ3w1JTWvwWsF9/u1D1cfCCyEDZm5hs/JEGei4zfnJWrI0BUg== X-Received: from raxel-pw.c.googlers.com ([fda3:e722:ac3:cc00:14:4d90:c0a8:2fda]) (user=raxel job=sendgmr) by 2002:ad4:4a8c:: with SMTP id h12mr477294qvx.62.1627485870293; Wed, 28 Jul 2021 08:24:30 -0700 (PDT) Date: Wed, 28 Jul 2021 15:24:17 +0000 In-Reply-To: <20210728152419.3588812-1-raxel@google.com> Message-Id: <20210728152419.3588812-4-raxel@google.com> Mime-Version: 1.0 References: <20210728152419.3588812-1-raxel@google.com> X-Mailer: git-send-email 2.32.0.554.ge1b32706d8-goog Subject: [PATCH v3 3/5] patch-list: style modification forms as an action bar From: Raxel Gutierrez To: patchwork@lists.ozlabs.org X-BeenThere: patchwork@lists.ozlabs.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: Patchwork development List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" Added styling to the new patch list html code to make the change property and bundle action forms more usable. Before [1] and after [2] images for reference. [1] https://imgur.com/Pzelipp [2] https://imgur.com/UtNJXuf Signed-off-by: Raxel Gutierrez --- htdocs/css/style.css | 77 ++++++++++++++++++++++++++++++++++++-------- patchwork/forms.py | 44 +++++++++++++++++++------ 2 files changed, 99 insertions(+), 22 deletions(-) diff --git a/htdocs/css/style.css b/htdocs/css/style.css index 1bcc93e..9982f92 100644 --- a/htdocs/css/style.css +++ b/htdocs/css/style.css @@ -1,3 +1,7 @@ +:root { + --light-color: #F7F7F7; +} + h2 { font-size: 25px; margin: 18px 0 18px 0; @@ -122,10 +126,6 @@ a.colinactive:hover { div.filters { } -div.patch-forms { - margin-top: 1em; -} - /* list order manipulation */ table.patchlist tr.draghover { @@ -149,7 +149,7 @@ input#reorder-change { .paginator { text-align: right; clear: both; - margin: 8px 0 15px; + margin: 8px 0 15px; } .paginator .prev-na, @@ -346,13 +346,62 @@ table.bundlelist td padding-right: 2em; } +.patch-list-actions { + width: 100%; + display: inline-flex; + flex-wrap: wrap; + justify-content: space-between; +} + /* forms that appear for a patch */ +.patch-forms { + display: inline-flex; + flex-wrap: wrap; + margin: 16px 0px; +} + div.patch-form { - border: thin solid #080808; - padding-left: 0.6em; - padding-right: 0.6em; - float: left; - margin: 0.5em 5em 0.5em 10px; + display: flex; + flex-wrap: wrap; + align-items: center; +} + +select[class^=change-property-], .archive-patch-select, .add-bundle { + padding: 4px; + margin-right: 8px; + box-sizing: border-box; + border-radius: 4px; + background-color: var(--light-color); +} + +#patch-form-archive { + display: flex; + align-items: center; + margin-right: 4px; +} + +#patch-form-archive > label { + margin: 0px; +} + +#patch-form-archive > select, #patch-form-archive > input { + margin: 0px 4px 0px 4px; +} + +.patch-form-submit { + font-weight: bold; + padding: 4px; +} + +#patch-form-bundle, #add-to-bundle, #remove-bundle { + margin-left: 16px; +} + +.create-bundle { + padding: 4px; + margin-right: 8px; + box-sizing: border-box; + border-radius: 4px; } div.patch-form h3 { @@ -371,15 +420,17 @@ div.patch-form ul { margin-top: 0em; } -/* forms */ -table.form { +.create-bundle { + padding: 4px; + margin-right: 8px; + box-sizing: border-box; + border-radius: 4px; } span.help_text { font-size: 80%; } - table.form td { padding: 0.6em; vertical-align: top; diff --git a/patchwork/forms.py b/patchwork/forms.py index 3670142..a8d5fb2 100644 --- a/patchwork/forms.py +++ b/patchwork/forms.py @@ -53,7 +53,10 @@ class BundleForm(forms.ModelForm): field_mapping = {'name': 'bundle_name'} name = forms.RegexField( regex=r'^[^/]+$', min_length=1, max_length=50, required=False, - error_messages={'invalid': 'Bundle names can\'t contain slashes'}) + error_messages={'invalid': 'Bundle names can\'t contain slashes'}, + widget=forms.TextInput( + attrs={'class': 'create-bundle', + 'placeholder': 'Bundle name'})) # Changes the HTML 'name' attr of the input element from "name" # (inherited from the model field 'name' of the Bundle object) @@ -128,18 +131,28 @@ class PatchForm(forms.ModelForm): def __init__(self, instance=None, project=None, *args, **kwargs): super(PatchForm, self).__init__(instance=instance, *args, **kwargs) self.fields['delegate'] = forms.ModelChoiceField( - queryset=_get_delegate_qs(project, instance), required=False) + queryset=_get_delegate_qs(project, instance), + widget=forms.Select(attrs={'class': 'change-property-delegate'}), + required=False) class Meta: model = Patch fields = ['state', 'archived', 'delegate'] + widgets = { + 'state': forms.Select( + attrs={'class': 'change-property-state'}), + 'archived': forms.CheckboxInput( + attrs={'class': 'archive-patch-check'}), + } class OptionalModelChoiceField(forms.ModelChoiceField): - no_change_choice = ('*', 'no change') + no_change_choice = ('*', 'No change') to_field_name = None - def __init__(self, *args, **kwargs): + def __init__(self, *args, placeholder, className, **kwargs): + self.no_change_choice = ('*', placeholder) + self.widget = forms.Select(attrs={'class': className}) super(OptionalModelChoiceField, self).__init__( initial=self.no_change_choice[0], *args, **kwargs) @@ -168,6 +181,10 @@ class OptionalModelChoiceField(forms.ModelChoiceField): class OptionalBooleanField(forms.TypedChoiceField): + def __init__(self, className, *args, **kwargs): + self.widget = forms.Select(attrs={'class': className}) + super(OptionalBooleanField, self).__init__(*args, **kwargs) + def is_no_change(self, value): return value == self.empty_value @@ -175,17 +192,26 @@ class OptionalBooleanField(forms.TypedChoiceField): class MultiplePatchForm(forms.Form): action = 'update' archived = OptionalBooleanField( - choices=[('*', 'no change'), ('True', 'Archived'), - ('False', 'Unarchived')], + className="archive-patch-select", + choices=[('*', 'No change'), ('True', 'Archive'), + ('False', 'Unarchive')], coerce=lambda x: x == 'True', - empty_value='*') + empty_value='*', + label="Archived") def __init__(self, project, *args, **kwargs): super(MultiplePatchForm, self).__init__(*args, **kwargs) self.fields['delegate'] = OptionalModelChoiceField( - queryset=_get_delegate_qs(project=project), required=False) + queryset=_get_delegate_qs(project=project), + placeholder="Delegate to", + className="change-property-delegate", + label="Delegate to", + required=False) self.fields['state'] = OptionalModelChoiceField( - queryset=State.objects.all()) + queryset=State.objects.all(), + placeholder="Change state", + className="change-property-state", + label="Change state") def save(self, instance, commit=True): opts = instance.__class__._meta From patchwork Wed Jul 28 15:24:18 2021 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Raxel Gutierrez X-Patchwork-Id: 1510901 Return-Path: X-Original-To: incoming@patchwork.ozlabs.org Delivered-To: patchwork-incoming@bilbo.ozlabs.org Authentication-Results: ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=lists.ozlabs.org (client-ip=112.213.38.117; helo=lists.ozlabs.org; envelope-from=patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org; receiver=) Authentication-Results: ozlabs.org; dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=rb19I4Rr; dkim-atps=neutral Received: from lists.ozlabs.org (lists.ozlabs.org [112.213.38.117]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits)) (No client certificate requested) by ozlabs.org (Postfix) with ESMTPS id 4GZcsf70qxz9sT6 for ; Thu, 29 Jul 2021 01:24:54 +1000 (AEST) Received: from boromir.ozlabs.org (localhost [IPv6:::1]) by lists.ozlabs.org (Postfix) with ESMTP id 4GZcsf6DdCz3bWn for ; Thu, 29 Jul 2021 01:24:54 +1000 (AEST) Authentication-Results: lists.ozlabs.org; dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=rb19I4Rr; dkim-atps=neutral X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=flex--raxel.bounces.google.com (client-ip=2607:f8b0:4864:20::849; helo=mail-qt1-x849.google.com; envelope-from=3r3ybyqukcbahqnubweewbu.secfqjsxmehabyiji.epbqri.ehw@flex--raxel.bounces.google.com; receiver=) Authentication-Results: lists.ozlabs.org; dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20161025 header.b=rb19I4Rr; dkim-atps=neutral Received: from mail-qt1-x849.google.com (mail-qt1-x849.google.com [IPv6:2607:f8b0:4864:20::849]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 4GZcsH5bgtz30Cj for ; Thu, 29 Jul 2021 01:24:34 +1000 (AEST) Received: by mail-qt1-x849.google.com with SMTP id 15-20020ac84e8f0000b029024e8c2383c1so1202911qtp.5 for ; Wed, 28 Jul 2021 08:24:34 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20161025; h=date:in-reply-to:message-id:mime-version:references:subject:from:to :cc; bh=0tB1smgjkrV2M2ecmwtAd3ltDDMzLEzjs0VzB9nCOak=; b=rb19I4RrPqpRTkAwAD2mLuY6HrmXqpcSpTlaEaQZcQx/QSil8iAG20mMXzD5XqFrZ2 9hlU/8Lsidj871whRA/HjRxUlluarTSHHZj3BN0SKP/jnxn7qiIJHeyskezdm9UQ8ZIH nmFYiBx9UJme+q8OLVaiME6d3+vjk3qiNSlLDQDaOsjNGLPeucuB6Iivc9Mj8/7fF2Ph 8h9UQxmuilVNBnTzyqxPkOVUnFbF0yimR7VEIeNPOfoKl55mmNWe6Nn6KXfdWxodUenq jY+jY2gESP/CmdXVQ3CH0fAF00lxS0a8J72y9KlkezhhkWp+5FqHqiSQL4+EVBPUkYIQ W54w== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20161025; h=x-gm-message-state:date:in-reply-to:message-id:mime-version :references:subject:from:to:cc; bh=0tB1smgjkrV2M2ecmwtAd3ltDDMzLEzjs0VzB9nCOak=; b=rdRB6TeuqmDuNAfKElIOTUrki5RWNA0ZQUruKlKt5EWXBBRzgLOCez2a40RTrHee2b Wj6OTgkzqYqB4duaCrGsjIr8Mq4ZjEuEkioGgR4vis1OyflfTGPULVt7AJgZWeYcCltu mb1OLW4WqdN0g7EtycMhkSgsKA1I2dSbnNYbA56MjsM9qicUp40OK4Gh+kGAgWgk6OnE sn1QPOv42Nhlmr+lIs6EK6AqmF56TSrLLYVn30plDB3mh99sQBTpff2OVjs+O05hJJzP PLa79dWCXazBmAA5GBYF7RH/YJss92y92UdiWZjOkY4v/UOwzupaq183RZZKDTtx1NFh JCzg== X-Gm-Message-State: AOAM532eP0M9U/I2LOAi+z1qxQUhl490iwUxWZqBhGXk7Adro4a53E2F yS5sY1hXRMu2BdNEl5uHSzJLBqGhLX6/gKQT8jIBttkOO6nKsCI+ITgeYoB4SEvmQ4nm2zAkDo3 r78q1tDe6vjqpkIE0uQmVVrBqvWnlE0HbbwcrMKSJOTTHope5AOnBNyhMdB1IPZLh X-Google-Smtp-Source: ABdhPJyvF3ELrsxl4aK2F76Ozkf9DE/BNHbiMqQynHIX86T+1wIyG4CFFrQO/hFonaOC9W4JTwTn84uDYg== X-Received: from raxel-pw.c.googlers.com ([fda3:e722:ac3:cc00:14:4d90:c0a8:2fda]) (user=raxel job=sendgmr) by 2002:a0c:fd48:: with SMTP id j8mr499383qvs.60.1627485871717; Wed, 28 Jul 2021 08:24:31 -0700 (PDT) Date: Wed, 28 Jul 2021 15:24:18 +0000 In-Reply-To: <20210728152419.3588812-1-raxel@google.com> Message-Id: <20210728152419.3588812-5-raxel@google.com> Mime-Version: 1.0 References: <20210728152419.3588812-1-raxel@google.com> X-Mailer: git-send-email 2.32.0.554.ge1b32706d8-goog Subject: [PATCH v3 4/5] static: add rest.js to handle requests & respective messages From: Raxel Gutierrez To: patchwork@lists.ozlabs.org X-BeenThere: patchwork@lists.ozlabs.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: Patchwork development List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" Add js file to have REST API requests utilities JavaScript module to be reused by other Patchwork js files making updates to objects. - Add function to make fetch requests that update properties through 'PATCH' requests with the REST API endpoints. - Add functions that handle update & error messages for these 'PATCH' update requests following the Django messages framework format and form error styling. The subsequent patch will make use of these functions which will be also reused in future features the make use fetch requests to update object fields. Signed-off-by: Raxel Gutierrez --- htdocs/README.rst | 7 +++++ htdocs/js/rest.js | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 htdocs/js/rest.js diff --git a/htdocs/README.rst b/htdocs/README.rst index 404356a..2e76013 100644 --- a/htdocs/README.rst +++ b/htdocs/README.rst @@ -138,6 +138,13 @@ js Part of Patchwork. +``rest.js.`` + + Utility module for REST API requests to be used by other Patchwork js files + (fetch requests, handling update & error messages). + + Part of Patchwork. + ``selectize.min.js`` Selectize is the hybrid of a ``textbox`` and `` + {% if not patch.delegate.username %} + + {% else %} + + {% endif %} + {% for maintainer in maintainers %} + {% if maintainer.name == patch.delegate.username %} + + {% else %} + + {% endif %} + {% endfor %} + + {% else %} + {{ patch.delegate.username }} + {% endif %} + + + {% if user.is_authenticated %} + + {% else %} + {{ patch.state }} + {% endif %} + {% empty %} diff --git a/patchwork/views/__init__.py b/patchwork/views/__init__.py index 3ea2af4..c70c6be 100644 --- a/patchwork/views/__init__.py +++ b/patchwork/views/__init__.py @@ -16,6 +16,7 @@ from patchwork.models import Bundle from patchwork.models import BundlePatch from patchwork.models import Patch from patchwork.models import Project +from patchwork.models import State from patchwork.models import Check from patchwork.paginator import Paginator @@ -178,6 +179,8 @@ def generic_list(request, project, view, view_args=None, filter_settings=None, 'project': project, 'projects': Project.objects.all(), 'filters': filters, + 'maintainers': project.maintainer_project.all(), + 'states': State.objects.all(), } # pagination diff --git a/templates/base.html b/templates/base.html index 8700602..e57e2d5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -114,7 +114,7 @@ {% endfor %}
{% endif %} -
+
{% block body %} {% endblock %}