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