diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index 13cdc9cd..cc0d97e6 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -246,6 +246,14 @@ paths: schema: title: '' type: string + - in: query + name: msgid + description: > + The cover message-id as a case-sensitive string, without leading or + trailing angle brackets, to filter by. + schema: + title: '' + type: string responses: '200': description: '' @@ -474,6 +482,14 @@ paths: schema: title: '' type: string + - in: query + name: msgid + description: > + The patch message-id as a case-sensitive string, without leading or + trailing angle brackets, to filter by. + schema: + title: '' + type: string responses: '200': description: '' diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index bd714d5e..af15df1c 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -251,6 +251,16 @@ paths: schema: title: '' type: string +{% if version >= (1, 2) %} + - in: query + name: msgid + description: > + The cover message-id as a case-sensitive string, without leading or + trailing angle brackets, to filter by. + schema: + title: '' + type: string +{% endif %} responses: '200': description: '' @@ -488,6 +498,14 @@ paths: schema: title: '' type: string + - in: query + name: msgid + description: > + The patch message-id as a case-sensitive string, without leading or + trailing angle brackets, to filter by. + schema: + title: '' + type: string {% endif %} responses: '200': @@ -1803,7 +1821,7 @@ components: current_state: title: Current state type: string -{% if version >= (1, 1) %} +{% if version >= (1, 2) %} EventPatchRelationChanged: allOf: - $ref: '#/components/schemas/EventBase' @@ -1819,9 +1837,11 @@ components: previous_relation: title: Previous relation type: string + nullable: true current_relation: title: Current relation type: string + nullable: true {% endif %} EventPatchDelegated: allOf: diff --git a/docs/api/schemas/v1.1/patchwork.yaml b/docs/api/schemas/v1.1/patchwork.yaml index 6b497aba..babc972a 100644 --- a/docs/api/schemas/v1.1/patchwork.yaml +++ b/docs/api/schemas/v1.1/patchwork.yaml @@ -1551,24 +1551,6 @@ components: current_state: title: Current state type: string - EventPatchRelationChanged: - allOf: - - $ref: '#/components/schemas/EventBase' - - type: object - properties: - category: - enum: - - patch-relation-changed - payload: - properties: - patch: - $ref: '#/components/schemas/PatchEmbedded' - previous_relation: - title: Previous relation - type: string - current_relation: - title: Current relation - type: string EventPatchDelegated: allOf: - $ref: '#/components/schemas/EventBase' diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml index db2ed122..db60c789 100644 --- a/docs/api/schemas/v1.2/patchwork.yaml +++ b/docs/api/schemas/v1.2/patchwork.yaml @@ -246,6 +246,14 @@ paths: schema: title: '' type: string + - in: query + name: msgid + description: > + The cover message-id as a case-sensitive string, without leading or + trailing angle brackets, to filter by. + schema: + title: '' + type: string responses: '200': description: '' @@ -474,6 +482,14 @@ paths: schema: title: '' type: string + - in: query + name: msgid + description: > + The patch message-id as a case-sensitive string, without leading or + trailing angle brackets, to filter by. + schema: + title: '' + type: string responses: '200': description: '' @@ -1752,9 +1768,11 @@ components: previous_relation: title: Previous relation type: string + nullable: true current_relation: title: Current relation type: string + nullable: true EventPatchDelegated: allOf: - $ref: '#/components/schemas/EventBase' diff --git a/docs/deployment/installation.rst b/docs/deployment/installation.rst index 667a6a69..12801a79 100644 --- a/docs/deployment/installation.rst +++ b/docs/deployment/installation.rst @@ -139,14 +139,14 @@ The first requirement is Patchwork itself. It can be downloaded like so: .. code-block:: shell - $ wget https://github.com/getpatchwork/patchwork/archive/v2.1.0.tar.gz + $ wget https://github.com/getpatchwork/patchwork/archive/v2.2.0.tar.gz We will install this under ``/opt``, though this is only a suggestion: .. code-block:: shell - $ tar -xvzf v2.1.0.tar.gz - $ sudo mv v2.1.0 /opt/patchwork + $ tar -xvzf v2.2.0.tar.gz + $ sudo mv v2.2.0 /opt/patchwork .. important:: diff --git a/lib/sql/grant-all.mysql.sql b/lib/sql/grant-all.mysql.sql index 02770777..c8044c68 100644 --- a/lib/sql/grant-all.mysql.sql +++ b/lib/sql/grant-all.mysql.sql @@ -23,6 +23,7 @@ GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_emailoptout TO 'www-data'@loca GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_event TO 'www-data'@localhost; GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_patch TO 'www-data'@localhost; GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_patchchangenotification TO 'www-data'@localhost; +GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_patchrelation TO 'www-data'@localhost; GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_patchtag TO 'www-data'@localhost; GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_person TO 'www-data'@localhost; GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_project TO 'www-data'@localhost; @@ -38,12 +39,14 @@ GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_userprofile_maintainer_project -- cover letters) and series GRANT INSERT, SELECT ON patchwork_comment TO 'nobody'@localhost; GRANT INSERT, SELECT ON patchwork_coverletter TO 'nobody'@localhost; +GRANT INSERT, SELECT ON patchwork_event TO 'nobody'@localhost; GRANT INSERT, SELECT ON patchwork_patch TO 'nobody'@localhost; GRANT INSERT, SELECT ON patchwork_person TO 'nobody'@localhost; GRANT INSERT, SELECT ON patchwork_series TO 'nobody'@localhost; GRANT INSERT, SELECT ON patchwork_seriesreference TO 'nobody'@localhost; GRANT INSERT, SELECT ON patchwork_submission TO 'nobody'@localhost; GRANT INSERT, SELECT, UPDATE, DELETE ON patchwork_patchtag TO 'nobody'@localhost; +GRANT SELECT ON auth_user TO 'nobody'@localhost; GRANT SELECT ON patchwork_delegationrule TO 'nobody'@localhost; GRANT SELECT ON patchwork_project TO 'nobody'@localhost; GRANT SELECT ON patchwork_state TO 'nobody'@localhost; diff --git a/lib/sql/grant-all.postgres.sql b/lib/sql/grant-all.postgres.sql index 10ec8d2c..cac70a87 100644 --- a/lib/sql/grant-all.postgres.sql +++ b/lib/sql/grant-all.postgres.sql @@ -21,8 +21,10 @@ GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_delegationrule, patchwork_emailconfirmation, patchwork_emailoptout, + patchwork_event, patchwork_patch, patchwork_patchchangenotification, + patchwork_patchrelation, patchwork_patchtag, patchwork_person, patchwork_project, @@ -50,7 +52,9 @@ GRANT SELECT, UPDATE ON patchwork_comment_id_seq, patchwork_delegationrule_id_seq, patchwork_emailconfirmation_id_seq, + patchwork_event_id_seq, patchwork_patch_id_seq, + patchwork_patchrelation_id_seq, patchwork_patchtag_id_seq, patchwork_person_id_seq, patchwork_project_id_seq, @@ -68,7 +72,9 @@ GRANT INSERT, SELECT ON patchwork_comment, patchwork_coverletter, patchwork_event, - patchwork_seriesreference, + patchwork_seriesreference +TO "nobody"; +GRANT INSERT, SELECT, UPDATE ON patchwork_submission TO "nobody"; GRANT INSERT, SELECT, UPDATE, DELETE ON @@ -78,6 +84,7 @@ GRANT INSERT, SELECT, UPDATE, DELETE ON patchwork_series TO "nobody"; GRANT SELECT ON + auth_user, patchwork_delegationrule, patchwork_project, patchwork_state, diff --git a/patchwork/__init__.py b/patchwork/__init__.py index b55d4b3b..0ed62d3c 100644 --- a/patchwork/__init__.py +++ b/patchwork/__init__.py @@ -5,7 +5,7 @@ from patchwork.version import get_latest_version -VERSION = (2, 2, 0) +VERSION = (2, 2, 7, 'alpha', 0) __version__ = get_latest_version(VERSION) diff --git a/patchwork/admin.py b/patchwork/admin.py index c3d45240..b55eb431 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -126,7 +126,7 @@ class SeriesAdmin(admin.ModelAdmin): list_filter = ('project', 'submitter') list_select_related = ('submitter', 'project') readonly_fields = ('received_total', 'received_all') - search_fields = ('submitter_name', 'submitter_email') + search_fields = ('submitter__name', 'submitter__email') exclude = ('patches', ) inlines = (PatchInline, ) diff --git a/patchwork/api/__init__.py b/patchwork/api/__init__.py index e69de29b..efc0dd89 100644 --- a/patchwork/api/__init__.py +++ b/patchwork/api/__init__.py @@ -0,0 +1,50 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2020, Stephen Finucane +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from rest_framework.fields import empty +from rest_framework.fields import get_attribute +from rest_framework.fields import SkipField +from rest_framework.relations import ManyRelatedField + + +# monkey patch django-rest-framework to work around issue #7550 [1] until #7574 +# [2] or some other variant lands +# +# [1] https://github.com/encode/django-rest-framework/issues/7550 +# [2] https://github.com/encode/django-rest-framework/pull/7574 + +def _get_attribute(self, instance): + # Can't have any relationships if not created + if hasattr(instance, 'pk') and instance.pk is None: + return [] + + try: + relationship = get_attribute(instance, self.source_attrs) + except (KeyError, AttributeError) as exc: + if self.default is not empty: + return self.get_default() + if self.allow_null: + return None + if not self.required: + raise SkipField() + msg = ( + 'Got {exc_type} when attempting to get a value for field ' + '`{field}` on serializer `{serializer}`.\nThe serializer ' + 'field might be named incorrectly and not match ' + 'any attribute or key on the `{instance}` instance.\n' + 'Original exception text was: {exc}.'.format( + exc_type=type(exc).__name__, + field=self.field_name, + serializer=self.parent.__class__.__name__, + instance=instance.__class__.__name__, + exc=exc + ) + ) + raise type(exc)(msg) + + return relationship.all() if hasattr(relationship, 'all') else relationship + + +ManyRelatedField.get_attribute = _get_attribute diff --git a/patchwork/api/base.py b/patchwork/api/base.py index 89a43114..6cb703b1 100644 --- a/patchwork/api/base.py +++ b/patchwork/api/base.py @@ -96,6 +96,9 @@ def to_representation(self, instance): # field was added, we drop it if not utils.has_version(request, version): for field in self.Meta.versioned_fields[version]: - data.pop(field) + # After a PATCH with an older API version, we may not see + # these fields. If they don't exist, don't panic, return + # (and then discard) None. + data.pop(field, None) return data diff --git a/patchwork/api/bundle.py b/patchwork/api/bundle.py index b8c0f178..93e32316 100644 --- a/patchwork/api/bundle.py +++ b/patchwork/api/bundle.py @@ -62,7 +62,8 @@ class BundleSerializer(BaseHyperlinkedModelSerializer): project = ProjectSerializer(read_only=True) mbox = SerializerMethodField() owner = UserSerializer(read_only=True) - patches = PatchSerializer(many=True, required=True) + patches = PatchSerializer(many=True, required=True, + style={'base_template': 'input.html'}) def get_web_url(self, instance): request = self.context.get('request') @@ -79,10 +80,11 @@ def create(self, validated_data): return instance def update(self, instance, validated_data): - patches = validated_data.pop('patches') + patches = validated_data.pop('patches', None) instance = super(BundleSerializer, self).update( instance, validated_data) - instance.overwrite_patches(patches) + if patches: + instance.overwrite_patches(patches) return instance def validate_patches(self, value): @@ -96,7 +98,8 @@ def validate_patches(self, value): return value def validate(self, data): - data['project'] = data['patches'][0].project + if data.get('patches'): + data['project'] = data['patches'][0].project return super(BundleSerializer, self).validate(data) diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py index 85a30cae..ca799efe 100644 --- a/patchwork/api/embedded.py +++ b/patchwork/api/embedded.py @@ -12,9 +12,8 @@ from collections import OrderedDict from rest_framework.serializers import CharField -from rest_framework.serializers import SerializerMethodField from rest_framework.serializers import PrimaryKeyRelatedField -from rest_framework.serializers import ValidationError +from rest_framework.serializers import SerializerMethodField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import CheckHyperlinkedIdentityField @@ -139,30 +138,6 @@ class Meta: } -class PatchRelationSerializer(BaseHyperlinkedModelSerializer): - """Hide the PatchRelation model, just show the list""" - patches = PatchSerializer(many=True) - - def to_internal_value(self, data): - if not isinstance(data, type([])): - raise ValidationError( - "Patch relations must be specified as a list of patch IDs" - ) - result = super(PatchRelationSerializer, self).to_internal_value( - {'patches': data} - ) - return result - - def to_representation(self, instance): - data = super(PatchRelationSerializer, self).to_representation(instance) - data = data['patches'] - return data - - class Meta: - model = models.PatchRelation - fields = ('patches',) - - class PersonSerializer(SerializedRelatedField): class _Serializer(BaseHyperlinkedModelSerializer): diff --git a/patchwork/api/event.py b/patchwork/api/event.py index d7a99c7d..bc1bda46 100644 --- a/patchwork/api/event.py +++ b/patchwork/api/event.py @@ -13,7 +13,6 @@ from patchwork.api.embedded import CheckSerializer from patchwork.api.embedded import CoverLetterSerializer from patchwork.api.embedded import PatchSerializer -from patchwork.api.embedded import PatchRelationSerializer from patchwork.api.embedded import ProjectSerializer from patchwork.api.embedded import SeriesSerializer from patchwork.api.embedded import UserSerializer @@ -34,8 +33,8 @@ class EventSerializer(ModelSerializer): current_delegate = UserSerializer() created_check = SerializerMethodField() created_check = CheckSerializer() - previous_relation = PatchRelationSerializer(read_only=True) - current_relation = PatchRelationSerializer(read_only=True) + previous_relation = SerializerMethodField() + current_relation = SerializerMethodField() _category_map = { Event.CATEGORY_COVER_CREATED: ['cover'], @@ -52,6 +51,12 @@ class EventSerializer(ModelSerializer): Event.CATEGORY_SERIES_COMPLETED: ['series'], } + def get_previous_relation(self, instance): + return None + + def get_current_relation(self, instance): + return None + def to_representation(self, instance): data = super(EventSerializer, self).to_representation(instance) payload = OrderedDict() @@ -71,10 +76,12 @@ def to_representation(self, instance): class Meta: model = Event - fields = ('id', 'category', 'project', 'date', 'actor', 'patch', - 'series', 'cover', 'previous_state', 'current_state', - 'previous_delegate', 'current_delegate', 'created_check', - 'previous_relation', 'current_relation',) + fields = ( + 'id', 'category', 'project', 'date', 'actor', 'patch', + 'series', 'cover', 'previous_state', 'current_state', + 'previous_delegate', 'current_delegate', 'created_check', + 'previous_relation', 'current_relation', + ) read_only_fields = fields versioned_fields = { '1.2': ('actor', ), diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py index f7b6a6f6..1f02cfa0 100644 --- a/patchwork/api/filters.py +++ b/patchwork/api/filters.py @@ -172,6 +172,10 @@ class Meta: fields = ('submitter', 'project') +def msgid_filter(queryset, name, value): + return queryset.filter(**{name: '<' + value + '>'}) + + class CoverLetterFilterSet(TimestampMixin, BaseFilterSet): project = ProjectFilter(queryset=Project.objects.all(), distinct=False) @@ -180,6 +184,7 @@ class CoverLetterFilterSet(TimestampMixin, BaseFilterSet): series = BaseFilter(queryset=Project.objects.all(), widget=MultipleHiddenInput, distinct=False) submitter = PersonFilter(queryset=Person.objects.all(), distinct=False) + msgid = CharFilter(method=msgid_filter) class Meta: model = CoverLetter @@ -198,17 +203,18 @@ class PatchFilterSet(TimestampMixin, BaseFilterSet): delegate = UserFilter(queryset=User.objects.all(), distinct=False) state = StateFilter(queryset=State.objects.all(), distinct=False) hash = CharFilter(lookup_expr='iexact') + msgid = CharFilter(method=msgid_filter) class Meta: model = Patch - # NOTE(dja): ideally we want to version the hash field, but I cannot - # find a way to do that which is reliable and not extremely ugly. + # NOTE(dja): ideally we want to version the hash/msgid field, but I + # can't find a way to do that which is reliable and not extremely ugly. # The best I can come up with is manually working with request.GET # which seems to rather defeat the point of using django-filters. fields = ('project', 'series', 'submitter', 'delegate', - 'state', 'archived', 'hash') + 'state', 'archived', 'hash', 'msgid') versioned_fields = { - '1.2': ('hash', ), + '1.2': ('hash', 'msgid'), } diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py index 15fce8ef..c94e2a7d 100644 --- a/patchwork/api/patch.py +++ b/patchwork/api/patch.py @@ -10,7 +10,6 @@ from django.core.exceptions import ValidationError from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ -from rest_framework import status from rest_framework.exceptions import APIException from rest_framework.exceptions import PermissionDenied from rest_framework.generics import ListAPIView @@ -18,15 +17,16 @@ from rest_framework.relations import RelatedField from rest_framework.reverse import reverse from rest_framework.serializers import SerializerMethodField +from rest_framework import status from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import PatchworkPermission -from patchwork.api.filters import PatchFilterSet -from patchwork.api.embedded import PatchRelationSerializer +from patchwork.api.embedded import PatchSerializer from patchwork.api.embedded import PersonSerializer from patchwork.api.embedded import ProjectSerializer from patchwork.api.embedded import SeriesSerializer from patchwork.api.embedded import UserSerializer +from patchwork.api.filters import PatchFilterSet from patchwork.models import Patch from patchwork.models import PatchRelation from patchwork.models import State @@ -83,7 +83,9 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer): check = SerializerMethodField() checks = SerializerMethodField() tags = SerializerMethodField() - related = PatchRelationSerializer() + related = PatchSerializer( + source='related.patches', many=True, default=[], + style={'base_template': 'input.html'}) def get_web_url(self, instance): request = self.context.get('request') @@ -127,14 +129,11 @@ def to_representation(self, instance): data = super(PatchListSerializer, self).to_representation(instance) data['series'] = [data['series']] if data['series'] else [] - # stop the related serializer returning this patch in the list of - # related patches. Also make it return an empty list, not null/None - if 'related' in data: - if data['related']: - data['related'] = [p for p in data['related'] - if p['id'] != instance.id] - else: - data['related'] = [] + # Remove this patch from 'related' + if 'related' in data and data['related']: + data['related'] = [ + x for x in data['related'] if x['id'] != data['id'] + ] return data diff --git a/patchwork/models.py b/patchwork/models.py index e295e173..028beb3c 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -380,10 +380,13 @@ class Submission(FilenameMixin, EmailMixin, models.Model): def list_archive_url(self): if not self.project.list_archive_url_format: return None + if not self.msgid: return None + return self.project.list_archive_url_format.format( - self.url_msgid) + self.url_msgid, + ) # patchwork metadata @@ -513,50 +516,13 @@ def is_editable(self, user): return True return False - @property - def combined_check_state(self): - """Return the combined state for all checks. - - Generate the combined check's state for this patch. This check - is one of the following, based on the value of each unique - check: - - * failure, if any context's latest check reports as failure - * warning, if any context's latest check reports as warning - * pending, if there are no checks, or a context's latest - Check reports as pending - * success, if latest checks for all contexts reports as - success - """ - state_names = dict(Check.STATE_CHOICES) - states = [check.state for check in self.checks] - - if not states: - return state_names[Check.STATE_PENDING] - - for state in [Check.STATE_FAIL, Check.STATE_WARNING, - Check.STATE_PENDING]: # order sensitive - if state in states: - return state_names[state] - - return state_names[Check.STATE_SUCCESS] - - @property - def checks(self): - """Return the list of unique checks. - - Generate a list of checks associated with this patch 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. - """ + @staticmethod + def filter_unique_checks(checks): + """Filter the provided checks to generate the unique list.""" unique = {} duplicates = [] - for check in self.check_set.all(): + for check in checks: ctx = check.context user = check.user_id @@ -583,7 +549,50 @@ def checks(self): # prefetch_related.) So, do it 'by hand' in Python. We can # also be confident that this won't be worse, seeing as we've # just iterated over self.check_set.all() *anyway*. - return [c for c in self.check_set.all() if c.id not in duplicates] + return [c for c in checks if c.id not in duplicates] + + @property + def checks(self): + """Return the list of unique checks. + + Generate a list of checks associated with this patch 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. + """ + return self.filter_unique_checks(self.check_set.all()) + + @property + def combined_check_state(self): + """Return the combined state for all checks. + + Generate the combined check's state for this patch. This check + is one of the following, based on the value of each unique + check: + + * failure, if any context's latest check reports as failure + * warning, if any context's latest check reports as warning + * pending, if there are no checks, or a context's latest check reports + as pending + * success, if latest checks for all contexts reports as success + """ + state_names = dict(Check.STATE_CHOICES) + states = [check.state for check in self.checks] + + if not states: + return state_names[Check.STATE_PENDING] + + # order sensitive + for state in ( + Check.STATE_FAIL, Check.STATE_WARNING, Check.STATE_PENDING, + ): + if state in states: + return state_names[state] + + return state_names[Check.STATE_SUCCESS] @property def check_count(self): @@ -640,10 +649,13 @@ class Comment(EmailMixin, models.Model): def list_archive_url(self): if not self.submission.project.list_archive_url_format: return None + if not self.msgid: return None - return self.project.list_archive_url_format.format( - self.url_msgid) + + return self.submission.project.list_archive_url_format.format( + self.url_msgid, + ) def get_absolute_url(self): return reverse('comment-redirect', kwargs={'comment_id': self.id}) diff --git a/patchwork/parser.py b/patchwork/parser.py index 45930b45..a1c4a8b4 100644 --- a/patchwork/parser.py +++ b/patchwork/parser.py @@ -392,6 +392,13 @@ def get_original_sender(mail, name, email): # Mailman uses the format " via " # Google Groups uses "'' via " stripped_name = name[:name.rfind(' via ')].strip().strip("'") + elif name.endswith(' via'): + # Sometimes this seems to happen (perhaps if Mailman isn't set up with + # any list name) + stripped_name = name[:name.rfind(' via')].strip().strip("'") + else: + # We've hit a format that we don't expect + stripped_name = None original_from = clean_header(mail.get('X-Original-From', '')) if original_from: @@ -1085,7 +1092,10 @@ def parse_mail(mail, list_id=None): filenames = find_filenames(diff) delegate = find_delegate_by_filename(project, filenames) - try: + with transaction.atomic(): + if Patch.objects.filter(project=project, msgid=msgid): + raise DuplicateMailError(msgid=msgid) + patch = Patch.objects.create( msgid=msgid, project=project, @@ -1100,8 +1110,6 @@ def parse_mail(mail, list_id=None): delegate=delegate, state=find_state(mail)) logger.debug('Patch saved') - except IntegrityError: - raise DuplicateMailError(msgid=msgid) for attempt in range(1, 11): # arbitrary retry count try: @@ -1242,7 +1250,10 @@ def parse_mail(mail, list_id=None): SeriesReference.objects.create( msgid=msgid, project=project, series=series) - try: + with transaction.atomic(): + if CoverLetter.objects.filter(project=project, msgid=msgid): + raise DuplicateMailError(msgid=msgid) + cover_letter = CoverLetter.objects.create( msgid=msgid, project=project, @@ -1251,8 +1262,6 @@ def parse_mail(mail, list_id=None): headers=headers, submitter=author, content=message) - except IntegrityError: - raise DuplicateMailError(msgid=msgid) logger.debug('Cover letter saved') @@ -1269,7 +1278,9 @@ def parse_mail(mail, list_id=None): author = get_or_create_author(mail, project) - try: + with transaction.atomic(): + if Comment.objects.filter(submission=submission, msgid=msgid): + raise DuplicateMailError(msgid=msgid) comment = Comment.objects.create( submission=submission, msgid=msgid, @@ -1277,8 +1288,6 @@ def parse_mail(mail, list_id=None): headers=headers, submitter=author, content=message) - except IntegrityError: - raise DuplicateMailError(msgid=msgid) logger.debug('Comment saved') diff --git a/patchwork/signals.py b/patchwork/signals.py index 3a2f0fbd..0771104a 100644 --- a/patchwork/signals.py +++ b/patchwork/signals.py @@ -137,14 +137,12 @@ def create_event(patch, before, after): @receiver(pre_save, sender=Patch) def create_patch_relation_changed_event(sender, instance, raw, **kwargs): - def create_event(patch, before, after): + def create_event(patch): return Event.objects.create( category=Event.CATEGORY_PATCH_RELATION_CHANGED, project=patch.project, actor=getattr(patch, '_edited_by', None), - patch=patch, - previous_relation=before, - current_relation=after) + patch=patch) # don't trigger for items loaded from fixtures or new items if raw or not instance.pk: @@ -155,7 +153,7 @@ def create_event(patch, before, after): if orig_patch.related == instance.related: return - create_event(instance, orig_patch.related, instance.related) + create_event(instance) @receiver(pre_save, sender=Patch) diff --git a/patchwork/tests/api/test_bundle.py b/patchwork/tests/api/test_bundle.py index d03f26f1..79924486 100644 --- a/patchwork/tests/api/test_bundle.py +++ b/patchwork/tests/api/test_bundle.py @@ -288,9 +288,34 @@ def test_update(self): self.assertEqual(status.HTTP_200_OK, resp.status_code) self.assertEqual(2, len(resp.data['patches'])) self.assertEqual('hello-bundle', resp.data['name']) + self.assertFalse(resp.data['public']) self.assertEqual(1, Bundle.objects.all().count()) self.assertEqual(2, len(Bundle.objects.first().patches.all())) self.assertEqual('hello-bundle', Bundle.objects.first().name) + self.assertFalse(Bundle.objects.first().public) + + def test_update_no_patches(self): + """Validate we handle updating only the name.""" + user, project, patch_a, patch_b = self._test_create_update() + bundle = create_bundle(owner=user, project=project) + + bundle.append_patch(patch_a) + bundle.append_patch(patch_b) + + self.assertEqual(1, Bundle.objects.all().count()) + self.assertEqual(2, len(Bundle.objects.first().patches.all())) + + resp = self.client.patch(self.api_url(bundle.id), { + 'name': 'hello-bundle', 'public': True, + }) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(2, len(resp.data['patches'])) + self.assertEqual('hello-bundle', resp.data['name']) + self.assertTrue(resp.data['public']) + self.assertEqual(1, Bundle.objects.all().count()) + self.assertEqual(2, len(Bundle.objects.first().patches.all())) + self.assertEqual('hello-bundle', Bundle.objects.first().name) + self.assertTrue(Bundle.objects.first().public) @utils.store_samples('bundle-delete-not-found') def test_delete_anonymous(self): diff --git a/patchwork/tests/api/test_comment.py b/patchwork/tests/api/test_comment.py index f48bfce1..e9520562 100644 --- a/patchwork/tests/api/test_comment.py +++ b/patchwork/tests/api/test_comment.py @@ -53,6 +53,18 @@ def test_list(self): self.assertEqual(status.HTTP_200_OK, resp.status_code) self.assertEqual(1, len(resp.data)) self.assertSerialized(comment, resp.data[0]) + self.assertIn('list_archive_url', resp.data[0]) + + def test_list_version_1_1(self): + """List cover letter comments using API v1.1.""" + cover = create_cover() + comment = create_comment(submission=cover) + + resp = self.client.get(self.api_url(cover, version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertSerialized(comment, resp.data[0]) + self.assertNotIn('list_archive_url', resp.data[0]) def test_list_version_1_0(self): """List cover letter comments using API v1.0.""" @@ -104,6 +116,18 @@ def test_list(self): self.assertEqual(status.HTTP_200_OK, resp.status_code) self.assertEqual(1, len(resp.data)) self.assertSerialized(comment, resp.data[0]) + self.assertIn('list_archive_url', resp.data[0]) + + def test_list_version_1_1(self): + """List patch comments using API v1.1.""" + patch = create_patch() + comment = create_comment(submission=patch) + + resp = self.client.get(self.api_url(patch, version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertSerialized(comment, resp.data[0]) + self.assertNotIn('list_archive_url', resp.data[0]) def test_list_version_1_0(self): """List patch comments using API v1.0.""" diff --git a/patchwork/tests/api/test_cover.py b/patchwork/tests/api/test_cover.py index 5eeb1902..1b19ded1 100644 --- a/patchwork/tests/api/test_cover.py +++ b/patchwork/tests/api/test_cover.py @@ -111,6 +111,18 @@ def test_list_filter_submitter(self): 'submitter': 'test@example.org'}) self.assertEqual(0, len(resp.data)) + def test_list_filter_msgid(self): + """Filter covers by msgid.""" + cover = create_cover() + + resp = self.client.get(self.api_url(), {'msgid': cover.url_msgid}) + self.assertEqual([cover.id], [x['id'] for x in resp.data]) + + # empty response if nothing matches + resp = self.client.get(self.api_url(), { + 'msgid': 'fishfish@fish.fish'}) + self.assertEqual(0, len(resp.data)) + @utils.store_samples('cover-list-1-0') def test_list_version_1_0(self): create_cover() diff --git a/patchwork/tests/api/test_patch.py b/patchwork/tests/api/test_patch.py index b24c5ab2..b94ad229 100644 --- a/patchwork/tests/api/test_patch.py +++ b/patchwork/tests/api/test_patch.py @@ -199,6 +199,18 @@ def test_list_filter_hash_version_1_1(self): {'hash': 'garbagevalue'}) self.assertEqual(1, len(resp.data)) + def test_list_filter_msgid(self): + """Filter patches by msgid.""" + patch = self._create_patch() + + resp = self.client.get(self.api_url(), {'msgid': patch.url_msgid}) + self.assertEqual([patch.id], [x['id'] for x in resp.data]) + + # empty response if nothing matches + resp = self.client.get(self.api_url(), { + 'msgid': 'fishfish@fish.fish'}) + self.assertEqual(0, len(resp.data)) + @utils.store_samples('patch-list-1-0') def test_list_version_1_0(self): """List patches using API v1.0.""" @@ -322,6 +334,20 @@ def test_update_maintainer(self): self.assertEqual(status.HTTP_200_OK, resp.status_code, resp) self.assertIsNone(Patch.objects.get(id=patch.id).delegate) + def test_update_maintainer_version_1_0(self): + """Update patch as maintainer on v1.1.""" + project = create_project() + patch = create_patch(project=project) + state = create_state() + user = create_maintainer(project) + + self.client.force_authenticate(user=user) + resp = self.client.patch(self.api_url(patch.id, version="1.1"), + {'state': state.slug, 'delegate': user.id}) + self.assertEqual(status.HTTP_200_OK, resp.status_code, resp) + self.assertEqual(Patch.objects.get(id=patch.id).state, state) + self.assertEqual(Patch.objects.get(id=patch.id).delegate, user) + @utils.store_samples('patch-update-error-bad-request') def test_update_invalid_state(self): """Update patch with invalid fields. diff --git a/patchwork/tests/api/test_project.py b/patchwork/tests/api/test_project.py index 5a767674..701a4ed0 100644 --- a/patchwork/tests/api/test_project.py +++ b/patchwork/tests/api/test_project.py @@ -71,6 +71,26 @@ def test_list_authenticated(self): self.assertEqual(status.HTTP_200_OK, resp.status_code) self.assertEqual(1, len(resp.data)) self.assertSerialized(project, resp.data[0]) + self.assertIn('subject_match', resp.data[0]) + self.assertIn('list_archive_url', resp.data[0]) + self.assertIn('list_archive_url_format', resp.data[0]) + self.assertIn('commit_url_format', resp.data[0]) + + @utils.store_samples('project-list-1.1') + def test_list_version_1_1(self): + """List projects using API v1.1. + + Validate that newer fields are dropped for older API versions. + """ + create_project() + + resp = self.client.get(self.api_url(version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertIn('subject_match', resp.data[0]) + self.assertNotIn('list_archive_url', resp.data[0]) + self.assertNotIn('list_archive_url_format', resp.data[0]) + self.assertNotIn('commit_url_format', resp.data[0]) @utils.store_samples('project-list-1.0') def test_list_version_1_0(self): @@ -86,7 +106,7 @@ def test_list_version_1_0(self): self.assertNotIn('subject_match', resp.data[0]) @utils.store_samples('project-detail') - def test_detail_by_id(self): + def test_detail(self): """Show project using ID lookup. Validate that it's possible to filter by pk. @@ -96,6 +116,10 @@ def test_detail_by_id(self): resp = self.client.get(self.api_url(project.pk)) self.assertEqual(status.HTTP_200_OK, resp.status_code) self.assertSerialized(project, resp.data) + self.assertIn('subject_match', resp.data) + self.assertIn('list_archive_url', resp.data) + self.assertIn('list_archive_url_format', resp.data) + self.assertIn('commit_url_format', resp.data) def test_detail_by_linkname(self): """Show project using linkname lookup. @@ -119,6 +143,22 @@ def test_detail_by_numeric_linkname(self): self.assertEqual(status.HTTP_200_OK, resp.status_code) self.assertSerialized(project, resp.data) + @utils.store_samples('project-detail-1.1') + def test_detail_version_1_1(self): + """Show project using API v1.1. + + Validate that newer fields are dropped for older API versions. + """ + project = create_project() + + resp = self.client.get(self.api_url(project.pk, version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertIn('name', resp.data) + self.assertIn('subject_match', resp.data) + self.assertNotIn('list_archive_url', resp.data) + self.assertNotIn('list_archive_url_format', resp.data) + self.assertNotIn('commit_url_format', resp.data) + @utils.store_samples('project-detail-1.0') def test_detail_version_1_0(self): """Show project using API v1.0. diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py index d48c62bc..5f8048f2 100644 --- a/patchwork/tests/api/test_relation.py +++ b/patchwork/tests/api/test_relation.py @@ -48,8 +48,8 @@ def test_no_relation(self): @utils.store_samples('relation-list') def test_list_two_patch_relation(self): - relation = create_relation(2, project=self.project) - patches = relation.patches.all() + relation = create_relation() + patches = create_patches(2, project=self.project, related=relation) # nobody resp = self.client.get(self.api_url(item=patches[0].pk)) @@ -101,8 +101,8 @@ def test_create_two_patch_relation_maintainer(self): self.assertEqual(patches[1].related, patches[0].related) def test_delete_two_patch_relation_nobody(self): - relation = create_relation(project=self.project) - patch = relation.patches.all()[0] + relation = create_relation() + patch = create_patches(2, project=self.project, related=relation)[0] self.assertEqual(PatchRelation.objects.count(), 1) @@ -112,8 +112,8 @@ def test_delete_two_patch_relation_nobody(self): @utils.store_samples('relation-delete') def test_delete_two_patch_relation_maintainer(self): - relation = create_relation(project=self.project) - patch = relation.patches.all()[0] + relation = create_relation() + patch = create_patches(2, project=self.project, related=relation)[0] self.assertEqual(PatchRelation.objects.count(), 1) @@ -145,8 +145,8 @@ def test_create_three_patch_relation(self): self.assertEqual(patches[1].related, patches[2].related) def test_delete_from_three_patch_relation(self): - relation = create_relation(3, project=self.project) - patch = relation.patches.all()[0] + relation = create_relation() + patch = create_patches(3, project=self.project, related=relation)[0] self.assertEqual(PatchRelation.objects.count(), 1) @@ -159,8 +159,9 @@ def test_delete_from_three_patch_relation(self): @utils.store_samples('relation-extend-through-new') def test_extend_relation_through_new(self): - relation = create_relation(project=self.project) - existing_patch_a = relation.patches.first() + relation = create_relation() + existing_patch_a = create_patches( + 2, project=self.project, related=relation)[0] new_patch = create_patch(project=self.project) @@ -173,8 +174,9 @@ def test_extend_relation_through_new(self): self.assertEqual(relation.patches.count(), 3) def test_extend_relation_through_old(self): - relation = create_relation(project=self.project) - existing_patch_a = relation.patches.first() + relation = create_relation() + existing_patch_a = create_patches( + 2, project=self.project, related=relation)[0] new_patch = create_patch(project=self.project) @@ -188,8 +190,9 @@ def test_extend_relation_through_old(self): self.assertEqual(relation.patches.count(), 3) def test_extend_relation_through_new_two(self): - relation = create_relation(project=self.project) - existing_patch_a = relation.patches.first() + relation = create_relation() + existing_patch_a = create_patches( + 2, project=self.project, related=relation)[0] new_patch_a = create_patch(project=self.project) new_patch_b = create_patch(project=self.project) @@ -210,8 +213,9 @@ def test_extend_relation_through_new_two(self): @utils.store_samples('relation-extend-through-old') def test_extend_relation_through_old_two(self): - relation = create_relation(project=self.project) - existing_patch_a = relation.patches.first() + relation = create_relation() + existing_patch_a = create_patches( + 2, project=self.project, related=relation)[0] new_patch_a = create_patch(project=self.project) new_patch_b = create_patch(project=self.project) @@ -232,9 +236,10 @@ def test_extend_relation_through_old_two(self): self.assertEqual(relation.patches.count(), 4) def test_remove_one_patch_from_relation_bad(self): - relation = create_relation(3, project=self.project) - keep_patch_a = relation.patches.all()[1] - keep_patch_b = relation.patches.all()[2] + relation = create_relation() + patches = create_patches(3, project=self.project, related=relation) + keep_patch_a = patches[1] + keep_patch_b = patches[1] # this should do nothing - it is interpreted as # _adding_ keep_patch_b again which is a no-op. @@ -248,8 +253,9 @@ def test_remove_one_patch_from_relation_bad(self): self.assertEqual(relation.patches.count(), 3) def test_remove_one_patch_from_relation_good(self): - relation = create_relation(3, project=self.project) - target_patch = relation.patches.all()[0] + relation = create_relation() + target_patch = create_patches( + 3, project=self.project, related=relation)[0] # maintainer self.client.force_authenticate(user=self.maintainer) @@ -263,8 +269,10 @@ def test_remove_one_patch_from_relation_good(self): @utils.store_samples('relation-forbid-moving-between-relations') def test_forbid_moving_patch_between_relations(self): """Test the break-before-make logic""" - relation_a = create_relation(project=self.project) - relation_b = create_relation(project=self.project) + relation_a = create_relation() + create_patches(2, project=self.project, related=relation_a) + relation_b = create_relation() + create_patches(2, project=self.project, related=relation_b) patch_a = relation_a.patches.first() patch_b = relation_b.patches.first() diff --git a/patchwork/tests/test_detail.py b/patchwork/tests/test_detail.py index 92fe2d7e..326e27bc 100644 --- a/patchwork/tests/test_detail.py +++ b/patchwork/tests/test_detail.py @@ -3,13 +3,19 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +from datetime import datetime as dt +from datetime import timedelta + from django.test import TestCase from django.urls import reverse +from patchwork.models import Check +from patchwork.tests.utils import create_check from patchwork.tests.utils import create_comment from patchwork.tests.utils import create_cover from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_project +from patchwork.tests.utils import create_user class CoverLetterViewTest(TestCase): @@ -156,6 +162,38 @@ def test_invalid_patch_id(self): response = self.client.get(requested_url) self.assertEqual(response.status_code, 404) + def test_patch_with_checks(self): + user = create_user() + patch = create_patch() + check_a = create_check( + patch=patch, user=user, context='foo', state=Check.STATE_FAIL, + date=(dt.utcnow() - timedelta(days=1))) + create_check( + patch=patch, user=user, context='foo', state=Check.STATE_SUCCESS) + check_b = create_check( + patch=patch, user=user, context='bar', state=Check.STATE_PENDING) + requested_url = reverse( + 'patch-detail', + kwargs={ + 'project_id': patch.project.linkname, + 'msgid': patch.url_msgid, + }, + ) + response = self.client.get(requested_url) + + # the response should contain checks + self.assertContains(response, '

Checks

') + + # and it should only show the unique checks + self.assertEqual( + 1, response.content.decode('utf-8').count( + '%s/%s' % (check_a.user, check_a.context) + )) + self.assertEqual( + 1, response.content.decode('utf-8').count( + '%s/%s' % (check_b.user, check_b.context) + )) + class CommentRedirectTest(TestCase): diff --git a/patchwork/tests/test_events.py b/patchwork/tests/test_events.py index 415237f9..090b6dc0 100644 --- a/patchwork/tests/test_events.py +++ b/patchwork/tests/test_events.py @@ -172,6 +172,53 @@ def test_patch_delegated(self): Event.CATEGORY_PATCH_DELEGATED) self.assertEventFields(events[3], previous_delegate=delegate_b) + def test_patch_relations_changed(self): + # purposefully setting series to None to minimize additional events + relation = utils.create_relation() + patches = utils.create_patches(3, series=None) + + # mark the first two patches as related; the second patch should be the + # one that the event is raised for + + patches[0].related = relation + patches[0].save() + patches[1].related = relation + patches[1].save() + + events = _get_events(patch=patches[1]) + self.assertEqual(events.count(), 2) + self.assertEqual( + events[1].category, Event.CATEGORY_PATCH_RELATION_CHANGED) + self.assertEqual(events[1].project, patches[1].project) + self.assertIsNone(events[1].previous_relation) + self.assertIsNone(events[1].current_relation) + + # add the third patch + + patches[2].related = relation + patches[2].save() + + events = _get_events(patch=patches[2]) + self.assertEqual(events.count(), 2) + self.assertEqual( + events[1].category, Event.CATEGORY_PATCH_RELATION_CHANGED) + self.assertEqual(events[1].project, patches[1].project) + self.assertIsNone(events[1].previous_relation) + self.assertIsNone(events[1].current_relation) + + # drop the third patch + + patches[2].related = None + patches[2].save() + + events = _get_events(patch=patches[2]) + self.assertEqual(events.count(), 3) + self.assertEqual( + events[2].category, Event.CATEGORY_PATCH_RELATION_CHANGED) + self.assertEqual(events[2].project, patches[1].project) + self.assertIsNone(events[2].previous_relation) + self.assertIsNone(events[2].current_relation) + class CheckCreatedTest(_BaseTestCase): diff --git a/patchwork/tests/test_parser.py b/patchwork/tests/test_parser.py index 6fbc9da9..adefac49 100644 --- a/patchwork/tests/test_parser.py +++ b/patchwork/tests/test_parser.py @@ -12,6 +12,9 @@ import sys import unittest +import django +from django.db.transaction import atomic +from django.db import connection from django.test import TestCase from django.test import TransactionTestCase from django.utils import six @@ -20,6 +23,7 @@ from patchwork.models import Patch from patchwork.models import Person from patchwork.models import State +from patchwork.models import CoverLetter from patchwork.parser import clean_subject from patchwork.parser import get_or_create_author from patchwork.parser import find_patch_content as find_content @@ -32,6 +36,7 @@ from patchwork.parser import parse_version from patchwork.parser import split_prefixes from patchwork.parser import subject_check +from patchwork.parser import DuplicateMailError from patchwork.tests import TEST_MAIL_DIR from patchwork.tests import TEST_FUZZ_DIR from patchwork.tests.utils import create_project @@ -371,6 +376,29 @@ def test_google_dmarc_munging(self): self.assertEqual(person_b._state.adding, False) self.assertEqual(person_b.id, person_a.id) + def test_weird_dmarc_munging(self): + project = create_project() + real_sender = 'Existing Sender ' + munged_sender1 = "'Existing Sender' via <{}>".format(project.listemail) + munged_sender2 = "'Existing Sender' <{}>".format(project.listemail) + + # Unmunged author + mail = self._create_email(real_sender) + person_a = get_or_create_author(mail, project) + person_a.save() + + # Munged with no list name + mail = self._create_email(munged_sender1, None, None, real_sender) + person_b = get_or_create_author(mail, project) + self.assertEqual(person_b._state.adding, False) + self.assertEqual(person_b.id, person_a.id) + + # Munged with no 'via' + mail = self._create_email(munged_sender2, None, None, real_sender) + person_b = get_or_create_author(mail, project) + self.assertEqual(person_b._state.adding, False) + self.assertEqual(person_b.id, person_a.id) + class SeriesCorrelationTest(TestCase): """Validate correct behavior of find_series.""" @@ -1098,3 +1126,67 @@ def test_hdr(self): def test_x_face(self): self._test_patch('x-face.mbox') + + +@unittest.skipIf( + django.VERSION < (2, 0), + 'Django 1.11 does not provide an easy DB query introspection API' +) +class DuplicateMailTest(TestCase): + def setUp(self): + self.listid = 'patchwork.ozlabs.org' + create_project(listid=self.listid) + create_state() + + def _test_duplicate_mail(self, mail): + errors = [] + + def log_query_errors(execute, sql, params, many, context): + try: + result = execute(sql, params, many, context) + except Exception as e: + errors.append(e) + raise + return result + + _parse_mail(mail) + + with self.assertRaises(DuplicateMailError): + with connection.execute_wrapper(log_query_errors): + # If we see any database errors from the duplicate insert + # (typically an IntegrityError), the insert will abort the + # current transaction. This atomic() ensures that we can + # recover, and perform subsequent queries. + with atomic(): + _parse_mail(mail) + + self.assertEqual(errors, []) + + def test_duplicate_patch(self): + diff = read_patch('0001-add-line.patch') + m = create_email(diff, listid=self.listid, msgid='1@example.com') + + self._test_duplicate_mail(m) + + self.assertEqual(Patch.objects.count(), 1) + + def test_duplicate_comment(self): + diff = read_patch('0001-add-line.patch') + m1 = create_email(diff, listid=self.listid, msgid='1@example.com') + _parse_mail(m1) + + m2 = create_email('test', listid=self.listid, msgid='2@example.com', + in_reply_to='1@example.com') + self._test_duplicate_mail(m2) + + self.assertEqual(Patch.objects.count(), 1) + self.assertEqual(Comment.objects.count(), 1) + + def test_duplicate_coverletter(self): + m = create_email('test', listid=self.listid, msgid='1@example.com') + del m['Subject'] + m['Subject'] = '[PATCH 0/1] test cover letter' + + self._test_duplicate_mail(m) + + self.assertEqual(CoverLetter.objects.count(), 1) diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py index 7759c8f3..2cb0d8aa 100644 --- a/patchwork/tests/utils.py +++ b/patchwork/tests/utils.py @@ -60,6 +60,8 @@ def create_project(**kwargs): 'listid': 'test%d.example.com' % num, 'listemail': 'test%d@example.com' % num, 'subject_match': '', + 'list_archive_url': 'https://lists.example.com/', + 'list_archive_url_format': 'https://lists.example.com/mail/{}', } values.update(kwargs) @@ -297,6 +299,11 @@ def create_series_reference(**kwargs): return SeriesReference.objects.create(**values) +def create_relation(**kwargs): + """Create 'PatchRelation' object.""" + return PatchRelation.objects.create(**kwargs) + + def _create_submissions(create_func, count=1, **kwargs): """Create 'count' Submission-based objects. @@ -353,13 +360,3 @@ def create_covers(count=1, **kwargs): kwargs (dict): Overrides for various cover letter fields """ return _create_submissions(create_cover, count, **kwargs) - - -def create_relation(count_patches=2, **kwargs): - relation = PatchRelation.objects.create() - values = { - 'related': relation - } - values.update(kwargs) - create_patches(count_patches, **values) - return relation diff --git a/patchwork/views/patch.py b/patchwork/views/patch.py index 470ad197..34934dea 100644 --- a/patchwork/views/patch.py +++ b/patchwork/views/patch.py @@ -123,7 +123,9 @@ def patch_detail(request, project_id, msgid): related_different_project = [] context['comments'] = comments - context['checks'] = patch.check_set.all().select_related('user') + context['checks'] = Patch.filter_unique_checks( + patch.check_set.all().select_related('user'), + ) context['submission'] = patch context['patchform'] = form context['createbundleform'] = createbundleform diff --git a/releasenotes/notes/issue-357-1bef23dbfda2722d.yaml b/releasenotes/notes/issue-357-1bef23dbfda2722d.yaml new file mode 100644 index 00000000..1f337c72 --- /dev/null +++ b/releasenotes/notes/issue-357-1bef23dbfda2722d.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + An issue that preventing updating bundles via the REST API without + updating the included patches has been resolved. + (`#357 `__) diff --git a/releasenotes/notes/issue-358-7d664318a19385fa.yaml b/releasenotes/notes/issue-358-7d664318a19385fa.yaml new file mode 100644 index 00000000..a0eeaa01 --- /dev/null +++ b/releasenotes/notes/issue-358-7d664318a19385fa.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + The parser module now uses an atomic select-insert when creating new patch, + cover letter and comment entries. This prevents the integrity errors from + being logged in the DB logs. + (`#358 `__) diff --git a/releasenotes/notes/issue-379-7b518d15eb0276c1.yaml b/releasenotes/notes/issue-379-7b518d15eb0276c1.yaml new file mode 100644 index 00000000..6d92e951 --- /dev/null +++ b/releasenotes/notes/issue-379-7b518d15eb0276c1.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Resolve a bug that would prevent listing patches for a project via the + browseable API view when logged in with admin permissions (`issue #379`__) + + __ https://github.com/getpatchwork/patchwork/issues/379 diff --git a/releasenotes/notes/issue-391-4088c856247f228e.yaml b/releasenotes/notes/issue-391-4088c856247f228e.yaml new file mode 100644 index 00000000..597902b6 --- /dev/null +++ b/releasenotes/notes/issue-391-4088c856247f228e.yaml @@ -0,0 +1,6 @@ +--- +api: + - | + The ``list_archive_url`` field will now be correctly shown for patch + comments and cover letter comments. + (`#391 `__) diff --git a/releasenotes/notes/rest-filter-msgid-41f693cd4e53cf93.yaml b/releasenotes/notes/rest-filter-msgid-41f693cd4e53cf93.yaml new file mode 100644 index 00000000..0fcbbeb8 --- /dev/null +++ b/releasenotes/notes/rest-filter-msgid-41f693cd4e53cf93.yaml @@ -0,0 +1,6 @@ +--- +api: + - | + The REST API now supports filtering patches and cover letters by message + ID, using the ``msgid`` query parameter. Don't include leading or trailing + angle brackets. diff --git a/requirements-test.txt b/requirements-test.txt index bddc37d3..ed2e5aa6 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,4 +2,5 @@ mysqlclient~=1.4.4 # pyup: >=1.4.4,<1.5.0 psycopg2-binary~=2.8.0 # pyup: >=2.8.0,<2.9.0 sqlparse~=0.3.0 # pyup: >=0.3.0,<0.4.0 python-dateutil~=2.8.0 # pyup: >=2.8.0,<2.9.0 +pyrsistent<0.16.0; python_version < '3.0' # pyup: ignore openapi-core~=0.8.0 # pyup: >=0.8.0,<0.9.0 diff --git a/tools/docker/db/.gitignore b/tools/docker/db/.gitignore index 60baa9cb..c39b7253 100644 --- a/tools/docker/db/.gitignore +++ b/tools/docker/db/.gitignore @@ -1 +1,2 @@ -data/* +data +postdata