runs-on: ubuntu-latest
steps:
- name: Checkout source code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
- python-version: "3.13"
+ python-version: "3.14"
cache: 'pip'
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
- go-version: "1.21"
+ go-version: "1.26"
- name: Install dependencies
run: python -m pip install tox
- name: Run tox
matrix:
# NOTE: If you add a version here, don't forget to update the
# '[gh-actions]' section in tox.ini
- python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
+ python: ["3.11", "3.14"]
db: [postgres, mysql, sqlite3]
env:
DATABASE_TYPE: "${{ matrix.db }}"
MYSQL_ROOT_PASSWORD: root-${{ github.run_id }}
services:
postgres:
- image: postgres:latest
+ image: postgres:17
env:
POSTGRES_DB: ${{ env.DATABASE_NAME }}
POSTGRES_PASSWORD: ${{ env.DATABASE_PASSWORD }}
--health-timeout 5s
--health-retries 5
mysql:
- image: mysql:latest
+ image: mysql:8.4
env:
MYSQL_DATABASE: ${{ env.DATABASE_NAME }}
MYSQL_USER: ${{ env.DATABASE_USER }}
--health-retries 5
steps:
- name: Checkout source code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: 'pip'
runs-on: ubuntu-latest
steps:
- name: Checkout source code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
+ - name: Fetch all branches
+ run: git fetch --all
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
- python-version: "3.13"
+ python-version: "3.14"
cache: 'pip'
- name: Install dependencies
run: python -m pip install tox
- name: Build docs (via tox)
run: tox -e docs
- name: Archive build results
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: html-docs-build
path: docs/_build/html
COMPOSE_FILE: ${{ matrix.db == 'mysql' && 'docker-compose.yml' || (matrix.db == 'postgres' && 'docker-compose-pg.yml') || 'docker-compose-sqlite3.yml' }}
steps:
- name: Checkout source code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
- python-version: "3.13"
+ python-version: "3.14"
cache: 'pip'
- name: Build docker-compose service
run: |
{ echo patchwork; echo patchwork; } | \
docker compose run -T --rm web \
python manage.py changepassword patchwork
- # FIXME(stephenfin): Re-enable this once dbbackup supports Django 4.0
+ # FIXME(stephenfin): Re-enable this once we've update configuration accordingly
# - name: Test dbbackup/dbrestore
# run: |
# docker compose run -T --rm web python manage.py dbbackup
---
services:
db:
- image: postgres:latest
+ image: postgres:17
volumes:
- ./tools/docker/db/postdata:/var/lib/postgresql/data
environment:
'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)
fields = ('path', 'user', 'priority')
+@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'linkname', 'listid', 'listemail')
inlines = [
]
-admin.site.register(Project, ProjectAdmin)
-
-
+@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
list_display = ('__str__', 'has_account')
search_fields = ('name', 'email')
+ @admin.display(
+ description='Account',
+ boolean=True,
+ ordering='user',
+ )
def has_account(self, person):
return bool(person.user)
- has_account.boolean = True
- has_account.admin_order_field = 'user'
- has_account.short_description = 'Account'
-
-
-admin.site.register(Person, PersonAdmin)
-
+@admin.register(State)
class StateAdmin(admin.ModelAdmin):
list_display = ('name', 'action_required')
-admin.site.register(State, StateAdmin)
-
-
+@admin.register(Cover)
class CoverAdmin(admin.ModelAdmin):
list_display = ('name', 'submitter', 'project', 'date')
list_filter = ('project',)
date_hierarchy = 'date'
-admin.site.register(Cover, CoverAdmin)
-
-
+@admin.register(Patch)
class PatchAdmin(admin.ModelAdmin):
list_display = (
'name',
search_fields = ('name', 'submitter__name', 'submitter__email')
date_hierarchy = 'date'
+ @admin.display(
+ description='Pull',
+ boolean=True,
+ ordering='pull_url',
+ )
def is_pull_request(self, patch):
return bool(patch.pull_url)
- is_pull_request.boolean = True
- is_pull_request.admin_order_field = 'pull_url'
- is_pull_request.short_description = 'Pull'
-
-
-admin.site.register(Patch, PatchAdmin)
-
+@admin.register(CoverComment)
class CoverCommentAdmin(admin.ModelAdmin):
list_display = ('cover', 'submitter', 'date')
search_fields = ('cover__name', 'submitter__name', 'submitter__email')
date_hierarchy = 'date'
-admin.site.register(CoverComment, CoverCommentAdmin)
-
-
+@admin.register(PatchComment)
class PatchCommentAdmin(admin.ModelAdmin):
list_display = ('patch', 'submitter', 'date')
search_fields = ('patch__name', 'submitter__name', 'submitter__email')
date_hierarchy = 'date'
-admin.site.register(PatchComment, PatchCommentAdmin)
-
-
class PatchInline(admin.StackedInline):
model = Patch
extra = 0
+@admin.register(Series)
class SeriesAdmin(admin.ModelAdmin):
list_display = (
'name',
filter_horizontal = ('dependencies',)
inlines = (PatchInline,)
+ @admin.display(boolean=True)
def received_all(self, series):
return series.received_all
- received_all.boolean = True
-
def get_queryset(self, request):
qs = super(SeriesAdmin, self).get_queryset(request)
return qs.prefetch_related(
)
-admin.site.register(Series, SeriesAdmin)
-
-
+@admin.register(SeriesReference)
class SeriesReferenceAdmin(admin.ModelAdmin):
model = SeriesReference
-admin.site.register(SeriesReference, SeriesReferenceAdmin)
-
-
+@admin.register(Check)
class CheckAdmin(admin.ModelAdmin):
list_display = (
'patch',
date_hierarchy = 'date'
-admin.site.register(Check, CheckAdmin)
-
-
+@admin.register(Bundle)
class BundleAdmin(admin.ModelAdmin):
list_display = ('name', 'owner', 'project', 'public')
list_filter = ('public', 'project')
search_fields = ('name', 'owner')
-admin.site.register(Bundle, BundleAdmin)
-
-
+@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ('name',)
-admin.site.register(Tag, TagAdmin)
-
-
+@admin.register(PatchRelation)
class PatchRelationAdmin(admin.ModelAdmin):
model = PatchRelation
-
-
-admin.site.register(PatchRelation, PatchRelationAdmin)
#
# SPDX-License-Identifier: GPL-2.0-or-later
-import django
from django.contrib.auth.models import User
from django import forms
from django.forms import renderers
choices.append(self.no_change_choice)
return choices
- if django.VERSION >= (5, 0):
- choices = property(_get_choices, forms.ChoiceField.choices.fset)
- else:
- choices = property(_get_choices, forms.ChoiceField._set_choices)
+ choices = property(_get_choices, forms.ChoiceField.choices.fset)
def is_no_change(self, value):
return value == self.no_change_choice[0]
if os.getenv('DATABASE_TYPE') == 'postgres':
DATABASES = {
'default': {
- 'ENGINE': 'django.db.backends.postgresql_psycopg2',
+ 'ENGINE': 'django.db.backends.postgresql',
'HOST': os.environ.get('DATABASE_HOST', 'localhost'),
'PORT': os.environ.get('DATABASE_PORT', ''),
'NAME': os.environ.get('DATABASE_NAME', 'patchwork'),
STATIC_ROOT = os.environ.get('STATIC_ROOT', '/srv/patchwork/htdocs/static')
-STATICFILES_STORAGE = (
- 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
-)
+STORAGES = {
+ 'default': {
+ 'BACKEND': 'django.core.files.storage.FileSystemStorage',
+ },
+ 'staticfiles': {
+ 'BACKEND': 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage',
+ },
+}
# Check we can view as owner
auth_string = _get_auth_string(self.user)
- response = self.client.get(self.url, HTTP_AUTHORIZATION=auth_string)
+ response = self.client.get(
+ self.url, headers={'authorization': auth_string}
+ )
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.patches[0].name)
# Check we can't view as another user
auth_string = _get_auth_string(self.other_user)
- response = self.client.get(self.url, HTTP_AUTHORIZATION=auth_string)
+ response = self.client.get(
+ self.url, headers={'authorization': auth_string}
+ )
self.assertEqual(response.status_code, 404)
def test_private_bundle_mbox_token_auth(self):
# Check we can view as owner
auth_string = _get_auth_string(self.user)
- response = self.client.get(self.url, HTTP_AUTHORIZATION=auth_string)
+ response = self.client.get(
+ self.url, headers={'authorization': auth_string}
+ )
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.patches[0].name)
# Check we can't view as another user
auth_string = _get_auth_string(self.other_user)
- response = self.client.get(self.url, HTTP_AUTHORIZATION=auth_string)
+ response = self.client.get(
+ self.url, headers={'authorization': auth_string}
+ )
self.assertEqual(response.status_code, 404)
import re
-import django
from django.core import mail
from django.test import TestCase
from django.urls import reverse
response = self.client.post(reverse('mail-settings'), {'email': ''})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/mail.html')
- 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.assertFormError(
+ response.context['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')
- if django.VERSION >= (4, 1):
- self.assertFormError(
- response.context['form'],
- 'email',
- error_strings['email'],
- )
- else:
- self.assertFormError(
- response,
- 'form',
- 'email',
- error_strings['email'],
- )
+ self.assertFormError(
+ response.context['form'],
+ 'email',
+ error_strings['email'],
+ )
def test_post_optin(self):
email = 'foo@example.com'
def test_post_empty(self):
response = self.client.post(reverse('mail-optout'), {'email': ''})
self.assertEqual(response.status_code, 200)
- 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.assertFormError(
+ response.context['form'],
+ 'email',
+ 'This field is required.',
+ )
self.assertTrue(response.context['error'])
self.assertNotIn('confirmation', response.context)
self.assertEqual(len(mail.outbox), 0)
def test_post_non_email(self):
response = self.client.post(reverse('mail-optout'), {'email': 'foo'})
self.assertEqual(response.status_code, 200)
- if django.VERSION >= (4, 1):
- self.assertFormError(
- response.context['form'],
- 'email',
- error_strings['email'],
- )
- else:
- self.assertFormError(
- response,
- 'form',
- 'email',
- error_strings['email'],
- )
+ self.assertFormError(
+ response.context['form'],
+ 'email',
+ error_strings['email'],
+ )
self.assertTrue(response.context['error'])
self.assertNotIn('confirmation', response.context)
self.assertEqual(len(mail.outbox), 0)
def test_post_empty(self):
response = self.client.post(reverse('mail-optin'), {'email': ''})
self.assertEqual(response.status_code, 200)
- 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.assertFormError(
+ response.context['form'],
+ 'email',
+ 'This field is required.',
+ )
self.assertTrue(response.context['error'])
self.assertNotIn('confirmation', response.context)
self.assertEqual(len(mail.outbox), 0)
def test_post_non_email(self):
response = self.client.post(reverse('mail-optin'), {'email': 'foo'})
self.assertEqual(response.status_code, 200)
- if django.VERSION >= (4, 1):
- self.assertFormError(
- response.context['form'],
- 'email',
- error_strings['email'],
- )
- else:
- self.assertFormError(
- response,
- 'form',
- 'email',
- error_strings['email'],
- )
+ self.assertFormError(
+ response.context['form'],
+ 'email',
+ error_strings['email'],
+ )
self.assertTrue(response.context['error'])
self.assertNotIn('confirmation', response.context)
self.assertEqual(len(mail.outbox), 0)
import re
import unittest
-import django
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
new_states = [Patch.objects.get(pk=p.pk).state for p in self.patches]
self.assertEqual(new_states, orig_states)
- if django.VERSION >= (4, 1):
- self.assertFormError(
- response.context['patch_form'],
- 'state',
- 'Select a valid choice. That choice is not one '
- 'of the available choices.',
- )
- else:
- self.assertFormError(
- response,
- 'patch_form',
- 'state',
- 'Select a valid choice. That choice is not one '
- 'of the available choices.',
- )
+ self.assertFormError(
+ response.context['patch_form'],
+ '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()
import string
import secrets
-import django
from django.contrib.auth.models import User
from django.core import mail
from django.test.client import Client
del data[field]
response = self.client.post('/register/', data)
self.assertEqual(response.status_code, 200)
- if django.VERSION >= (4, 1):
- self.assertFormError(
- response.context['form'],
- field,
- self.required_error,
- )
- else:
- self.assertFormError(
- response,
- 'form',
- field,
- self.required_error,
- )
+ self.assertFormError(
+ response.context['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)
- if django.VERSION >= (4, 1):
- self.assertFormError(
- response.context['form'],
- 'username',
- self.invalid_error,
- )
- else:
- self.assertFormError(
- response,
- 'form',
- 'username',
- self.invalid_error,
- )
+ self.assertFormError(
+ response.context['form'],
+ 'username',
+ self.invalid_error,
+ )
def test_existing_username(self):
user = create_user()
data['username'] = user.username
response = self.client.post('/register/', data)
self.assertEqual(response.status_code, 200)
- 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.',
- )
+ self.assertFormError(
+ response.context['form'],
+ 'username',
+ 'This username is already taken. Please choose another.',
+ )
def test_existing_email(self):
user = create_user()
data['email'] = user.email
response = self.client.post('/register/', data)
self.assertEqual(response.status_code, 200)
- 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,
- )
+ self.assertFormError(
+ response.context['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)
response = self.client.post(reverse('user-link'), {'email': ''})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context['linkform'])
- 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.',
- )
+ self.assertFormError(
+ response.context['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'])
- if django.VERSION >= (4, 1):
- self.assertFormError(
- response.context['linkform'],
- 'email',
- error_strings['email'],
- )
- else:
- self.assertFormError(
- response,
- 'linkform',
- 'email',
- error_strings['email'],
- )
+ self.assertFormError(
+ response.context['linkform'],
+ 'email',
+ error_strings['email'],
+ )
def test_user_person_request_valid(self):
response = self.client.post(
def _user_for_request(self, request):
auth_header = None
- if 'HTTP_AUTHORIZATION' in request.META:
- auth_header = request.META.get('HTTP_AUTHORIZATION')
+ if 'authorization' in request.headers:
+ auth_header = request.headers.get('authorization')
elif 'Authorization' in request.META:
auth_header = request.META.get('Authorization')
now supported.
upgrade:
- |
- Django 3.2 and 4.1 are no longer supported. Bother releases are no longer
+ Django 3.2 and 4.1 are no longer supported. Both releases are no longer
supported upstream and most distributions provide a newer version.
--- /dev/null
+---
+features:
+ - |
+ `Django 5.2 <https://docs.djangoproject.com/en/dev/releases/5.2/>`_ is
+ now supported.
+upgrade:
+ - |
+ Django 4.2, 5.0 and 5.1 are no longer supported. These releases are no
+ longer supported upstream and most distributions provide a newer version.
--- /dev/null
+---
+features:
+ - |
+ `Django 6.0 <https://docs.djangoproject.com/en/dev/releases/6.0/>`_ is
+ now supported.
--- /dev/null
+---
+features:
+ - |
+ `Python 3.14 <https://www.python.org/downloads/release/python-3140/>`_ is
+ now supported.
+upgrade:
+ - |
+ Python 3.9 and 3.10 are no longer supported. These releases are no longer
+ supported upstream and most distributions provide a newer version.
-Django~=5.1.0
-djangorestframework~=3.15.2
-django-filter~=24.2.0
-django-debug-toolbar~=5.0.1
-django-dbbackup~=4.2.0
+Django~=6.0.0
+djangorestframework~=3.17.1
+django-filter~=25.2.0
+django-debug-toolbar~=6.3.0
+django-dbbackup~=5.3.0
-r requirements-test.txt
-Django~=5.1.0
-djangorestframework~=3.15.0
-django-filter~=24.3.0
-psycopg2~=2.9.10
-sqlparse~=0.5.1
+Django~=6.0.0
+djangorestframework~=3.17.1
+django-filter~=25.2.0
+psycopg~=3.3.4
+sqlparse~=0.5.5
-mysqlclient~=2.2.5
-psycopg2-binary~=2.9.9
-sqlparse~=0.5.1
+mysqlclient~=2.2.8
+psycopg~=3.3.4
+sqlparse~=0.5.5
python-dateutil~=2.9.0
-tblib~=3.0.0
+tblib~=3.2.2
openapi-core~=0.19.2
jsonschema-path~=0.3.3
-termcolor~=2.5.0
+termcolor~=3.3.0
[tox]
-minversion = 3.2
-envlist = pep8,docs,py{39,310,311}-django42,py{310,311,312}-django{50,51},py313-django51
+minversion = 4.6
+envlist = pep8,docs,py{311,312,313,314}-django52,py{312,313,314}-django60
[testenv]
skip_install = true
deps =
-r{toxinidir}/requirements-test.txt
- django42: django~=4.2.0
- django42: djangorestframework~=3.15.0
- django42: django-filter~=24.3.0
- django50: django~=5.0.0
- django50: djangorestframework~=3.15.0
- django50: django-filter~=24.3.0
- django51: django~=5.1.0
- django51: djangorestframework~=3.15.0
- django51: django-filter~=24.3.0
+ django52: django~=5.2.0
+ django52: djangorestframework~=3.16.0
+ django52: django-filter~=25.1.0
+ django60: django~=6.0.0
+ django60: djangorestframework~=3.16.0
+ django60: django-filter~=25.2.0
setenv =
DJANGO_SETTINGS_MODULE = patchwork.settings.dev
- PYTHONDONTWRITEBYTECODE = 1
PYTHONDEVMODE = 1
PYTHONWARNINGS = once
passenv =
[gh-actions]
python =
- 3.9: py39
- 3.10: py310
3.11: py311
3.12: py312
3.13: py313
+ 3.14: py314