]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/models.py
models: fix From header in mbox view
[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         mail = PatchMbox(body)
283         mail['Subject'] = self.name
284         mail['Date'] = email.utils.formatdate(
285                         time.mktime(self.date.utctimetuple()))
286         mail['From'] = email.utils.formataddr((
287                         str(Header(self.submitter.name, mail.patch_charset)),
288                         self.submitter.email))
289         mail['X-Patchwork-Id'] = str(self.id)
290         mail['Message-Id'] = self.msgid
291         mail.set_unixfrom('From patchwork ' + self.date.ctime())
292
293
294         copied_headers = ['To', 'Cc']
295         orig_headers = HeaderParser().parsestr(str(self.headers))
296         for header in copied_headers:
297             if header in orig_headers:
298                 mail[header] = orig_headers[header]
299
300         return mail
301
302     @models.permalink
303     def get_absolute_url(self):
304         return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
305
306     class Meta:
307         verbose_name_plural = 'Patches'
308         ordering = ['date']
309         unique_together = [('msgid', 'project')]
310
311 class Comment(models.Model):
312     patch = models.ForeignKey(Patch)
313     msgid = models.CharField(max_length=255)
314     submitter = models.ForeignKey(Person)
315     date = models.DateTimeField(default = datetime.datetime.now)
316     headers = models.TextField(blank = True)
317     content = models.TextField()
318
319     response_re = re.compile( \
320             '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
321             re.M | re.I)
322
323     def patch_responses(self):
324         return ''.join([ match.group(0) + '\n' for match in
325                                 self.response_re.finditer(self.content)])
326
327     class Meta:
328         ordering = ['date']
329         unique_together = [('msgid', 'patch')]
330
331 class Bundle(models.Model):
332     owner = models.ForeignKey(User)
333     project = models.ForeignKey(Project)
334     name = models.CharField(max_length = 50, null = False, blank = False)
335     patches = models.ManyToManyField(Patch, through = 'BundlePatch')
336     public = models.BooleanField(default = False)
337
338     def n_patches(self):
339         return self.patches.all().count()
340
341     def ordered_patches(self):
342         return self.patches.order_by('bundlepatch__order')
343
344     def append_patch(self, patch):
345         # todo: use the aggregate queries in django 1.1
346         orders = BundlePatch.objects.filter(bundle = self).order_by('-order') \
347                  .values('order')
348
349         if len(orders) > 0:
350             max_order = orders[0]['order']
351         else:
352             max_order = 0
353
354         # see if the patch is already in this bundle
355         if BundlePatch.objects.filter(bundle = self, patch = patch).count():
356             raise Exception("patch is already in bundle")
357
358         bp = BundlePatch.objects.create(bundle = self, patch = patch,
359                 order = max_order + 1)
360         bp.save()
361
362     class Meta:
363         unique_together = [('owner', 'name')]
364
365     def public_url(self):
366         if not self.public:
367             return None
368         site = Site.objects.get_current()
369         return 'http://%s%s' % (site.domain,
370                 reverse('patchwork.views.bundle.bundle',
371                         kwargs = {
372                                 'username': self.owner.username,
373                                 'bundlename': self.name
374                         }))
375
376     @models.permalink
377     def get_absolute_url(self):
378         return ('patchwork.views.bundle.bundle', (), {
379                                 'username': self.owner.username,
380                                 'bundlename': self.name,
381                             })
382
383     def mbox(self):
384         return '\n'.join([p.mbox().as_string(True)
385                         for p in self.ordered_patches()])
386
387 class BundlePatch(models.Model):
388     patch = models.ForeignKey(Patch)
389     bundle = models.ForeignKey(Bundle)
390     order = models.IntegerField()
391
392     class Meta:
393         unique_together = [('bundle', 'patch')]
394         ordering = ['order']
395
396 class EmailConfirmation(models.Model):
397     validity = datetime.timedelta(days = settings.CONFIRMATION_VALIDITY_DAYS)
398     type = models.CharField(max_length = 20, choices = [
399                                 ('userperson', 'User-Person association'),
400                                 ('registration', 'Registration'),
401                                 ('optout', 'Email opt-out'),
402                             ])
403     email = models.CharField(max_length = 200)
404     user = models.ForeignKey(User, null = True)
405     key = HashField()
406     date = models.DateTimeField(default = datetime.datetime.now)
407     active = models.BooleanField(default = True)
408
409     def deactivate(self):
410         self.active = False
411         self.save()
412
413     def is_valid(self):
414         return self.date + self.validity > datetime.datetime.now()
415
416     def save(self):
417         max = 1 << 32
418         if self.key == '':
419             str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
420             self.key = self._meta.get_field('key').construct(str).hexdigest()
421         super(EmailConfirmation, self).save()
422
423 class EmailOptout(models.Model):
424     email = models.CharField(max_length = 200, primary_key = True)
425
426     def __unicode__(self):
427         return self.email
428
429     @classmethod
430     def is_optout(cls, email):
431         email = email.lower().strip()
432         return cls.objects.filter(email = email).count() > 0
433
434 class PatchChangeNotification(models.Model):
435     patch = models.ForeignKey(Patch, primary_key = True)
436     last_modified = models.DateTimeField(default = datetime.datetime.now)
437     orig_state = models.ForeignKey(State)
438
439 def _patch_change_callback(sender, instance, **kwargs):
440     # we only want notification of modified patches
441     if instance.pk is None:
442         return
443
444     if instance.project is None or not instance.project.send_notifications:
445         return
446
447     try:
448         orig_patch = Patch.objects.get(pk = instance.pk)
449     except Patch.DoesNotExist:
450         return
451
452     # If there's no interesting changes, abort without creating the
453     # notification
454     if orig_patch.state == instance.state:
455         return
456
457     notification = None
458     try:
459         notification = PatchChangeNotification.objects.get(patch = instance)
460     except PatchChangeNotification.DoesNotExist:
461         pass
462
463     if notification is None:
464         notification = PatchChangeNotification(patch = instance,
465                                                orig_state = orig_patch.state)
466
467     elif notification.orig_state == instance.state:
468         # If we're back at the original state, there is no need to notify
469         notification.delete()
470         return
471
472     notification.last_modified = datetime.datetime.now()
473     notification.save()
474
475 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)