1 # Patchwork - automated patch tracking system
2 # Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
4 # This file is part of the Patchwork package.
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.
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.
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
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
32 from email.mime.nonmultipart import MIMENonMultipart
33 from email.encoders import encode_7or8bit
34 from email.parser import HeaderParser
37 # Python 2.4 compatibility
38 from email.MIMENonMultipart import MIMENonMultipart
39 from email.Encoders import encode_7or8bit
40 from email.Parser import HeaderParser
42 email.utils = email.Utils
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)
49 def __unicode__(self):
51 return u'%s <%s>' % (self.name, self.email)
55 def link_to_user(self, user):
56 self.name = user.get_profile().name()
60 verbose_name_plural = 'People'
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()
69 def __unicode__(self):
72 def is_editable(self, user):
73 if not user.is_authenticated():
75 return self in user.get_profile().maintainer_projects.all()
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')
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
95 def contributor_projects(self):
96 submitters = Person.objects.filter(user = self.user)
97 return Project.objects.filter(id__in =
99 submitter__in = submitters)
100 .values('project_id').query)
103 def sync_person(self):
106 def n_todo_patches(self):
107 return self.todo_patches().count()
109 def todo_patches(self, project = None):
111 # filter on project, if necessary
113 qs = Patch.objects.filter(project = project)
117 qs = qs.filter(archived = False) \
118 .filter(delegate = self.user) \
120 State.objects.filter(action_required = True)
125 super(UserProfile, self).save()
126 people = Person.objects.filter(email = self.user.email)
128 person = Person(email = self.user.email,
129 name = self.name(), user = self.user)
132 for person in people:
133 person.link_to_user(self.user)
136 def __unicode__(self):
139 def _user_saved_callback(sender, created, instance, **kwargs):
141 profile = instance.get_profile()
142 except UserProfile.DoesNotExist:
143 profile = UserProfile(user = instance)
146 models.signals.post_save.connect(_user_saved_callback, sender = User)
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)
153 def __unicode__(self):
157 ordering = ['ordering']
159 class HashField(models.CharField):
160 __metaclass__ = models.SubfieldBase
162 def __init__(self, algorithm = 'sha1', *args, **kwargs):
163 self.algorithm = algorithm
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())
171 modules = { 'sha1': 'sha', 'md5': 'md5'}
173 if algorithm not in modules.keys():
174 raise NameError("Unknown algorithm '%s'" % algorithm)
176 self.construct = __import__(modules[algorithm]).new
178 self.n_bytes = len(self.construct().hexdigest())
180 kwargs['max_length'] = self.n_bytes
181 super(HashField, self).__init__(*args, **kwargs)
184 return 'char(%d)' % self.n_bytes
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))
194 class Patch(models.Model):
195 project = models.ForeignKey(Project)
196 msgid = models.CharField(max_length=255)
197 name = models.CharField(max_length=255)
198 date = models.DateTimeField(default=datetime.datetime.now)
199 submitter = models.ForeignKey(Person)
200 delegate = models.ForeignKey(User, blank = True, null = True)
201 state = models.ForeignKey(State)
202 archived = models.BooleanField(default = False)
203 headers = models.TextField(blank = True)
204 content = models.TextField(null = True, blank = True)
205 pull_url = models.CharField(max_length=255, null = True, blank = True)
206 commit_ref = models.CharField(max_length=255, null = True, blank = True)
207 hash = HashField(null = True, blank = True)
209 def __unicode__(self):
213 return Comment.objects.filter(patch = self)
219 self.state = State.objects.get(ordering = 0)
221 if self.hash is None and self.content is not None:
222 self.hash = hash_patch(self.content).hexdigest()
224 super(Patch, self).save()
226 def is_editable(self, user):
227 if not user.is_authenticated():
230 if self.submitter.user == user or self.delegate == user:
233 return self.project.is_editable(user)
236 fname_re = re.compile('[^-_A-Za-z0-9\.]+')
237 str = fname_re.sub('-', self.name)
238 return str.strip('-') + '.patch'
241 postscript_re = re.compile('\n-{2,3} ?\n')
245 comment = Comment.objects.get(patch = self, msgid = self.msgid)
251 body = comment.content.strip() + "\n"
253 parts = postscript_re.split(body, 1)
255 (body, postscript) = parts
256 body = body.strip() + "\n"
257 postscript = postscript.strip() + "\n"
261 for comment in Comment.objects.filter(patch = self) \
262 .exclude(msgid = self.msgid):
263 body += comment.patch_responses()
269 body += '---\n' + postscript.strip() + '\n'
272 body += '\n' + self.content
274 mail = PatchMbox(body)
275 mail['Subject'] = self.name
276 mail['Date'] = email.utils.formatdate(
277 time.mktime(self.date.utctimetuple()))
278 mail['From'] = unicode(self.submitter)
279 mail['X-Patchwork-Id'] = str(self.id)
280 mail['Message-Id'] = self.msgid
281 mail.set_unixfrom('From patchwork ' + self.date.ctime())
284 copied_headers = ['To', 'Cc']
285 orig_headers = HeaderParser().parsestr(str(self.headers))
286 for header in copied_headers:
287 if header in orig_headers:
288 mail[header] = orig_headers[header]
293 def get_absolute_url(self):
294 return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
297 verbose_name_plural = 'Patches'
299 unique_together = [('msgid', 'project')]
301 class Comment(models.Model):
302 patch = models.ForeignKey(Patch)
303 msgid = models.CharField(max_length=255)
304 submitter = models.ForeignKey(Person)
305 date = models.DateTimeField(default = datetime.datetime.now)
306 headers = models.TextField(blank = True)
307 content = models.TextField()
309 response_re = re.compile( \
310 '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
313 def patch_responses(self):
314 return ''.join([ match.group(0) + '\n' for match in
315 self.response_re.finditer(self.content)])
319 unique_together = [('msgid', 'patch')]
321 class Bundle(models.Model):
322 owner = models.ForeignKey(User)
323 project = models.ForeignKey(Project)
324 name = models.CharField(max_length = 50, null = False, blank = False)
325 patches = models.ManyToManyField(Patch, through = 'BundlePatch')
326 public = models.BooleanField(default = False)
329 return self.patches.all().count()
331 def ordered_patches(self):
332 return self.patches.order_by('bundlepatch__order')
334 def append_patch(self, patch):
335 # todo: use the aggregate queries in django 1.1
336 orders = BundlePatch.objects.filter(bundle = self).order_by('-order') \
340 max_order = orders[0]['order']
344 # see if the patch is already in this bundle
345 if BundlePatch.objects.filter(bundle = self, patch = patch).count():
346 raise Exception("patch is already in bundle")
348 bp = BundlePatch.objects.create(bundle = self, patch = patch,
349 order = max_order + 1)
353 unique_together = [('owner', 'name')]
355 def public_url(self):
358 site = Site.objects.get_current()
359 return 'http://%s%s' % (site.domain,
360 reverse('patchwork.views.bundle.public',
362 'username': self.owner.username,
363 'bundlename': self.name
367 return '\n'.join([p.mbox().as_string(True)
368 for p in self.ordered_patches()])
370 class BundlePatch(models.Model):
371 patch = models.ForeignKey(Patch)
372 bundle = models.ForeignKey(Bundle)
373 order = models.IntegerField()
376 unique_together = [('bundle', 'patch')]
379 class EmailConfirmation(models.Model):
380 validity = datetime.timedelta(days = settings.CONFIRMATION_VALIDITY_DAYS)
381 type = models.CharField(max_length = 20, choices = [
382 ('userperson', 'User-Person association'),
383 ('registration', 'Registration'),
384 ('optout', 'Email opt-out'),
386 email = models.CharField(max_length = 200)
387 user = models.ForeignKey(User, null = True)
389 date = models.DateTimeField(default = datetime.datetime.now)
390 active = models.BooleanField(default = True)
392 def deactivate(self):
397 return self.date + self.validity > datetime.datetime.now()
402 str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
403 self.key = self._meta.get_field('key').construct(str).hexdigest()
404 super(EmailConfirmation, self).save()
406 class EmailOptout(models.Model):
407 email = models.CharField(max_length = 200, primary_key = True)
409 def __unicode__(self):
413 def is_optout(cls, email):
414 email = email.lower().strip()
415 return cls.objects.filter(email = email).count() > 0
417 class PatchChangeNotification(models.Model):
418 patch = models.ForeignKey(Patch, primary_key = True)
419 last_modified = models.DateTimeField(default = datetime.datetime.now)
420 orig_state = models.ForeignKey(State)
422 def _patch_change_callback(sender, instance, **kwargs):
423 # we only want notification of modified patches
424 if instance.pk is None:
427 if instance.project is None or not instance.project.send_notifications:
431 orig_patch = Patch.objects.get(pk = instance.pk)
432 except Patch.DoesNotExist:
435 # If there's no interesting changes, abort without creating the
437 if orig_patch.state == instance.state:
442 notification = PatchChangeNotification.objects.get(patch = instance)
443 except PatchChangeNotification.DoesNotExist:
446 if notification is None:
447 notification = PatchChangeNotification(patch = instance,
448 orig_state = orig_patch.state)
450 elif notification.orig_state == instance.state:
451 # If we're back at the original state, there is no need to notify
452 notification.delete()
455 notification.last_modified = datetime.datetime.now()
458 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)