]> git.ozlabs.org Git - patchwork/blob - patchwork/models.py
parsemail: Don't catch all exceptions when a Project isn't found
[patchwork] / patchwork / models.py
1 # Patchwork - automated patch tracking system
2 # Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
3 #
4 # This file is part of the Patchwork package.
5 #
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.
10 #
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.
15 #
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
19
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
27
28 import re
29 import datetime, time
30 import random
31 from collections import Counter, OrderedDict
32
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)
38
39     def __unicode__(self):
40         if self.name:
41             return u'%s <%s>' % (self.name, self.email)
42         else:
43             return self.email
44
45     def link_to_user(self, user):
46         self.name = user.profile.name()
47         self.user = user
48
49     class Meta:
50         verbose_name_plural = 'People'
51
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)
62
63     def __unicode__(self):
64         return self.name
65
66     def is_editable(self, user):
67         if not user.is_authenticated():
68             return False
69         return self in user.profile.maintainer_projects.all()
70
71     @cached_property
72     def tags(self):
73         if not self.use_tags:
74             return []
75         return list(Tag.objects.all())
76
77     class Meta:
78         ordering = ['linkname']
79
80
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')
92
93     def name(self):
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
98
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)
105
106     def sync_person(self):
107         pass
108
109     def n_todo_patches(self):
110         return self.todo_patches().count()
111
112     def todo_patches(self, project = None):
113
114         # filter on project, if necessary
115         if project:
116             qs = Patch.objects.filter(project = project)
117         else:
118             qs = Patch.objects
119
120         qs = qs.filter(archived = False) \
121              .filter(delegate = self.user) \
122              .filter(state__in =
123                      State.objects.filter(action_required = True)
124                          .values('pk').query)
125         return qs
126
127     def __unicode__(self):
128         return self.name()
129
130 def _user_saved_callback(sender, created, instance, **kwargs):
131     try:
132         profile = instance.profile
133     except UserProfile.DoesNotExist:
134         profile = UserProfile(user = instance)
135     profile.save()
136
137 models.signals.post_save.connect(_user_saved_callback, sender = User)
138
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)
143
144     def __unicode__(self):
145         return self.name
146
147     class Meta:
148         ordering = ['ordering']
149
150 class HashField(models.CharField):
151     __metaclass__ = models.SubfieldBase
152
153     def __init__(self, algorithm = 'sha1', *args, **kwargs):
154         self.algorithm = algorithm
155         try:
156             import hashlib
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())
161         except ImportError:
162             modules = { 'sha1': 'sha', 'md5': 'md5'}
163
164             if algorithm not in modules.keys():
165                 raise NameError("Unknown algorithm '%s'" % algorithm)
166
167             self.construct = __import__(modules[algorithm]).new
168
169         self.n_bytes = len(self.construct().hexdigest())
170
171         kwargs['max_length'] = self.n_bytes
172         super(HashField, self).__init__(*args, **kwargs)
173
174     def db_type(self, connection=None):
175         return 'char(%d)' % self.n_bytes
176
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')
186
187     def __unicode__(self):
188         return self.name
189
190     @property
191     def attr_name(self):
192         return 'tag_%d_count' % self.id
193
194     class Meta:
195         ordering = ['abbrev']
196
197 class PatchTag(models.Model):
198     patch = models.ForeignKey('Patch')
199     tag = models.ForeignKey('Tag')
200     count = models.IntegerField(default=1)
201
202     class Meta:
203         unique_together = [('patch', 'tag')]
204
205 def get_default_initial_patch_state():
206     return State.objects.get(ordering=0)
207
208 class PatchQuerySet(models.query.QuerySet):
209
210     def with_tag_counts(self, project):
211         if not project.use_tags:
212             return self
213
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
217         # references.
218         qs = self.prefetch_related('project')
219         select = OrderedDict()
220         select_params = []
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)
227
228         return qs.extra(select=select, select_params=select_params)
229
230 class PatchManager(models.Manager):
231     use_for_related_fields = True
232
233     def get_queryset(self):
234         return PatchQuerySet(self.model, using=self.db)
235
236     def with_tag_counts(self, project):
237         return self.get_queryset().with_tag_counts(project)
238
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)
254
255     objects = PatchManager()
256
257     def __unicode__(self):
258         return self.name
259
260     def comments(self):
261         return Comment.objects.filter(patch = self)
262
263     def _set_tag(self, tag, count):
264         if count == 0:
265             self.patchtag_set.filter(tag=tag).delete()
266             return
267         (patchtag, _) = PatchTag.objects.get_or_create(patch=self, tag=tag)
268         if patchtag.count != count:
269             patchtag.count = count
270             patchtag.save()
271
272     def refresh_tag_counts(self):
273         tags = self.project.tags
274         counter = Counter()
275         for comment in self.comment_set.all():
276             counter = counter + extract_tags(comment.content, tags)
277
278         for tag in tags:
279             self._set_tag(tag, counter[tag])
280
281     def save(self):
282         try:
283             s = self.state
284         except:
285             self.state = State.objects.get(ordering =  0)
286
287         if self.hash is None and self.content is not None:
288             self.hash = hash_patch(self.content).hexdigest()
289
290         super(Patch, self).save()
291
292     def is_editable(self, user):
293         if not user.is_authenticated():
294             return False
295
296         if self.submitter.user == user or self.delegate == user:
297             return True
298
299         return self.project.is_editable(user)
300
301     def filename(self):
302         fname_re = re.compile('[^-_A-Za-z0-9\.]+')
303         str = fname_re.sub('-', self.name)
304         return str.strip('-') + '.patch'
305
306     @models.permalink
307     def get_absolute_url(self):
308         return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
309
310     class Meta:
311         verbose_name_plural = 'Patches'
312         ordering = ['date']
313         unique_together = [('msgid', 'project')]
314
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()
322
323     response_re = re.compile( \
324             '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
325             re.M | re.I)
326
327     def patch_responses(self):
328         return ''.join([ match.group(0) + '\n' for match in
329                                 self.response_re.finditer(self.content)])
330
331     def save(self, *args, **kwargs):
332         super(Comment, self).save(*args, **kwargs)
333         self.patch.refresh_tag_counts()
334
335     def delete(self, *args, **kwargs):
336         super(Comment, self).delete(*args, **kwargs)
337         self.patch.refresh_tag_counts()
338
339     class Meta:
340         ordering = ['date']
341         unique_together = [('msgid', 'patch')]
342
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)
349
350     def n_patches(self):
351         return self.patches.all().count()
352
353     def ordered_patches(self):
354         return self.patches.order_by('bundlepatch__order')
355
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') \
359                  .values('order')
360
361         if len(orders) > 0:
362             max_order = orders[0]['order']
363         else:
364             max_order = 0
365
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")
369
370         bp = BundlePatch.objects.create(bundle = self, patch = patch,
371                 order = max_order + 1)
372         bp.save()
373
374     class Meta:
375         unique_together = [('owner', 'name')]
376
377     def public_url(self):
378         if not self.public:
379             return None
380         site = Site.objects.get_current()
381         return 'http://%s%s' % (site.domain,
382                 reverse('patchwork.views.bundle.bundle',
383                         kwargs = {
384                                 'username': self.owner.username,
385                                 'bundlename': self.name
386                         }))
387
388     @models.permalink
389     def get_absolute_url(self):
390         return ('patchwork.views.bundle.bundle', (), {
391                                 'username': self.owner.username,
392                                 'bundlename': self.name,
393                             })
394
395 class BundlePatch(models.Model):
396     patch = models.ForeignKey(Patch)
397     bundle = models.ForeignKey(Bundle)
398     order = models.IntegerField()
399
400     class Meta:
401         unique_together = [('bundle', 'patch')]
402         ordering = ['order']
403
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'),
410                             ])
411     email = models.CharField(max_length = 200)
412     user = models.ForeignKey(User, null = True)
413     key = HashField()
414     date = models.DateTimeField(default = datetime.datetime.now)
415     active = models.BooleanField(default = True)
416
417     def deactivate(self):
418         self.active = False
419         self.save()
420
421     def is_valid(self):
422         return self.date + self.validity > datetime.datetime.now()
423
424     def save(self):
425         max = 1 << 32
426         if self.key == '':
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()
430
431 class EmailOptout(models.Model):
432     email = models.CharField(max_length = 200, primary_key = True)
433
434     def __unicode__(self):
435         return self.email
436
437     @classmethod
438     def is_optout(cls, email):
439         email = email.lower().strip()
440         return cls.objects.filter(email = email).count() > 0
441
442 class PatchChangeNotification(models.Model):
443     patch = models.ForeignKey(Patch, primary_key = True)
444     last_modified = models.DateTimeField(default = datetime.datetime.now)
445     orig_state = models.ForeignKey(State)
446
447 def _patch_change_callback(sender, instance, **kwargs):
448     # we only want notification of modified patches
449     if instance.pk is None:
450         return
451
452     if instance.project is None or not instance.project.send_notifications:
453         return
454
455     try:
456         orig_patch = Patch.objects.get(pk = instance.pk)
457     except Patch.DoesNotExist:
458         return
459
460     # If there's no interesting changes, abort without creating the
461     # notification
462     if orig_patch.state == instance.state:
463         return
464
465     notification = None
466     try:
467         notification = PatchChangeNotification.objects.get(patch = instance)
468     except PatchChangeNotification.DoesNotExist:
469         pass
470
471     if notification is None:
472         notification = PatchChangeNotification(patch = instance,
473                                                orig_state = orig_patch.state)
474
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()
478         return
479
480     notification.last_modified = datetime.datetime.now()
481     notification.save()
482
483 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)