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