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
35 from email.header import Header
38 # Python 2.4 compatibility
39 from email.MIMENonMultipart import MIMENonMultipart
40 from email.Encoders import encode_7or8bit
41 from email.Parser import HeaderParser
42 from email.Header import Header
44 email.utils = email.Utils
46 class Person(models.Model):
47 email = models.CharField(max_length=255, unique = True)
48 name = models.CharField(max_length=255, null = True, blank = True)
49 user = models.ForeignKey(User, null = True, blank = True)
51 def __unicode__(self):
53 return u'%s <%s>' % (self.name, self.email)
57 def link_to_user(self, user):
58 self.name = user.get_profile().name()
62 verbose_name_plural = 'People'
64 class Project(models.Model):
65 linkname = models.CharField(max_length=255, unique=True)
66 name = models.CharField(max_length=255, unique=True)
67 listid = models.CharField(max_length=255, unique=True)
68 listemail = models.CharField(max_length=200)
69 web_url = models.CharField(max_length=2000, blank=True)
70 scm_url = models.CharField(max_length=2000, blank=True)
71 webscm_url = models.CharField(max_length=2000, blank=True)
72 send_notifications = models.BooleanField()
74 def __unicode__(self):
77 def is_editable(self, user):
78 if not user.is_authenticated():
80 return self in user.get_profile().maintainer_projects.all()
82 class UserProfile(models.Model):
83 user = models.ForeignKey(User, unique = True)
84 primary_project = models.ForeignKey(Project, null = True, blank = True)
85 maintainer_projects = models.ManyToManyField(Project,
86 related_name = 'maintainer_project')
87 send_email = models.BooleanField(default = False,
88 help_text = 'Selecting this option allows patchwork to send ' +
89 'email on your behalf')
90 patches_per_page = models.PositiveIntegerField(default = 100,
91 null = False, blank = False,
92 help_text = 'Number of patches to display per page')
95 if self.user.first_name or self.user.last_name:
96 names = filter(bool, [self.user.first_name, self.user.last_name])
97 return u' '.join(names)
98 return self.user.username
100 def contributor_projects(self):
101 submitters = Person.objects.filter(user = self.user)
102 return Project.objects.filter(id__in =
103 Patch.objects.filter(
104 submitter__in = submitters)
105 .values('project_id').query)
108 def sync_person(self):
111 def n_todo_patches(self):
112 return self.todo_patches().count()
114 def todo_patches(self, project = None):
116 # filter on project, if necessary
118 qs = Patch.objects.filter(project = project)
122 qs = qs.filter(archived = False) \
123 .filter(delegate = self.user) \
125 State.objects.filter(action_required = True)
130 super(UserProfile, self).save()
131 people = Person.objects.filter(email = self.user.email)
133 person = Person(email = self.user.email,
134 name = self.name(), user = self.user)
137 for person in people:
138 person.link_to_user(self.user)
141 def __unicode__(self):
144 def _user_saved_callback(sender, created, instance, **kwargs):
146 profile = instance.get_profile()
147 except UserProfile.DoesNotExist:
148 profile = UserProfile(user = instance)
151 models.signals.post_save.connect(_user_saved_callback, sender = User)
153 class State(models.Model):
154 name = models.CharField(max_length = 100)
155 ordering = models.IntegerField(unique = True)
156 action_required = models.BooleanField(default = True)
158 def __unicode__(self):
162 ordering = ['ordering']
164 class HashField(models.CharField):
165 __metaclass__ = models.SubfieldBase
167 def __init__(self, algorithm = 'sha1', *args, **kwargs):
168 self.algorithm = algorithm
171 def _construct(string = ''):
172 return hashlib.new(self.algorithm, string)
173 self.construct = _construct
174 self.n_bytes = len(hashlib.new(self.algorithm).hexdigest())
176 modules = { 'sha1': 'sha', 'md5': 'md5'}
178 if algorithm not in modules.keys():
179 raise NameError("Unknown algorithm '%s'" % algorithm)
181 self.construct = __import__(modules[algorithm]).new
183 self.n_bytes = len(self.construct().hexdigest())
185 kwargs['max_length'] = self.n_bytes
186 super(HashField, self).__init__(*args, **kwargs)
188 def db_type(self, connection=None):
189 return 'char(%d)' % self.n_bytes
191 class PatchMbox(MIMENonMultipart):
192 patch_charset = 'utf-8'
193 def __init__(self, _text):
194 MIMENonMultipart.__init__(self, 'text', 'plain',
195 **{'charset': self.patch_charset})
196 self.set_payload(_text.encode(self.patch_charset))
199 def get_default_initial_patch_state():
200 return State.objects.get(ordering=0)
202 class Patch(models.Model):
203 project = models.ForeignKey(Project)
204 msgid = models.CharField(max_length=255)
205 name = models.CharField(max_length=255)
206 date = models.DateTimeField(default=datetime.datetime.now)
207 submitter = models.ForeignKey(Person)
208 delegate = models.ForeignKey(User, blank = True, null = True)
209 state = models.ForeignKey(State, default=get_default_initial_patch_state)
210 archived = models.BooleanField(default = False)
211 headers = models.TextField(blank = True)
212 content = models.TextField(null = True, blank = True)
213 pull_url = models.CharField(max_length=255, null = True, blank = True)
214 commit_ref = models.CharField(max_length=255, null = True, blank = True)
215 hash = HashField(null = True, blank = True)
217 def __unicode__(self):
221 return Comment.objects.filter(patch = self)
227 self.state = State.objects.get(ordering = 0)
229 if self.hash is None and self.content is not None:
230 self.hash = hash_patch(self.content).hexdigest()
232 super(Patch, self).save()
234 def is_editable(self, user):
235 if not user.is_authenticated():
238 if self.submitter.user == user or self.delegate == user:
241 return self.project.is_editable(user)
244 fname_re = re.compile('[^-_A-Za-z0-9\.]+')
245 str = fname_re.sub('-', self.name)
246 return str.strip('-') + '.patch'
249 postscript_re = re.compile('\n-{2,3} ?\n')
253 comment = Comment.objects.get(patch = self, msgid = self.msgid)
259 body = comment.content.strip() + "\n"
261 parts = postscript_re.split(body, 1)
263 (body, postscript) = parts
264 body = body.strip() + "\n"
265 postscript = postscript.strip() + "\n"
269 for comment in Comment.objects.filter(patch = self) \
270 .exclude(msgid = self.msgid):
271 body += comment.patch_responses()
277 body += '---\n' + postscript.strip() + '\n'
280 body += '\n' + self.content
282 utc_timestamp = (self.date -
283 datetime.datetime.utcfromtimestamp(0)).total_seconds()
285 mail = PatchMbox(body)
286 mail['Subject'] = self.name
287 mail['Date'] = email.utils.formatdate(utc_timestamp)
288 mail['From'] = email.utils.formataddr((
289 str(Header(self.submitter.name, mail.patch_charset)),
290 self.submitter.email))
291 mail['X-Patchwork-Id'] = str(self.id)
292 mail['Message-Id'] = self.msgid
293 mail.set_unixfrom('From patchwork ' + self.date.ctime())
296 copied_headers = ['To', 'Cc']
297 orig_headers = HeaderParser().parsestr(str(self.headers))
298 for header in copied_headers:
299 if header in orig_headers:
300 mail[header] = orig_headers[header]
305 def get_absolute_url(self):
306 return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
309 verbose_name_plural = 'Patches'
311 unique_together = [('msgid', 'project')]
313 class Comment(models.Model):
314 patch = models.ForeignKey(Patch)
315 msgid = models.CharField(max_length=255)
316 submitter = models.ForeignKey(Person)
317 date = models.DateTimeField(default = datetime.datetime.now)
318 headers = models.TextField(blank = True)
319 content = models.TextField()
321 response_re = re.compile( \
322 '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
325 def patch_responses(self):
326 return ''.join([ match.group(0) + '\n' for match in
327 self.response_re.finditer(self.content)])
331 unique_together = [('msgid', 'patch')]
333 class Bundle(models.Model):
334 owner = models.ForeignKey(User)
335 project = models.ForeignKey(Project)
336 name = models.CharField(max_length = 50, null = False, blank = False)
337 patches = models.ManyToManyField(Patch, through = 'BundlePatch')
338 public = models.BooleanField(default = False)
341 return self.patches.all().count()
343 def ordered_patches(self):
344 return self.patches.order_by('bundlepatch__order')
346 def append_patch(self, patch):
347 # todo: use the aggregate queries in django 1.1
348 orders = BundlePatch.objects.filter(bundle = self).order_by('-order') \
352 max_order = orders[0]['order']
356 # see if the patch is already in this bundle
357 if BundlePatch.objects.filter(bundle = self, patch = patch).count():
358 raise Exception("patch is already in bundle")
360 bp = BundlePatch.objects.create(bundle = self, patch = patch,
361 order = max_order + 1)
365 unique_together = [('owner', 'name')]
367 def public_url(self):
370 site = Site.objects.get_current()
371 return 'http://%s%s' % (site.domain,
372 reverse('patchwork.views.bundle.bundle',
374 'username': self.owner.username,
375 'bundlename': self.name
379 def get_absolute_url(self):
380 return ('patchwork.views.bundle.bundle', (), {
381 'username': self.owner.username,
382 'bundlename': self.name,
386 return '\n'.join([p.mbox().as_string(True)
387 for p in self.ordered_patches()])
389 class BundlePatch(models.Model):
390 patch = models.ForeignKey(Patch)
391 bundle = models.ForeignKey(Bundle)
392 order = models.IntegerField()
395 unique_together = [('bundle', 'patch')]
398 class EmailConfirmation(models.Model):
399 validity = datetime.timedelta(days = settings.CONFIRMATION_VALIDITY_DAYS)
400 type = models.CharField(max_length = 20, choices = [
401 ('userperson', 'User-Person association'),
402 ('registration', 'Registration'),
403 ('optout', 'Email opt-out'),
405 email = models.CharField(max_length = 200)
406 user = models.ForeignKey(User, null = True)
408 date = models.DateTimeField(default = datetime.datetime.now)
409 active = models.BooleanField(default = True)
411 def deactivate(self):
416 return self.date + self.validity > datetime.datetime.now()
421 str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
422 self.key = self._meta.get_field('key').construct(str).hexdigest()
423 super(EmailConfirmation, self).save()
425 class EmailOptout(models.Model):
426 email = models.CharField(max_length = 200, primary_key = True)
428 def __unicode__(self):
432 def is_optout(cls, email):
433 email = email.lower().strip()
434 return cls.objects.filter(email = email).count() > 0
436 class PatchChangeNotification(models.Model):
437 patch = models.ForeignKey(Patch, primary_key = True)
438 last_modified = models.DateTimeField(default = datetime.datetime.now)
439 orig_state = models.ForeignKey(State)
441 def _patch_change_callback(sender, instance, **kwargs):
442 # we only want notification of modified patches
443 if instance.pk is None:
446 if instance.project is None or not instance.project.send_notifications:
450 orig_patch = Patch.objects.get(pk = instance.pk)
451 except Patch.DoesNotExist:
454 # If there's no interesting changes, abort without creating the
456 if orig_patch.state == instance.state:
461 notification = PatchChangeNotification.objects.get(patch = instance)
462 except PatchChangeNotification.DoesNotExist:
465 if notification is None:
466 notification = PatchChangeNotification(patch = instance,
467 orig_state = orig_patch.state)
469 elif notification.orig_state == instance.state:
470 # If we're back at the original state, there is no need to notify
471 notification.delete()
474 notification.last_modified = datetime.datetime.now()
477 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)