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