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.