]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/models.py
86a5266cff2d2177f682c57e6fcea8897b616b66
[patchwork] / apps / 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 patchwork.parser import hash_patch
26
27 import re
28 import datetime, time
29 import random
30
31 try:
32     from email.mime.nonmultipart import MIMENonMultipart
33     from email.encoders import encode_7or8bit
34     from email.parser import HeaderParser
35     import email.utils
36 except ImportError:
37     # Python 2.4 compatibility
38     from email.MIMENonMultipart import MIMENonMultipart
39     from email.Encoders import encode_7or8bit
40     from email.Parser import HeaderParser
41     import email.Utils
42     email.utils = email.Utils
43
44 class Person(models.Model):
45     email = models.CharField(max_length=255, unique = True)
46     name = models.CharField(max_length=255, null = True, blank = True)
47     user = models.ForeignKey(User, null = True, blank = True)
48
49     def __unicode__(self):
50         if self.name:
51             return u'%s <%s>' % (self.name, self.email)
52         else:
53             return self.email
54
55     def link_to_user(self, user):
56         self.name = user.get_profile().name()
57         self.user = user
58
59     class Meta:
60         verbose_name_plural = 'People'
61
62 class Project(models.Model):
63     linkname = models.CharField(max_length=255, unique=True)
64     name = models.CharField(max_length=255, unique=True)
65     listid = models.CharField(max_length=255, unique=True)
66     listemail = models.CharField(max_length=200)
67     web_url = models.CharField(max_length=2000, blank=True)
68     scm_url = models.CharField(max_length=2000, blank=True)
69     webscm_url = models.CharField(max_length=2000, blank=True)
70     send_notifications = models.BooleanField()
71
72     def __unicode__(self):
73         return self.name
74
75     def is_editable(self, user):
76         if not user.is_authenticated():
77             return False
78         return self in user.get_profile().maintainer_projects.all()
79
80 class UserProfile(models.Model):
81     user = models.ForeignKey(User, unique = True)
82     primary_project = models.ForeignKey(Project, null = True, blank = True)
83     maintainer_projects = models.ManyToManyField(Project,
84             related_name = 'maintainer_project')
85     send_email = models.BooleanField(default = False,
86             help_text = 'Selecting this option allows patchwork to send ' +
87                 'email on your behalf')
88     patches_per_page = models.PositiveIntegerField(default = 100,
89             null = False, blank = False,
90             help_text = 'Number of patches to display per page')
91
92     def name(self):
93         if self.user.first_name or self.user.last_name:
94             names = filter(bool, [self.user.first_name, self.user.last_name])
95             return u' '.join(names)
96         return self.user.username
97
98     def contributor_projects(self):
99         submitters = Person.objects.filter(user = self.user)
100         return Project.objects.filter(id__in =
101                                         Patch.objects.filter(
102                                             submitter__in = submitters)
103                                         .values('project_id').query)
104
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 save(self):
128         super(UserProfile, self).save()
129         people = Person.objects.filter(email = self.user.email)
130         if not people:
131             person = Person(email = self.user.email,
132                     name = self.name(), user = self.user)
133             person.save()
134         else:
135             for person in people:
136                  person.link_to_user(self.user)
137                  person.save()
138
139     def __unicode__(self):
140         return self.name()
141
142 def _user_saved_callback(sender, created, instance, **kwargs):
143     try:
144         profile = instance.get_profile()
145     except UserProfile.DoesNotExist:
146         profile = UserProfile(user = instance)
147     profile.save()
148
149 models.signals.post_save.connect(_user_saved_callback, sender = User)
150
151 class State(models.Model):
152     name = models.CharField(max_length = 100)
153     ordering = models.IntegerField(unique = True)
154     action_required = models.BooleanField(default = True)
155
156     def __unicode__(self):
157         return self.name
158
159     class Meta:
160         ordering = ['ordering']
161
162 class HashField(models.CharField):
163     __metaclass__ = models.SubfieldBase
164
165     def __init__(self, algorithm = 'sha1', *args, **kwargs):
166         self.algorithm = algorithm
167         try:
168             import hashlib
169             def _construct(string = ''):
170                 return hashlib.new(self.algorithm, string)
171             self.construct = _construct
172             self.n_bytes = len(hashlib.new(self.algorithm).hexdigest())
173         except ImportError:
174             modules = { 'sha1': 'sha', 'md5': 'md5'}
175
176             if algorithm not in modules.keys():
177                 raise NameError("Unknown algorithm '%s'" % algorithm)
178
179             self.construct = __import__(modules[algorithm]).new
180
181         self.n_bytes = len(self.construct().hexdigest())
182
183         kwargs['max_length'] = self.n_bytes
184         super(HashField, self).__init__(*args, **kwargs)
185
186     def db_type(self, connection=None):
187         return 'char(%d)' % self.n_bytes
188
189 class PatchMbox(MIMENonMultipart):
190     patch_charset = 'utf-8'
191     def __init__(self, _text):
192         MIMENonMultipart.__init__(self, 'text', 'plain',
193                         **{'charset': self.patch_charset})
194         self.set_payload(_text.encode(self.patch_charset))
195         encode_7or8bit(self)
196
197 def get_default_initial_patch_state():
198     return State.objects.get(ordering=0)
199
200 class Patch(models.Model):
201     project = models.ForeignKey(Project)
202     msgid = models.CharField(max_length=255)
203     name = models.CharField(max_length=255)
204     date = models.DateTimeField(default=datetime.datetime.now)
205     submitter = models.ForeignKey(Person)
206     delegate = models.ForeignKey(User, blank = True, null = True)
207     state = models.ForeignKey(State, default=get_default_initial_patch_state)
208     archived = models.BooleanField(default = False)
209     headers = models.TextField(blank = True)
210     content = models.TextField(null = True, blank = True)
211     pull_url = models.CharField(max_length=255, null = True, blank = True)
212     commit_ref = models.CharField(max_length=255, null = True, blank = True)
213     hash = HashField(null = True, blank = True)
214
215     def __unicode__(self):
216         return self.name
217
218     def comments(self):
219         return Comment.objects.filter(patch = self)
220
221     def save(self):
222         try:
223             s = self.state
224         except:
225             self.state = State.objects.get(ordering =  0)
226
227         if self.hash is None and self.content is not None:
228             self.hash = hash_patch(self.content).hexdigest()
229
230         super(Patch, self).save()
231
232     def is_editable(self, user):
233         if not user.is_authenticated():
234             return False
235
236         if self.submitter.user == user or self.delegate == user:
237             return True
238
239         return self.project.is_editable(user)
240
241     def filename(self):
242         fname_re = re.compile('[^-_A-Za-z0-9\.]+')
243         str = fname_re.sub('-', self.name)
244         return str.strip('-') + '.patch'
245
246     def mbox(self):
247         postscript_re = re.compile('\n-{2,3} ?\n')
248
249         comment = None
250         try:
251             comment = Comment.objects.get(patch = self, msgid = self.msgid)
252         except Exception:
253             pass
254
255         body = ''
256         if comment:
257             body = comment.content.strip() + "\n"
258
259         parts = postscript_re.split(body, 1)
260         if len(parts) == 2:
261             (body, postscript) = parts
262             body = body.strip() + "\n"
263             postscript = postscript.strip() + "\n"
264         else:
265             postscript = ''
266
267         for comment in Comment.objects.filter(patch = self) \
268                 .exclude(msgid = self.msgid):
269             body += comment.patch_responses()
270
271         if body:
272             body += '\n'
273
274         if postscript:
275             body += '---\n' + postscript.strip() + '\n'
276
277         if self.content:
278             body += '\n' + self.content
279
280         mail = PatchMbox(body)
281         mail['Subject'] = self.name
282         mail['Date'] = email.utils.formatdate(
283                         time.mktime(self.date.utctimetuple()))
284         mail['From'] = unicode(self.submitter)
285         mail['X-Patchwork-Id'] = str(self.id)
286         mail['Message-Id'] = self.msgid
287         mail.set_unixfrom('From patchwork ' + self.date.ctime())
288
289
290         copied_headers = ['To', 'Cc']
291         orig_headers = HeaderParser().parsestr(str(self.headers))
292         for header in copied_headers:
293             if header in orig_headers:
294                 mail[header] = orig_headers[header]
295
296         return mail
297
298     @models.permalink
299     def get_absolute_url(self):
300         return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
301
302     class Meta:
303         verbose_name_plural = 'Patches'
304         ordering = ['date']
305         unique_together = [('msgid', 'project')]
306
307 class Comment(models.Model):
308     patch = models.ForeignKey(Patch)
309     msgid = models.CharField(max_length=255)
310     submitter = models.ForeignKey(Person)
311     date = models.DateTimeField(default = datetime.datetime.now)
312     headers = models.TextField(blank = True)
313     content = models.TextField()
314
315     response_re = re.compile( \
316             '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
317             re.M | re.I)
318
319     def patch_responses(self):
320         return ''.join([ match.group(0) + '\n' for match in
321                                 self.response_re.finditer(self.content)])
322
323     class Meta:
324         ordering = ['date']
325         unique_together = [('msgid', 'patch')]
326
327 class Bundle(models.Model):
328     owner = models.ForeignKey(User)
329     project = models.ForeignKey(Project)
330     name = models.CharField(max_length = 50, null = False, blank = False)
331     patches = models.ManyToManyField(Patch, through = 'BundlePatch')
332     public = models.BooleanField(default = False)
333
334     def n_patches(self):
335         return self.patches.all().count()
336
337     def ordered_patches(self):
338         return self.patches.order_by('bundlepatch__order')
339
340     def append_patch(self, patch):
341         # todo: use the aggregate queries in django 1.1
342         orders = BundlePatch.objects.filter(bundle = self).order_by('-order') \
343                  .values('order')
344
345         if len(orders) > 0:
346             max_order = orders[0]['order']
347         else:
348             max_order = 0
349
350         # see if the patch is already in this bundle
351         if BundlePatch.objects.filter(bundle = self, patch = patch).count():
352             raise Exception("patch is already in bundle")
353
354         bp = BundlePatch.objects.create(bundle = self, patch = patch,
355                 order = max_order + 1)
356         bp.save()
357
358     class Meta:
359         unique_together = [('owner', 'name')]
360
361     def public_url(self):
362         if not self.public:
363             return None
364         site = Site.objects.get_current()
365         return 'http://%s%s' % (site.domain,
366                 reverse('patchwork.views.bundle.public',
367                         kwargs = {
368                                 'username': self.owner.username,
369                                 'bundlename': self.name
370                         }))
371
372     def mbox(self):
373         return '\n'.join([p.mbox().as_string(True)
374                         for p in self.ordered_patches()])
375
376 class BundlePatch(models.Model):
377     patch = models.ForeignKey(Patch)
378     bundle = models.ForeignKey(Bundle)
379     order = models.IntegerField()
380
381     class Meta:
382         unique_together = [('bundle', 'patch')]
383         ordering = ['order']
384
385 class EmailConfirmation(models.Model):
386     validity = datetime.timedelta(days = settings.CONFIRMATION_VALIDITY_DAYS)
387     type = models.CharField(max_length = 20, choices = [
388                                 ('userperson', 'User-Person association'),
389                                 ('registration', 'Registration'),
390                                 ('optout', 'Email opt-out'),
391                             ])
392     email = models.CharField(max_length = 200)
393     user = models.ForeignKey(User, null = True)
394     key = HashField()
395     date = models.DateTimeField(default = datetime.datetime.now)
396     active = models.BooleanField(default = True)
397
398     def deactivate(self):
399         self.active = False
400         self.save()
401
402     def is_valid(self):
403         return self.date + self.validity > datetime.datetime.now()
404
405     def save(self):
406         max = 1 << 32
407         if self.key == '':
408             str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
409             self.key = self._meta.get_field('key').construct(str).hexdigest()
410         super(EmailConfirmation, self).save()
411
412 class EmailOptout(models.Model):
413     email = models.CharField(max_length = 200, primary_key = True)
414
415     def __unicode__(self):
416         return self.email
417
418     @classmethod
419     def is_optout(cls, email):
420         email = email.lower().strip()
421         return cls.objects.filter(email = email).count() > 0
422
423 class PatchChangeNotification(models.Model):
424     patch = models.ForeignKey(Patch, primary_key = True)
425     last_modified = models.DateTimeField(default = datetime.datetime.now)
426     orig_state = models.ForeignKey(State)
427
428 def _patch_change_callback(sender, instance, **kwargs):
429     # we only want notification of modified patches
430     if instance.pk is None:
431         return
432
433     if instance.project is None or not instance.project.send_notifications:
434         return
435
436     try:
437         orig_patch = Patch.objects.get(pk = instance.pk)
438     except Patch.DoesNotExist:
439         return
440
441     # If there's no interesting changes, abort without creating the
442     # notification
443     if orig_patch.state == instance.state:
444         return
445
446     notification = None
447     try:
448         notification = PatchChangeNotification.objects.get(patch = instance)
449     except PatchChangeNotification.DoesNotExist:
450         pass
451
452     if notification is None:
453         notification = PatchChangeNotification(patch = instance,
454                                                orig_state = orig_patch.state)
455
456     elif notification.orig_state == instance.state:
457         # If we're back at the original state, there is no need to notify
458         notification.delete()
459         return
460
461     notification.last_modified = datetime.datetime.now()
462     notification.save()
463
464 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)