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