1 # Patchwork - automated patch tracking system
2 # Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
4 # This file is part of the Patchwork package.
6 # Patchwork is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
11 # Patchwork is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with Patchwork; if not, write to the Free Software
18 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20 from django.db import models
21 from django.contrib.auth.models import User
22 from django.core.urlresolvers import reverse
23 from django.contrib.sites.models import Site
24 from django.conf import settings
25 from django.utils.functional import cached_property
26 from patchwork.parser import hash_patch, extract_tags
31 from collections import Counter, OrderedDict
33 class Person(models.Model):
34 email = models.CharField(max_length=255, unique = True)
35 name = models.CharField(max_length=255, null = True, blank = True)
36 user = models.ForeignKey(User, null = True, blank = True,
37 on_delete = models.SET_NULL)
39 def __unicode__(self):
41 return u'%s <%s>' % (self.name, self.email)
45 def link_to_user(self, user):
46 self.name = user.profile.name()
50 verbose_name_plural = 'People'
52 class Project(models.Model):
53 linkname = models.CharField(max_length=255, unique=True)
54 name = models.CharField(max_length=255, unique=True)
55 listid = models.CharField(max_length=255, unique=True)
56 listemail = models.CharField(max_length=200)
57 web_url = models.CharField(max_length=2000, blank=True)
58 scm_url = models.CharField(max_length=2000, blank=True)
59 webscm_url = models.CharField(max_length=2000, blank=True)
60 send_notifications = models.BooleanField(default=False)
61 use_tags = models.BooleanField(default=True)
63 def __unicode__(self):
66 def is_editable(self, user):
67 if not user.is_authenticated():
69 return self in user.profile.maintainer_projects.all()
75 return list(Tag.objects.all())
78 ordering = ['linkname']
81 class UserProfile(models.Model):
82 user = models.OneToOneField(User, unique = True, related_name='profile')
83 primary_project = models.ForeignKey(Project, null = True, blank = True)
84 maintainer_projects = models.ManyToManyField(Project,
85 related_name = 'maintainer_project')
86 send_email = models.BooleanField(default = False,
87 help_text = 'Selecting this option allows patchwork to send ' +
88 'email on your behalf')
89 patches_per_page = models.PositiveIntegerField(default = 100,
90 null = False, blank = False,
91 help_text = 'Number of patches to display per page')
94 if self.user.first_name or self.user.last_name:
95 names = filter(bool, [self.user.first_name, self.user.last_name])
96 return u' '.join(names)
97 return self.user.username
99 def contributor_projects(self):
100 submitters = Person.objects.filter(user = self.user)
101 return Project.objects.filter(id__in =
102 Patch.objects.filter(
103 submitter__in = submitters)
104 .values('project_id').query)
106 def sync_person(self):
109 def n_todo_patches(self):
110 return self.todo_patches().count()
112 def todo_patches(self, project = None):
114 # filter on project, if necessary
116 qs = Patch.objects.filter(project = project)
120 qs = qs.filter(archived = False) \
121 .filter(delegate = self.user) \
123 State.objects.filter(action_required = True)
127 def __unicode__(self):
130 def _user_saved_callback(sender, created, instance, **kwargs):
132 profile = instance.profile
133 except UserProfile.DoesNotExist:
134 profile = UserProfile(user = instance)
137 models.signals.post_save.connect(_user_saved_callback, sender = User)
139 class State(models.Model):
140 name = models.CharField(max_length = 100)
141 ordering = models.IntegerField(unique = True)
142 action_required = models.BooleanField(default = True)
144 def __unicode__(self):
148 ordering = ['ordering']
150 class HashField(models.CharField):
151 __metaclass__ = models.SubfieldBase
153 def __init__(self, algorithm = 'sha1', *args, **kwargs):
154 self.algorithm = algorithm
157 def _construct(string = ''):
158 return hashlib.new(self.algorithm, string)
159 self.construct = _construct
160 self.n_bytes = len(hashlib.new(self.algorithm).hexdigest())
162 modules = { 'sha1': 'sha', 'md5': 'md5'}
164 if algorithm not in modules.keys():
165 raise NameError("Unknown algorithm '%s'" % algorithm)
167 self.construct = __import__(modules[algorithm]).new
169 self.n_bytes = len(self.construct().hexdigest())
171 kwargs['max_length'] = self.n_bytes
172 super(HashField, self).__init__(*args, **kwargs)
174 def db_type(self, connection=None):
175 return 'char(%d)' % self.n_bytes
177 class Tag(models.Model):
178 name = models.CharField(max_length=20)
179 pattern = models.CharField(max_length=50,
180 help_text='A simple regex to match the tag in the content of '
181 'a message. Will be used with MULTILINE and IGNORECASE '
182 'flags. eg. ^Acked-by:')
183 abbrev = models.CharField(max_length=2, unique=True,
184 help_text='Short (one-or-two letter) abbreviation for the tag, '
185 'used in table column headers')
187 def __unicode__(self):
192 return 'tag_%d_count' % self.id
195 ordering = ['abbrev']
197 class PatchTag(models.Model):
198 patch = models.ForeignKey('Patch')
199 tag = models.ForeignKey('Tag')
200 count = models.IntegerField(default=1)
203 unique_together = [('patch', 'tag')]
205 def get_default_initial_patch_state():
206 return State.objects.get(ordering=0)
208 class PatchQuerySet(models.query.QuerySet):
210 def with_tag_counts(self, project):
211 if not project.use_tags:
214 # We need the project's use_tags field loaded for Project.tags().
215 # Using prefetch_related means we'll share the one instance of
216 # Project, and share the project.tags cache between all patch.project
218 qs = self.prefetch_related('project')
219 select = OrderedDict()
221 for tag in project.tags:
222 select[tag.attr_name] = ("coalesce("
223 "(SELECT count FROM patchwork_patchtag "
224 "WHERE patchwork_patchtag.patch_id=patchwork_patch.id "
225 "AND patchwork_patchtag.tag_id=%s), 0)")
226 select_params.append(tag.id)
228 return qs.extra(select=select, select_params=select_params)
230 class PatchManager(models.Manager):
231 use_for_related_fields = True
233 def get_queryset(self):
234 return PatchQuerySet(self.model, using=self.db)
236 def with_tag_counts(self, project):
237 return self.get_queryset().with_tag_counts(project)
239 class Patch(models.Model):
240 project = models.ForeignKey(Project)
241 msgid = models.CharField(max_length=255)
242 name = models.CharField(max_length=255)
243 date = models.DateTimeField(default=datetime.datetime.now)
244 submitter = models.ForeignKey(Person)
245 delegate = models.ForeignKey(User, blank = True, null = True)
246 state = models.ForeignKey(State, default=get_default_initial_patch_state)
247 archived = models.BooleanField(default = False)
248 headers = models.TextField(blank = True)
249 content = models.TextField(null = True, blank = True)
250 pull_url = models.CharField(max_length=255, null = True, blank = True)
251 commit_ref = models.CharField(max_length=255, null = True, blank = True)
252 hash = HashField(null = True, blank = True)
253 tags = models.ManyToManyField(Tag, through=PatchTag)
255 objects = PatchManager()
257 def __unicode__(self):
261 return Comment.objects.filter(patch = self)
263 def _set_tag(self, tag, count):
265 self.patchtag_set.filter(tag=tag).delete()
267 (patchtag, _) = PatchTag.objects.get_or_create(patch=self, tag=tag)
268 if patchtag.count != count:
269 patchtag.count = count
272 def refresh_tag_counts(self):
273 tags = self.project.tags
275 for comment in self.comment_set.all():
276 counter = counter + extract_tags(comment.content, tags)
279 self._set_tag(tag, counter[tag])
285 self.state = State.objects.get(ordering = 0)
287 if self.hash is None and self.content is not None:
288 self.hash = hash_patch(self.content).hexdigest()
290 super(Patch, self).save()
292 def is_editable(self, user):
293 if not user.is_authenticated():
296 if self.submitter.user == user or self.delegate == user:
299 return self.project.is_editable(user)
302 fname_re = re.compile('[^-_A-Za-z0-9\.]+')
303 str = fname_re.sub('-', self.name)
304 return str.strip('-') + '.patch'
307 def get_absolute_url(self):
308 return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
311 verbose_name_plural = 'Patches'
313 unique_together = [('msgid', 'project')]
315 class Comment(models.Model):
316 patch = models.ForeignKey(Patch)
317 msgid = models.CharField(max_length=255)
318 submitter = models.ForeignKey(Person)
319 date = models.DateTimeField(default = datetime.datetime.now)
320 headers = models.TextField(blank = True)
321 content = models.TextField()
323 response_re = re.compile( \
324 '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
327 def patch_responses(self):
328 return ''.join([ match.group(0) + '\n' for match in
329 self.response_re.finditer(self.content)])
331 def save(self, *args, **kwargs):
332 super(Comment, self).save(*args, **kwargs)
333 self.patch.refresh_tag_counts()
335 def delete(self, *args, **kwargs):
336 super(Comment, self).delete(*args, **kwargs)
337 self.patch.refresh_tag_counts()
341 unique_together = [('msgid', 'patch')]
343 class Bundle(models.Model):
344 owner = models.ForeignKey(User)
345 project = models.ForeignKey(Project)
346 name = models.CharField(max_length = 50, null = False, blank = False)
347 patches = models.ManyToManyField(Patch, through = 'BundlePatch')
348 public = models.BooleanField(default = False)
351 return self.patches.all().count()
353 def ordered_patches(self):
354 return self.patches.order_by('bundlepatch__order')
356 def append_patch(self, patch):
357 # todo: use the aggregate queries in django 1.1
358 orders = BundlePatch.objects.filter(bundle = self).order_by('-order') \
362 max_order = orders[0]['order']
366 # see if the patch is already in this bundle
367 if BundlePatch.objects.filter(bundle = self, patch = patch).count():
368 raise Exception("patch is already in bundle")
370 bp = BundlePatch.objects.create(bundle = self, patch = patch,
371 order = max_order + 1)
375 unique_together = [('owner', 'name')]
377 def public_url(self):
380 site = Site.objects.get_current()
381 return 'http://%s%s' % (site.domain,
382 reverse('patchwork.views.bundle.bundle',
384 'username': self.owner.username,
385 'bundlename': self.name
389 def get_absolute_url(self):
390 return ('patchwork.views.bundle.bundle', (), {
391 'username': self.owner.username,
392 'bundlename': self.name,
395 class BundlePatch(models.Model):
396 patch = models.ForeignKey(Patch)
397 bundle = models.ForeignKey(Bundle)
398 order = models.IntegerField()
401 unique_together = [('bundle', 'patch')]
404 class EmailConfirmation(models.Model):
405 validity = datetime.timedelta(days = settings.CONFIRMATION_VALIDITY_DAYS)
406 type = models.CharField(max_length = 20, choices = [
407 ('userperson', 'User-Person association'),
408 ('registration', 'Registration'),
409 ('optout', 'Email opt-out'),
411 email = models.CharField(max_length = 200)
412 user = models.ForeignKey(User, null = True)
414 date = models.DateTimeField(default = datetime.datetime.now)
415 active = models.BooleanField(default = True)
417 def deactivate(self):
422 return self.date + self.validity > datetime.datetime.now()
427 str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
428 self.key = self._meta.get_field('key').construct(str).hexdigest()
429 super(EmailConfirmation, self).save()
431 class EmailOptout(models.Model):
432 email = models.CharField(max_length = 200, primary_key = True)
434 def __unicode__(self):
438 def is_optout(cls, email):
439 email = email.lower().strip()
440 return cls.objects.filter(email = email).count() > 0
442 class PatchChangeNotification(models.Model):
443 patch = models.OneToOneField(Patch, primary_key = True)
444 last_modified = models.DateTimeField(default = datetime.datetime.now)
445 orig_state = models.ForeignKey(State)
447 def _patch_change_callback(sender, instance, **kwargs):
448 # we only want notification of modified patches
449 if instance.pk is None:
452 if instance.project is None or not instance.project.send_notifications:
456 orig_patch = Patch.objects.get(pk = instance.pk)
457 except Patch.DoesNotExist:
460 # If there's no interesting changes, abort without creating the
462 if orig_patch.state == instance.state:
467 notification = PatchChangeNotification.objects.get(patch = instance)
468 except PatchChangeNotification.DoesNotExist:
471 if notification is None:
472 notification = PatchChangeNotification(patch = instance,
473 orig_state = orig_patch.state)
475 elif notification.orig_state == instance.state:
476 # If we're back at the original state, there is no need to notify
477 notification.delete()
480 notification.last_modified = datetime.datetime.now()
483 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)