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