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 mail = PatchMbox(body)
283 mail['Subject'] = self.name
284 mail['Date'] = email.utils.formatdate(
285 time.mktime(self.date.utctimetuple()))
286 mail['From'] = email.utils.formataddr((
287 str(Header(self.submitter.name, mail.patch_charset)),
288 self.submitter.email))
289 mail['X-Patchwork-Id'] = str(self.id)
290 mail['Message-Id'] = self.msgid
291 mail.set_unixfrom('From patchwork ' + self.date.ctime())
294 copied_headers = ['To', 'Cc']
295 orig_headers = HeaderParser().parsestr(str(self.headers))
296 for header in copied_headers:
297 if header in orig_headers:
298 mail[header] = orig_headers[header]
303 def get_absolute_url(self):
304 return ('patchwork.views.patch.patch', (), {'patch_id': self.id})
307 verbose_name_plural = 'Patches'
309 unique_together = [('msgid', 'project')]
311 class Comment(models.Model):
312 patch = models.ForeignKey(Patch)
313 msgid = models.CharField(max_length=255)
314 submitter = models.ForeignKey(Person)
315 date = models.DateTimeField(default = datetime.datetime.now)
316 headers = models.TextField(blank = True)
317 content = models.TextField()
319 response_re = re.compile( \
320 '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
323 def patch_responses(self):
324 return ''.join([ match.group(0) + '\n' for match in
325 self.response_re.finditer(self.content)])
329 unique_together = [('msgid', 'patch')]
331 class Bundle(models.Model):
332 owner = models.ForeignKey(User)
333 project = models.ForeignKey(Project)
334 name = models.CharField(max_length = 50, null = False, blank = False)
335 patches = models.ManyToManyField(Patch, through = 'BundlePatch')
336 public = models.BooleanField(default = False)
339 return self.patches.all().count()
341 def ordered_patches(self):
342 return self.patches.order_by('bundlepatch__order')
344 def append_patch(self, patch):
345 # todo: use the aggregate queries in django 1.1
346 orders = BundlePatch.objects.filter(bundle = self).order_by('-order') \
350 max_order = orders[0]['order']
354 # see if the patch is already in this bundle
355 if BundlePatch.objects.filter(bundle = self, patch = patch).count():
356 raise Exception("patch is already in bundle")
358 bp = BundlePatch.objects.create(bundle = self, patch = patch,
359 order = max_order + 1)
363 unique_together = [('owner', 'name')]
365 def public_url(self):
368 site = Site.objects.get_current()
369 return 'http://%s%s' % (site.domain,
370 reverse('patchwork.views.bundle.bundle',
372 'username': self.owner.username,
373 'bundlename': self.name
377 def get_absolute_url(self):
378 return ('patchwork.views.bundle.bundle', (), {
379 'username': self.owner.username,
380 'bundlename': self.name,
384 return '\n'.join([p.mbox().as_string(True)
385 for p in self.ordered_patches()])
387 class BundlePatch(models.Model):
388 patch = models.ForeignKey(Patch)
389 bundle = models.ForeignKey(Bundle)
390 order = models.IntegerField()
393 unique_together = [('bundle', 'patch')]
396 class EmailConfirmation(models.Model):
397 validity = datetime.timedelta(days = settings.CONFIRMATION_VALIDITY_DAYS)
398 type = models.CharField(max_length = 20, choices = [
399 ('userperson', 'User-Person association'),
400 ('registration', 'Registration'),
401 ('optout', 'Email opt-out'),
403 email = models.CharField(max_length = 200)
404 user = models.ForeignKey(User, null = True)
406 date = models.DateTimeField(default = datetime.datetime.now)
407 active = models.BooleanField(default = True)
409 def deactivate(self):
414 return self.date + self.validity > datetime.datetime.now()
419 str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
420 self.key = self._meta.get_field('key').construct(str).hexdigest()
421 super(EmailConfirmation, self).save()
423 class EmailOptout(models.Model):
424 email = models.CharField(max_length = 200, primary_key = True)
426 def __unicode__(self):
430 def is_optout(cls, email):
431 email = email.lower().strip()
432 return cls.objects.filter(email = email).count() > 0
434 class PatchChangeNotification(models.Model):
435 patch = models.ForeignKey(Patch, primary_key = True)
436 last_modified = models.DateTimeField(default = datetime.datetime.now)
437 orig_state = models.ForeignKey(State)
439 def _patch_change_callback(sender, instance, **kwargs):
440 # we only want notification of modified patches
441 if instance.pk is None:
444 if instance.project is None or not instance.project.send_notifications:
448 orig_patch = Patch.objects.get(pk = instance.pk)
449 except Patch.DoesNotExist:
452 # If there's no interesting changes, abort without creating the
454 if orig_patch.state == instance.state:
459 notification = PatchChangeNotification.objects.get(patch = instance)
460 except PatchChangeNotification.DoesNotExist:
463 if notification is None:
464 notification = PatchChangeNotification(patch = instance,
465 orig_state = orig_patch.state)
467 elif notification.orig_state == instance.state:
468 # If we're back at the original state, there is no need to notify
469 notification.delete()
472 notification.last_modified = datetime.datetime.now()
475 models.signals.pre_save.connect(_patch_change_callback, sender = Patch)