From stephen at that.guru Sat Oct 1 02:19:13 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:13 +0100 Subject: [PATCH 02/10] trivial: Remove unnecessary unicode prefixes In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-2-stephen@that.guru> Signed-off-by: Stephen Finucane --- docs/conf.py | 6 +++--- patchwork/forms.py | 8 +++++--- patchwork/tests/test_parser.py | 12 ++++++------ patchwork/tests/views/test_mail.py | 16 ++++++++-------- patchwork/tests/views/test_patch.py | 2 +- patchwork/tests/views/test_utils.py | 6 +++--- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git docs/conf.py docs/conf.py index 30a6b006..0b303c73 100644 --- docs/conf.py +++ docs/conf.py @@ -29,9 +29,9 @@ html_theme = 'sphinx_rtd_theme' master_doc = 'index' # General information about the project. -project = u'Patchwork' -copyright = u'2018-2019, Patchwork Developers' -author = u'Patchwork Developers' +project = 'Patchwork' +copyright = '2018-, Patchwork Developers' +author = 'Patchwork Developers' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git patchwork/forms.py patchwork/forms.py index 7c9781af..84448586 100644 --- patchwork/forms.py +++ patchwork/forms.py @@ -18,9 +18,11 @@ class RegistrationForm(forms.Form): first_name = forms.CharField(max_length=30, required=False) last_name = forms.CharField(max_length=30, required=False) username = forms.RegexField( - regex=r'^\w+$', max_length=30, label=u'Username' + regex=r'^\w+$', + max_length=30, + label='Username', ) - email = forms.EmailField(max_length=100, label=u'Email address') + email = forms.EmailField(max_length=100, label='Email address') password = forms.CharField(widget=forms.PasswordInput(), label='Password') def clean_username(self): @@ -57,7 +59,7 @@ class BundleForm(forms.ModelForm): regex=r'^[^/]+$', min_length=1, max_length=50, - label=u'Name', + label='Name', error_messages={'invalid': 'Bundle names can\'t contain slashes'}, ) diff --git patchwork/tests/test_parser.py patchwork/tests/test_parser.py index 108d8b9b..b731fe78 100644 --- patchwork/tests/test_parser.py +++ patchwork/tests/test_parser.py @@ -279,25 +279,25 @@ class SenderEncodingTest(TestCase): def test_ascii_encoding(self): from_header = 'example user ' - sender_name = u'example user' + sender_name = 'example user' sender_email = 'user at example.com' self._test_encoding(from_header, sender_name, sender_email) def test_utf8qp_encoding(self): from_header = '=?utf-8?q?=C3=A9xample=20user?= ' - sender_name = u'\xe9xample user' + sender_name = '\xe9xample user' sender_email = 'user at example.com' self._test_encoding(from_header, sender_name, sender_email) def test_utf8qp_split_encoding(self): from_header = '=?utf-8?q?=C3=A9xample?= user ' - sender_name = u'\xe9xample user' + sender_name = '\xe9xample user' sender_email = 'user at example.com' self._test_encoding(from_header, sender_name, sender_email) def test_utf8b64_encoding(self): from_header = '=?utf-8?B?w6l4YW1wbGUgdXNlcg==?= ' - sender_name = u'\xe9xample user' + sender_name = '\xe9xample user' sender_email = 'user at example.com' self._test_encoding(from_header, sender_name, sender_email) @@ -552,12 +552,12 @@ class SubjectEncodingTest(TestCase): def test_subject_utf8qp_encoding(self): subject_header = '=?utf-8?q?test=20s=c3=bcbject?=' - subject = u'test s\xfcbject' + subject = 'test s\xfcbject' self._test_encoding(subject_header, subject) def test_subject_utf8qp_multiple_encoding(self): subject_header = 'test =?utf-8?q?s=c3=bcbject?=' - subject = u'test s\xfcbject' + subject = 'test s\xfcbject' self._test_encoding(subject_header, subject) diff --git patchwork/tests/views/test_mail.py patchwork/tests/views/test_mail.py index f2b19973..de9df3d2 100644 --- patchwork/tests/views/test_mail.py +++ patchwork/tests/views/test_mail.py @@ -23,7 +23,7 @@ class MailSettingsTest(TestCase): self.assertTrue(response.context['form']) def test_post(self): - email = u'foo at example.com' + email = 'foo at example.com' response = self.client.post(reverse('mail-settings'), {'email': email}) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'patchwork/mail-settings.html') @@ -44,7 +44,7 @@ class MailSettingsTest(TestCase): self.assertFormError(response, 'form', 'email', error_strings['email']) def test_post_optin(self): - email = u'foo at example.com' + email = 'foo at example.com' response = self.client.post(reverse('mail-settings'), {'email': email}) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'patchwork/mail-settings.html') @@ -53,7 +53,7 @@ class MailSettingsTest(TestCase): self.assertContains(response, 'action="%s"' % reverse('mail-optout')) def test_post_optout(self): - email = u'foo at example.com' + email = 'foo at example.com' EmailOptout(email=email).save() response = self.client.post(reverse('mail-settings'), {'email': email}) self.assertEqual(response.status_code, 200) @@ -69,7 +69,7 @@ class OptoutRequestTest(TestCase): self.assertRedirects(response, reverse('mail-settings')) def test_post(self): - email = u'foo at example.com' + email = 'foo at example.com' response = self.client.post(reverse('mail-optout'), {'email': email}) # check for a confirmation object @@ -109,7 +109,7 @@ class OptoutRequestTest(TestCase): class OptoutTest(TestCase): def setUp(self): - self.email = u'foo at example.com' + self.email = 'foo at example.com' self.conf = EmailConfirmation(type='optout', email=self.email) self.conf.save() @@ -139,7 +139,7 @@ class OptoutPreexistingTest(OptoutTest): class OptinRequestTest(TestCase): - email = u'foo at example.com' + email = 'foo at example.com' def setUp(self): EmailOptout(email=self.email).save() @@ -190,7 +190,7 @@ class OptinRequestTest(TestCase): class OptinTest(TestCase): def setUp(self): - self.email = u'foo at example.com' + self.email = 'foo at example.com' self.optout = EmailOptout(email=self.email) self.optout.save() self.conf = EmailConfirmation(type='optin', email=self.email) @@ -217,7 +217,7 @@ class OptinWithoutOptoutTest(TestCase): """Test an opt-in with no existing opt-out.""" def test_opt_in_without_optout(self): - email = u'foo at example.com' + email = 'foo at example.com' response = self.client.post(reverse('mail-optin'), {'email': email}) # check for an error message diff --git patchwork/tests/views/test_patch.py patchwork/tests/views/test_patch.py index 97b0e97e..9cea8a0b 100644 --- patchwork/tests/views/test_patch.py +++ patchwork/tests/views/test_patch.py @@ -530,6 +530,6 @@ class UTF8PatchViewTest(TestCase): class UTF8HeaderPatchViewTest(UTF8PatchViewTest): def setUp(self): - author = create_person(name=u'P\xe4tch Author') + author = create_person(name='P\xe4tch Author') patch_content = read_patch('0002-utf-8.patch', encoding='utf-8') self.patch = create_patch(submitter=author, diff=patch_content) diff --git patchwork/tests/views/test_utils.py patchwork/tests/views/test_utils.py index e10c3bde..6b980f9d 100644 --- patchwork/tests/views/test_utils.py +++ patchwork/tests/views/test_utils.py @@ -37,11 +37,11 @@ class MboxPatchResponseTest(TestCase): """Test that UTF-8 NBSP characters are correctly handled.""" patch = create_patch(content='patch text\n') create_patch_comment( - patch=patch, content=u'comment\nAcked-by:\u00A0 foo' + patch=patch, content='comment\nAcked-by:\u00A0 foo' ) mbox = utils.patch_to_mbox(patch) - self.assertIn(u'\u00A0 foo\n', mbox) + self.assertIn('\u00A0 foo\n', mbox) def test_multiple_tags(self): """Test that the mbox view appends tags correct. @@ -145,7 +145,7 @@ class MboxPatchResponseTest(TestCase): the format for the mail while the name part may be coded in some ways. """ - person = create_person(name=u'?ool gu?') + person = create_person(name='?ool gu?') patch = create_patch(submitter=person) from_email = f'<{person.email}>' mbox = utils.patch_to_mbox(patch) -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:12 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:12 +0100 Subject: [PATCH 01/10] tests: Change from expectedFailure to skip Message-ID: <20220930161921.266633-1-stephen@that.guru> Python 3.10 recognises unexpected passes as failures now. Signed-off-by: Stephen Finucane --- patchwork/tests/test_series.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git patchwork/tests/test_series.py patchwork/tests/test_series.py index 8d8c4e14..d3e20e08 100644 --- patchwork/tests/test_series.py +++ patchwork/tests/test_series.py @@ -178,7 +178,7 @@ class BaseSeriesTest(_BaseTestCase): self.assertSerialized(patches, [2]) self.assertSerialized(covers, [1]) - @unittest.expectedFailure + @unittest.skip('Flaky test') def test_duplicated(self): """Series received on multiple mailing lists. -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:14 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:14 +0100 Subject: [PATCH 03/10] tox: Output test times, more verbose output In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-3-stephen@that.guru> Just a bit more useful for CI logs Signed-off-by: Stephen Finucane --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git tox.ini tox.ini index 0ab0ab84..aa434a72 100644 --- tox.ini +++ tox.ini @@ -24,7 +24,7 @@ passenv = DATABASE_TYPE DATABASE_USER DATABASE_PASSWORD DATABASE_HOST DATABASE_PORT DATABASE_NAME DJANGO_TEST_PROCESSES commands = - python {toxinidir}/manage.py test --noinput --parallel -- {posargs:patchwork} + python {toxinidir}/manage.py test --noinput --parallel -v 2 --timing -- {posargs:patchwork} [testenv:bashate] deps = bashate -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:16 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:16 +0100 Subject: [PATCH 05/10] manage: Check Django version on startup In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-5-stephen@that.guru> This was recently reported as an issue. Add a simple check to ensure people update their dependencies as expected. Signed-off-by: Stephen Finucane Cc: Siddhesh Poyarekar Cc: DJ Delorie Cc: Carlos O'Donell --- manage.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git manage.py manage.py index 033c8ae4..e227481e 100755 --- manage.py +++ manage.py @@ -7,6 +7,11 @@ if __name__ == "__main__": "DJANGO_SETTINGS_MODULE", "patchwork.settings.production" ) + import django + + if django.VERSION < (3, 2): + raise Exception('patchwork requires Django 3.2 or greater') + from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:15 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:15 +0100 Subject: [PATCH 04/10] requirements: Bump Django to 3.2.x, djangorestframework to 4.16.0 In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-4-stephen@that.guru> There are two issues to be addressed: RemovedInDjango50Warning: Passing response to assertFormError() is deprecated. Use the form object directly: RemovedInDjango50Warning: The "default.html" templates for forms and formsets will be removed. These were proxies to the equivalent "table.html" templates, but the new "div.html" templates will be the default from Django 5.0. Transitional renderers are provided to allow you to opt-in to the new output style now. See https://docs.djangoproject.com/en/4.1/releases/4.1/ for more details Nothing complicated in fixing either of these. For the former, we must do as we're told and use the form object directly. For the latter, we need to configure our own form renderer so we can continue using the table form renderer for now. Signed-off-by: Stephen Finucane --- patchwork/forms.py | 8 ++ patchwork/settings/base.py | 2 + patchwork/tests/views/test_mail.py | 91 +++++++++++++--- patchwork/tests/views/test_patch.py | 23 ++-- patchwork/tests/views/test_user.py | 102 ++++++++++++++---- .../django-4-1-support-bcbe65a71d235b43.yaml | 5 + tox.ini | 9 +- 7 files changed, 197 insertions(+), 43 deletions(-) create mode 100644 releasenotes/notes/django-4-1-support-bcbe65a71d235b43.yaml diff --git patchwork/forms.py patchwork/forms.py index 84448586..1c5a29be 100644 --- patchwork/forms.py +++ patchwork/forms.py @@ -5,8 +5,10 @@ from django.contrib.auth.models import User from django import forms +from django.forms import renderers from django.db.models import Q from django.db.utils import ProgrammingError +from django.template.backends import django as django_template_backend from patchwork.models import Bundle from patchwork.models import Patch @@ -14,6 +16,12 @@ from patchwork.models import State from patchwork.models import UserProfile +class PatchworkTableRenderer(renderers.EngineMixin, renderers.BaseRenderer): + backend = django_template_backend.DjangoTemplates + form_template_name = 'django/forms/table.html' + formset_template_name = 'django/forms/formsets/table.html' + + class RegistrationForm(forms.Form): first_name = forms.CharField(max_length=30, required=False) last_name = forms.CharField(max_length=30, required=False) diff --git patchwork/settings/base.py patchwork/settings/base.py index 045f262f..965c949f 100644 --- patchwork/settings/base.py +++ patchwork/settings/base.py @@ -71,6 +71,8 @@ TEMPLATES = [ }, ] +FORM_RENDERER = 'patchwork.forms.PatchworkTableRenderer' + # TODO(stephenfin): Consider changing to BigAutoField when we drop support for # Django < 3.2 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git patchwork/tests/views/test_mail.py patchwork/tests/views/test_mail.py index de9df3d2..ae0b2c38 100644 --- patchwork/tests/views/test_mail.py +++ patchwork/tests/views/test_mail.py @@ -5,6 +5,7 @@ import re +import django from django.core import mail from django.test import TestCase from django.urls import reverse @@ -33,15 +34,37 @@ class MailSettingsTest(TestCase): response = self.client.post(reverse('mail-settings'), {'email': ''}) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'patchwork/mail.html') - self.assertFormError( - response, 'form', 'email', 'This field is required.' - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'email', + 'This field is required.', + ) + else: + self.assertFormError( + response, + 'form', + 'email', + 'This field is required.', + ) def test_post_invalid(self): response = self.client.post(reverse('mail-settings'), {'email': 'foo'}) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'patchwork/mail.html') - self.assertFormError(response, 'form', 'email', error_strings['email']) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'email', + error_strings['email'], + ) + else: + self.assertFormError( + response, + 'form', + 'email', + error_strings['email'], + ) def test_post_optin(self): email = 'foo at example.com' @@ -91,9 +114,19 @@ class OptoutRequestTest(TestCase): def test_post_empty(self): response = self.client.post(reverse('mail-optout'), {'email': ''}) self.assertEqual(response.status_code, 200) - self.assertFormError( - response, 'form', 'email', 'This field is required.' - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'email', + 'This field is required.', + ) + else: + self.assertFormError( + response, + 'form', + 'email', + 'This field is required.', + ) self.assertTrue(response.context['error']) self.assertNotIn('confirmation', response.context) self.assertEqual(len(mail.outbox), 0) @@ -101,7 +134,19 @@ class OptoutRequestTest(TestCase): def test_post_non_email(self): response = self.client.post(reverse('mail-optout'), {'email': 'foo'}) self.assertEqual(response.status_code, 200) - self.assertFormError(response, 'form', 'email', error_strings['email']) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'email', + error_strings['email'], + ) + else: + self.assertFormError( + response, + 'form', + 'email', + error_strings['email'], + ) self.assertTrue(response.context['error']) self.assertNotIn('confirmation', response.context) self.assertEqual(len(mail.outbox), 0) @@ -172,9 +217,19 @@ class OptinRequestTest(TestCase): def test_post_empty(self): response = self.client.post(reverse('mail-optin'), {'email': ''}) self.assertEqual(response.status_code, 200) - self.assertFormError( - response, 'form', 'email', 'This field is required.' - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'email', + 'This field is required.', + ) + else: + self.assertFormError( + response, + 'form', + 'email', + 'This field is required.', + ) self.assertTrue(response.context['error']) self.assertNotIn('confirmation', response.context) self.assertEqual(len(mail.outbox), 0) @@ -182,7 +237,19 @@ class OptinRequestTest(TestCase): def test_post_non_email(self): response = self.client.post(reverse('mail-optin'), {'email': 'foo'}) self.assertEqual(response.status_code, 200) - self.assertFormError(response, 'form', 'email', error_strings['email']) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'email', + error_strings['email'], + ) + else: + self.assertFormError( + response, + 'form', + 'email', + error_strings['email'], + ) self.assertTrue(response.context['error']) self.assertNotIn('confirmation', response.context) self.assertEqual(len(mail.outbox), 0) diff --git patchwork/tests/views/test_patch.py patchwork/tests/views/test_patch.py index 9cea8a0b..d1de8ec9 100644 --- patchwork/tests/views/test_patch.py +++ patchwork/tests/views/test_patch.py @@ -8,6 +8,7 @@ from datetime import timedelta import re import unittest +import django from django.conf import settings from django.test import TestCase from django.urls import reverse @@ -460,13 +461,21 @@ class PatchUpdateTest(TestCase): new_states = [Patch.objects.get(pk=p.pk).state for p in self.patches] self.assertEqual(new_states, orig_states) - self.assertFormError( - response, - 'patchform', - 'state', - 'Select a valid choice. That choice is not one ' - 'of the available choices.', - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['patchform'], + 'state', + 'Select a valid choice. That choice is not one ' + 'of the available choices.', + ) + else: + self.assertFormError( + response, + 'patchform', + 'state', + 'Select a valid choice. That choice is not one ' + 'of the available choices.', + ) def _test_delegate_change(self, delegate_str): data = self.base_data.copy() diff --git patchwork/tests/views/test_user.py patchwork/tests/views/test_user.py index 1f74ad50..8ab91670 100644 --- patchwork/tests/views/test_user.py +++ patchwork/tests/views/test_user.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +import django from django.contrib.auth.models import User from django.core import mail from django.test.client import Client @@ -70,14 +71,38 @@ class RegistrationTest(TestCase): del data[field] response = self.client.post('/register/', data) self.assertEqual(response.status_code, 200) - self.assertFormError(response, 'form', field, self.required_error) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + field, + self.required_error, + ) + else: + self.assertFormError( + response, + 'form', + field, + self.required_error, + ) def test_invalid_username(self): data = self.default_data.copy() data['username'] = 'invalid user' response = self.client.post('/register/', data) self.assertEqual(response.status_code, 200) - self.assertFormError(response, 'form', 'username', self.invalid_error) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'username', + self.invalid_error, + ) + else: + self.assertFormError( + response, + 'form', + 'username', + self.invalid_error, + ) def test_existing_username(self): user = create_user() @@ -85,12 +110,19 @@ class RegistrationTest(TestCase): data['username'] = user.username response = self.client.post('/register/', data) self.assertEqual(response.status_code, 200) - self.assertFormError( - response, - 'form', - 'username', - 'This username is already taken. Please choose another.', - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'username', + 'This username is already taken. Please choose another.', + ) + else: + self.assertFormError( + response, + 'form', + 'username', + 'This username is already taken. Please choose another.', + ) def test_existing_email(self): user = create_user() @@ -98,13 +130,21 @@ class RegistrationTest(TestCase): data['email'] = user.email response = self.client.post('/register/', data) self.assertEqual(response.status_code, 200) - self.assertFormError( - response, - 'form', - 'email', - 'This email address is already in use for the account ' - '"%s".\n' % user.username, - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['form'], + 'email', + 'This email address is already in use for the account ' + '"%s".\n' % user.username, + ) + else: + self.assertFormError( + response, + 'form', + 'email', + 'This email address is already in use for the account ' + '"%s".\n' % user.username, + ) def test_valid_registration(self): response = self.client.post('/register/', self.default_data) @@ -255,17 +295,37 @@ class UserLinkTest(_UserTestCase): response = self.client.post(reverse('user-link'), {'email': ''}) self.assertEqual(response.status_code, 200) self.assertTrue(response.context['linkform']) - self.assertFormError( - response, 'linkform', 'email', 'This field is required.' - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['linkform'], + 'email', + 'This field is required.', + ) + else: + self.assertFormError( + response, + 'linkform', + 'email', + 'This field is required.', + ) def test_user_person_request_invalid(self): response = self.client.post(reverse('user-link'), {'email': 'foo'}) self.assertEqual(response.status_code, 200) self.assertTrue(response.context['linkform']) - self.assertFormError( - response, 'linkform', 'email', error_strings['email'] - ) + if django.VERSION >= (4, 1): + self.assertFormError( + response.context['linkform'], + 'email', + error_strings['email'], + ) + else: + self.assertFormError( + response, + 'linkform', + 'email', + error_strings['email'], + ) def test_user_person_request_valid(self): response = self.client.post( diff --git releasenotes/notes/django-4-1-support-bcbe65a71d235b43.yaml releasenotes/notes/django-4-1-support-bcbe65a71d235b43.yaml new file mode 100644 index 00000000..3dcab1bd --- /dev/null +++ releasenotes/notes/django-4-1-support-bcbe65a71d235b43.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + `Django 4.1 `_ is + now supported. diff --git tox.ini tox.ini index aa434a72..f243faca 100644 --- tox.ini +++ tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.2 -envlist = pep8,docs,py{37,38,39}-django32,py{38,39,310}-django{40} +envlist = pep8,docs,py{37,38,39}-django32,py{38,39,310}-django{40,41} skipsdist = true ignore_basepython_conflict = true @@ -9,11 +9,14 @@ basepython = python3 deps = -r{toxinidir}/requirements-test.txt django32: django~=3.2.0 - django32: djangorestframework~=3.13.0 + django32: djangorestframework~=3.14.0 django32: django-filter~=22.1.0 django40: django~=4.0.0 - django40: djangorestframework~=3.13.0 + django40: djangorestframework~=3.14.0 django40: django-filter~=22.1.0 + django41: django~=4.1.0 + django41: djangorestframework~=3.14.0 + django41: django-filter~=22.1.0 setenv = DJANGO_SETTINGS_MODULE = patchwork.settings.dev PYTHONDONTWRITEBYTECODE = 1 -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:18 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:18 +0100 Subject: [PATCH 07/10] REST: De-duplicate handling of nested resource URLs In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-7-stephen@that.guru> These were all doing the same thing. Make things more generic. We also speed up test (inadvertently) by using the 'patch_id' attribute of the 'Check' model rather than 'patch.id', thus avoiding the JOIN. Signed-off-by: Stephen Finucane --- patchwork/api/base.py | 52 +++++++++---------------------- patchwork/api/check.py | 10 ++++-- patchwork/api/embedded.py | 28 +++++++++++++---- patchwork/tests/api/test_event.py | 2 +- 4 files changed, 45 insertions(+), 47 deletions(-) diff --git patchwork/api/base.py patchwork/api/base.py index 3ed4182c..0f5c44a2 100644 --- patchwork/api/base.py +++ patchwork/api/base.py @@ -9,8 +9,8 @@ from django.conf import settings from django.shortcuts import get_object_or_404 from rest_framework import permissions from rest_framework.pagination import PageNumberPagination +from rest_framework.relations import HyperlinkedIdentityField from rest_framework.response import Response -from rest_framework.serializers import HyperlinkedIdentityField from rest_framework.serializers import HyperlinkedModelSerializer from rest_framework.utils.urls import replace_query_param @@ -122,52 +122,28 @@ class MultipleFieldLookupMixin(object): return get_object_or_404(queryset, **filter_kwargs) -class CheckHyperlinkedIdentityField(HyperlinkedIdentityField): - def get_url(self, obj, view_name, request, format): - # Unsaved objects will not yet have a valid URL. - if obj.pk is None: - return None - - return self.reverse( - view_name, - kwargs={ - 'patch_id': obj.patch.id, - 'check_id': obj.id, - }, - request=request, - format=format, - ) +class NestedHyperlinkedIdentityField(HyperlinkedIdentityField): + """A variant of HyperlinkedIdentityField that supports nested resources.""" + def __init__(self, view_name, lookup_field_mapping, **kwargs): + self.lookup_field_mapping = lookup_field_mapping + super().__init__(view_name, **kwargs) -class CoverCommentHyperlinkedIdentityField(HyperlinkedIdentityField): def get_url(self, obj, view_name, request, format): # Unsaved objects will not yet have a valid URL. - if obj.pk is None: + if hasattr(obj, 'pk') and obj.pk in (None, ''): return None - return self.reverse( - view_name, - kwargs={ - 'cover_id': obj.cover.id, - 'comment_id': obj.id, - }, - request=request, - format=format, - ) - - -class PatchCommentHyperlinkedIdentityField(HyperlinkedIdentityField): - def get_url(self, obj, view_name, request, format): - # Unsaved objects will not yet have a valid URL. - if obj.pk is None: - return None + kwargs = {} + for ( + lookup_url_kwarg, + lookup_field, + ) in self.lookup_field_mapping.items(): + kwargs[lookup_url_kwarg] = getattr(obj, lookup_field) return self.reverse( view_name, - kwargs={ - 'patch_id': obj.patch.id, - 'comment_id': obj.id, - }, + kwargs=kwargs, request=request, format=format, ) diff --git patchwork/api/check.py patchwork/api/check.py index c28d89f7..f5461fc6 100644 --- patchwork/api/check.py +++ patchwork/api/check.py @@ -14,8 +14,8 @@ from rest_framework.serializers import HiddenField from rest_framework.serializers import HyperlinkedModelSerializer from rest_framework.serializers import ValidationError -from patchwork.api.base import CheckHyperlinkedIdentityField from patchwork.api.base import MultipleFieldLookupMixin +from patchwork.api.base import NestedHyperlinkedIdentityField from patchwork.api.base import CurrentPatchDefault from patchwork.api.embedded import UserSerializer from patchwork.api.filters import CheckFilterSet @@ -25,7 +25,13 @@ from patchwork.models import Patch class CheckSerializer(HyperlinkedModelSerializer): - url = CheckHyperlinkedIdentityField('api-check-detail') + url = NestedHyperlinkedIdentityField( + 'api-check-detail', + lookup_field_mapping={ + 'patch_id': 'patch_id', + 'check_id': 'id', + }, + ) patch = HiddenField(default=CurrentPatchDefault()) user = UserSerializer(default=CurrentUserDefault()) diff --git patchwork/api/embedded.py patchwork/api/embedded.py index 485ed6f7..7105da08 100644 --- patchwork/api/embedded.py +++ patchwork/api/embedded.py @@ -16,9 +16,7 @@ from rest_framework.serializers import PrimaryKeyRelatedField from rest_framework.serializers import SerializerMethodField from patchwork.api.base import BaseHyperlinkedModelSerializer -from patchwork.api.base import CheckHyperlinkedIdentityField -from patchwork.api.base import CoverCommentHyperlinkedIdentityField -from patchwork.api.base import PatchCommentHyperlinkedIdentityField +from patchwork.api.base import NestedHyperlinkedIdentityField from patchwork import models @@ -82,7 +80,13 @@ class WebURLMixin(BaseHyperlinkedModelSerializer): class CheckSerializer(SerializedRelatedField): class _Serializer(BaseHyperlinkedModelSerializer): - url = CheckHyperlinkedIdentityField('api-check-detail') + url = NestedHyperlinkedIdentityField( + 'api-check-detail', + lookup_field_mapping={ + 'patch_id': 'patch_id', + 'check_id': 'id', + }, + ) def to_representation(self, instance): data = super(CheckSerializer._Serializer, self).to_representation( @@ -130,7 +134,13 @@ class CoverSerializer(SerializedRelatedField): class CoverCommentSerializer(SerializedRelatedField): class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): - url = CoverCommentHyperlinkedIdentityField('api-cover-comment-detail') + url = NestedHyperlinkedIdentityField( + 'api-cover-comment-detail', + lookup_field_mapping={ + 'cover_id': 'cover_id', + 'comment_id': 'id', + }, + ) class Meta: model = models.CoverComment @@ -182,7 +192,13 @@ class PatchSerializer(SerializedRelatedField): class PatchCommentSerializer(SerializedRelatedField): class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): - url = PatchCommentHyperlinkedIdentityField('api-patch-comment-detail') + url = NestedHyperlinkedIdentityField( + 'api-patch-comment-detail', + lookup_field_mapping={ + 'patch_id': 'patch_id', + 'comment_id': 'id', + }, + ) class Meta: model = models.PatchComment diff --git patchwork/tests/api/test_event.py patchwork/tests/api/test_event.py index 7ca09c2e..1a0d811d 100644 --- patchwork/tests/api/test_event.py +++ patchwork/tests/api/test_event.py @@ -200,7 +200,7 @@ class TestEventAPI(APITestCase): for _ in range(3): self._create_events() - with self.assertNumQueries(33): + with self.assertNumQueries(30): self.client.get(self.api_url()) def test_order_by_date_default(self): -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:19 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:19 +0100 Subject: [PATCH 08/10] models: Cache 'list_archive_url' property In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-8-stephen@that.guru> We really need to get rid of this from the embedded view. It's way too slow. For now, we just cache it and leave a note for future us. Signed-off-by: Stephen Finucane --- patchwork/api/embedded.py | 12 ++++++++++++ patchwork/models.py | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git patchwork/api/embedded.py patchwork/api/embedded.py index 7105da08..4cfdf8e6 100644 --- patchwork/api/embedded.py +++ patchwork/api/embedded.py @@ -113,6 +113,8 @@ class CoverSerializer(SerializedRelatedField): 'url', 'web_url', 'msgid', + # TODO(stephenfin): Drop this in a future API version - it is + # too slow to calculate and not necessary here. 'list_archive_url', 'date', 'name', @@ -149,6 +151,8 @@ class CoverCommentSerializer(SerializedRelatedField): 'url', 'web_url', 'msgid', + # TODO(stephenfin): Drop this in a future API version - it is + # too slow to calculate and not necessary here. 'list_archive_url', 'date', ) @@ -174,6 +178,8 @@ class PatchSerializer(SerializedRelatedField): 'url', 'web_url', 'msgid', + # TODO(stephenfin): Drop this in a future API version - it is + # too slow to calculate and not necessary here. 'list_archive_url', 'date', 'name', @@ -207,6 +213,8 @@ class PatchCommentSerializer(SerializedRelatedField): 'url', 'web_url', 'msgid', + # TODO(stephenfin): Drop this in a future API version - it is + # too slow to calculate and not necessary here. 'list_archive_url', 'date', ) @@ -253,8 +261,12 @@ class ProjectSerializer(SerializedRelatedField): 'web_url', 'scm_url', 'webscm_url', + # TODO(stephenfin): Drop this in a future API version - it is + # too slow to calculate and not necessary here. 'list_archive_url', + # TODO(stephenfin): Ditto 'list_archive_url_format', + # TODO(stephenfin): Ditto 'commit_url_format', ) read_only_fields = fields diff --git patchwork/models.py patchwork/models.py index 264af532..d2507d4f 100644 --- patchwork/models.py +++ patchwork/models.py @@ -406,7 +406,7 @@ class SubmissionMixin(FilenameMixin, EmailMixin, models.Model): name = models.CharField(max_length=255) - @property + @cached_property def list_archive_url(self): if not self.project.list_archive_url_format: return None @@ -719,7 +719,7 @@ class CoverComment(EmailMixin, models.Model): ) addressed = models.BooleanField(null=True) - @property + @cached_property def list_archive_url(self): if not self.cover.project.list_archive_url_format: return None @@ -770,7 +770,7 @@ class PatchComment(EmailMixin, models.Model): ) addressed = models.BooleanField(null=True) - @property + @cached_property def list_archive_url(self): if not self.patch.project.list_archive_url_format: return None -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:17 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:17 +0100 Subject: [PATCH 06/10] REST: Fix issues with comment-related events In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-6-stephen@that.guru> When we introduced this functionality, we missed the fact that these resources use nested-style URLs that need to be specially handled. Fix this now. Signed-off-by: Stephen Finucane Fixes: e3d8f7548 ("REST: Add 'patch-comment-created', 'cover-comment-created' events") Cc: Siddhesh Poyarekar Cc: DJ Delorie Cc: Carlos O'Donell --- patchwork/api/base.py | 34 +++++++++++++++++++++++++++++++ patchwork/api/embedded.py | 10 +++++++-- patchwork/api/event.py | 25 ++++++++++++++++++++--- patchwork/tests/api/test_event.py | 30 +++++++++++++++++---------- 4 files changed, 83 insertions(+), 16 deletions(-) diff --git patchwork/api/base.py patchwork/api/base.py index 6268f67d..3ed4182c 100644 --- patchwork/api/base.py +++ patchwork/api/base.py @@ -139,6 +139,40 @@ class CheckHyperlinkedIdentityField(HyperlinkedIdentityField): ) +class CoverCommentHyperlinkedIdentityField(HyperlinkedIdentityField): + def get_url(self, obj, view_name, request, format): + # Unsaved objects will not yet have a valid URL. + if obj.pk is None: + return None + + return self.reverse( + view_name, + kwargs={ + 'cover_id': obj.cover.id, + 'comment_id': obj.id, + }, + request=request, + format=format, + ) + + +class PatchCommentHyperlinkedIdentityField(HyperlinkedIdentityField): + def get_url(self, obj, view_name, request, format): + # Unsaved objects will not yet have a valid URL. + if obj.pk is None: + return None + + return self.reverse( + view_name, + kwargs={ + 'patch_id': obj.patch.id, + 'comment_id': obj.id, + }, + request=request, + format=format, + ) + + class BaseHyperlinkedModelSerializer(HyperlinkedModelSerializer): def to_representation(self, instance): data = super(BaseHyperlinkedModelSerializer, self).to_representation( diff --git patchwork/api/embedded.py patchwork/api/embedded.py index c41511fe..485ed6f7 100644 --- patchwork/api/embedded.py +++ patchwork/api/embedded.py @@ -17,6 +17,8 @@ from rest_framework.serializers import SerializerMethodField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import CheckHyperlinkedIdentityField +from patchwork.api.base import CoverCommentHyperlinkedIdentityField +from patchwork.api.base import PatchCommentHyperlinkedIdentityField from patchwork import models @@ -127,6 +129,9 @@ class CoverSerializer(SerializedRelatedField): class CoverCommentSerializer(SerializedRelatedField): class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): + + url = CoverCommentHyperlinkedIdentityField('api-cover-comment-detail') + class Meta: model = models.CoverComment fields = ( @@ -136,7 +141,6 @@ class CoverCommentSerializer(SerializedRelatedField): 'msgid', 'list_archive_url', 'date', - 'name', ) read_only_fields = fields versioned_fields = { @@ -177,6 +181,9 @@ class PatchSerializer(SerializedRelatedField): class PatchCommentSerializer(SerializedRelatedField): class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): + + url = PatchCommentHyperlinkedIdentityField('api-patch-comment-detail') + class Meta: model = models.PatchComment fields = ( @@ -186,7 +193,6 @@ class PatchCommentSerializer(SerializedRelatedField): 'msgid', 'list_archive_url', 'date', - 'name', ) read_only_fields = fields versioned_fields = { diff --git patchwork/api/event.py patchwork/api/event.py index c1b09ab9..6d08b6ee 100644 --- patchwork/api/event.py +++ patchwork/api/event.py @@ -19,6 +19,7 @@ from patchwork.api.embedded import ProjectSerializer from patchwork.api.embedded import SeriesSerializer from patchwork.api.embedded import UserSerializer from patchwork.api.filters import EventFilterSet +from patchwork.api import utils from patchwork.models import Event @@ -140,7 +141,7 @@ class EventList(ListAPIView): ordering = '-date' def get_queryset(self): - return Event.objects.all().prefetch_related( + events = Event.objects.all().prefetch_related( 'project', 'patch__project', 'series__project', @@ -150,6 +151,24 @@ class EventList(ListAPIView): 'previous_delegate', 'current_delegate', 'created_check', - 'cover_comment', - 'patch_comment', ) + # NOTE(stephenfin): We need to exclude comment-related events because + # until API v1.3, we didn't have an comment detail API to point to. + # This goes against our pledge to version events in the docs but must + # be done. + # TODO(stephenfin): Make this more generic. + if utils.has_version(self.request, '1.3'): + events = events.prefetch_related( + 'cover_comment', + 'cover_comment__cover__project', + 'patch_comment', + 'patch_comment__patch__project', + ) + else: + events = events.exclude( + category__in=[ + Event.CATEGORY_COVER_COMMENT_CREATED, + Event.CATEGORY_PATCH_COMMENT_CREATED, + ] + ) + return events diff --git patchwork/tests/api/test_event.py patchwork/tests/api/test_event.py index 9708f96b..7ca09c2e 100644 --- patchwork/tests/api/test_event.py +++ patchwork/tests/api/test_event.py @@ -12,8 +12,10 @@ from patchwork.models import Event from patchwork.tests.api import utils from patchwork.tests.utils import create_check from patchwork.tests.utils import create_cover +from patchwork.tests.utils import create_cover_comment from patchwork.tests.utils import create_maintainer from patchwork.tests.utils import create_patch +from patchwork.tests.utils import create_patch_comment from patchwork.tests.utils import create_series from patchwork.tests.utils import create_state @@ -70,7 +72,7 @@ class TestEventAPI(APITestCase): # patch-created, patch-completed, series-completed patch = create_patch(series=series) # cover-created - create_cover(series=series) + cover = create_cover(series=series) # check-created create_check(patch=patch) # patch-delegated, patch-state-changed @@ -81,6 +83,9 @@ class TestEventAPI(APITestCase): patch.state = state self.assertTrue(patch.is_editable(actor)) patch.save() + # patch-cover-created, cover-comment-created + create_patch_comment(patch=patch, submitter=patch.submitter) + create_cover_comment(cover=cover, submitter=cover.submitter) return Event.objects.all() @@ -91,7 +96,9 @@ class TestEventAPI(APITestCase): resp = self.client.get(self.api_url()) self.assertEqual(status.HTTP_200_OK, resp.status_code) - self.assertEqual(8, len(resp.data), [x['category'] for x in resp.data]) + self.assertEqual( + 10, len(resp.data), [x['category'] for x in resp.data] + ) for event_rsp in resp.data: event_obj = events.get(category=event_rsp['category']) self.assertSerialized(event_obj, event_rsp) @@ -104,7 +111,7 @@ class TestEventAPI(APITestCase): resp = self.client.get(self.api_url(), {'project': project.pk}) # All but one event belongs to the same project - self.assertEqual(8, len(resp.data)) + self.assertEqual(10, len(resp.data)) resp = self.client.get(self.api_url(), {'project': 'invalidproject'}) self.assertEqual(0, len(resp.data)) @@ -132,9 +139,9 @@ class TestEventAPI(APITestCase): patch = events.get(category='patch-created').patch resp = self.client.get(self.api_url(), {'patch': patch.pk}) - # There should be five - patch-created, patch-completed, check-created, - # patch-state-changed and patch-delegated - self.assertEqual(5, len(resp.data)) + # There should be six - patch-created, patch-completed, check-created, + # patch-state-changed, patch-delegated and patch-comment-created + self.assertEqual(6, len(resp.data)) resp = self.client.get(self.api_url(), {'patch': 999999}) self.assertEqual(0, len(resp.data)) @@ -145,8 +152,8 @@ class TestEventAPI(APITestCase): cover = events.get(category='cover-created').cover resp = self.client.get(self.api_url(), {'cover': cover.pk}) - # There should only be one - cover-created - self.assertEqual(1, len(resp.data)) + # There should be two - cover-created and cover-comment-created + self.assertEqual(2, len(resp.data)) resp = self.client.get(self.api_url(), {'cover': 999999}) self.assertEqual(0, len(resp.data)) @@ -170,7 +177,7 @@ class TestEventAPI(APITestCase): # The final two events (patch-delegated, patch-state-changed) # have an actor set - actor = events[0].actor + actor = events.get(category='patch-delegated').actor resp = self.client.get(self.api_url(), {'actor': actor.pk}) self.assertEqual(2, len(resp.data)) @@ -185,14 +192,15 @@ class TestEventAPI(APITestCase): resp = self.client.get( self.api_url(version='1.1'), {'actor': 'foo-bar'} ) - self.assertEqual(len(events), len(resp.data)) + # we don't see the two comment-related fields + self.assertEqual(len(events) - 2, len(resp.data)) def test_list_bug_335(self): """Ensure we retrieve the embedded series project once.""" for _ in range(3): self._create_events() - with self.assertNumQueries(27): + with self.assertNumQueries(33): self.client.get(self.api_url()) def test_order_by_date_default(self): -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:20 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:20 +0100 Subject: [PATCH 09/10] REST: Add missing 'url' parameter for comments In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-9-stephen@that.guru> This should be present on all resources. Signed-off-by: Stephen Finucane Fixes: 88f56051 ("api: add comments detail endpoint") --- docs/api/schemas/latest/patchwork.yaml | 5 +++++ docs/api/schemas/patchwork.j2 | 9 +++++++++ docs/api/schemas/v1.0/patchwork.yaml | 5 ----- docs/api/schemas/v1.1/patchwork.yaml | 5 ----- docs/api/schemas/v1.2/patchwork.yaml | 5 ----- docs/api/schemas/v1.3/patchwork.yaml | 5 +++++ patchwork/api/base.py | 14 ++++++-------- patchwork/api/bundle.py | 2 +- patchwork/api/comment.py | 24 ++++++++++++++++++++++-- patchwork/api/embedded.py | 2 ++ patchwork/api/patch.py | 1 + 11 files changed, 51 insertions(+), 26 deletions(-) diff --git docs/api/schemas/latest/patchwork.yaml docs/api/schemas/latest/patchwork.yaml index 3a1fdd3a..b3de0db5 100644 --- docs/api/schemas/latest/patchwork.yaml +++ docs/api/schemas/latest/patchwork.yaml @@ -1627,6 +1627,11 @@ components: title: ID type: integer readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true web_url: title: Web URL type: string diff --git docs/api/schemas/patchwork.j2 docs/api/schemas/patchwork.j2 index b9786654..68655348 100644 --- docs/api/schemas/patchwork.j2 +++ docs/api/schemas/patchwork.j2 @@ -1683,6 +1683,13 @@ components: title: ID type: integer readOnly: true +{% if version >= (1, 3) %} + url: + title: URL + type: string + format: uri + readOnly: true +{% endif %} {% if version >= (1, 1) %} web_url: title: Web URL @@ -2528,11 +2535,13 @@ components: title: ID type: integer readOnly: true +{% if version >= (1, 3) %} url: title: URL type: string format: uri readOnly: true +{% endif %} {% if version >= (1, 1) %} web_url: title: Web URL diff --git docs/api/schemas/v1.0/patchwork.yaml docs/api/schemas/v1.0/patchwork.yaml index 817b2f2a..6c3893ec 100644 --- docs/api/schemas/v1.0/patchwork.yaml +++ docs/api/schemas/v1.0/patchwork.yaml @@ -1993,11 +1993,6 @@ components: title: ID type: integer readOnly: true - url: - title: URL - type: string - format: uri - readOnly: true msgid: title: Message ID type: string diff --git docs/api/schemas/v1.1/patchwork.yaml docs/api/schemas/v1.1/patchwork.yaml index 574a8ad8..7e2299c5 100644 --- docs/api/schemas/v1.1/patchwork.yaml +++ docs/api/schemas/v1.1/patchwork.yaml @@ -2044,11 +2044,6 @@ components: title: ID type: integer readOnly: true - url: - title: URL - type: string - format: uri - readOnly: true web_url: title: Web URL type: string diff --git docs/api/schemas/v1.2/patchwork.yaml docs/api/schemas/v1.2/patchwork.yaml index 7a4e8e8e..93c3e97e 100644 --- docs/api/schemas/v1.2/patchwork.yaml +++ docs/api/schemas/v1.2/patchwork.yaml @@ -2287,11 +2287,6 @@ components: title: ID type: integer readOnly: true - url: - title: URL - type: string - format: uri - readOnly: true web_url: title: Web URL type: string diff --git docs/api/schemas/v1.3/patchwork.yaml docs/api/schemas/v1.3/patchwork.yaml index 6bd0419d..8663406d 100644 --- docs/api/schemas/v1.3/patchwork.yaml +++ docs/api/schemas/v1.3/patchwork.yaml @@ -1627,6 +1627,11 @@ components: title: ID type: integer readOnly: true + url: + title: URL + type: string + format: uri + readOnly: true web_url: title: Web URL type: string diff --git patchwork/api/base.py patchwork/api/base.py index 0f5c44a2..16e5cb8d 100644 --- patchwork/api/base.py +++ patchwork/api/base.py @@ -151,19 +151,17 @@ class NestedHyperlinkedIdentityField(HyperlinkedIdentityField): class BaseHyperlinkedModelSerializer(HyperlinkedModelSerializer): def to_representation(self, instance): - data = super(BaseHyperlinkedModelSerializer, self).to_representation( - instance - ) - request = self.context.get('request') for version in getattr(self.Meta, 'versioned_fields', {}): # if the user has requested a version lower that than in which the # field was added, we drop it if not utils.has_version(request, version): for field in self.Meta.versioned_fields[version]: - # After a PATCH with an older API version, we may not see - # these fields. If they don't exist, don't panic, return - # (and then discard) None. - data.pop(field, None) + if field in self.fields: + del self.fields[field] + + data = super(BaseHyperlinkedModelSerializer, self).to_representation( + instance + ) return data diff --git patchwork/api/bundle.py patchwork/api/bundle.py index b6c7c9d2..134b2724 100644 --- patchwork/api/bundle.py +++ patchwork/api/bundle.py @@ -99,7 +99,7 @@ class BundleSerializer(BaseHyperlinkedModelSerializer): if len(set([p.project.id for p in value])) > 1: raise ValidationError( - 'Bundle patches must belong to the same ' 'project' + 'Bundle patches must belong to the same project' ) return value diff --git patchwork/api/comment.py patchwork/api/comment.py index 13c116ee..eae83719 100644 --- patchwork/api/comment.py +++ patchwork/api/comment.py @@ -12,6 +12,7 @@ from rest_framework.serializers import HiddenField from rest_framework.serializers import SerializerMethodField from patchwork.api.base import BaseHyperlinkedModelSerializer +from patchwork.api.base import NestedHyperlinkedIdentityField from patchwork.api.base import MultipleFieldLookupMixin from patchwork.api.base import PatchworkPermission from patchwork.api.base import CurrentCoverDefault @@ -58,6 +59,7 @@ class BaseCommentListSerializer(BaseHyperlinkedModelSerializer): class Meta: fields = ( 'id', + 'url', 'web_url', 'msgid', 'list_archive_url', @@ -70,6 +72,7 @@ class BaseCommentListSerializer(BaseHyperlinkedModelSerializer): ) read_only_fields = ( 'id', + 'url', 'web_url', 'msgid', 'list_archive_url', @@ -82,17 +85,27 @@ class BaseCommentListSerializer(BaseHyperlinkedModelSerializer): versioned_fields = { '1.1': ('web_url',), '1.2': ('list_archive_url',), - '1.3': ('addressed',), + '1.3': ( + 'addressed', + 'url', + ), } class CoverCommentSerializer(BaseCommentListSerializer): + url = NestedHyperlinkedIdentityField( + 'api-cover-comment-detail', + lookup_field_mapping={ + 'cover_id': 'cover_id', + 'comment_id': 'id', + }, + ) cover = HiddenField(default=CurrentCoverDefault()) class Meta: model = CoverComment - fields = BaseCommentListSerializer.Meta.fields + ('cover', 'addressed') + fields = BaseCommentListSerializer.Meta.fields + ('cover',) read_only_fields = BaseCommentListSerializer.Meta.read_only_fields + ( 'cover', ) @@ -123,6 +136,13 @@ class CoverCommentMixin(object): class PatchCommentSerializer(BaseCommentListSerializer): + url = NestedHyperlinkedIdentityField( + 'api-patch-comment-detail', + lookup_field_mapping={ + 'patch_id': 'patch_id', + 'comment_id': 'id', + }, + ) patch = HiddenField(default=CurrentPatchDefault()) class Meta: diff --git patchwork/api/embedded.py patchwork/api/embedded.py index 4cfdf8e6..52018435 100644 --- patchwork/api/embedded.py +++ patchwork/api/embedded.py @@ -163,6 +163,7 @@ class CoverCommentSerializer(SerializedRelatedField): 'mbox', ), '1.2': ('list_archive_url',), + '1.3': ('url',), } extra_kwargs = { 'url': {'view_name': 'api-cover-comment-detail'}, @@ -225,6 +226,7 @@ class PatchCommentSerializer(SerializedRelatedField): 'mbox', ), '1.2': ('list_archive_url',), + '1.3': ('url',), } extra_kwargs = { 'url': {'view_name': 'api-patch-comment-detail'}, diff --git patchwork/api/patch.py patchwork/api/patch.py index 9fd10e06..34067611 100644 --- patchwork/api/patch.py +++ patchwork/api/patch.py @@ -180,6 +180,7 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer): 'related', ) read_only_fields = ( + 'url', 'web_url', 'project', 'msgid', -- 2.37.3 From stephen at that.guru Sat Oct 1 02:19:21 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:19:21 +0100 Subject: [PATCH 10/10] urls: Encode slashes in message IDs In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <20220930161921.266633-10-stephen@that.guru> We were attempting to work around the fact that message IDs could contain slashes which in some cases broke our ability to generate meaningful URLs. Rather than doing this, insist that users encode these slashes so that we can distinguish between semantically meaningful slashes and those that form the URL. This is a slightly breaking change, but the current behavior is already broken (see the linked bug) so this seems reasonable. Signed-off-by: Stephen Finucane Closes: #433 Cc: dja at axtens.net Cc: siddhesh at gotplt.org --- notes/issue-433-5f048abbe3789556.yaml | 19 +++++++++++++++ patchwork/models.py | 23 ++++++++++++++----- .../patchwork/partials/download-buttons.html | 6 ++--- .../patchwork/partials/patch-list.html | 2 +- patchwork/templates/patchwork/submission.html | 8 +++---- patchwork/tests/api/test_cover.py | 2 +- patchwork/tests/api/test_patch.py | 2 +- patchwork/tests/views/test_bundles.py | 8 +++---- patchwork/tests/views/test_cover.py | 10 ++++---- patchwork/tests/views/test_patch.py | 22 +++++++++--------- patchwork/urls.py | 20 +++++----------- patchwork/views/comment.py | 4 ++-- patchwork/views/cover.py | 4 ++-- patchwork/views/patch.py | 8 +++---- 14 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 notes/issue-433-5f048abbe3789556.yaml diff --git notes/issue-433-5f048abbe3789556.yaml notes/issue-433-5f048abbe3789556.yaml new file mode 100644 index 00000000..1d0c1553 --- /dev/null +++ notes/issue-433-5f048abbe3789556.yaml @@ -0,0 +1,19 @@ +--- +fixes: + - | + Message IDs containing slashes will now have these slashes percent-encoded. + Previously, attempts to access submissions whose Message IDs contained + slashes would result in a HTTP 404 on some Django versions. If you wish to + access such a submission, you must now percent-encode the slashes first. + For example, to access a patch, cover letter or comment with the following + message ID: + + bug-28101-10460-NydYNmfPGz at http.sourceware.org/bugzilla/ + + You should now use: + + bug-28101-10460-NydYNmfPGz at http.sourceware.org%2Dbugzilla%2D + + Both the web UI and REST API have been updated to generate URLs in this + format so this should only be noticable to users manually generating such + URLs. diff --git patchwork/models.py patchwork/models.py index d2507d4f..20ec9f06 100644 --- patchwork/models.py +++ patchwork/models.py @@ -369,12 +369,24 @@ class EmailMixin(models.Model): @property def url_msgid(self): - """A trimmed messageid, suitable for inclusion in URLs""" + """A trimmed Message ID, suitable for inclusion in URLs""" if settings.DEBUG: assert self.msgid[0] == '<' and self.msgid[-1] == '>' return self.msgid.strip('<>') + @property + def encoded_msgid(self): + """Like 'url_msgid' but with slashes percentage encoded.""" + # We don't want to encode all characters (i.e. use urllib.parse.quote) + # because that would result in us encoding the '@' present in all + # message IDs. Instead we only percent-encode any slashes present [1]. + # These are not common so this is very much expected to be an edge + # case. + # + # [1] https://datatracker.ietf.org/doc/html/rfc3986.html#section-2 + return self.url_msgid.replace('/', '%2F') + def save(self, *args, **kwargs): # Modifying a submission via admin interface changes '\n' newlines in # message content to '\r\n'. We need to fix them to avoid problems, @@ -436,7 +448,7 @@ class Cover(SubmissionMixin): 'cover-detail', kwargs={ 'project_id': self.project.linkname, - 'msgid': self.url_msgid, + 'msgid': self.encoded_msgid, }, ) @@ -445,7 +457,7 @@ class Cover(SubmissionMixin): 'cover-mbox', kwargs={ 'project_id': self.project.linkname, - 'msgid': self.url_msgid, + 'msgid': self.encoded_msgid, }, ) @@ -671,7 +683,7 @@ class Patch(SubmissionMixin): 'patch-detail', kwargs={ 'project_id': self.project.linkname, - 'msgid': self.url_msgid, + 'msgid': self.encoded_msgid, }, ) @@ -680,7 +692,7 @@ class Patch(SubmissionMixin): 'patch-mbox', kwargs={ 'project_id': self.project.linkname, - 'msgid': self.url_msgid, + 'msgid': self.encoded_msgid, }, ) @@ -760,7 +772,6 @@ class CoverComment(EmailMixin, models.Model): class PatchComment(EmailMixin, models.Model): - # parent patch = models.ForeignKey( Patch, diff --git patchwork/templates/patchwork/partials/download-buttons.html patchwork/templates/patchwork/partials/download-buttons.html index 149bbc62..34c5f8fc 100644 --- patchwork/templates/patchwork/partials/download-buttons.html +++ patchwork/templates/patchwork/partials/download-buttons.html @@ -4,16 +4,16 @@ {{ submission.id }} {% if submission.diff %} - diff - mbox {% else %} - mbox diff --git patchwork/templates/patchwork/partials/patch-list.html patchwork/templates/patchwork/partials/patch-list.html index a9a262eb..a882cd9d 100644 --- patchwork/templates/patchwork/partials/patch-list.html +++ patchwork/templates/patchwork/partials/patch-list.html @@ -186,7 +186,7 @@ $(document).ready(function() { {% endif %} - + {{ patch.name|default:"[no subject]"|truncatechars:100 }} diff --git patchwork/templates/patchwork/submission.html patchwork/templates/patchwork/submission.html index 266744d9..6ebd8415 100644 --- patchwork/templates/patchwork/submission.html +++ patchwork/templates/patchwork/submission.html @@ -72,7 +72,7 @@ {% if cover == submission %} {{ cover.name|default:"[no subject]"|truncatechars:100 }} {% else %} - + {{ cover.name|default:"[no subject]"|truncatechars:100 }} {% endif %} @@ -84,7 +84,7 @@ {% if sibling == submission %} {{ sibling.name|default:"[no subject]"|truncatechars:100 }} {% else %} - + {{ sibling.name|default:"[no subject]"|truncatechars:100 }} {% endif %} @@ -105,7 +105,7 @@ {% for sibling in related_same_project %}
  • {% if sibling.id != submission.id %} - + {{ sibling.name|default:"[no subject]"|truncatechars:100 }} {% endif %} @@ -116,7 +116,7 @@
  • - + {{ sibling.name|default:"[no subject]"|truncatechars:100 }} (in {{ sibling.project }})
  • diff --git patchwork/tests/api/test_cover.py patchwork/tests/api/test_cover.py index 126b3af1..44ae2ebf 100644 --- patchwork/tests/api/test_cover.py +++ patchwork/tests/api/test_cover.py @@ -116,7 +116,7 @@ class TestCoverAPI(utils.APITestCase): """Filter covers by msgid.""" cover = create_cover() - resp = self.client.get(self.api_url(), {'msgid': cover.url_msgid}) + resp = self.client.get(self.api_url(), {'msgid': cover.encoded_msgid}) self.assertEqual([cover.id], [x['id'] for x in resp.data]) # empty response if nothing matches diff --git patchwork/tests/api/test_patch.py patchwork/tests/api/test_patch.py index 0ba3042b..03b5be11 100644 --- patchwork/tests/api/test_patch.py +++ patchwork/tests/api/test_patch.py @@ -218,7 +218,7 @@ class TestPatchAPI(utils.APITestCase): """Filter patches by msgid.""" patch = self._create_patch() - resp = self.client.get(self.api_url(), {'msgid': patch.url_msgid}) + resp = self.client.get(self.api_url(), {'msgid': patch.encoded_msgid}) self.assertEqual([patch.id], [x['id'] for x in resp.data]) # empty response if nothing matches diff --git patchwork/tests/views/test_bundles.py patchwork/tests/views/test_bundles.py index b26badc8..b730bdf3 100644 --- patchwork/tests/views/test_bundles.py +++ patchwork/tests/views/test_bundles.py @@ -496,7 +496,7 @@ class BundleCreateFromPatchTest(BundleTestBase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ), params, @@ -519,7 +519,7 @@ class BundleCreateFromPatchTest(BundleTestBase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ), params, @@ -655,7 +655,7 @@ class BundleAddFromPatchTest(BundleTestBase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ), params, @@ -680,7 +680,7 @@ class BundleAddFromPatchTest(BundleTestBase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ), params, diff --git patchwork/tests/views/test_cover.py patchwork/tests/views/test_cover.py index f33a6238..ee1f205f 100644 --- patchwork/tests/views/test_cover.py +++ patchwork/tests/views/test_cover.py @@ -19,14 +19,14 @@ class CoverViewTest(TestCase): 'patch-detail', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ) redirect_url = reverse( 'cover-detail', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ) @@ -43,7 +43,7 @@ class CoverViewTest(TestCase): 'cover-detail', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ) @@ -60,7 +60,7 @@ class CoverViewTest(TestCase): 'cover-mbox', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ) @@ -98,7 +98,7 @@ class CommentRedirectTest(TestCase): 'cover-detail', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ), comment_id, diff --git patchwork/tests/views/test_patch.py patchwork/tests/views/test_patch.py index d1de8ec9..70a2c836 100644 --- patchwork/tests/views/test_patch.py +++ patchwork/tests/views/test_patch.py @@ -212,14 +212,14 @@ class PatchViewTest(TestCase): 'cover-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) redirect_url = reverse( 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) @@ -238,7 +238,7 @@ class PatchViewTest(TestCase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ), comment_id, @@ -257,7 +257,7 @@ class PatchViewTest(TestCase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) @@ -274,7 +274,7 @@ class PatchViewTest(TestCase): 'patch-mbox', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) @@ -291,7 +291,7 @@ class PatchViewTest(TestCase): 'patch-raw', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) @@ -315,7 +315,7 @@ class PatchViewTest(TestCase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) response = self.client.get(requested_url) @@ -358,7 +358,7 @@ class PatchViewTest(TestCase): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) response = self.client.get(requested_url) @@ -511,7 +511,7 @@ class UTF8PatchViewTest(TestCase): response = self.client.get( reverse( 'patch-detail', - args=[self.patch.project.linkname, self.patch.url_msgid], + args=[self.patch.project.linkname, self.patch.encoded_msgid], ) ) self.assertContains(response, self.patch.name) @@ -520,7 +520,7 @@ class UTF8PatchViewTest(TestCase): response = self.client.get( reverse( 'patch-mbox', - args=[self.patch.project.linkname, self.patch.url_msgid], + args=[self.patch.project.linkname, self.patch.encoded_msgid], ) ) self.assertEqual(response.status_code, 200) @@ -530,7 +530,7 @@ class UTF8PatchViewTest(TestCase): response = self.client.get( reverse( 'patch-raw', - args=[self.patch.project.linkname, self.patch.url_msgid], + args=[self.patch.project.linkname, self.patch.encoded_msgid], ) ) self.assertEqual(response.status_code, 200) diff --git patchwork/urls.py patchwork/urls.py index ab606f1c..f4d67aa7 100644 --- patchwork/urls.py +++ patchwork/urls.py @@ -47,29 +47,21 @@ urlpatterns = [ name='project-detail', ), # patch views - # NOTE(dja): Per the RFC, msgids can contain slashes. There doesn't seem - # to be an easy way to tell Django to urlencode the slash when generating - # URLs, so instead we must use a permissive regex (.+ rather than [^/]+). - # This also means we need to put the raw and mbox URLs first, otherwise the - # patch-detail regex will just greedily grab those parts into a massive and - # wrong msgid. - # - # This does mean that message-ids that end in '/raw/' or '/mbox/' will not - # work, but it is RECOMMENDED by the RFC that the right hand side of the @ - # contains a domain, so I think breaking on messages that have "domains" - # ending in /raw/ or /mbox/ is good enough. + # NOTE(stephenfin): Per the RFC, msgids can contain slashes. Users are + # required to percent-encode any slashes present to generate valid URLs. + # The API does this automatically. path( - 'project//patch//raw/', + 'project//patch//raw/', patch_views.patch_raw, name='patch-raw', ), path( - 'project//patch//mbox/', + 'project//patch//mbox/', patch_views.patch_mbox, name='patch-mbox', ), path( - 'project//patch//', + 'project//patch//', patch_views.patch_detail, name='patch-detail', ), diff --git patchwork/views/comment.py patchwork/views/comment.py index 4f699224..98232a9e 100644 --- patchwork/views/comment.py +++ patchwork/views/comment.py @@ -29,7 +29,7 @@ def comment(request, comment_id): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) else: # cover @@ -37,7 +37,7 @@ def comment(request, comment_id): 'cover-detail', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ) diff --git patchwork/views/cover.py patchwork/views/cover.py index 3368186b..15013a89 100644 --- patchwork/views/cover.py +++ patchwork/views/cover.py @@ -71,7 +71,7 @@ def cover_by_id(request, cover_id): 'cover-detail', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ) @@ -85,7 +85,7 @@ def cover_mbox_by_id(request, cover_id): 'cover-mbox', kwargs={ 'project_id': cover.project.linkname, - 'msgid': cover.url_msgid, + 'msgid': cover.encoded_msgid, }, ) diff --git patchwork/views/patch.py patchwork/views/patch.py index 75705720..9f1bb415 100644 --- patchwork/views/patch.py +++ patchwork/views/patch.py @@ -40,7 +40,7 @@ def patch_list(request, project_id): def patch_detail(request, project_id, msgid): project = get_object_or_404(Project, linkname=project_id) - db_msgid = '<%s>' % msgid + db_msgid = f"<{msgid.replace('%2F', '/')}>" # redirect to cover letters where necessary try: @@ -190,7 +190,7 @@ def patch_by_id(request, patch_id): 'patch-detail', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) @@ -204,7 +204,7 @@ def patch_mbox_by_id(request, patch_id): 'patch-mbox', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) @@ -218,7 +218,7 @@ def patch_raw_by_id(request, patch_id): 'patch-raw', kwargs={ 'project_id': patch.project.linkname, - 'msgid': patch.url_msgid, + 'msgid': patch.encoded_msgid, }, ) -- 2.37.3 From stephen at that.guru Sat Oct 1 02:21:26 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:21:26 +0100 Subject: [PATCH 10/10] urls: Encode slashes in message IDs In-Reply-To: <20220930161921.266633-10-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> <20220930161921.266633-10-stephen@that.guru> Message-ID: <79200e0fff42cd529c7004ebb7d284620689c358.camel@that.guru> On Fri, 2022-09-30 at 17:19 +0100, Stephen Finucane wrote: > We were attempting to work around the fact that message IDs could > contain slashes which in some cases broke our ability to generate > meaningful URLs. Rather than doing this, insist that users encode these > slashes so that we can distinguish between semantically meaningful > slashes and those that form the URL. This is a slightly breaking change, > but the current behavior is already broken (see the linked bug) so this > seems reasonable. > > Signed-off-by: Stephen Finucane > Closes: #433 > Cc: dja at axtens.net > Cc: siddhesh at gotplt.org > Whoops. This was already sent and shouldn't have been included in the series. Apologies for the noise. Stephen From stephen at that.guru Sat Oct 1 02:54:07 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:54:07 +0100 Subject: [PATCH 01/10] tests: Change from expectedFailure to skip In-Reply-To: <20220930161921.266633-1-stephen@that.guru> References: <20220930161921.266633-1-stephen@that.guru> Message-ID: <624ce89b0c5684890e950f403c0261d8fc2fe7f0.camel@that.guru> On Fri, 2022-09-30 at 17:19 +0100, Stephen Finucane wrote: > Python 3.10 recognises unexpected passes as failures now. > > Signed-off-by: Stephen Finucane I added some release notes and merged this whole series. Stephen From stephen at that.guru Sat Oct 1 02:55:55 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:55:55 +0100 Subject: Patchwork v3.0.6 Available Message-ID: <429d4dfd5c4f6893d2e0f94decf221255afb421f.camel@that.guru> We're pleased to announce the release of Patchwork v3.0.6. This release is part of the "Grosgrain" release series: https://github.com/getpatchwork/patchwork/releases/tag/v3.0.6 This release is a PATCH release that focuses on bugfixes. For more details, please see below. Happy patchworking! --- Changes in Patchwork v3.0.5..v3.0.6 ----------------------------------- 297399bc Release 3.0.6 c51425bc docs: Actually configure reno to use the main branch 948c51ce Replace references to master with main 78e257db parser: Handle binary git patches f4c05031 [StableOnly] Fix docs job 12cd2a89 lib: fix table names f8cbc778 Post-release version bump From stephen at that.guru Sat Oct 1 02:57:02 2022 From: stephen at that.guru (Stephen Finucane) Date: Fri, 30 Sep 2022 17:57:02 +0100 Subject: Patchwork v3.1.1 Available Message-ID: We're pleased to announce the release of Patchwork v3.1.1. This release is part of the "Hessian" release series: https://github.com/getpatchwork/patchwork/releases/tag/v3.1.1 This release is a PATCH release that focuses on bugfixes. For more details, please see below. Happy patchworking! --- Changes in Patchwork v3.1.0..v3.1.1 ----------------------------------- c1f897a4 Release 3.1.1 04b7b671 tests: Change from expectedFailure to skip a2f322dc REST: De-duplicate handling of nested resource URLs 27153773 REST: Fix issues with comment-related events 40bf7ca6 manage: Check Django version on startup 106242ab urls: Encode slashes in message IDs 8a0031bf trivial: Fix style issues b89ba00e docs: Actually configure reno to use the main branch 13f86fb0 Replace references to master with main From robin at jarry.cc Thu Oct 6 01:24:05 2022 From: robin at jarry.cc (Robin Jarry) Date: Wed, 5 Oct 2022 16:24:05 +0200 Subject: [PATCH] css: make diff colors more accessible Message-ID: <20221005142405.20896-1-robin@jarry.cc> The colors used to display patch diffs are confusing. The context color is very similar to the added line color and the contrast between added and removed lines is very low. Originally, the choice of purple/blue (instead of the more common red/green palette) may have been made with colorblindness accessibility in mind. However, after inspecting the current colors with colorblindness "simulators", I found that the low contrast was consistent no matter what vision deficiency (if any) you might have. Update the colors to use a more common red/green palette. Add background colors to increase contrast for colorblind people. Use less confusing colors for context and diff hunks. Use normal line height to prevent background colors from overlapping. Use a different color for email quotes (blue) to avoid confusion with added lines. I have made a compilation of the current and updated color palette previews for normal vision and all common color deficiencies. I also included the same diff as seen from Github interface for reference. Link: http://files.diabeteman.com/patchwork-diff-colors/ Signed-off-by: Robin Jarry --- htdocs/css/style.css | 16 ++++++++-------- ...-colors-more-accessible-82eda58a89984d46.yaml | 5 +++++ 2 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 releasenotes/notes/make-diff-colors-more-accessible-82eda58a89984d46.yaml diff --git a/htdocs/css/style.css b/htdocs/css/style.css index 9156aa6ee073..1a739510924c 100644 --- a/htdocs/css/style.css +++ b/htdocs/css/style.css @@ -17,7 +17,7 @@ h2 a, h2 span { } pre { - line-height: 110%; + line-height: normal; background-color: white; border-radius: 0; } @@ -354,15 +354,15 @@ button[class^=comment-action] { } .quote { - color: #007f00; + color: #365cb5; } -span.p_header { color: #2e8b57; font-weight: bold; } -span.p_chunk { color: #a52a2a; font-weight: bold; } -span.p_context { color: #a020f0; } -span.p_add { color: #008b8b; } -span.p_del { color: #6a5acd; } -span.p_mod { color: #0000ff; } +span.p_header { font-weight: bold; } +span.p_chunk { color: #329fb0; font-weight: bold; } +span.p_context { } +span.p_add { color: #1b9d09; background-color: #edffed; } +span.p_del { color: #c80101; background-color: #ffe2e2; } +span.p_mod { color: #a020f0; } .acked-by { color: #2d4566; diff --git a/releasenotes/notes/make-diff-colors-more-accessible-82eda58a89984d46.yaml b/releasenotes/notes/make-diff-colors-more-accessible-82eda58a89984d46.yaml new file mode 100644 index 000000000000..f65995e51d9a --- /dev/null +++ b/releasenotes/notes/make-diff-colors-more-accessible-82eda58a89984d46.yaml @@ -0,0 +1,5 @@ +--- +other: + - | + The patch diff color palette was modified to make it more accessible for + all users, including those with common color deficiencies. -- 2.37.3