summaryrefslogtreecommitdiffstats
path: root/patchwork
diff options
context:
space:
mode:
Diffstat (limited to 'patchwork')
-rw-r--r--patchwork/admin.py7
-rw-r--r--patchwork/fixtures/default_tags.xml18
-rw-r--r--patchwork/management/__init__.py0
-rw-r--r--patchwork/management/commands/__init__.py0
-rw-r--r--patchwork/management/commands/retag.py26
-rw-r--r--patchwork/models.py99
-rw-r--r--patchwork/parser.py9
-rw-r--r--patchwork/templates/patchwork/patch-list.html10
-rw-r--r--patchwork/templatetags/patch.py13
-rw-r--r--patchwork/tests/__init__.py1
-rw-r--r--patchwork/tests/test_patchparser.py27
-rw-r--r--patchwork/tests/test_tags.py217
-rw-r--r--patchwork/views/__init__.py3
13 files changed, 427 insertions, 3 deletions
diff --git a/patchwork/admin.py b/patchwork/admin.py
index 5297903..eb8daa1 100644
--- a/patchwork/admin.py
+++ b/patchwork/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
from patchwork.models import Project, Person, UserProfile, State, Patch, \
- Comment, Bundle
+ Comment, Bundle, Tag
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'linkname','listid', 'listemail')
@@ -48,3 +48,8 @@ class BundleAdmin(admin.ModelAdmin):
list_filter = ('public', 'project')
search_fields = ('name', 'owner')
admin.site.register(Bundle, BundleAdmin)
+
+class TagAdmin(admin.ModelAdmin):
+ list_display = ('name',)
+admin.site.register(Tag, TagAdmin)
+
diff --git a/patchwork/fixtures/default_tags.xml b/patchwork/fixtures/default_tags.xml
new file mode 100644
index 0000000..ca5ccfd
--- /dev/null
+++ b/patchwork/fixtures/default_tags.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<django-objects version="1.0">
+ <object pk="1" model="patchwork.tag">
+ <field type="CharField" name="name">Acked-by</field>
+ <field type="CharField" name="pattern">^Acked-by:</field>
+ <field type="CharField" name="abbrev">A</field>
+ </object>
+ <object pk="2" model="patchwork.tag">
+ <field type="CharField" name="name">Reviewed-by</field>
+ <field type="CharField" name="pattern">^Reviewed-by:</field>
+ <field type="CharField" name="abbrev">R</field>
+ </object>
+ <object pk="3" model="patchwork.tag">
+ <field type="CharField" name="name">Tested-by</field>
+ <field type="CharField" name="pattern">^Tested-by:</field>
+ <field type="CharField" name="abbrev">T</field>
+ </object>
+</django-objects> \ No newline at end of file
diff --git a/patchwork/management/__init__.py b/patchwork/management/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/patchwork/management/__init__.py
diff --git a/patchwork/management/commands/__init__.py b/patchwork/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/patchwork/management/commands/__init__.py
diff --git a/patchwork/management/commands/retag.py b/patchwork/management/commands/retag.py
new file mode 100644
index 0000000..e07594a
--- /dev/null
+++ b/patchwork/management/commands/retag.py
@@ -0,0 +1,26 @@
+
+from django.core.management.base import BaseCommand, CommandError
+from patchwork.models import Patch
+import sys
+
+class Command(BaseCommand):
+ help = 'Update the tag (Ack/Review/Test) counts on existing patches'
+ args = '[<patch_id>...]'
+
+ def handle(self, *args, **options):
+
+ qs = Patch.objects
+
+ if args:
+ qs = qs.filter(id__in = args)
+
+ count = qs.count()
+ i = 0
+
+ for patch in qs.iterator():
+ patch.refresh_tag_counts()
+ i += 1
+ if (i % 10) == 0 or i == count:
+ sys.stdout.write('%06d/%06d\r' % (i, count))
+ sys.stdout.flush()
+ sys.stderr.write('\ndone\n')
diff --git a/patchwork/models.py b/patchwork/models.py
index 54b8656..928ddec 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -22,11 +22,13 @@ from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.contrib.sites.models import Site
from django.conf import settings
-from patchwork.parser import hash_patch
+from django.utils.functional import cached_property
+from patchwork.parser import hash_patch, extract_tags
import re
import datetime, time
import random
+from collections import Counter, OrderedDict
class Person(models.Model):
email = models.CharField(max_length=255, unique = True)
@@ -56,6 +58,7 @@ class Project(models.Model):
scm_url = models.CharField(max_length=2000, blank=True)
webscm_url = models.CharField(max_length=2000, blank=True)
send_notifications = models.BooleanField(default=False)
+ use_tags = models.BooleanField(default=True)
def __unicode__(self):
return self.name
@@ -65,6 +68,12 @@ class Project(models.Model):
return False
return self in user.profile.maintainer_projects.all()
+ @cached_property
+ def tags(self):
+ if not self.use_tags:
+ return []
+ return list(Tag.objects.all())
+
class Meta:
ordering = ['linkname']
@@ -165,9 +174,68 @@ class HashField(models.CharField):
def db_type(self, connection=None):
return 'char(%d)' % self.n_bytes
+class Tag(models.Model):
+ name = models.CharField(max_length=20)
+ pattern = models.CharField(max_length=50,
+ help_text='A simple regex to match the tag in the content of '
+ 'a message. Will be used with MULTILINE and IGNORECASE '
+ 'flags. eg. ^Acked-by:')
+ abbrev = models.CharField(max_length=2, unique=True,
+ help_text='Short (one-or-two letter) abbreviation for the tag, '
+ 'used in table column headers')
+
+ def __unicode__(self):
+ return self.name
+
+ @property
+ def attr_name(self):
+ return 'tag_%d_count' % self.id
+
+ class Meta:
+ ordering = ['abbrev']
+
+class PatchTag(models.Model):
+ patch = models.ForeignKey('Patch')
+ tag = models.ForeignKey('Tag')
+ count = models.IntegerField(default=1)
+
+ class Meta:
+ unique_together = [('patch', 'tag')]
+
def get_default_initial_patch_state():
return State.objects.get(ordering=0)
+class PatchQuerySet(models.query.QuerySet):
+
+ def with_tag_counts(self, project):
+ if not project.use_tags:
+ return self
+
+ # We need the project's use_tags field loaded for Project.tags().
+ # Using prefetch_related means we'll share the one instance of
+ # Project, and share the project.tags cache between all patch.project
+ # references.
+ qs = self.prefetch_related('project')
+ select = OrderedDict()
+ select_params = []
+ for tag in project.tags:
+ select[tag.attr_name] = ("coalesce("
+ "(SELECT count FROM patchwork_patchtag "
+ "WHERE patchwork_patchtag.patch_id=patchwork_patch.id "
+ "AND patchwork_patchtag.tag_id=%s), 0)")
+ select_params.append(tag.id)
+
+ return qs.extra(select=select, select_params=select_params)
+
+class PatchManager(models.Manager):
+ use_for_related_fields = True
+
+ def get_queryset(self):
+ return PatchQuerySet(self.model, using=self.db)
+
+ def with_tag_counts(self, project):
+ return self.get_queryset().with_tag_counts(project)
+
class Patch(models.Model):
project = models.ForeignKey(Project)
msgid = models.CharField(max_length=255)
@@ -182,6 +250,9 @@ class Patch(models.Model):
pull_url = models.CharField(max_length=255, null = True, blank = True)
commit_ref = models.CharField(max_length=255, null = True, blank = True)
hash = HashField(null = True, blank = True)
+ tags = models.ManyToManyField(Tag, through=PatchTag)
+
+ objects = PatchManager()
def __unicode__(self):
return self.name
@@ -189,6 +260,24 @@ class Patch(models.Model):
def comments(self):
return Comment.objects.filter(patch = self)
+ def _set_tag(self, tag, count):
+ if count == 0:
+ self.patchtag_set.filter(tag=tag).delete()
+ return
+ (patchtag, _) = PatchTag.objects.get_or_create(patch=self, tag=tag)
+ if patchtag.count != count:
+ patchtag.count = count
+ patchtag.save()
+
+ def refresh_tag_counts(self):
+ tags = self.project.tags
+ counter = Counter()
+ for comment in self.comment_set.all():
+ counter = counter + extract_tags(comment.content, tags)
+
+ for tag in tags:
+ self._set_tag(tag, counter[tag])
+
def save(self):
try:
s = self.state
@@ -239,6 +328,14 @@ class Comment(models.Model):
return ''.join([ match.group(0) + '\n' for match in
self.response_re.finditer(self.content)])
+ def save(self, *args, **kwargs):
+ super(Comment, self).save(*args, **kwargs)
+ self.patch.refresh_tag_counts()
+
+ def delete(self, *args, **kwargs):
+ super(Comment, self).delete(*args, **kwargs)
+ self.patch.refresh_tag_counts()
+
class Meta:
ordering = ['date']
unique_together = [('msgid', 'patch')]
diff --git a/patchwork/parser.py b/patchwork/parser.py
index a51a7b6..8afb334 100644
--- a/patchwork/parser.py
+++ b/patchwork/parser.py
@@ -21,6 +21,7 @@
import re
+from collections import Counter
try:
import hashlib
@@ -234,6 +235,14 @@ def hash_patch(str):
return hash
+def extract_tags(content, tags):
+ counts = Counter()
+
+ for tag in tags:
+ regex = re.compile(tag.pattern, re.MULTILINE | re.IGNORECASE)
+ counts[tag] = len(regex.findall(content))
+
+ return counts
def main(args):
from optparse import OptionParser
diff --git a/patchwork/templates/patchwork/patch-list.html b/patchwork/templates/patchwork/patch-list.html
index 675f67f..718949e 100644
--- a/patchwork/templates/patchwork/patch-list.html
+++ b/patchwork/templates/patchwork/patch-list.html
@@ -1,5 +1,6 @@
{% load person %}
{% load listurl %}
+{% load patch %}
{% load static %}
{% include "patchwork/pagination.html" %}
@@ -69,6 +70,12 @@
</th>
<th>
+ <span
+ title="{% for tag in project.tags %}{{tag.name}}{% if not forloop.last %} / {% endif %}{% endfor %}"
+ >{% for tag in project.tags %}{{tag.abbrev}}{% if not forloop.last %}/{% endif %}{% endfor %}</span>
+ </th>
+
+ <th>
{% ifequal order.name "date" %}
<a class="colactive"
href="{% listurl order=order.reversed_name %}"><img
@@ -166,6 +173,7 @@
{% endif %}
<td><a href="{% url 'patchwork.views.patch.patch' patch_id=patch.id %}"
>{{ patch.name|default:"[no subject]" }}</a></td>
+ <td style="white-space: nowrap;">{{ patch|patch_tags }}</td>
<td>{{ patch.date|date:"Y-m-d" }}</td>
<td>{{ patch.submitter|personify:project }}</td>
<td>{{ patch.delegate.username }}</td>
@@ -259,7 +267,7 @@
{% else %}
<tr>
- <td colspan="5">No patches to display</td>
+ <td colspan="6">No patches to display</td>
</tr>
{% endif %}
diff --git a/patchwork/templatetags/patch.py b/patchwork/templatetags/patch.py
index bec0cab..ea23ebd 100644
--- a/patchwork/templatetags/patch.py
+++ b/patchwork/templatetags/patch.py
@@ -18,6 +18,7 @@
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
from django import template
+from django.utils.safestring import mark_safe
import re
register = template.Library()
@@ -63,3 +64,15 @@ class EditablePatchNode(template.Node):
return self.nodelist_false.render(context)
return self.nodelist_true.render(context)
+
+@register.filter(name='patch_tags')
+def patch_tags(patch):
+ counts = []
+ titles = []
+ for tag in patch.project.tags:
+ count = getattr(patch, tag.attr_name)
+ titles.append('%d %s' % (count, tag.name))
+ counts.append(str(count))
+ return mark_safe('<span title="%s">%s</span>' % (
+ ' / '.join(titles),
+ ' '.join(counts)))
diff --git a/patchwork/tests/__init__.py b/patchwork/tests/__init__.py
index 85200bd..662386a 100644
--- a/patchwork/tests/__init__.py
+++ b/patchwork/tests/__init__.py
@@ -18,6 +18,7 @@
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
from patchwork.tests.test_patchparser import *
+from patchwork.tests.test_tags import *
from patchwork.tests.test_encodings import *
from patchwork.tests.test_bundles import *
from patchwork.tests.test_mboxviews import *
diff --git a/patchwork/tests/test_patchparser.py b/patchwork/tests/test_patchparser.py
index 119936a..5eefeb5 100644
--- a/patchwork/tests/test_patchparser.py
+++ b/patchwork/tests/test_patchparser.py
@@ -552,3 +552,30 @@ class InitialPatchStateTest(TestCase):
def tearDown(self):
self.p1.delete()
self.user.delete()
+
+class ParseInitialTagsTest(PatchTest):
+ patch_filename = '0001-add-line.patch'
+ test_comment = ('test comment\n\n' +
+ 'Tested-by: Test User <test@example.com>\n' +
+ 'Reviewed-by: Test User <test@example.com>\n')
+ fixtures = ['default_tags']
+
+ def setUp(self):
+ project = defaults.project
+ project.listid = 'test.example.com'
+ project.save()
+ self.orig_patch = read_patch(self.patch_filename)
+ email = create_email(self.test_comment + '\n' + self.orig_patch,
+ project = project)
+ email['Message-Id'] = '<1@example.com>'
+ parse_mail(email)
+
+ def testTags(self):
+ self.assertEquals(Patch.objects.count(), 1)
+ patch = Patch.objects.all()[0]
+ self.assertEquals(patch.patchtag_set.filter(
+ tag__name='Acked-by').count(), 0)
+ self.assertEquals(patch.patchtag_set.get(
+ tag__name='Reviewed-by').count, 1)
+ self.assertEquals(patch.patchtag_set.get(
+ tag__name='Tested-by').count, 1)
diff --git a/patchwork/tests/test_tags.py b/patchwork/tests/test_tags.py
new file mode 100644
index 0000000..f1196e7
--- /dev/null
+++ b/patchwork/tests/test_tags.py
@@ -0,0 +1,217 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2014 Jeremy Kerr <jk@ozlabs.org>
+#
+# This file is part of the Patchwork package.
+#
+# Patchwork is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# Patchwork is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Patchwork; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+import unittest
+import datetime
+from django.test import TestCase, TransactionTestCase
+from patchwork.models import Project, Patch, Comment, Tag, PatchTag
+from patchwork.tests.utils import defaults
+from patchwork.parser import extract_tags
+
+from django.conf import settings
+from django.db import connection
+
+class ExtractTagsTest(TestCase):
+
+ email = 'test@exmaple.com'
+ name_email = 'test name <' + email + '>'
+ fixtures = ['default_tags']
+
+ def assertTagsEqual(self, str, acks, reviews, tests):
+ counts = extract_tags(str, Tag.objects.all())
+ self.assertEquals((acks, reviews, tests),
+ (counts[Tag.objects.get(name='Acked-by')],
+ counts[Tag.objects.get(name='Reviewed-by')],
+ counts[Tag.objects.get(name='Tested-by')]))
+
+ def testEmpty(self):
+ self.assertTagsEqual("", 0, 0, 0)
+
+ def testNoTag(self):
+ self.assertTagsEqual("foo", 0, 0, 0)
+
+ def testAck(self):
+ self.assertTagsEqual("Acked-by: %s" % self.name_email, 1, 0, 0)
+
+ def testAckEmailOnly(self):
+ self.assertTagsEqual("Acked-by: %s" % self.email, 1, 0, 0)
+
+ def testReviewed(self):
+ self.assertTagsEqual("Reviewed-by: %s" % self.name_email, 0, 1, 0)
+
+ def testTested(self):
+ self.assertTagsEqual("Tested-by: %s" % self.name_email, 0, 0, 1)
+
+ def testAckAfterNewline(self):
+ self.assertTagsEqual("\nAcked-by: %s" % self.name_email, 1, 0, 0)
+
+ def testMultipleAcks(self):
+ str = "Acked-by: %s\nAcked-by: %s\n" % ((self.name_email,) * 2)
+ self.assertTagsEqual(str, 2, 0, 0)
+
+ def testMultipleTypes(self):
+ str = "Acked-by: %s\nAcked-by: %s\nReviewed-by: %s\n" % (
+ (self.name_email,) * 3)
+ self.assertTagsEqual(str, 2, 1, 0)
+
+ def testLower(self):
+ self.assertTagsEqual("acked-by: %s" % self.name_email, 1, 0, 0)
+
+ def testUpper(self):
+ self.assertTagsEqual("ACKED-BY: %s" % self.name_email, 1, 0, 0)
+
+ def testAckInReply(self):
+ self.assertTagsEqual("> Acked-by: %s\n" % self.name_email, 0, 0, 0)
+
+class PatchTagsTest(TransactionTestCase):
+ ACK = 1
+ REVIEW = 2
+ TEST = 3
+ fixtures = ['default_tags']
+
+ def assertTagsEqual(self, patch, acks, reviews, tests):
+ patch = Patch.objects.get(pk=patch.pk)
+
+ def count(name):
+ try:
+ return patch.patchtag_set.get(tag__name=name).count
+ except PatchTag.DoesNotExist:
+ return 0
+
+ counts = (
+ count(name='Acked-by'),
+ count(name='Reviewed-by'),
+ count(name='Tested-by'),
+ )
+
+ self.assertEqual(counts, (acks, reviews, tests))
+
+ def create_tag(self, tagtype = None):
+ tags = {
+ self.ACK: 'Acked',
+ self.REVIEW: 'Reviewed',
+ self.TEST: 'Tested'
+ }
+ if tagtype not in tags:
+ return ''
+ return '%s-by: %s\n' % (tags[tagtype], self.tagger)
+
+ def create_tag_comment(self, patch, tagtype = None):
+ comment = Comment(patch=patch, msgid=str(datetime.datetime.now()),
+ submitter=defaults.patch_author_person,
+ content=self.create_tag(tagtype))
+ comment.save()
+ return comment
+
+ def setUp(self):
+ settings.DEBUG = True
+ project = Project(linkname='test-project', name='Test Project',
+ use_tags=True)
+ project.save()
+ defaults.patch_author_person.save()
+ self.patch = Patch(project=project,
+ msgid='x', name=defaults.patch_name,
+ submitter=defaults.patch_author_person,
+ content='')
+ self.patch.save()
+ self.tagger = 'Test Tagger <tagger@example.com>'
+
+ def tearDown(self):
+ self.patch.delete()
+
+ def testNoComments(self):
+ self.assertTagsEqual(self.patch, 0, 0, 0)
+
+ def testNoTagComment(self):
+ self.create_tag_comment(self.patch, None)
+ self.assertTagsEqual(self.patch, 0, 0, 0)
+
+ def testSingleComment(self):
+ self.create_tag_comment(self.patch, self.ACK)
+ self.assertTagsEqual(self.patch, 1, 0, 0)
+
+ def testMultipleComments(self):
+ self.create_tag_comment(self.patch, self.ACK)
+ self.create_tag_comment(self.patch, self.ACK)
+ self.assertTagsEqual(self.patch, 2, 0, 0)
+
+ def testMultipleCommentTypes(self):
+ self.create_tag_comment(self.patch, self.ACK)
+ self.create_tag_comment(self.patch, self.REVIEW)
+ self.create_tag_comment(self.patch, self.TEST)
+ self.assertTagsEqual(self.patch, 1, 1, 1)
+
+ def testCommentAdd(self):
+ self.create_tag_comment(self.patch, self.ACK)
+ self.assertTagsEqual(self.patch, 1, 0, 0)
+ self.create_tag_comment(self.patch, self.ACK)
+ self.assertTagsEqual(self.patch, 2, 0, 0)
+
+ def testCommentUpdate(self):
+ comment = self.create_tag_comment(self.patch, self.ACK)
+ self.assertTagsEqual(self.patch, 1, 0, 0)
+
+ comment.content += self.create_tag(self.ACK)
+ comment.save()
+ self.assertTagsEqual(self.patch, 2, 0, 0)
+
+ def testCommentDelete(self):
+ comment = self.create_tag_comment(self.patch, self.ACK)
+ self.assertTagsEqual(self.patch, 1, 0, 0)
+ comment.delete()
+ self.assertTagsEqual(self.patch, 0, 0, 0)
+
+ def testSingleCommentMultipleTags(self):
+ comment = self.create_tag_comment(self.patch, self.ACK)
+ comment.content += self.create_tag(self.REVIEW)
+ comment.save()
+ self.assertTagsEqual(self.patch, 1, 1, 0)
+
+ def testMultipleCommentsMultipleTags(self):
+ c1 = self.create_tag_comment(self.patch, self.ACK)
+ c1.content += self.create_tag(self.REVIEW)
+ c1.save()
+ self.assertTagsEqual(self.patch, 1, 1, 0)
+
+class PatchTagManagerTest(PatchTagsTest):
+
+ def assertTagsEqual(self, patch, acks, reviews, tests):
+
+ tagattrs = {}
+ for tag in Tag.objects.all():
+ tagattrs[tag.name] = tag.attr_name
+
+ # force project.tags to be queried outside of the assertNumQueries
+ patch.project.tags
+
+ # we should be able to do this with two queries: one for
+ # the patch table lookup, and the prefetch_related for the
+ # projects table.
+ with self.assertNumQueries(2):
+ patch = Patch.objects.with_tag_counts(project=patch.project) \
+ .get(pk = patch.pk)
+
+ counts = (
+ getattr(patch, tagattrs['Acked-by']),
+ getattr(patch, tagattrs['Reviewed-by']),
+ getattr(patch, tagattrs['Tested-by']),
+ )
+
+ self.assertEqual(counts, (acks, reviews, tests))
+
diff --git a/patchwork/views/__init__.py b/patchwork/views/__init__.py
index dfca56d..b7916f0 100644
--- a/patchwork/views/__init__.py
+++ b/patchwork/views/__init__.py
@@ -104,6 +104,9 @@ def generic_list(request, project, view,
if patches is None:
patches = Patch.objects.filter(project=project)
+ # annotate with tag counts
+ patches = patches.with_tag_counts(project)
+
patches = context.filters.apply(patches)
if not editable_order:
patches = order.apply(patches)