From patchwork Sat Oct 8 19:09:03 2016 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Stephen Finucane X-Patchwork-Id: 679963 Return-Path: X-Original-To: incoming@patchwork.ozlabs.org Delivered-To: patchwork-incoming@bilbo.ozlabs.org Received: from lists.ozlabs.org (lists.ozlabs.org [103.22.144.68]) (using TLSv1.2 with cipher AECDH-AES256-SHA (256/256 bits)) (No client certificate requested) by ozlabs.org (Postfix) with ESMTPS id 3srwvr62v0z9s2Q for ; Sun, 9 Oct 2016 06:10:48 +1100 (AEDT) Authentication-Results: ozlabs.org; dkim=fail reason="key not found in DNS" (0-bit key; unprotected) header.d=that.guru header.i=@that.guru header.b=E5KrAvCp; dkim-atps=neutral Received: from lists.ozlabs.org (lists.ozlabs.org [IPv6:2401:3900:2:1::3]) by lists.ozlabs.org (Postfix) with ESMTP id 3srwvr4y6hzDsgc for ; Sun, 9 Oct 2016 06:10:48 +1100 (AEDT) Authentication-Results: lists.ozlabs.org; dkim=fail reason="key not found in DNS" (0-bit key; unprotected) header.d=that.guru header.i=@that.guru header.b=E5KrAvCp; dkim-atps=neutral X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Received: from brown.birch.relay.mailchannels.net (brown.birch.relay.mailchannels.net [23.83.209.23]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 3srwtQ0hJfzDrWN for ; Sun, 9 Oct 2016 06:09:33 +1100 (AEDT) Authentication-Results: lists.ozlabs.org; dkim=fail reason="key not found in DNS" (0-bit key; unprotected) header.d=that.guru header.i=@that.guru header.b=E5KrAvCp; dkim-atps=neutral X-Sender-Id: mxroute|x-authuser|stephen@that.guru Received: from relay.mailchannels.net (localhost [127.0.0.1]) by relay.mailchannels.net (Postfix) with ESMTP id E4939A14DB for ; Sat, 8 Oct 2016 19:09:23 +0000 (UTC) Received: from one.mxroute.com (ip-10-229-2-62.us-west-2.compute.internal [10.229.2.62]) by relay.mailchannels.net (Postfix) with ESMTPA id 77873A132B for ; Sat, 8 Oct 2016 19:09:22 +0000 (UTC) X-Sender-Id: mxroute|x-authuser|stephen@that.guru Received: from one.mxroute.com ([TEMPUNAVAIL]. [10.102.194.57]) (using TLSv1.2 with cipher DHE-RSA-AES256-GCM-SHA384) by 0.0.0.0:2500 (trex/5.7.8); Sat, 08 Oct 2016 19:09:23 +0000 X-MC-Relay: Neutral X-MailChannels-SenderId: mxroute|x-authuser|stephen@that.guru X-MailChannels-Auth-Id: mxroute X-MC-Loop-Signature: 1475953762806:1900113114 X-MC-Ingress-Time: 1475953762804 DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=that.guru; s=default; h=References:In-Reply-To:Message-Id:Date:Subject:To:From:Sender: Reply-To:Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID: Content-Description:Resent-Date:Resent-From:Resent-Sender:Resent-To:Resent-Cc :Resent-Message-ID:List-Id:List-Help:List-Unsubscribe:List-Subscribe: List-Post:List-Owner:List-Archive; bh=6X3GQDswLdn+xDh8i6cwLhdI5Mxvk2UoXoHnrGWpSo0=; b=E5KrAvCpmQYzjwdfP8MQewDG6W gDHHPLK60PFWkYJvTfwAGW1X9Ys5lzuun8QvPzlt5rChv5ImGZuw+N3FkDn1DJr3Qf1webV20SlUA 8Jh5N8IIxXgk3XIOkFvMVjC/JtgZIIuLVKOL5IMerie8NEEk3fNPEYXOa4I517Pw9WH5yrDCG+vBT yYVXKWvDxN0zMP1tTlR/U7YYr8q2KnjW2IHBdkHS0o+8l9HzXC+6pUiom4rbEJvz+4I/ObsL0lS2V 8r7XgLUG3d/juS3X5ojAo2AD53xnEprdE78YNCOB5jN7DKJbV0xR/puyX2Ygg32FbAF14mflel/Gf t9l9R79g==; From: Stephen Finucane To: patchwork@lists.ozlabs.org Subject: [PATCH v4 2/7] models: Add 'Series' model and related models Date: Sat, 8 Oct 2016 20:09:03 +0100 Message-Id: <1475953748-26649-3-git-send-email-stephen@that.guru> X-Mailer: git-send-email 2.7.4 In-Reply-To: <1475953748-26649-1-git-send-email-stephen@that.guru> References: <1475953748-26649-1-git-send-email-stephen@that.guru> X-AuthUser: stephen@that.guru X-BeenThere: patchwork@lists.ozlabs.org X-Mailman-Version: 2.1.23 Precedence: list List-Id: Patchwork development List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , MIME-Version: 1.0 Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" Add a series model. This model is intentionally very minimal to allow as much dynaminism as possible. It is expected that patches will be migrated between series as new data is provided. Signed-off-by: Stephen Finucane --- v4: - Convert 'SeriesRevision'-'Patch' relationship from one-to-many to many-to-many - Remove 'Series' model, which is not used yet (revisioning is a minefield that's being addressed separately) - Add 'name' field to 'SeriesRevision' v2: - Resolve issue with REST API (Andrew Donnellan) - Use more meaningful names for untitled series (Andrew Donnellan) v1: - Rename 'SeriesGroup' to 'Series' - Rename 'Series' to 'SeriesRevision' --- patchwork/admin.py | 67 ++++++++++- patchwork/migrations/0014_add_series_models.py | 67 +++++++++++ patchwork/models.py | 154 ++++++++++++++++++++++--- 3 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 patchwork/migrations/0014_add_series_models.py diff --git a/patchwork/admin.py b/patchwork/admin.py index 85ffecf..49bd55b 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -21,9 +21,20 @@ from __future__ import absolute_import from django.contrib import admin -from patchwork.models import (Project, Person, UserProfile, State, Submission, - Patch, CoverLetter, Comment, Bundle, Tag, Check, - DelegationRule) +from patchwork.models import Bundle +from patchwork.models import Check +from patchwork.models import Comment +from patchwork.models import CoverLetter +from patchwork.models import DelegationRule +from patchwork.models import Patch +from patchwork.models import Person +from patchwork.models import Project +from patchwork.models import SeriesReference +from patchwork.models import SeriesRevision +from patchwork.models import State +from patchwork.models import Submission +from patchwork.models import Tag +from patchwork.models import UserProfile class DelegationRuleInline(admin.TabularInline): @@ -68,13 +79,22 @@ class SubmissionAdmin(admin.ModelAdmin): search_fields = ('name', 'submitter__name', 'submitter__email') date_hierarchy = 'date' admin.site.register(Submission, SubmissionAdmin) -admin.site.register(CoverLetter, SubmissionAdmin) + + +class CoverLetterAdmin(admin.ModelAdmin): + list_display = ('name', 'submitter', 'project', 'date', 'series') + list_filter = ('project', ) + readonly_fields = ('series', ) + search_fields = ('name', 'submitter__name', 'submitter__email') + date_hierarchy = 'date' +admin.site.register(CoverLetter, CoverLetterAdmin) class PatchAdmin(admin.ModelAdmin): list_display = ('name', 'submitter', 'project', 'state', 'date', - 'archived', 'is_pull_request') + 'archived', 'is_pull_request', 'series') list_filter = ('project', 'state', 'archived') + readonly_fields = ('series', ) search_fields = ('name', 'submitter__name', 'submitter__email') date_hierarchy = 'date' @@ -94,6 +114,43 @@ class CommentAdmin(admin.ModelAdmin): admin.site.register(Comment, CommentAdmin) +class PatchInline(admin.StackedInline): + model = SeriesRevision.patches.through + extra = 0 + + +class SeriesRevisionAdmin(admin.ModelAdmin): + list_display = ('name', 'date', 'submitter', 'version', 'total', + 'actual_total', 'complete') + readonly_fields = ('actual_total', 'complete') + search_fields = ('submitter_name', 'submitter_email') + exclude = ('patches', ) + inlines = (PatchInline, ) + + def complete(self, series): + return series.complete + complete.boolean = True +admin.site.register(SeriesRevision, SeriesRevisionAdmin) + + +class SeriesRevisionInline(admin.StackedInline): + model = SeriesRevision + readonly_fields = ('date', 'submitter', 'version', 'total', + 'actual_total', 'complete') + ordering = ('-date', ) + show_change_link = True + extra = 0 + + def complete(self, series): + return series.complete + complete.boolean = True + + +class SeriesReferenceAdmin(admin.ModelAdmin): + model = SeriesReference +admin.site.register(SeriesReference, SeriesReferenceAdmin) + + class CheckAdmin(admin.ModelAdmin): list_display = ('patch', 'user', 'state', 'target_url', 'description', 'context') diff --git a/patchwork/migrations/0014_add_series_models.py b/patchwork/migrations/0014_add_series_models.py new file mode 100644 index 0000000..8d0fffa --- /dev/null +++ b/patchwork/migrations/0014_add_series_models.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('patchwork', '0013_slug_check_context'), + ] + + operations = [ + migrations.CreateModel( + name='SeriesReference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('msgid', models.CharField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name='SeriesRevision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, help_text=b'An optional name to associate with the series, e.g. "John\'s PCI series".', max_length=255, null=True)), + ('date', models.DateTimeField()), + ('version', models.IntegerField(default=1, help_text=b'Version of series revision as indicated by the subject prefix(es)')), + ('total', models.IntegerField(help_text=b'Number of patches in series as indicated by the subject prefix(es)')), + ('cover_letter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='series_revisions', to='patchwork.CoverLetter')), + ], + options={ + 'ordering': ('date',), + }, + ), + migrations.CreateModel( + name='SeriesRevisionPatch', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('number', models.PositiveSmallIntegerField(help_text=b'The number assigned to this patch in the series revision')), + ('patch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patchwork.Patch')), + ('revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patchwork.SeriesRevision')), + ], + options={ + 'ordering': ['number'], + }, + ), + migrations.AddField( + model_name='seriesrevision', + name='patches', + field=models.ManyToManyField(related_name='series_revisions', related_query_name=b'series_revision', through='patchwork.SeriesRevisionPatch', to='patchwork.Patch'), + ), + migrations.AddField( + model_name='seriesrevision', + name='submitter', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patchwork.Person'), + ), + migrations.AddField( + model_name='seriesreference', + name='series', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', related_query_name=b'reference', to='patchwork.SeriesRevision'), + ), + migrations.AlterUniqueTogether( + name='seriesrevisionpatch', + unique_together=set([('revision', 'number'), ('revision', 'patch')]), + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index 28e9861..a04c4ec 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -293,7 +293,7 @@ class EmailMixin(models.Model): @python_2_unicode_compatible class Submission(EmailMixin, models.Model): - # parent + # parents project = models.ForeignKey(Project) @@ -318,11 +318,22 @@ class Submission(EmailMixin, models.Model): class CoverLetter(Submission): - pass + + @property + def series(self): + """Get a simple series reference. + + Return the last series revision that (ordered by date) that + this submission is a member of. + """ + # NOTE(stephenfin): We don't use 'latest()' here, as this can raise an + # exception if no series revisions exist + return self.series_revisions.order_by('-date').first() @python_2_unicode_compatible class Patch(Submission): + # patch metadata diff = models.TextField(null=True, blank=True) @@ -419,17 +430,6 @@ class Patch(Submission): for tag in tags: self._set_tag(tag, counter[tag]) - def save(self, *args, **kwargs): - if not hasattr(self, 'state') or not self.state: - self.state = get_default_initial_patch_state() - - if self.hash is None and self.diff is not None: - self.hash = self.hash_diff(self.diff).hexdigest() - - super(Patch, self).save(**kwargs) - - self.refresh_tag_counts() - def is_editable(self, user): if not user.is_authenticated(): return False @@ -440,6 +440,17 @@ class Patch(Submission): return self.project.is_editable(user) @property + def series(self): + """Get a simple series reference. + + Return the last series revision that (ordered by date) that + this submission is a member of. + """ + # NOTE(stephenfin): We don't use 'latest()' here, as this can raise an + # exception if no series revisions exist + return self.series_revisions.order_by('-date').first() + + @property def filename(self): fname_re = re.compile(r'[^-_A-Za-z0-9\.]+') str = fname_re.sub('-', self.name) @@ -546,6 +557,17 @@ class Patch(Submission): def __str__(self): return self.name + def save(self, *args, **kwargs): + if not hasattr(self, 'state') or not self.state: + self.state = get_default_initial_patch_state() + + if self.hash is None and self.diff is not None: + self.hash = self.hash_diff(self.diff).hexdigest() + + super(Patch, self).save(**kwargs) + + self.refresh_tag_counts() + class Meta: verbose_name_plural = 'Patches' @@ -569,6 +591,112 @@ class Comment(EmailMixin, models.Model): unique_together = [('msgid', 'submission')] +@python_2_unicode_compatible +class SeriesRevision(models.Model): + """An individual revision of a series.""" + + # content + cover_letter = models.ForeignKey(CoverLetter, + related_name='series_revisions', + null=True, blank=True) + patches = models.ManyToManyField(Patch, through='SeriesRevisionPatch', + related_name='series_revisions', + related_query_name='series_revision') + + # metadata + name = models.CharField(max_length=255, blank=True, null=True, + help_text='An optional name to associate with ' + 'the series, e.g. "John\'s PCI series".') + date = models.DateTimeField() + submitter = models.ForeignKey(Person) + version = models.IntegerField(default=1, + help_text='Version of series revision as ' + 'indicated by the subject prefix(es)') + total = models.IntegerField(help_text='Number of patches in series as ' + 'indicated by the subject prefix(es)') + + @cached_property + def display_name(self): + if self.name: + return self.name + + if self.cover_letter: + return self.cover_letter.name.split(']')[-1] + + return 'Untitled series #%d' % self.id + + @property + def actual_total(self): + return self.patches.count() + + @property + def complete(self): + return self.total == self.actual_total + + def add_cover(self, cover): + """Add a cover letter to the series revision. + + Helper method so we can use the same pattern to add both + patches and cover letters. + """ + self.cover_letter = cover + self.save() + + def add_patch(self, patch, number): + """Add a patch to the series revision.""" + # see if the patch is already in this series + if SeriesRevisionPatch.objects.filter(revision=self, + patch=patch).count(): + return + + return SeriesRevisionPatch.objects.create(patch=patch, + revision=self, + number=number) + + def __str__(self): + return self.display_name + + class Meta: + ordering = ('date',) + + +@python_2_unicode_compatible +class SeriesRevisionPatch(models.Model): + """A patch in a series revision. + + Patches can belong to many series revisions. This allows for things + like auto-completion of partial series. + """ + patch = models.ForeignKey(Patch) + revision = models.ForeignKey(SeriesRevision) + number = models.PositiveSmallIntegerField( + help_text='The number assigned to this patch in the series revision') + + def __str__(self): + return self.patch.name + + class Meta: + unique_together = [('revision', 'patch'), ('revision', 'number')] + ordering = ['number'] + + +@python_2_unicode_compatible +class SeriesReference(models.Model): + """A reference found in a series. + + Message IDs should be created for all patches in a series, + including those of patches that have not yet been received. This is + required to handle the case whereby one or more patches are + received before the cover letter. + """ + series = models.ForeignKey(SeriesRevision, related_name='references', + related_query_name='reference') + msgid = models.CharField(max_length=255, unique=True) + + def __str__(self): + return self.msgid + + class Bundle(models.Model): owner = models.ForeignKey(User) project = models.ForeignKey(Project)