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 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()
72 def __unicode__(self):
75 def is_editable(self, user):
76 if not user.is_authenticated():
78 return self in user.get_profile().maintainer_projects.all()
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')
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
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)
106 def sync_person(self):
109 def n_todo_patches(self):
110 return self.todo_patches().count()
112 def todo_patches(self, project = None):
114 # filter on project, if necessary
116 qs = Patch.objects.filter(project = project)
120 qs = qs.filter(archived = False) \
121 .filter(delegate = self.user) \
123 State.objects.filter(action_required = True)
128 super(UserProfile, self).save()
129 people = Person.objects.filter(email = self.user.email)
131 person = Person(email = self.user.email,
132 name = self.name(), user = self.user)
135 for person in people:
136 person.link_to_user(self.user)
139 def __unicode__(self):
142 def _user_saved_callback(sender, created, instance, **kwargs):
144 profile = instance.get_profile()
145 except UserProfile.DoesNotExist:
146 profile = UserProfile(user = instance)
149 models.signals.post_save.connect(_user_saved_callback, sender = User)
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)
156 def __unicode__(self):
160 ordering = ['ordering']
162 class HashField(models.CharField):
163 __metaclass__ = models.SubfieldBase
165 def __init__(self, algorithm = 'sha1', *args, **kwargs):
166 self.algorithm = algorithm
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())
174 modules = { 'sha1': 'sha', 'md5': 'md5'}
176 if algorithm not in modules.keys():
177 raise NameError("Unknown algorithm '%s'" % algorithm)
179 self.construct = __import__(modules[algorithm]).new
181 self.n_bytes = len(self.construct().hexdigest())
183 kwargs['max_length'] = self.n_bytes
184 super(HashField, self).__init__(*args, **kwargs)
186 def db_type(self, connection=None):
187 return 'char(%d)' % self.n_bytes
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))
197 def get_default_initial_patch_state():
198 return State.objects.get(ordering=0)
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)
215 def __unicode__(self):
219 return Comment.objects.filter(patch = self)
225 self.state = State.objects.get(ordering = 0)
227 if self.hash is None and self.content is not None:
228 self.hash = hash_patch(self.content).hexdigest()
230 super(Patch, self).save()
232 def is_editable(self, user):
233 if not user.is_authenticated():
236 if self.submitter.user == user or self.delegate == user:
239 return self.project.is_editable(user)
242 fname_re = re.compile('[^-_A-Za-z0-9\.]+')
243 str = fname_re.sub('-', self.name)
244 return str.strip('-') + '.patch'
247 postscript_re = re.compile('\n-{2,3} ?\n')
251 comment = Comment.objects.get(patch = self, msgid = self.msgid)
257 body = comment.content.strip() + "\n"
259 parts = postscript_re.split(body, 1)
261 (body, postscript) = parts
262 body = body.strip() + "\n"
263 postscript = postscript.strip() + "\n"
267 for comment in Comment.objects.filter(patch = self) \
268 .exclude(msgid = self.msgid):
269 body += comment.patch_responses()
275 body += '---\n' + postscript.strip() + '\n'
278 body += '\n' + self.content
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())
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]
299 def get_absolute_url(self):
300 return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
303 verbose_name_plural = 'Patches'
305 unique_together = [('msgid', 'project')]
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()
315 response_re = re.compile( \
316 '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
319 def patch_responses(self):
320 return ''.join([ match.group(0) + '\n' for match in
321 self.response_re.finditer(self.content)])
325 unique_together = [('msgid', 'patch')]
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)
335 return self.patches.all().count()
337 def ordered_patches(self):
338 return self.patches.order_by('bundlepatch__order')
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') \
346 max_order = orders[0]['order']
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")
354 bp = BundlePatch.objects.create(bundle = self, patch = patch,
355 order = max_order + 1)
359 unique_together = [('owner', 'name')]
361 def public_url(self):
364 site = Site.objects.get_current()
365 return 'http://%s%s' % (site.domain,
366 reverse('patchwork.views.bundle.public',
368 'username': self.owner.username,
369 'bundlename': self.name
373 return '\n'.join([p.mbox().as_string(True)
374 for p in self.ordered_patches()])
376 class BundlePatch(models.Model):
377 patch = models.ForeignKey(Patch)
378 bundle = models.ForeignKey(Bundle)
379 order = models.IntegerField()
382 unique_together = [('bundle', 'patch')]
385 class EmailConfirmation(models.Model):
386 validity = datetime.timedelta(days = settings.CONFIRMATION_VALIDITY_DAYS)
387 type = models.CharField(max_length = 20, choices = [
388 ('userperson', 'User-Person association'),
389 ('registration', 'Registration'),
390 ('optout', 'Email opt-out'),
392 email = models.CharField(max_length = 200)
393 user = models.ForeignKey(User, null = True)
395 date = models.DateTimeField(default = datetime.datetime.now)
396 active = models.BooleanField(default = True)
398 def deactivate(self):
403 return self.date + self.validity > datetime.datetime.now()
408 str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
409 self.key = self._meta.get_field('key').construct(str).hexdigest()
410 super(EmailConfirmation, self).save()
412 class EmailOptout(models.Model):
413 email = models.CharField(max_length = 200, primary_key = True)
415 def __unicode__(self):
419 def is_optout(cls, email):
420 email = email.lower().strip()
421 return cls.objects.filter(email = email).count() > 0
423 class PatchChangeNotification(models.Model):
424 patch = models.ForeignKey(Patch, primary_key = True)
425 last_modified = models.DateTimeField(default = datetime.datetime.now)
426 orig_state = models.ForeignKey(State)
428 def _patch_change_callback(sender, instance, **kwargs):
429 # we only want notification of modified patches
430 if instance.pk is None:
433 if instance.project is None or not instance.project.send_notifications:
437 orig_patch = Patch.objects.get(pk = instance.pk)
438 except Patch.DoesNotExist:
441 # If there's no interesting changes, abort without creating the
443 if orig_patch.state == instance.state:
448 notification = PatchChangeNotification.objects.get(patch = instance)
449 except PatchChangeNotification.DoesNotExist:
452 if notification is None:
453 notification = PatchChangeNotification(patch = instance,
454 orig_state = orig_patch.state)
456 elif notification.orig_state == instance.state:
457 # If we're back at the original state, there is no need to notify
458 notification.delete()
461 notification.last_modified = datetime.datetime.now()
464 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)