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 def get_default_initial_patch_state():
195 return State.objects.get(ordering=0)
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)
212 def __unicode__(self):
216 return Comment.objects.filter(patch = self)
222 self.state = State.objects.get(ordering = 0)
224 if self.hash is None and self.content is not None:
225 self.hash = hash_patch(self.content).hexdigest()
227 super(Patch, self).save()
229 def is_editable(self, user):
230 if not user.is_authenticated():
233 if self.submitter.user == user or self.delegate == user:
236 return self.project.is_editable(user)
239 fname_re = re.compile('[^-_A-Za-z0-9\.]+')
240 str = fname_re.sub('-', self.name)
241 return str.strip('-') + '.patch'
244 postscript_re = re.compile('\n-{2,3} ?\n')
248 comment = Comment.objects.get(patch = self, msgid = self.msgid)
254 body = comment.content.strip() + "\n"
256 parts = postscript_re.split(body, 1)
258 (body, postscript) = parts
259 body = body.strip() + "\n"
260 postscript = postscript.strip() + "\n"
264 for comment in Comment.objects.filter(patch = self) \
265 .exclude(msgid = self.msgid):
266 body += comment.patch_responses()
272 body += '---\n' + postscript.strip() + '\n'
275 body += '\n' + self.content
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())
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]
296 def get_absolute_url(self):
297 return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
300 verbose_name_plural = 'Patches'
302 unique_together = [('msgid', 'project')]
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()
312 response_re = re.compile( \
313 '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
316 def patch_responses(self):
317 return ''.join([ match.group(0) + '\n' for match in
318 self.response_re.finditer(self.content)])
322 unique_together = [('msgid', 'patch')]
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)
332 return self.patches.all().count()
334 def ordered_patches(self):
335 return self.patches.order_by('bundlepatch__order')
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') \
343 max_order = orders[0]['order']
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")
351 bp = BundlePatch.objects.create(bundle = self, patch = patch,
352 order = max_order + 1)
356 unique_together = [('owner', 'name')]
358 def public_url(self):
361 site = Site.objects.get_current()
362 return 'http://%s%s' % (site.domain,
363 reverse('patchwork.views.bundle.public',
365 'username': self.owner.username,
366 'bundlename': self.name
370 return '\n'.join([p.mbox().as_string(True)
371 for p in self.ordered_patches()])
373 class BundlePatch(models.Model):
374 patch = models.ForeignKey(Patch)
375 bundle = models.ForeignKey(Bundle)
376 order = models.IntegerField()
379 unique_together = [('bundle', 'patch')]
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'),
389 email = models.CharField(max_length = 200)
390 user = models.ForeignKey(User, null = True)
392 date = models.DateTimeField(default = datetime.datetime.now)
393 active = models.BooleanField(default = True)
395 def deactivate(self):
400 return self.date + self.validity > datetime.datetime.now()
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()
409 class EmailOptout(models.Model):
410 email = models.CharField(max_length = 200, primary_key = True)
412 def __unicode__(self):
416 def is_optout(cls, email):
417 email = email.lower().strip()
418 return cls.objects.filter(email = email).count() > 0
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)
425 def _patch_change_callback(sender, instance, **kwargs):
426 # we only want notification of modified patches
427 if instance.pk is None:
430 if instance.project is None or not instance.project.send_notifications:
434 orig_patch = Patch.objects.get(pk = instance.pk)
435 except Patch.DoesNotExist:
438 # If there's no interesting changes, abort without creating the
440 if orig_patch.state == instance.state:
445 notification = PatchChangeNotification.objects.get(patch = instance)
446 except PatchChangeNotification.DoesNotExist:
449 if notification is None:
450 notification = PatchChangeNotification(patch = instance,
451 orig_state = orig_patch.state)
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()
458 notification.last_modified = datetime.datetime.now()
461 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)