Series |
-
+
{{ submission.series.name }}
|
@@ -187,6 +201,34 @@ Message
+
+ Users pending actions
+ {% if user.is_authenticated and user not in attention_set %}
+
+ {% endif %}
+
+{% if attention_set %}
+
+ {% for set_user in attention_set %}
+ -
+
+
+ {% endfor %}
+
+{% endif %}
+
{% for item in comments %}
{% if forloop.first %}
Comments
diff --git a/patchwork/templatetags/patch.py b/patchwork/templatetags/patch.py
index d3f023f7..26282d83 100644
--- a/patchwork/templatetags/patch.py
+++ b/patchwork/templatetags/patch.py
@@ -75,3 +75,18 @@ def patch_commit_display(patch):
return mark_safe(
'%s' % (escape(fmt.format(commit)), escape(commit))
)
+
+
+@register.filter(name='patch_interest')
+def patch_interest(patch):
+ reviews = patch.attention_set.count()
+ review_title = (
+ f'has {reviews} interested reviewers'
+ if reviews > 0
+ else 'no interested reviewers'
+ )
+ review_class = 'exists' if reviews > 0 else ''
+ return mark_safe(
+ '%s'
+ % (review_class, review_title, reviews if reviews > 0 else '-')
+ )
diff --git a/patchwork/templatetags/series.py b/patchwork/templatetags/series.py
new file mode 100644
index 00000000..a235b420
--- /dev/null
+++ b/patchwork/templatetags/series.py
@@ -0,0 +1,76 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2008 Jeremy Kerr
+# Copyright (C) 2015 Intel Corporation
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from django import template
+from django.utils.safestring import mark_safe
+
+from patchwork.models import Check
+
+
+register = template.Library()
+
+
+@register.filter(name='series_tags')
+def series_tags(series):
+ counts = []
+ titles = []
+
+ for tag in [t for t in series.project.tags if t.show_column]:
+ count = 0
+ for patch in series.patches.with_tag_counts(series.project).all():
+ count += getattr(patch, tag.attr_name)
+
+ titles.append('%d %s' % (count, tag.name))
+ if count == 0:
+ counts.append('-')
+ else:
+ counts.append(str(count))
+
+ return mark_safe(
+ '%s' % (' / '.join(titles), ' '.join(counts))
+ )
+
+
+@register.filter(name='series_checks')
+def series_checks(series):
+ required = [Check.STATE_SUCCESS, Check.STATE_WARNING, Check.STATE_FAIL]
+ titles = ['Success', 'Warning', 'Fail']
+ counts = series.check_count
+
+ check_elements = []
+ for state in required[::-1]:
+ if counts[state]:
+ color = dict(Check.STATE_CHOICES).get(state)
+ count = str(counts[state])
+ else:
+ color = ''
+ count = '-'
+
+ check_elements.append(
+ f'{count}'
+ )
+
+ check_elements.reverse()
+
+ return mark_safe(
+ '%s'
+ % (' / '.join(titles), ''.join(check_elements))
+ )
+
+
+@register.filter(name='series_interest')
+def series_interest(series):
+ reviews = series.interest_count
+ review_title = (
+ f'has {reviews} interested reviewers'
+ if reviews > 0
+ else 'no interested reviewers'
+ )
+ review_class = 'exists' if reviews > 0 else ''
+ return mark_safe(
+ '%s'
+ % (review_class, review_title, reviews if reviews > 0 else '-')
+ )
diff --git a/patchwork/tests/api/test_patch.py b/patchwork/tests/api/test_patch.py
index 2661d75c..252a345e 100644
--- a/patchwork/tests/api/test_patch.py
+++ b/patchwork/tests/api/test_patch.py
@@ -11,9 +11,9 @@
from django.urls import reverse
from rest_framework import status
-from patchwork.models import Patch
+from patchwork.models import Patch, PatchAttentionSet
from patchwork.tests.api import utils
-from patchwork.tests.utils import create_maintainer
+from patchwork.tests.utils import create_attention_set, create_maintainer
from patchwork.tests.utils import create_patch
from patchwork.tests.utils import create_patches
from patchwork.tests.utils import create_person
@@ -238,7 +238,7 @@ def test_list_bug_335(self):
series = create_series()
create_patches(5, series=series)
- with self.assertNumQueries(5):
+ with self.assertNumQueries(6):
self.client.get(self.api_url())
@utils.store_samples('patch-detail')
@@ -456,3 +456,235 @@ def test_delete(self):
self.client.authenticate(user=user)
resp = self.client.delete(self.api_url(patch.id))
self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+ def test_declare_review_intention(self):
+ project = create_project()
+ state = create_state()
+ patch = create_patch(project=project, state=state)
+ user = create_user()
+ self.client.authenticate(user=user)
+
+ # No intention of reviewing
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 0,
+ )
+
+ # declare intention
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [user.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 1,
+ )
+
+ # redeclare intention should have no effect
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [user.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 1,
+ )
+
+ def test_remove_review_intention(self):
+ project = create_project()
+ state = create_state()
+ patch = create_patch(project=project, state=state)
+ user = create_user()
+ create_attention_set(patch=patch, user=user)
+ self.client.authenticate(user=user)
+
+ # Existing intention of reviewing
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 1,
+ )
+
+ # remove intention
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [-user.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 0,
+ )
+ # uses soft delete
+ self.assertEqual(
+ len(
+ PatchAttentionSet.raw_objects.filter(
+ patch=patch, user=user
+ ).all()
+ ),
+ 1,
+ )
+
+ def test_add_review_intention_updates_old_entry(self):
+ project = create_project()
+ state = create_state()
+ patch = create_patch(project=project, state=state)
+ user = create_user()
+ interest = create_attention_set(patch=patch, user=user, removed=True)
+ self.client.authenticate(user=user)
+
+ # Existing deleted intention of reviewing
+ self.assertTrue(interest.removed)
+
+ # updates intention
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [user.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 1,
+ )
+ # uses upsert
+ self.assertEqual(
+ len(
+ PatchAttentionSet.raw_objects.filter(
+ patch=patch, user=user
+ ).all()
+ ),
+ 1,
+ )
+
+ def test_remove_review_intention_with_empty_array(self):
+ project = create_project()
+ state = create_state()
+ patch = create_patch(project=project, state=state)
+ user = create_user()
+ create_attention_set(patch=patch, user=user)
+ self.client.authenticate(user=user)
+
+ # Existing intention of reviewing
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 1,
+ )
+
+ # remove intention
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': []},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 0,
+ )
+
+ def test_remove_review_intention_of_others(self):
+ project = create_project()
+ state = create_state()
+ patch = create_patch(project=project, state=state)
+ user = create_user()
+ user2 = create_user()
+ create_attention_set(patch=patch, user=user2)
+
+ self.client.authenticate(user=user)
+
+ # remove intention
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [-user2.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user2).all()
+ ),
+ 1,
+ )
+
+ def test_remove_review_intention_of_others_as_maintainer(self):
+ project = create_project()
+ state = create_state()
+ patch = create_patch(project=project, state=state)
+ maintainer = create_maintainer(project)
+ user2 = create_user()
+ create_attention_set(patch=patch, user=user2)
+
+ self.client.authenticate(user=maintainer)
+
+ # remove intention
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [-user2.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user2).all()
+ ),
+ 0,
+ )
+
+ def test_declare_review_intention_of_others(self):
+ project = create_project()
+ state = create_state()
+ patch = create_patch(project=project, state=state)
+ user = create_user()
+ maintainer = create_maintainer(project)
+ user2 = create_user()
+ self.client.authenticate(user=user)
+
+ # declare intention
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [user2.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 0,
+ )
+
+ # maintaners also can't assign someone
+ self.client.authenticate(user=maintainer)
+ resp = self.client.patch(
+ self.api_url(patch.id),
+ {'attention_set': [user2.id]},
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(
+ len(
+ PatchAttentionSet.objects.filter(patch=patch, user=user).all()
+ ),
+ 0,
+ )
diff --git a/patchwork/tests/test_signals.py b/patchwork/tests/test_signals.py
index c2c6cc6d..f7c45620 100644
--- a/patchwork/tests/test_signals.py
+++ b/patchwork/tests/test_signals.py
@@ -5,7 +5,7 @@
from django.test import TestCase
-from patchwork.models import Event
+from patchwork.models import Event, PatchAttentionSet
from patchwork.tests import utils
BASE_FIELDS = [
@@ -311,3 +311,19 @@ def test_patch_comment_created(self):
)
self.assertEqual(events[0].project, comment.patch.project)
self.assertEventFields(events[0])
+
+ def test_comment_removes_user_from_attention_set(self):
+ patch = utils.create_patch()
+ user = utils.create_user()
+ submitter = utils.create_person(user=user)
+ interest = utils.create_attention_set(patch=patch, user=user)
+
+ # we have an active interest
+ self.assertFalse(interest.removed)
+ utils.create_patch_comment(patch=patch, submitter=submitter)
+
+ attention_set = PatchAttentionSet.raw_objects.filter(
+ patch=patch, user=user
+ ).all()
+ self.assertEqual(len(attention_set), 1)
+ self.assertTrue(attention_set[0].removed)
diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py
index 4f404891..9d75ac13 100644
--- a/patchwork/tests/utils.py
+++ b/patchwork/tests/utils.py
@@ -11,7 +11,7 @@
from django.contrib.auth.models import User
from django.utils import timezone as tz_utils
-from patchwork.models import Bundle
+from patchwork.models import Bundle, PatchAttentionSet
from patchwork.models import Check
from patchwork.models import Cover
from patchwork.models import CoverComment
@@ -206,6 +206,16 @@ def create_patch(**kwargs):
return patch
+def create_attention_set(**kwargs):
+ values = {
+ 'patch': create_patch() if 'patch' not in kwargs else None,
+ 'user': create_person() if 'user' not in kwargs else None,
+ }
+ values.update(kwargs)
+
+ return PatchAttentionSet.objects.create(**values)
+
+
def create_cover(**kwargs):
"""Create 'Cover' object."""
num = Cover.objects.count()
diff --git a/patchwork/tests/views/test_patch.py b/patchwork/tests/views/test_patch.py
index 3de558f0..eec50526 100644
--- a/patchwork/tests/views/test_patch.py
+++ b/patchwork/tests/views/test_patch.py
@@ -17,7 +17,7 @@
from patchwork.models import Check
from patchwork.models import Patch
from patchwork.models import State
-from patchwork.tests.utils import create_check
+from patchwork.tests.utils import create_attention_set, create_check
from patchwork.tests.utils import create_maintainer
from patchwork.tests.utils import create_patch
from patchwork.tests.utils import create_patch_comment
@@ -205,6 +205,10 @@ def test_utf8_handling(self):
class PatchViewTest(TestCase):
+ def setUp(self):
+ self.project = create_project()
+ self.maintainer = create_maintainer(self.project)
+
def test_redirect(self):
patch = create_patch()
@@ -380,6 +384,122 @@ def test_patch_with_checks(self):
),
)
+ def test_patch_with_attention_set(self):
+ user = create_user()
+ patch = create_patch(project=self.project)
+ create_attention_set(patch=patch, user=user)
+ create_attention_set(patch=patch, user=self.maintainer)
+
+ self.client.login(
+ username=self.maintainer.username,
+ password=self.maintainer.username,
+ )
+ requested_url = reverse(
+ 'patch-detail',
+ kwargs={
+ 'project_id': patch.project.linkname,
+ 'msgid': patch.encoded_msgid,
+ },
+ )
+ response = self.client.get(requested_url)
+
+ # the response should contain attention set list
+ self.assertContains(response, 'Users pending actions')
+
+ # and it should show the existing users in the list
+ self.assertEqual(
+ response.content.decode().count(
+ f'{self.maintainer.username} ({self.maintainer.email})'
+ ),
+ 1,
+ )
+ self.assertEqual(
+ response.content.decode().count(f'{user.username} ({user.email})'),
+ 1,
+ )
+
+ # should display remove button for all
+ self.assertEqual(
+ response.content.decode().count('glyphicon-trash'),
+ 2,
+ )
+
+ def test_patch_with_anonymous_user_with_attention_list(self):
+ # show not show a declare interest button nor remove buttons
+ user = create_user()
+ patch = create_patch(project=self.project)
+ create_attention_set(patch=patch, user=user)
+ create_attention_set(patch=patch, user=self.maintainer)
+
+ requested_url = reverse(
+ 'patch-detail',
+ kwargs={
+ 'project_id': patch.project.linkname,
+ 'msgid': patch.encoded_msgid,
+ },
+ )
+ response = self.client.get(requested_url)
+
+ self.assertEqual(
+ response.content.decode().count('Declare interest'),
+ 0,
+ )
+ self.assertEqual(
+ response.content.decode().count('glyphicon-trash'),
+ 0,
+ )
+
+ def test_patch_with_user_not_in_attention_list(self):
+ # a declare interest button should be displayed
+ patch = create_patch(project=self.project)
+
+ self.client.login(
+ username=self.maintainer.username,
+ password=self.maintainer.username,
+ )
+ requested_url = reverse(
+ 'patch-detail',
+ kwargs={
+ 'project_id': patch.project.linkname,
+ 'msgid': patch.encoded_msgid,
+ },
+ )
+ response = self.client.get(requested_url)
+
+ self.assertEqual(
+ response.content.decode().count('Declare interest'),
+ 1,
+ )
+
+ def test_patch_with_user_in_attention_list(self):
+ # a remove button should be displayed if he is authenticated
+ # should not show option for other users
+ user = create_user()
+ patch = create_patch(project=self.project)
+ create_attention_set(patch=patch, user=user)
+ create_attention_set(patch=patch, user=self.maintainer)
+
+ self.client.login(
+ username=user.username,
+ password=user.username,
+ )
+ requested_url = reverse(
+ 'patch-detail',
+ kwargs={
+ 'project_id': patch.project.linkname,
+ 'msgid': patch.encoded_msgid,
+ },
+ )
+ response = self.client.get(requested_url)
+ self.assertEqual(
+ response.content.decode().count(f'{user.username} ({user.email})'),
+ 1,
+ )
+ self.assertEqual(
+ response.content.decode().count('glyphicon-trash'),
+ 1,
+ )
+
class PatchUpdateTest(TestCase):
properties_form_id = 'patch-form-properties'
diff --git a/patchwork/tests/views/test_series.py b/patchwork/tests/views/test_series.py
new file mode 100644
index 00000000..c37a98df
--- /dev/null
+++ b/patchwork/tests/views/test_series.py
@@ -0,0 +1,51 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2024 Meta Platforms, Inc. and affiliates.
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from datetime import datetime as dt
+
+from django.test import TestCase
+from django.urls import reverse
+
+from patchwork.models import Person
+from patchwork.tests.utils import create_patch
+from patchwork.tests.utils import create_cover
+from patchwork.tests.utils import create_person
+from patchwork.tests.utils import create_project
+from patchwork.tests.utils import create_series
+from patchwork.tests.utils import create_user
+
+
+class SeriesList(TestCase):
+ def setUp(self):
+ self.project = create_project()
+ self.user = create_user()
+ self.person_1 = Person.objects.get(user=self.user)
+ self.person_2 = create_person()
+ self.series_1 = create_series(project=self.project)
+ self.series_2 = create_series(project=self.project)
+ create_cover(project=self.project, series=self.series_1)
+
+ for i in range(5):
+ create_patch(
+ submitter=self.person_1,
+ project=self.project,
+ series=self.series_1,
+ date=dt(2014, 3, 16, 13, 4, 50, 155643),
+ )
+ create_patch(
+ submitter=self.person_2,
+ project=self.project,
+ series=self.series_2,
+ date=dt(2014, 3, 16, 13, 4, 50, 155643),
+ )
+
+ def test_series_list(self):
+ requested_url = reverse(
+ 'series-list',
+ kwargs={'project_id': self.project.linkname},
+ )
+ response = self.client.get(requested_url)
+
+ self.assertEqual(response.status_code, 200)
diff --git a/patchwork/urls.py b/patchwork/urls.py
index 11cd8e7c..9c036bb6 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -33,9 +33,19 @@
path('', project_views.project_list, name='project-list'),
path(
'project//list/',
+ patch_views.patch_list_redirect,
+ name='patch-list-redirect',
+ ),
+ path(
+ 'project//patches/',
patch_views.patch_list,
name='patch-list',
),
+ path(
+ 'project//series/',
+ series_views.series_list,
+ name='series-list',
+ ),
path(
'project//bundles/',
bundle_views.bundle_list,
@@ -110,6 +120,11 @@
name='comment-redirect',
),
# series views
+ path(
+ 'project//series//',
+ series_views.series_detail,
+ name='series-detail',
+ ),
path(
'series//mbox/',
series_views.series_mbox,
diff --git a/patchwork/views/__init__.py b/patchwork/views/__init__.py
index db484c79..76f2ce20 100644
--- a/patchwork/views/__init__.py
+++ b/patchwork/views/__init__.py
@@ -344,7 +344,7 @@ def process_multiplepatch_form(request, form, action, patches, context):
changed_patches = 0
for patch in patches:
- if not patch.is_editable(request.user):
+ if not patch.is_editable(request.user, form.review_status_only()):
errors.append(
"You don't have permissions to edit patch '%s'" % patch.name
)
diff --git a/patchwork/views/patch.py b/patchwork/views/patch.py
index efe94f17..9b7339cc 100644
--- a/patchwork/views/patch.py
+++ b/patchwork/views/patch.py
@@ -4,18 +4,21 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from django.contrib import messages
+from django.contrib.auth.models import User
from django.http import Http404
from django.http import HttpResponse
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import render
+from django.shortcuts import redirect
from django.urls import reverse
from patchwork.forms import CreateBundleForm
from patchwork.forms import PatchForm
from patchwork.models import Cover
from patchwork.models import Patch
+from patchwork.models import PatchAttentionSet
from patchwork.models import Project
from patchwork.views import generic_list
from patchwork.views import set_bundle
@@ -38,6 +41,11 @@ def patch_list(request, project_id):
return render(request, 'patchwork/list.html', context)
+def patch_list_redirect(request, project_id):
+ new_url = reverse('patch-list', kwargs={'project_id': project_id})
+ return redirect(f'{new_url}?{request.GET.urlencode()}')
+
+
def patch_detail(request, project_id, msgid):
project = get_object_or_404(Project, linkname=project_id)
db_msgid = Patch.decode_msgid(msgid)
@@ -61,6 +69,10 @@ def patch_detail(request, project_id, msgid):
editable = patch.is_editable(request.user)
context = {'project': patch.project}
+ is_maintainer = (
+ request.user.is_authenticated
+ and project in request.user.profile.maintainer_projects.all()
+ )
form = None
create_bundle_form = None
@@ -80,6 +92,50 @@ def patch_detail(request, project_id, msgid):
errors = set_bundle(
request, project, action, request.POST, [patch]
)
+ elif action in ['add-interest', 'remove-interest']:
+ if request.user.is_authenticated:
+ if action == 'add-interest':
+ PatchAttentionSet.objects.get_or_create(
+ patch=patch, user=request.user
+ )
+ message = (
+ 'You have declared interest in reviewing this patch'
+ )
+ else:
+ user_id = request.POST.get('attention_set')
+
+ if is_maintainer or user_id == str(request.user.id):
+ rm_user = User.objects.get(pk=user_id)
+ PatchAttentionSet.objects.filter(
+ patch=patch, user=rm_user
+ ).delete()
+
+ rm_user_name = (
+ f"'{rm_user.username}'"
+ if rm_user != request.user
+ else 'yourself'
+ )
+ message = (
+ f"You removed {rm_user_name} from patch's "
+ 'attention list'
+ )
+
+ patch.save()
+ messages.success(
+ request,
+ message,
+ )
+ else:
+ messages.error(
+ request,
+ "You can't remove another user interest in this "
+ 'patch',
+ )
+ else:
+ messages.error(
+ request,
+ 'You must be logged in to change the user attention list.',
+ )
elif not editable:
return HttpResponseForbidden()
@@ -93,6 +149,13 @@ def patch_detail(request, project_id, msgid):
if request.user.is_authenticated:
context['bundles'] = request.user.bundles.all()
+ attention_set = [
+ data.user for data in PatchAttentionSet.objects.filter(patch=patch)
+ ]
+
+ context['attention_set'] = attention_set
+ context['is_maintainer'] = is_maintainer
+
comments = patch.comments.all()
comments = comments.select_related('submitter')
comments = comments.only(
@@ -127,6 +190,20 @@ def patch_detail(request, project_id, msgid):
if errors:
context['errors'] = errors
+ try:
+ context['previous_submission'] = Patch.objects.get(
+ series=patch.series, number=patch.number - 1
+ )
+ except Patch.DoesNotExist:
+ context['previous_submission'] = None
+
+ try:
+ context['next_submission'] = Patch.objects.get(
+ series=patch.series, number=patch.number + 1
+ )
+ except Patch.DoesNotExist:
+ context['next_submission'] = None
+
return render(request, 'patchwork/submission.html', context)
diff --git a/patchwork/views/series.py b/patchwork/views/series.py
index a8892ae6..a36b8041 100644
--- a/patchwork/views/series.py
+++ b/patchwork/views/series.py
@@ -2,12 +2,18 @@
# Copyright (C) 2017 Stephen Finucane
#
# SPDX-License-Identifier: GPL-2.0-or-later
+import collections
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
+from django.shortcuts import render
from patchwork.models import Series
+from patchwork.models import Patch
+from patchwork.models import Project
+from patchwork.views import generic_list
from patchwork.views.utils import series_to_mbox
+from patchwork.paginator import Paginator
def series_mbox(request, series_id):
@@ -20,3 +26,64 @@ def series_mbox(request, series_id):
)
return response
+
+
+def series_detail(request, project_id, series_id):
+ series = get_object_or_404(Series, id=series_id)
+
+ patches = Patch.objects.filter(series=series)
+
+ context = generic_list(
+ request,
+ series.project,
+ 'series-detail',
+ view_args={
+ 'project_id': project_id,
+ 'series_id': series_id,
+ },
+ patches=patches,
+ )
+
+ context.update({'series': series})
+
+ return render(request, 'patchwork/series-detail.html', context)
+
+
+def series_list(request, project_id):
+ project = get_object_or_404(Project, linkname=project_id)
+ sort = request.GET.get('order', 'date.desc')
+ sort_field, sort_dir = sort.split('.')
+ sort_order = f"{'-' if sort_dir == 'desc' else ''}{sort_field}"
+ context = {}
+ series_list = (
+ Series.objects.filter(project=project)
+ .only(
+ 'submitter',
+ 'project',
+ 'version',
+ 'name',
+ 'date',
+ 'id',
+ 'cover_letter',
+ )
+ .select_related('project', 'submitter', 'cover_letter')
+ .order_by(sort_order)
+ )
+
+ paginator = Paginator(request, series_list)
+ context.update(
+ {
+ 'project': project,
+ 'projects': Project.objects.all(),
+ 'series_list': series_list,
+ 'page': paginator.current_page,
+ 'order': sort,
+ 'list_view': {
+ 'view': 'series-list',
+ 'view_params': {'project_id': project.linkname},
+ 'params': collections.OrderedDict(),
+ },
+ }
+ )
+
+ return render(request, 'patchwork/series-list.html', context)
diff --git a/releasenotes/notes/add-series-dependencies-6696458586e795c7.yaml b/releasenotes/notes/add-series-dependencies-6696458586e795c7.yaml
index 3cb80fef..c8fb6a7f 100644
--- a/releasenotes/notes/add-series-dependencies-6696458586e795c7.yaml
+++ b/releasenotes/notes/add-series-dependencies-6696458586e795c7.yaml
@@ -13,6 +13,7 @@ features:
``Depends-on: <20240726221429.221611-1-user@example.com>``
Alternatively, the web URL of the patch or series may be given:
``Depends-on: http://patchwork.example.com/project/test/list?series=1111``
+ ``Depends-on: http://patchwork.example.com/project/test/series/1111``
api:
- |
The API version has been updated to v1.4.
diff --git a/releasenotes/notes/add_series_list_view-bf219022216fea6a.yaml b/releasenotes/notes/add_series_list_view-bf219022216fea6a.yaml
new file mode 100644
index 00000000..c8b32e6a
--- /dev/null
+++ b/releasenotes/notes/add_series_list_view-bf219022216fea6a.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ A series view is now available, allowing users to list available series and
+ view details of individual series.
diff --git a/templates/base.html b/templates/base.html
index 9519ecc5..49663d7f 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -48,6 +48,12 @@
{% block navbarmenu %}
{% if project %}
|