From de6b759bad9e190a530d407d64c5cc3342a9039f Mon Sep 17 00:00:00 2001
From: andrepapoti <andrepapoti@gmail.com>
Date: Mon, 1 Apr 2024 10:14:39 -0300
Subject: [PATCH 01/14] models: add attention set to patches

Patches that needs the attention of a specific user can now be linked to
them by the attention set field.

Attention flags by default are soft deleted.

Signed-off-by: andrepapoti <andrepapoti@gmail.com>
Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 .../0049_add_attention_set_to_patches.py      |  61 +++++++++++
 patchwork/models.py                           | 101 ++++++++++++++++++
 2 files changed, 162 insertions(+)
 create mode 100644 patchwork/migrations/0049_add_attention_set_to_patches.py

diff --git a/patchwork/migrations/0049_add_attention_set_to_patches.py b/patchwork/migrations/0049_add_attention_set_to_patches.py
new file mode 100644
index 00000000..864f9b9f
--- /dev/null
+++ b/patchwork/migrations/0049_add_attention_set_to_patches.py
@@ -0,0 +1,61 @@
+# Generated by Django 5.1.7 on 2025-03-22 05:06
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('patchwork', '0048_series_dependencies'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PatchAttentionSet',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                ('last_updated', models.DateTimeField(auto_now=True)),
+                ('removed', models.BooleanField(default=False)),
+                (
+                    'removed_reason',
+                    models.CharField(blank=True, max_length=50),
+                ),
+                (
+                    'patch',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='patchwork.patch',
+                    ),
+                ),
+                (
+                    'user',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                'unique_together': {('patch', 'user')},
+            },
+        ),
+        migrations.AddField(
+            model_name='patch',
+            name='attention_set',
+            field=models.ManyToManyField(
+                related_name='attention_set',
+                through='patchwork.PatchAttentionSet',
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
diff --git a/patchwork/models.py b/patchwork/models.py
index ae2f4a6d..cea4db67 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -503,6 +503,9 @@ class Patch(SubmissionMixin):
         null=True,
         on_delete=models.CASCADE,
     )
+    attention_set = models.ManyToManyField(
+        User, through='PatchAttentionSet', related_name='attention_set'
+    )
     state = models.ForeignKey(State, null=True, on_delete=models.CASCADE)
     archived = models.BooleanField(default=False)
     hash = HashField(null=True, blank=True, db_index=True)
@@ -827,6 +830,104 @@ class Meta:
         ]
 
 
+class PatchAttentionSetManager(models.Manager):
+    def get_queryset(self):
+        return super().get_queryset().filter(removed=False)
+
+    def upsert(self, patch, users):
+        """Add or updates deleted attention set entries
+
+        :param patch: patch object to be updated
+        :type patch: Patch
+        :param users: list of users to be added to the attention set list
+        :type users: list[int]
+        """
+        qs = super().get_queryset().filter(patch=patch)
+
+        existing = {
+            obj.user.id: obj for obj in qs.filter(user__in=users).all()
+        }
+        update_list = []
+        for obj in existing.values():
+            if obj.removed:
+                obj.removed = False
+                obj.removed_reason = ''
+                update_list.append(obj)
+        insert_list = [user for user in users if user not in existing.keys()]
+
+        qs.bulk_create(
+            [PatchAttentionSet(patch=patch, user_id=id) for id in insert_list]
+        )
+        qs.bulk_update(update_list, ['removed', 'removed_reason'])
+
+    def soft_delete(self, patch, users, reason=''):
+        """Mark attention set entries as deleted
+
+        :param patch: patch object to be updated
+        :type patch: Patch
+        :param users: list of users to be added to the attention set list
+        :type users: list[int]
+        :param reason: reason for removal
+        :type reason: string
+        """
+        qs = super().get_queryset().filter(patch=patch)
+
+        existing = {
+            obj.user.id: obj for obj in qs.filter(user__in=users).all()
+        }
+        update_list = []
+        for obj in existing.values():
+            if not obj.removed:
+                obj.removed = True
+                obj.removed_reason = reason
+                update_list.append(obj)
+
+        self.bulk_update(update_list, ['removed', 'removed_reason'])
+
+
+class PatchAttentionSet(models.Model):
+    patch = models.ForeignKey(Patch, on_delete=models.CASCADE)
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    last_updated = models.DateTimeField(auto_now=True)
+    removed = models.BooleanField(default=False)
+    removed_reason = models.CharField(max_length=50, blank=True)
+
+    objects = PatchAttentionSetManager()
+    raw_objects = models.Manager()
+
+    def delete(self):
+        """Soft deletes an user from the patch attention set"""
+        self.removed = True
+        self.removed_reason = 'reviewed or commented on the patch'
+        self.save()
+
+    def __str__(self):
+        return f'<{self.user} - {self.user.email}>'
+
+    class Meta:
+        unique_together = [('patch', 'user')]
+
+
+def _remove_user_from_patch_attention_set(sender, instance, created, **kwargs):
+    if created:
+        submitter = instance.submitter
+        patch = instance.patch
+        if submitter.user:
+            try:
+                # Don't use the RelatedManager since it will execute a hard
+                # delete
+                PatchAttentionSet.objects.get(
+                    patch=patch, user=submitter.user
+                ).delete()
+            except PatchAttentionSet.DoesNotExist:
+                pass
+
+
+models.signals.post_save.connect(
+    _remove_user_from_patch_attention_set, sender=PatchComment
+)
+
+
 class Series(FilenameMixin, models.Model):
     """A collection of patches."""
 

From 3d96133f9dc7fdb055b81e643821b2c0f67d0fb5 Mon Sep 17 00:00:00 2001
From: andrepapoti <andrepapoti@gmail.com>
Date: Mon, 1 Apr 2024 10:26:35 -0300
Subject: [PATCH 02/14] api: add attention_set to patch

Users can now add/remove themselves from a patch attention list if they
want to sinalize that they will look into it.

To add themselves to the attention list they must execute a patch with a
list with their IDs in the `attention_set` property. To remove
themselves they must send a list with their negative IDs or an empty
list.

Maintainers can also remove anyone from the attention list by sending a
list with negative IDs.

Signed-off-by: andrepapoti <andrepapoti@gmail.com>
Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 patchwork/api/patch.py            | 110 ++++++++++++++++++++++++++----
 patchwork/tests/api/test_patch.py |   2 +-
 2 files changed, 97 insertions(+), 15 deletions(-)

diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
index 443c3822..dae4269b 100644
--- a/patchwork/api/patch.py
+++ b/patchwork/api/patch.py
@@ -7,6 +7,7 @@
 
 import email.parser
 
+from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
@@ -15,6 +16,7 @@
 from rest_framework.generics import ListAPIView
 from rest_framework.generics import RetrieveUpdateAPIView
 from rest_framework.relations import RelatedField
+from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.serializers import SerializerMethodField
 from rest_framework import status
@@ -28,6 +30,7 @@
 from patchwork.api.embedded import UserSerializer
 from patchwork.api.filters import PatchFilterSet
 from patchwork.models import Patch
+from patchwork.models import PatchAttentionSet
 from patchwork.models import PatchRelation
 from patchwork.models import State
 from patchwork.parser import clean_subject
@@ -76,12 +79,27 @@ class PatchConflict(APIException):
     )
 
 
+class PatchAttentionSetSerializer(BaseHyperlinkedModelSerializer):
+    user = UserSerializer()
+
+    class Meta:
+        model = PatchAttentionSet
+        fields = [
+            'user',
+            'last_updated',
+        ]
+
+
 class PatchListSerializer(BaseHyperlinkedModelSerializer):
     web_url = SerializerMethodField()
     project = ProjectSerializer(read_only=True)
     state = StateField()
     submitter = PersonSerializer(read_only=True)
     delegate = UserSerializer(allow_null=True)
+    attention_set = PatchAttentionSetSerializer(
+        source='patchattentionset_set',
+        many=True,
+    )
     mbox = SerializerMethodField()
     series = SeriesSerializer(read_only=True)
     comments = SerializerMethodField()
@@ -170,6 +188,7 @@ class Meta:
             'hash',
             'submitter',
             'delegate',
+            'attention_set',
             'mbox',
             'series',
             'comments',
@@ -201,6 +220,7 @@ class Meta:
                 'list_archive_url',
                 'related',
             ),
+            '1.4': ('attention_set',),
         }
         extra_kwargs = {
             'url': {'view_name': 'api-patch-detail'},
@@ -228,16 +248,7 @@ def get_headers(self, patch):
     def get_prefixes(self, instance):
         return clean_subject(instance.name)[1]
 
-    def update(self, instance, validated_data):
-        # d-r-f cannot handle writable nested models, so we handle that
-        # specifically ourselves and let d-r-f handle the rest
-        if 'related' not in validated_data:
-            return super(PatchDetailSerializer, self).update(
-                instance, validated_data
-            )
-
-        related = validated_data.pop('related')
-
+    def update_related(self, instance, related):
         # Validation rules
         # ----------------
         #
@@ -278,9 +289,7 @@ def update(self, instance, validated_data):
             if instance.related and instance.related.patches.count() == 2:
                 instance.related.delete()
             instance.related = None
-            return super(PatchDetailSerializer, self).update(
-                instance, validated_data
-            )
+            return
 
         # break before make
         relations = {patch.related for patch in patches if patch.related}
@@ -304,6 +313,14 @@ def update(self, instance, validated_data):
         instance.related = relation
         instance.save()
 
+    def update(self, instance, validated_data):
+        # d-r-f cannot handle writable nested models, so we handle that
+        # specifically ourselves and let d-r-f handle the rest
+
+        if 'related' in validated_data:
+            related = validated_data.pop('related')
+            self.update_related(instance, related)
+
         return super(PatchDetailSerializer, self).update(
             instance, validated_data
         )
@@ -367,6 +384,7 @@ def get_queryset(self):
                 'project',
                 'series__project',
                 'related__patches__project',
+                'patchattentionset_set',
             )
             .select_related('state', 'submitter', 'series')
             .defer('content', 'diff', 'headers')
@@ -381,11 +399,16 @@ class PatchDetail(RetrieveUpdateAPIView):
     patch:
     Update a patch.
 
+    Users can set their intention to review or comment about a patch using the
+    `attention_set` property. Users can set their intentions by adding their
+    IDs or its negative value to the list. Maintainers can remove people from
+    the list but only a user can add itself.
+
+
     put:
     Update a patch.
     """
 
-    permission_classes = (PatchworkPermission,)
     serializer_class = PatchDetailSerializer
 
     def get_queryset(self):
@@ -396,3 +419,62 @@ def get_queryset(self):
                 'project', 'state', 'submitter', 'delegate', 'series'
             )
         )
+
+    def partial_update(self, request, *args, **kwargs):
+        obj = self.get_object()
+        req_user_id = request.user.id
+        is_maintainer = request.user.is_authenticated and (
+            obj.project in request.user.profile.maintainer_projects.all()
+        )
+
+        if 'attention_set' in request.data and request.method in ('PATCH',):
+            attention_set = request.data.get('attention_set', None)
+            del request.data['attention_set']
+            removal_list = [
+                -user_id for user_id in set(attention_set) if user_id < 0
+            ]
+            addition_list = [
+                user_id for user_id in set(attention_set) if user_id > 0
+            ]
+
+            if not addition_list and not removal_list:
+                removal_list = [req_user_id]
+
+            if len(addition_list) > 1 or (
+                addition_list and req_user_id not in addition_list
+            ):
+                raise PermissionDenied(
+                    detail="Only the user can declare it's own intention of "
+                    'reviewing a patch'
+                )
+
+            if not is_maintainer:
+                if removal_list and req_user_id not in removal_list:
+                    raise PermissionDenied(
+                        detail="Only the user can remove it's own "
+                        'intention of reviewing a patch'
+                    )
+
+            try:
+                if addition_list:
+                    PatchAttentionSet.objects.upsert(obj, addition_list)
+                if removal_list:
+                    PatchAttentionSet.objects.soft_delete(
+                        obj, removal_list, reason=f'removed by {request.user}'
+                    )
+            except User.DoesNotExist:
+                return Response(
+                    {'message': 'Unable to find referenced user'},
+                    status=404,
+                )
+
+            if not is_maintainer:
+                serializer = self.get_serializer(obj)
+                return Response(
+                    serializer.data,
+                    status=200,
+                )
+
+        return super(PatchDetail, self).partial_update(
+            request, *args, **kwargs
+        )
diff --git a/patchwork/tests/api/test_patch.py b/patchwork/tests/api/test_patch.py
index 2661d75c..7011160e 100644
--- a/patchwork/tests/api/test_patch.py
+++ b/patchwork/tests/api/test_patch.py
@@ -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')

From 1f51b1f8c04ca981717125113fcc0bb7f2ce92e8 Mon Sep 17 00:00:00 2001
From: Andre Papoti <andre.papoti@profusion.mobi>
Date: Mon, 23 Dec 2024 16:55:49 -0300
Subject: [PATCH 03/14] views: manage users in patch attention list

* List view: show number of users interested in a specific patch
* Detail view: show a list of users who are interested in the patch
    * Allow users to add/remove interest in a patch
    * Allow managers to remove interested user from a patch

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 htdocs/css/style.css                          | 24 ++++++++
 patchwork/forms.py                            | 19 +++++++
 patchwork/models.py                           |  5 +-
 .../patchwork/partials/patch-list.html        |  5 ++
 patchwork/templates/patchwork/submission.html | 28 +++++++++
 patchwork/templatetags/patch.py               | 15 +++++
 patchwork/views/__init__.py                   |  2 +-
 patchwork/views/patch.py                      | 57 +++++++++++++++++++
 8 files changed, 152 insertions(+), 3 deletions(-)

diff --git a/htdocs/css/style.css b/htdocs/css/style.css
index 268a8c37..ede3314c 100644
--- a/htdocs/css/style.css
+++ b/htdocs/css/style.css
@@ -288,6 +288,18 @@ table.patch-meta tr th, table.patch-meta tr td {
     text-decoration: underline;
 }
 
+.patchinterest {
+    display: inline-block;
+    border-radius: 7px;
+    min-width: 0.9em;
+    padding: 0 2px;
+    text-align: center;
+}
+
+.patchinterest.exists {
+    background-color: #82ca9d;
+}
+
 .patchlistchecks {
     display: inline-block;
     border-radius: 7px;
@@ -344,6 +356,18 @@ table.patch-meta tr th, table.patch-meta tr td {
     font-family: "DejaVu Sans Mono", fixed;
 }
 
+.submission-attention-set {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+}
+
+button[class*=interest-action] {
+    padding: 0.2em 0.5em;
+    border-radius: 4px;
+}
+
 div[class^="comment-status-bar-"] {
     display: flex;
     flex-wrap: wrap;
diff --git a/patchwork/forms.py b/patchwork/forms.py
index cf77bdcc..f877e625 100644
--- a/patchwork/forms.py
+++ b/patchwork/forms.py
@@ -252,8 +252,27 @@ def save(self, instance, commit=True):
             if field.is_no_change(data[f.name]):
                 continue
 
+            if f.name == 'review_status':
+                if data[f.name]:
+                    self.instance.attention_set.add(self.user)
+                else:
+                    self.instance.attention_set.remove(self.user)
+                continue
+
             setattr(instance, f.name, data[f.name])
 
         if commit:
             instance.save()
         return instance
+
+    def review_status_only(self):
+        review_status_only = True
+        field_names = set(self.fields.keys())
+        field_names.discard({'review_status', 'action'})
+
+        for field_name in field_names:
+            data = self.data.get(field_name, '*')
+            if data != '*':
+                review_status_only = False
+
+        return review_status_only
diff --git a/patchwork/models.py b/patchwork/models.py
index cea4db67..9b8e1eb5 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -582,7 +582,7 @@ def save(self, *args, **kwargs):
 
         self.refresh_tag_counts()
 
-    def is_editable(self, user):
+    def is_editable(self, user, declare_interest_only=False):
         if not user.is_authenticated:
             return False
 
@@ -593,7 +593,8 @@ def is_editable(self, user):
         if self.project.is_editable(user):
             self._edited_by = user
             return True
-        return False
+
+        return declare_interest_only
 
     @staticmethod
     def filter_unique_checks(checks):
diff --git a/patchwork/templates/patchwork/partials/patch-list.html b/patchwork/templates/patchwork/partials/patch-list.html
index 981ceee5..fcc4793e 100644
--- a/patchwork/templates/patchwork/partials/patch-list.html
+++ b/patchwork/templates/patchwork/partials/patch-list.html
@@ -80,6 +80,10 @@
           <span title="Success / Warning / Fail">S/W/F</span>
         </th>
 
+        <th>
+          <span title="Declared review interest">Review Interest</span>
+        </th>
+
         <th>
 {% if not order.editable %}
 {% if order.name == "date" %}
@@ -182,6 +186,7 @@
         </td>
         <td id="patch-tags:{{patch.id}}" class="text-nowrap">{{ patch|patch_tags }}</td>
         <td id="patch-checks:{{patch.id}}" class="text-nowrap">{{ patch|patch_checks }}</td>
+        <td id="patch-interest:{{patch.id}}" class="text-nowrap">{{ patch|patch_interest }}</td>
         <td id="patch-date:{{patch.id}}" class="text-nowrap">{{ patch.date|date:"Y-m-d" }}</td>
         <td id="patch-submitter:{{patch.id}}">{{ patch.submitter|personify:project }}</td>
         <td id="patch-delegate:{{patch.id}}">{{ patch.delegate.username }}</td>
diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html
index cd74491c..fb882d97 100644
--- a/patchwork/templates/patchwork/submission.html
+++ b/patchwork/templates/patchwork/submission.html
@@ -187,6 +187,34 @@ <h2>Message</h2>
   </pre>
 </div>
 
+<div class="submission-attention-set">
+  <h2>Users pending actions</h2>
+  {% if user.is_authenticated and user not in attention_set %}
+  <form id="declare-review-form" method="post">
+    {% csrf_token %}
+    <input type="hidden" name="action" value="add-interest">
+    <button type="submit" class="btn-default interest-action-add">Declare interest</button>
+  </form>
+  {% endif %}
+</div>
+{% if attention_set %}
+  <ul>
+    {% for set_user in attention_set %}
+      <li>
+        <form id="declare-review-form" method="post">
+        {% csrf_token %}
+        <input type="hidden" name="action" value="remove-interest">
+          <input type="hidden" name="attention_set" value="{{set_user.id}}">
+        {{ set_user.username }} ({{ set_user.email }})
+        {% if set_user == user or is_maintainer %}
+            <button type="submit" class="btn-link"><span class="glyphicon glyphicon-trash" ></span></button>
+        {% endif %}
+        </form>
+      </li>
+    {% endfor %}
+  </ul>
+{% endif %}
+
 {% for item in comments %}
 {% if forloop.first %}
 <h2>Comments</h2>
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(
         '<a href="%s">%s</a>' % (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(
+        '<span class="patchinterest %s" title="%s">%s</span>'
+        % (review_class, review_title, reviews if reviews > 0 else '-')
+    )
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..6c0e90f7 100644
--- a/patchwork/views/patch.py
+++ b/patchwork/views/patch.py
@@ -4,6 +4,7 @@
 # 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
@@ -16,6 +17,7 @@
 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
@@ -61,6 +63,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 +86,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 +143,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(

From 38d4d249baeae5496236a18a07b965e1e8ed5808 Mon Sep 17 00:00:00 2001
From: Victor Accarini <victor.accarini@profusion.mobi>
Date: Thu, 20 Mar 2025 23:32:26 -0300
Subject: [PATCH 04/14] admin: add attention set information in patches

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 patchwork/admin.py | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/patchwork/admin.py b/patchwork/admin.py
index d1c389a1..409df2f8 100644
--- a/patchwork/admin.py
+++ b/patchwork/admin.py
@@ -23,6 +23,7 @@
 from patchwork.models import State
 from patchwork.models import Tag
 from patchwork.models import UserProfile
+from patchwork.models import PatchAttentionSet
 
 
 class UserProfileInline(admin.StackedInline):
@@ -86,6 +87,17 @@ class CoverAdmin(admin.ModelAdmin):
 admin.site.register(Cover, CoverAdmin)
 
 
+class PatchAttentionSetInline(admin.StackedInline):
+    model = PatchAttentionSet
+    fields = ('user',)
+    extra = 0
+    verbose_name = 'user'
+    verbose_name_plural = 'attention set users'
+
+    def has_change_permission(self, request, obj=None):
+        return False
+
+
 class PatchAdmin(admin.ModelAdmin):
     list_display = (
         'name',
@@ -99,6 +111,7 @@ class PatchAdmin(admin.ModelAdmin):
     list_filter = ('project', 'submitter', 'state', 'archived')
     list_select_related = ('submitter', 'project', 'state')
     search_fields = ('name', 'submitter__name', 'submitter__email')
+    inlines = (PatchAttentionSetInline,)
     date_hierarchy = 'date'
 
     def is_pull_request(self, patch):

From 229b9c478d2a459436668297891c5b531552851d Mon Sep 17 00:00:00 2001
From: Victor Accarini <victor.accarini@profusion.mobi>
Date: Fri, 21 Mar 2025 12:08:45 -0300
Subject: [PATCH 05/14] docs: update schema with patch attention set

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 docs/api/schemas/latest/patchwork.yaml | 23 ++++++++++++++++++++
 docs/api/schemas/patchwork.j2          | 29 ++++++++++++++++++++++++++
 docs/api/schemas/v1.4/patchwork.yaml   | 23 ++++++++++++++++++++
 3 files changed, 75 insertions(+)

diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml
index b2bb220f..7847095c 100644
--- a/docs/api/schemas/latest/patchwork.yaml
+++ b/docs/api/schemas/latest/patchwork.yaml
@@ -2312,6 +2312,11 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/PatchEmbedded'
+        attention_set:
+          title: AttentionSet
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchAttentionSet'
     PatchDetail:
       type: object
       title: Patches
@@ -2390,6 +2395,11 @@ components:
           type: array
           items:
             type: integer
+        attention_set:
+          title: Attention Set
+          type: array
+          items:
+            type: integer
     Person:
       type: object
       title: Person
@@ -2831,6 +2841,19 @@ components:
           type: string
           format: uri
           readOnly: true
+    PatchAttentionSet:
+      type: object
+      title: PatchAttentionSet
+      description: |
+        A user interested or with pending actions over a patch
+      properties:
+        user:
+          $ref: '#/components/schemas/UserEmbedded'
+        last_updated:
+          title: last_updated
+          type: string
+          format: iso8601
+          readOnly: true
     PatchEmbedded:
       type: object
       title: Patch
diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
index f37d3213..31ef75ce 100644
--- a/docs/api/schemas/patchwork.j2
+++ b/docs/api/schemas/patchwork.j2
@@ -2397,6 +2397,13 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/PatchEmbedded'
+{% endif %}
+{% if version >= (1, 4) %}
+        attention_set:
+          title: AttentionSet
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchAttentionSet'
 {% endif %}
     PatchDetail:
       type: object
@@ -2477,6 +2484,13 @@ components:
           type: array
           items:
             type: integer
+{% endif %}
+{% if version >= (1, 4) %}
+        attention_set:
+          title: Attention Set
+          type: array
+          items:
+            type: integer
 {% endif %}
     Person:
       type: object
@@ -2941,6 +2955,21 @@ components:
           type: string
           format: uri
           readOnly: true
+{% if version >= (1, 4) %}
+    PatchAttentionSet:
+      type: object
+      title: PatchAttentionSet
+      description: |
+        A user interested or with pending actions over a patch
+      properties:
+        user:
+          $ref: '#/components/schemas/UserEmbedded'
+        last_updated:
+          title: last_updated
+          type: string
+          format: iso8601
+          readOnly: true
+{% endif %}
     PatchEmbedded:
       type: object
       title: Patch
diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml
index 036fe15f..41d59daf 100644
--- a/docs/api/schemas/v1.4/patchwork.yaml
+++ b/docs/api/schemas/v1.4/patchwork.yaml
@@ -2312,6 +2312,11 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/PatchEmbedded'
+        attention_set:
+          title: AttentionSet
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchAttentionSet'
     PatchDetail:
       type: object
       title: Patches
@@ -2390,6 +2395,11 @@ components:
           type: array
           items:
             type: integer
+        attention_set:
+          title: Attention Set
+          type: array
+          items:
+            type: integer
     Person:
       type: object
       title: Person
@@ -2831,6 +2841,19 @@ components:
           type: string
           format: uri
           readOnly: true
+    PatchAttentionSet:
+      type: object
+      title: PatchAttentionSet
+      description: |
+        A user interested or with pending actions over a patch
+      properties:
+        user:
+          $ref: '#/components/schemas/UserEmbedded'
+        last_updated:
+          title: last_updated
+          type: string
+          format: iso8601
+          readOnly: true
     PatchEmbedded:
       type: object
       title: Patch

From 60586e3e68e45e5fea6207f63852572938eee220 Mon Sep 17 00:00:00 2001
From: Victor Accarini <victor.accarini@profusion.mobi>
Date: Fri, 21 Mar 2025 12:29:15 -0300
Subject: [PATCH 06/14] tests: validate patch attention_set api behavior

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 patchwork/tests/api/test_patch.py | 236 +++++++++++++++++++++++++++++-
 patchwork/tests/utils.py          |  12 +-
 2 files changed, 245 insertions(+), 3 deletions(-)

diff --git a/patchwork/tests/api/test_patch.py b/patchwork/tests/api/test_patch.py
index 7011160e..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
@@ -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/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()

From 748355a6a50b0054167a8c06f5571d04f8f8731c Mon Sep 17 00:00:00 2001
From: Victor Accarini <victor.accarini@profusion.mobi>
Date: Fri, 21 Mar 2025 17:02:44 -0300
Subject: [PATCH 07/14] tests: update patch view tests

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 patchwork/tests/test_signals.py     |  18 +++-
 patchwork/tests/views/test_patch.py | 122 +++++++++++++++++++++++++++-
 2 files changed, 138 insertions(+), 2 deletions(-)

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/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, '<h2>Users pending actions</h2>')
+
+        # 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'

From 9e708d2648d301e08a56ddb399fceb945641b6bb Mon Sep 17 00:00:00 2001
From: andrepapoti <andrepapoti@gmail.com>
Date: Mon, 22 Apr 2024 15:25:34 -0300
Subject: [PATCH 08/14] views: add series-list view

This view is meant to give a quickoverview on a project since some of
them can have hundreds of patches actives.
This view also allows the user to apply changes on all of the series
patches at once

Signed-off-by: andrepapoti <andrepapoti@gmail.com>
Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 htdocs/css/style.css                          |   6 +
 patchwork/models.py                           |  25 ++++
 .../templates/patchwork/series-list.html      | 111 ++++++++++++++++++
 patchwork/templatetags/series.py              |  76 ++++++++++++
 patchwork/urls.py                             |  10 ++
 patchwork/views/patch.py                      |   6 +
 patchwork/views/series.py                     |  44 +++++++
 7 files changed, 278 insertions(+)
 create mode 100644 patchwork/templates/patchwork/series-list.html
 create mode 100644 patchwork/templatetags/series.py

diff --git a/htdocs/css/style.css b/htdocs/css/style.css
index ede3314c..49366230 100644
--- a/htdocs/css/style.css
+++ b/htdocs/css/style.css
@@ -431,6 +431,12 @@ span.p_mod    { color: #a020f0; }
     font-weight: bold;
 }
 
+/* series */
+a.series-list-header {
+    color: inherit;  /* Inherit color from parent element */
+    text-decoration: none; /* Optional: removes underline */
+}
+
 /* bundles */
 table.bundlelist {
     margin-top: 2em;
diff --git a/patchwork/models.py b/patchwork/models.py
index 9b8e1eb5..4c3c6bf7 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -14,6 +14,7 @@
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.core.validators import validate_unicode_slug
+from django.db.models import Count
 from django.db import models
 from django.urls import reverse
 from django.utils.functional import cached_property
@@ -1015,6 +1016,30 @@ def add_dependencies(self, dependencies):
         )
         self.save()
 
+    @property
+    def interest_count(self):
+        count = self.patches.aggregate(Count('attention_set', distinct=True))
+        return count['attention_set__count']
+
+    @property
+    def check_count(self):
+        """Generate a list of unique checks for all patchs in the series.
+
+        Compile a list of checks associated with this series patches for each
+        type of check. Only "unique" checks are considered, identified by their
+        'context' field. This means, given n checks with the same 'context', the
+        newest check is the only one counted regardless of its value. The end
+        result will be a association of types to number of unique checks for
+        said type.
+        """
+        counts = {key: 0 for key, _ in Check.STATE_CHOICES}
+
+        for p in self.patches.all():
+            for check in p.checks:
+                counts[check.state] += 1
+
+        return counts
+
     def add_cover_letter(self, cover):
         """Add a cover letter to the series.
 
diff --git a/patchwork/templates/patchwork/series-list.html b/patchwork/templates/patchwork/series-list.html
new file mode 100644
index 00000000..c181bc86
--- /dev/null
+++ b/patchwork/templates/patchwork/series-list.html
@@ -0,0 +1,111 @@
+{% extends "base.html" %}
+
+{% load person %}
+{% load static %}
+
+{% block title %}{{project.name}}{% endblock %}
+{% block series_active %}active{% endblock %}
+
+{% block body %}
+
+{% load person %}
+{% load listurl %}
+{% load patch %}
+{% load series %}
+{% load project %}
+{% load static %}
+
+{% include "patchwork/partials/pagination.html" %}
+
+<input type="hidden" name="form" value="serieslistform"/>
+<input type="hidden" name="project" value="{{project.id}}"/>
+
+<table id="serieslist" class="table table-hover table-extra-condensed table-striped pw-list" data-toggle="checkboxes" data-range="true">
+  <thead>
+    <tr>
+{% if user.is_authenticated and user.profile.show_ids %}
+      <th>
+        ID
+      </th>
+{% endif %}
+
+      <th>
+        <span class="series-list-header">Series</span>
+      </th>
+
+      <th>
+        Version
+      </th>
+
+      <th>
+        {% project_tags %}
+      </th>
+
+      <th>
+        <span class="series-list-header" title="Success / Warning / Fail">S/W/F</span>
+      </th>
+
+      <th>
+        <span class="series-list-header" title="Declared review interest">Review Interest</span>
+      </th>
+
+      <th>
+        <span class="series-list-header">Patches</span>
+      </th>
+
+      <th>
+        {% if 'date.asc' == order %}
+        <a href="{% listurl order='date.desc' %}" class="colactive">
+          <span class="glyphicon glyphicon-chevron-up"></span>
+        {% elif 'date.desc' == order %}
+        <a href="{% listurl order='date.asc' %}" class="colactive">
+          <span class="glyphicon glyphicon-chevron-down"></span>
+        {% endif %}
+          <span class="series-list-header">Date</span>
+        {% if 'date.asc' == order or 'date.desc' == order%}
+        </a>
+        {% endif %}
+      </th>
+
+      <th>
+        <span class="series-list-header">Submitter</span>
+      </th>
+    </tr>
+  </thead>
+
+  <tbody>
+{% for series in page %}
+    <tr id="series_row:{{series.id}}">
+{% if user.is_authenticated and user.profile.show_ids %}
+      <td>
+        <button type="button" class="btn btn-xs btn-copy" data-clipboard-text="{{ series.id }}" title="Copy to Clipboard">
+          {{ series.id }}
+        </button>
+      </td>
+{% endif %}
+      <td>
+          {{ series.name|default:"[no subject]"|truncatechars:100 }}
+      </td>
+      <td>
+        {{ series.version|default:"-"}}
+      </td>
+
+      <td id="series-tags:{{series.id}}" class="text-nowrap">{{ series|series_tags }}</td>
+      <td id="series-checks:{{series.id}}" class="text-nowrap">{{ series|series_checks }}</td>
+      <td id="series-interest:{{series.id}}" class="text-nowrap">{{ series|series_interest }}</td>
+      <td>{{ series.received_total}}</td>
+      <td class="text-nowrap">{{ series.date|date:"Y-m-d" }}</td>
+      <td>{{ series.submitter|personify:project }}</td>
+    </tr>
+{% empty %}
+    <tr>
+      <td colspan="8">No series to display</td>
+    </tr>
+{% endfor %}
+  </tbody>
+</table>
+
+{% if page.paginator.count %}
+{% include "patchwork/partials/pagination.html" %}
+{% endif %}
+{% endblock %}
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 <jk@ozlabs.org>
+# 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(
+        '<span title="%s">%s</span>' % (' / '.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'<span class="patchlistchecks {color}">{count}</span>'
+        )
+
+    check_elements.reverse()
+
+    return mark_safe(
+        '<span title="%s">%s</span>'
+        % (' / '.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(
+        '<span class="patchinterest %s" title="%s">%s</span>'
+        % (review_class, review_title, reviews if reviews > 0 else '-')
+    )
diff --git a/patchwork/urls.py b/patchwork/urls.py
index 11cd8e7c..19952ee1 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -33,9 +33,19 @@
     path('', project_views.project_list, name='project-list'),
     path(
         'project/<project_id>/list/',
+        patch_views.patch_list_redirect,
+        name='patch-list-redirect',
+    ),
+    path(
+        'project/<project_id>/patches/',
         patch_views.patch_list,
         name='patch-list',
     ),
+    path(
+        'project/<project_id>/series/',
+        series_views.series_list,
+        name='series-list',
+    ),
     path(
         'project/<project_id>/bundles/',
         bundle_views.bundle_list,
diff --git a/patchwork/views/patch.py b/patchwork/views/patch.py
index 6c0e90f7..80eedce4 100644
--- a/patchwork/views/patch.py
+++ b/patchwork/views/patch.py
@@ -11,6 +11,7 @@
 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
@@ -40,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)
diff --git a/patchwork/views/series.py b/patchwork/views/series.py
index a8892ae6..7c45fb74 100644
--- a/patchwork/views/series.py
+++ b/patchwork/views/series.py
@@ -2,12 +2,16 @@
 # Copyright (C) 2017 Stephen Finucane <stephen@that.guru>
 #
 # 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 Project
 from patchwork.views.utils import series_to_mbox
+from patchwork.paginator import Paginator
 
 
 def series_mbox(request, series_id):
@@ -20,3 +24,43 @@ def series_mbox(request, series_id):
     )
 
     return response
+
+
+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)

From a90c63ba022e80ccbfd852572fe867455ef7817b Mon Sep 17 00:00:00 2001
From: andrepapoti <andrepapoti@gmail.com>
Date: Mon, 22 Apr 2024 15:26:04 -0300
Subject: [PATCH 09/14] tests: Add tests for series list view

Signed-off-by: andrepapoti <andrepapoti@gmail.com>
Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 patchwork/tests/views/test_series.py | 51 ++++++++++++++++++++++++++++
 1 file changed, 51 insertions(+)
 create mode 100644 patchwork/tests/views/test_series.py

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)

From 09a6a81bb00dd0cc0db69ae2a84ec4f89d9463c3 Mon Sep 17 00:00:00 2001
From: andrepapoti <andrepapoti@gmail.com>
Date: Tue, 23 Apr 2024 02:28:28 -0300
Subject: [PATCH 10/14] views: add series-detail view

Signed-off-by: andrepapoti <andrepapoti@gmail.com>
Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 patchwork/models.py                           |  6 +--
 .../patchwork/partials/download-buttons.html  | 11 ++++
 .../templates/patchwork/series-detail.html    | 54 +++++++++++++++++++
 .../templates/patchwork/series-list.html      |  2 +
 patchwork/urls.py                             |  5 ++
 patchwork/views/series.py                     | 23 ++++++++
 6 files changed, 98 insertions(+), 3 deletions(-)
 create mode 100644 patchwork/templates/patchwork/series-detail.html

diff --git a/patchwork/models.py b/patchwork/models.py
index 4c3c6bf7..287478a8 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -1095,10 +1095,10 @@ def add_patch(self, patch, number):
         return patch
 
     def get_absolute_url(self):
-        # TODO(stephenfin): We really need a proper series view
         return reverse(
-            'patch-list', kwargs={'project_id': self.project.linkname}
-        ) + ('?series=%d' % self.id)
+            'series-detail',
+            kwargs={'project_id': self.project.linkname, 'series_id': self.id},
+        )
 
     def get_mbox_url(self):
         return reverse('series-mbox', kwargs={'series_id': self.id})
diff --git a/patchwork/templates/patchwork/partials/download-buttons.html b/patchwork/templates/patchwork/partials/download-buttons.html
index 34c5f8fc..292ecb7b 100644
--- a/patchwork/templates/patchwork/partials/download-buttons.html
+++ b/patchwork/templates/patchwork/partials/download-buttons.html
@@ -1,4 +1,5 @@
 <div class="btn-group pull-right">
+{% if submission %}
   <button type="button" class="btn btn-default btn-copy"
       data-clipboard-text="{{ submission.id }}" title="Copy to Clipboard">
     {{ submission.id }}
@@ -24,4 +25,14 @@
     series
   </a>
 {% endif %}
+{% elif series %}
+  <button type="button" class="btn btn-default btn-copy"
+      data-clipboard-text="{{ series.id }}" title="Copy to Clipboard">
+    {{ series.id }}
+  </button>
+  <a href="{% url 'series-mbox' series_id=series.id %}"
+      class="btn btn-default" role="button" title="Download series mbox">
+    series
+  </a>
+{% endif %}
 </div>
diff --git a/patchwork/templates/patchwork/series-detail.html b/patchwork/templates/patchwork/series-detail.html
new file mode 100644
index 00000000..3da9edbb
--- /dev/null
+++ b/patchwork/templates/patchwork/series-detail.html
@@ -0,0 +1,54 @@
+{% extends "base.html" %}
+
+{% load humanize %}
+{% load syntax %}
+{% load person %}
+{% load patch %}
+{% load static %}
+{% load utils %}
+
+{% block title %}{{series.name}}{% endblock %}
+
+{% block body %}
+
+<div>
+{% include "patchwork/partials/download-buttons.html" %}
+  <h1>{{ series.name }}</h1>
+</div>
+
+<table class="patch-meta">
+    <tr>
+        <th>Series ID</th>
+        <td>
+            {{ series.id }}
+            <span class="btn-link btn-copy glyphicon glyphicon-copy" data-clipboard-text="{{ request.build_absolute_uri }}{{ series.get_absolute_url }}" title="Copy to Clipboard"></span>
+        </td>
+    </tr>
+    <tr>
+        <th>Date</th>
+        <td>{{ series.date }}</td>
+    </tr>
+    <tr>
+        <th>Submitter</th>
+        <td>{{ series.submitter }}</td>
+    </tr>
+    <tr>
+        <th>Total</th>
+        <td>{{ series.patches }}</td>
+    </tr>
+</table>
+<br>
+<h2>Patches</h2>
+<br>
+{% include "patchwork/partials/patch-list.html" %}
+
+<div id="cover-letter">
+    <h2>Cover Letter</h2>
+    {% if series.cover_letter.content %}
+    <pre class="content">{{ series.cover_letter.content }}</pre>
+    {% else %}
+    No cover letter available
+    {% endif %}
+</div>
+
+{% endblock %}
diff --git a/patchwork/templates/patchwork/series-list.html b/patchwork/templates/patchwork/series-list.html
index c181bc86..02887ebb 100644
--- a/patchwork/templates/patchwork/series-list.html
+++ b/patchwork/templates/patchwork/series-list.html
@@ -84,7 +84,9 @@
       </td>
 {% endif %}
       <td>
+        <a href="{% url 'series-detail' project_id=project.linkname series_id=series.id %}?state=*">
           {{ series.name|default:"[no subject]"|truncatechars:100 }}
+        </a>
       </td>
       <td>
         {{ series.version|default:"-"}}
diff --git a/patchwork/urls.py b/patchwork/urls.py
index 19952ee1..9c036bb6 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -120,6 +120,11 @@
         name='comment-redirect',
     ),
     # series views
+    path(
+        'project/<project_id>/series/<int:series_id>/',
+        series_views.series_detail,
+        name='series-detail',
+    ),
     path(
         'series/<int:series_id>/mbox/',
         series_views.series_mbox,
diff --git a/patchwork/views/series.py b/patchwork/views/series.py
index 7c45fb74..a36b8041 100644
--- a/patchwork/views/series.py
+++ b/patchwork/views/series.py
@@ -9,7 +9,9 @@
 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
 
@@ -26,6 +28,27 @@ 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')

From 33132c6471caa8b58b313ad6b0de3cde344e6295 Mon Sep 17 00:00:00 2001
From: Andre Papoti <andre.papoti@profusion.mobi>
Date: Tue, 24 Dec 2024 17:18:39 -0300
Subject: [PATCH 11/14] views: add shortcut to series-list view on navigation
 bar

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 templates/base.html | 6 ++++++
 1 file changed, 6 insertions(+)

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 %}
           <ul class="nav navbar-nav">
+            <li class="{% block series_active %}{% endblock %}">
+              <a href="{% url 'series-list' project_id=project.linkname %}">
+                <span class="glyphicon glyphicon-book"></span>
+                Series
+              </a>
+            </li>
             <li class="{% block patch_active %}{% endblock %}">
               <a href="{% url 'patch-list' project_id=project.linkname %}">
                 <span class="glyphicon glyphicon-file"></span>

From 7ce70681f39cf425bc03e0493aed71bb9ce36faf Mon Sep 17 00:00:00 2001
From: Andre Papoti <andre.papoti@profusion.mobi>
Date: Tue, 24 Dec 2024 17:19:05 -0300
Subject: [PATCH 12/14] views: add series navigation buttons for patch-detail
 view

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 patchwork/templates/patchwork/submission.html | 14 ++++++++++++++
 patchwork/views/patch.py                      | 14 ++++++++++++++
 2 files changed, 28 insertions(+)

diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html
index fb882d97..c8e36e80 100644
--- a/patchwork/templates/patchwork/submission.html
+++ b/patchwork/templates/patchwork/submission.html
@@ -20,6 +20,20 @@
   <h1>{{ submission.name }}</h1>
 </div>
 
+<div class="btn-group pull-right">
+  <a class="btn btn-default {% if not next_submission %} disabled {% endif %}"
+  {% if next_submission %} href="{% url 'patch-detail' project_id=project.linkname msgid=next_submission.encoded_msgid %}" {% endif %}>
+    next
+  </a>
+</div>
+
+<div class="btn-group pull-right">
+  <a class="btn btn-default {% if not previous_submission %} disabled {% endif %}"
+    {% if previous_submission %} href="{% url 'patch-detail' project_id=project.linkname msgid=previous_submission.encoded_msgid %}" {% endif %}>
+    previous
+  </a>
+</div>
+
 <table id="patch-meta" class="patch-meta" data-submission-type={{submission|verbose_name_plural|lower}} data-submission-id={{submission.id}}>
   <tr>
     <th>Message ID</th>
diff --git a/patchwork/views/patch.py b/patchwork/views/patch.py
index 80eedce4..9b7339cc 100644
--- a/patchwork/views/patch.py
+++ b/patchwork/views/patch.py
@@ -190,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)
 
 

From 06c090b5103f5bf50d29993f0e7adc52eb643788 Mon Sep 17 00:00:00 2001
From: andrepapoti <andrepapoti@gmail.com>
Date: Mon, 22 Apr 2024 15:44:07 -0300
Subject: [PATCH 13/14] docs: add release notes for series-list and
 series-detail

Signed-off-by: andrepapoti <andrepapoti@gmail.com>
Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 .../notes/add_series_list_view-bf219022216fea6a.yaml         | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 releasenotes/notes/add_series_list_view-bf219022216fea6a.yaml

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.

From 87cd68aaf10a0ab4d2b47d0761f5e3a845c024e4 Mon Sep 17 00:00:00 2001
From: Victor Accarini <victor.accarini@profusion.mobi>
Date: Thu, 8 May 2025 19:31:38 -0300
Subject: [PATCH 14/14] feat: use series-detail view instead of patch-list for
 series

- Solved TODOs of patch-list view being used to refer to series
- Now we handle series-detail urls in the parser for the Depends-on tag

Signed-off-by: Victor Accarini <victor.accarini@profusion.mobi>
---
 docs/usage/overview.rst                                      | 1 +
 patchwork/parser.py                                          | 5 ++++-
 patchwork/templates/patchwork/partials/patch-list.html       | 2 +-
 patchwork/templates/patchwork/submission.html                | 2 +-
 .../notes/add-series-dependencies-6696458586e795c7.yaml      | 1 +
 5 files changed, 8 insertions(+), 3 deletions(-)

diff --git a/docs/usage/overview.rst b/docs/usage/overview.rst
index 539de9ca..0c04cf4b 100644
--- a/docs/usage/overview.rst
+++ b/docs/usage/overview.rst
@@ -154,6 +154,7 @@ or a cover letter or the web URL to a patch or a series:
 
     Depends-on: <20240726221429.221611-1-user@example.com>
     Depends-on: https://pw.example.com/project/myproject/list?series=1234
+    Depends-on: https://pw.example.com/project/myproject/series/1234
 
 .. note::
 
diff --git a/patchwork/parser.py b/patchwork/parser.py
index c33ada8d..8c516250 100644
--- a/patchwork/parser.py
+++ b/patchwork/parser.py
@@ -1068,7 +1068,10 @@ def find_series_from_url(url):
         logging.warning('Failed to resolve series or patch URL: %s', url)
         return None
 
-    # TODO: Use the series detail view here.
+    if result.view_name == 'series-detail':
+        return Series.objects.get(pk=result.kwargs['series_id'])
+
+    # Handles series as a patch-list view
     if result.view_name == 'patch-list' and parse_result.query:
         # Parse the query string.
         # This can be replaced with something much friendlier once the
diff --git a/patchwork/templates/patchwork/partials/patch-list.html b/patchwork/templates/patchwork/partials/patch-list.html
index fcc4793e..482ce3db 100644
--- a/patchwork/templates/patchwork/partials/patch-list.html
+++ b/patchwork/templates/patchwork/partials/patch-list.html
@@ -179,7 +179,7 @@
         </td>
         <td id="patch-series:{{patch.id}}">
 {% if patch.series %}
-          <a href="?series={{patch.series.id}}">
+          <a href="{% url 'series-detail' series_id=patch.series.id project_id=project.linkname %}">
             {{ patch.series|truncatechars:100 }}
           </a>
 {% endif %}
diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html
index c8e36e80..9fe89dd1 100644
--- a/patchwork/templates/patchwork/submission.html
+++ b/patchwork/templates/patchwork/submission.html
@@ -76,7 +76,7 @@ <h1>{{ submission.name }}</h1>
   <tr>
     <th>Series</th>
     <td>
-      <a href="{% url 'patch-list' project_id=project.linkname %}?series={{ submission.series.id }}">
+      <a href="{% url 'series-detail' series_id=submission.series.id project_id=project.linkname %}">
         {{ submission.series.name }}
       </a> |
       <button id="toggle-patch-series">expand</button>
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.