Add a series model. This model is expected to act like a collection for patches, similar to bundles but thread-orientated.
Signed-off-by: Stephen Finucane <[email protected]> Reviewed-by: Andy Doan <[email protected]> Reviewed-by: Daniel Axtens <[email protected]> Reviewed-by: Andrew Donnellan <[email protected]> Tested-by: Russell Currey <[email protected]> --- v7: - Rename 'SeriesRevision' to 'Series' - Rename 'Series.actual_total' to 'Series.received_total' - Rename 'Series.complete' to 'Series.received_all' - Move 'Patch.series' and 'CoverLetter.series' properties into a mixin and rename 'latest_series' - Don't call the 'latest_series' property in admin, as this results in excessive database queries - Clarify precendence of names v6: - Store first patch names in 'SeriesRevision.name' field, if cover a name is not already set v5: - Store cover letter name in 'SeriesRevision.name' field - Add warning about using the 'Patch.series' property, which causes a new query each time 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 | 54 ++++++++- patchwork/migrations/0015_add_series_models.py | 67 +++++++++++ patchwork/models.py | 160 ++++++++++++++++++++++++- 3 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 patchwork/migrations/0015_add_series_models.py diff --git a/patchwork/admin.py b/patchwork/admin.py index 85ffecf..ef041c4 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 Series +from patchwork.models import SeriesReference +from patchwork.models import State +from patchwork.models import Submission +from patchwork.models import Tag +from patchwork.models import UserProfile class DelegationRuleInline(admin.TabularInline): @@ -94,6 +105,43 @@ class CommentAdmin(admin.ModelAdmin): admin.site.register(Comment, CommentAdmin) +class PatchInline(admin.StackedInline): + model = Series.patches.through + extra = 0 + + +class SeriesAdmin(admin.ModelAdmin): + list_display = ('name', 'date', 'submitter', 'version', 'total', + 'received_total', 'received_all') + readonly_fields = ('received_total', 'received_all') + search_fields = ('submitter_name', 'submitter_email') + exclude = ('patches', ) + inlines = (PatchInline, ) + + def received_all(self, series): + return series.received_all + received_all.boolean = True +admin.site.register(Series, SeriesAdmin) + + +class SeriesInline(admin.StackedInline): + model = Series + readonly_fields = ('date', 'submitter', 'version', 'total', + 'received_total', 'received_all') + ordering = ('-date', ) + show_change_link = True + extra = 0 + + def received_all(self, series): + return series.received_all + received_all.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/0015_add_series_models.py b/patchwork/migrations/0015_add_series_models.py new file mode 100644 index 0000000..b7c3dc7 --- /dev/null +++ b/patchwork/migrations/0015_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', '0014_remove_userprofile_primary_project'), + ] + + 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='Series', + 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 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', to='patchwork.CoverLetter')), + ], + options={ + 'ordering': ('date',), + }, + ), + migrations.CreateModel( + name='SeriesPatch', + 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')), + ('patch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patchwork.Patch')), + ('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='patchwork.Series')), + ], + options={ + 'ordering': ['number'], + }, + ), + migrations.AddField( + model_name='series', + name='patches', + field=models.ManyToManyField(related_name='series', through='patchwork.SeriesPatch', to='patchwork.Patch'), + ), + migrations.AddField( + model_name='series', + 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.Series'), + ), + migrations.AlterUniqueTogether( + name='seriespatch', + unique_together=set([('series', 'number'), ('series', 'patch')]), + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index 8a9762a..a27dda6 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -314,12 +314,31 @@ class Submission(EmailMixin, models.Model): unique_together = [('msgid', 'project')] -class CoverLetter(Submission): +class SeriesMixin(object): + + @property + def latest_series(self): + """Get the latest series this is a member of. + + Return the last series that (ordered by date) that this + submission is a member of. + + .. warning:: + Be judicious in your use of this. For example, do not use it + in list templates as doing so will result in a new query for + each item in the list. + """ + # NOTE(stephenfin): We don't use 'latest()' here, as this can raise an + # exception if no series exist + return self.series.order_by('-date').first() + + +class CoverLetter(SeriesMixin, Submission): pass @python_2_unicode_compatible -class Patch(Submission): +class Patch(SeriesMixin, Submission): # patch metadata diff = models.TextField(null=True, blank=True) @@ -566,6 +585,143 @@ class Comment(EmailMixin, models.Model): unique_together = [('msgid', 'submission')] +@python_2_unicode_compatible +class Series(models.Model): + """An collection of patches.""" + + # content + cover_letter = models.ForeignKey(CoverLetter, + related_name='series', + null=True, blank=True) + patches = models.ManyToManyField(Patch, through='SeriesPatch', + related_name='series') + + # 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 as indicated ' + 'by the subject prefix(es)') + total = models.IntegerField(help_text='Number of patches in series as ' + 'indicated by the subject prefix(es)') + + @property + def received_total(self): + return self.patches.count() + + @property + def received_all(self): + return self.total == self.received_total + + def add_cover_letter(self, cover): + """Add a cover letter to the series. + + Helper method so we can use the same pattern to add both + patches and cover letters. + """ + + def _format_name(obj): + return obj.name.split(']')[-1] + + if self.cover_letter: + # TODO(stephenfin): We may wish to raise an exception here in the + # future + return + + self.cover_letter = cover + + # we allow "upgrading of series names. Names from different + # sources are prioritized: + # + # 1. user-provided names + # 2. cover letter-based names + # 3. first patch-based (i.e. 01/nn) names + # + # Names are never "downgraded" - a cover letter received after + # the first patch will result in the name being upgraded to a + # cover letter-based name, but receiving the first patch after + # the cover letter will not change the name of the series. + # + # If none of the above are available, the name will be null. + + if not self.name: + self.name = _format_name(cover) + else: + try: + name = SeriesPatch.objects.get(series=self, + number=1).patch.name + except SeriesPatch.DoesNotExist: + name = None + + if self.name == name: + self.name = _format_name(cover) + + self.save() + + def add_patch(self, patch, number): + """Add a patch to the series.""" + # see if the patch is already in this series + if SeriesPatch.objects.filter(series=self, patch=patch).count(): + # TODO(stephenfin): We may wish to raise an exception here in the + # future + return + + # both user defined names and cover letter-based names take precedence + if not self.name and number == 1: + self.name = patch.name # keep the prefixes for patch-based names + self.save() + + return SeriesPatch.objects.create(series=self, + patch=patch, + number=number) + + def __str__(self): + return self.name if self.name else 'Untitled series #%d' % self.id + + class Meta: + ordering = ('date',) + + +@python_2_unicode_compatible +class SeriesPatch(models.Model): + """A patch in a series. + + Patches can belong to many series. This allows for things like + auto-completion of partial series. + """ + patch = models.ForeignKey(Patch) + series = models.ForeignKey(Series) + number = models.PositiveSmallIntegerField( + help_text='The number assigned to this patch in the series') + + def __str__(self): + return self.patch.name + + class Meta: + unique_together = [('series', 'patch'), ('series', '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(Series, 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) -- 2.7.4 _______________________________________________ Patchwork mailing list [email protected] https://lists.ozlabs.org/listinfo/patchwork
