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