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_created_callback(sender, created, instance, **kwargs):
142 profile = UserProfile(user = instance)
145 models.signals.post_save.connect(_user_created_callback, sender = User)
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)
152 def __unicode__(self):
156 ordering = ['ordering']
158 class HashField(models.CharField):
159 __metaclass__ = models.SubfieldBase
161 def __init__(self, algorithm = 'sha1', *args, **kwargs):
162 self.algorithm = algorithm
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())
170 modules = { 'sha1': 'sha', 'md5': 'md5'}
172 if algorithm not in modules.keys():
173 raise NameError("Unknown algorithm '%s'" % algorithm)
175 self.construct = __import__(modules[algorithm]).new
177 self.n_bytes = len(self.construct().hexdigest())
179 kwargs['max_length'] = self.n_bytes
180 super(HashField, self).__init__(*args, **kwargs)
183 return 'char(%d)' % self.n_bytes
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))
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)
208 def __unicode__(self):
212 return Comment.objects.filter(patch = self)
218 self.state = State.objects.get(ordering = 0)
220 if self.hash is None and self.content is not None:
221 self.hash = hash_patch(self.content).hexdigest()
223 super(Patch, self).save()
225 def is_editable(self, user):
226 if not user.is_authenticated():
229 if self.submitter.user == user or self.delegate == user:
232 return self.project.is_editable(user)
235 fname_re = re.compile('[^-_A-Za-z0-9\.]+')
236 str = fname_re.sub('-', self.name)
237 return str.strip('-') + '.patch'
240 postscript_re = re.compile('\n-{2,3} ?\n')
244 comment = Comment.objects.get(patch = self, msgid = self.msgid)
250 body = comment.content.strip() + "\n"
252 parts = postscript_re.split(body, 1)
254 (body, postscript) = parts
255 body = body.strip() + "\n"
256 postscript = postscript.strip() + "\n"
260 for comment in Comment.objects.filter(patch = self) \
261 .exclude(msgid = self.msgid):
262 body += comment.patch_responses()
268 body += '---\n' + postscript.strip() + '\n'
271 body += '\n' + self.content
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())
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]
292 def get_absolute_url(self):
293 return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
296 verbose_name_plural = 'Patches'
298 unique_together = [('msgid', 'project')]
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()
308 response_re = re.compile( \
309 '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
312 def patch_responses(self):
313 return ''.join([ match.group(0) + '\n' for match in
314 self.response_re.finditer(self.content)])
318 unique_together = [('msgid', 'patch')]
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)
328 return self.patches.all().count()
330 def ordered_patches(self):
331 return self.patches.order_by('bundlepatch__order')
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') \
339 max_order = orders[0]['order']
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")
347 bp = BundlePatch.objects.create(bundle = self, patch = patch,
348 order = max_order + 1)
352 unique_together = [('owner', 'name')]
354 def public_url(self):
357 site = Site.objects.get_current()
358 return 'http://%s%s' % (site.domain,
359 reverse('patchwork.views.bundle.public',
361 'username': self.owner.username,
362 'bundlename': self.name
366 return '\n'.join([p.mbox().as_string(True)
367 for p in self.ordered_patches()])
369 class BundlePatch(models.Model):
370 patch = models.ForeignKey(Patch)
371 bundle = models.ForeignKey(Bundle)
372 order = models.IntegerField()
375 unique_together = [('bundle', 'patch')]
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'),
385 email = models.CharField(max_length = 200)
386 user = models.ForeignKey(User, null = True)
388 date = models.DateTimeField(default = datetime.datetime.now)
389 active = models.BooleanField(default = True)
391 def deactivate(self):
396 return self.date + self.validity > datetime.datetime.now()
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()
405 class EmailOptout(models.Model):
406 email = models.CharField(max_length = 200, primary_key = True)
408 def __unicode__(self):
411 class PatchChangeNotification(models.Model):
412 patch = models.ForeignKey(Patch, primary_key = True)
413 last_modified = models.DateTimeField(default = datetime.datetime.now)
414 orig_state = models.ForeignKey(State)
416 def _patch_change_callback(sender, instance, **kwargs):
417 # we only want notification of modified patches
418 if instance.pk is None:
421 if instance.project is None or not instance.project.send_notifications:
425 orig_patch = Patch.objects.get(pk = instance.pk)
426 except Patch.DoesNotExist:
429 # If there's no interesting changes, abort without creating the
431 if orig_patch.state == instance.state:
436 notification = PatchChangeNotification.objects.get(patch = instance)
437 except PatchChangeNotification.DoesNotExist:
440 if notification is None:
441 notification = PatchChangeNotification(patch = instance,
442 orig_state = orig_patch.state)
444 elif notification.orig_state == instance.state:
445 # If we're back at the original state, there is no need to notify
446 notification.delete()
449 notification.last_modified = datetime.datetime.now()
452 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)