models.py 26.3 KB
Newer Older
Jeremy Kerr's avatar
Jeremy Kerr committed
1 2
# Patchwork - automated patch tracking system
# Copyright (C) 2008 Jeremy Kerr <jk@ozlabs.org>
3
# Copyright (C) 2015 Intel Corporation
Jeremy Kerr's avatar
Jeremy Kerr committed
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
#
# This file is part of the Patchwork package.
#
# Patchwork is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# Patchwork is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Patchwork; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

21
from django.contrib import auth
Jeremy Kerr's avatar
Jeremy Kerr committed
22 23 24
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.contrib.sites.models import Site
25
from django.conf import settings
26 27 28 29 30
from django.db import models
from django.db.models import Q
import django.dispatch
from django.utils import six
from django.utils.encoding import python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
31
from django.utils.functional import cached_property
32
from django.utils.six import add_metaclass
33
import jsonfield
Jeremy Kerr's avatar
Jeremy Kerr committed
34

35 36
from patchwork.parser import hash_patch, extract_tags

Jeremy Kerr's avatar
Jeremy Kerr committed
37
import re
38 39
import datetime
import time
Jeremy Kerr's avatar
Jeremy Kerr committed
40
import random
Jeremy Kerr's avatar
Jeremy Kerr committed
41
from collections import Counter, OrderedDict
42

43

44
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
45
class Person(models.Model):
46 47 48 49
    email = models.CharField(max_length=255, unique=True)
    name = models.CharField(max_length=255, null=True, blank=True)
    user = models.ForeignKey(User, null=True, blank=True,
                             on_delete=models.SET_NULL)
Jeremy Kerr's avatar
Jeremy Kerr committed
50

51
    def display_name(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
52
        if self.name:
53
            return self.name
Jeremy Kerr's avatar
Jeremy Kerr committed
54 55 56
        else:
            return self.email

57 58 59 60 61 62
    def email_name(self):
        if (self.name):
            return "%s <%s>" % (self.name, self.email)
        else:
            return self.email

Jeremy Kerr's avatar
Jeremy Kerr committed
63
    def link_to_user(self, user):
64
        self.name = user.profile.name()
Jeremy Kerr's avatar
Jeremy Kerr committed
65 66
        self.user = user

67 68 69
    def __str__(self):
        return self.display_name()

Jeremy Kerr's avatar
Jeremy Kerr committed
70 71 72
    class Meta:
        verbose_name_plural = 'People'

73

74
def get_comma_separated_field(value):
75 76
    if not value:
        return []
77 78 79 80
    tags = [v.strip() for v in value.split(',')]
    tags = [tag for tag in tags if tag]
    return tags

81

82
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
83 84 85
class Project(models.Model):
    linkname = models.CharField(max_length=255, unique=True)
    name = models.CharField(max_length=255, unique=True)
86
    description = models.TextField(blank=True, null=True)
87
    listid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
88
    listemail = models.CharField(max_length=200)
Simo Sorce's avatar
Simo Sorce committed
89 90 91
    web_url = models.CharField(max_length=2000, blank=True)
    scm_url = models.CharField(max_length=2000, blank=True)
    webscm_url = models.CharField(max_length=2000, blank=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
92
    send_notifications = models.BooleanField(default=False)
Jeremy Kerr's avatar
Jeremy Kerr committed
93
    use_tags = models.BooleanField(default=True)
94
    git_send_email_only = models.BooleanField(default=False)
95
    subject_prefix_tags = models.CharField(max_length=255, blank=True,
96
                                           help_text='Comma separated list of tags')
Jeremy Kerr's avatar
Jeremy Kerr committed
97

98 99 100
    def is_editable(self, user):
        if not user.is_authenticated():
            return False
101
        return self in user.profile.maintainer_projects.all()
102

Jeremy Kerr's avatar
Jeremy Kerr committed
103 104 105 106 107 108
    @cached_property
    def tags(self):
        if not self.use_tags:
            return []
        return list(Tag.objects.all())

109
    def get_subject_prefix_tags(self):
110
        return get_comma_separated_field(self.subject_prefix_tags)
111

112 113 114
    def __str__(self):
        return self.name

115 116 117
    class Meta:
        ordering = ['linkname']

118

119 120
def user_name(user):
    if user.first_name or user.last_name:
121
        names = list(filter(bool, [user.first_name, user.last_name]))
122 123
        return u' '.join(names)
    return user.username
124

125 126
auth.models.User.add_to_class('name', user_name)

127

128
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
129
class UserProfile(models.Model):
130 131
    user = models.OneToOneField(User, unique=True, related_name='profile')
    primary_project = models.ForeignKey(Project, null=True, blank=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
132
    maintainer_projects = models.ManyToManyField(Project,
133 134 135 136 137 138 139
                                                 related_name='maintainer_project')
    send_email = models.BooleanField(default=False,
                                     help_text='Selecting this option allows patchwork to send ' +
                                     'email on your behalf')
    patches_per_page = models.PositiveIntegerField(default=100,
                                                   null=False, blank=False,
                                                   help_text='Number of patches to display per page')
Jeremy Kerr's avatar
Jeremy Kerr committed
140 141

    def name(self):
142
        return user_name(self.user)
Jeremy Kerr's avatar
Jeremy Kerr committed
143 144

    def contributor_projects(self):
145 146 147 148
        submitters = Person.objects.filter(user=self.user)
        return Project.objects.filter(id__in=Patch.objects.filter(
            submitter__in=submitters)
            .values('project_id').query)
Jeremy Kerr's avatar
Jeremy Kerr committed
149 150 151 152 153 154 155

    def sync_person(self):
        pass

    def n_todo_patches(self):
        return self.todo_patches().count()

156
    def todo_patches(self, project=None):
Jeremy Kerr's avatar
Jeremy Kerr committed
157 158 159

        # filter on project, if necessary
        if project:
160
            qs = Patch.objects.filter(project=project)
Jeremy Kerr's avatar
Jeremy Kerr committed
161 162 163
        else:
            qs = Patch.objects

164 165 166 167
        qs = qs.filter(archived=False) \
            .filter(delegate=self.user) \
            .filter(state__in=State.objects.filter(action_required=True)
                    .values('pk').query)
Jeremy Kerr's avatar
Jeremy Kerr committed
168 169
        return qs

170
    def __str__(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
171 172
        return self.name()

173

174 175
def _user_saved_callback(sender, created, instance, **kwargs):
    try:
176
        profile = instance.profile
177
    except UserProfile.DoesNotExist:
178
        profile = UserProfile(user=instance)
179 180
    profile.save()

181 182
models.signals.post_save.connect(_user_saved_callback, sender=User)

183

184
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
185
class State(models.Model):
186 187 188
    name = models.CharField(max_length=100)
    ordering = models.IntegerField(unique=True)
    action_required = models.BooleanField(default=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
189

190
    def __str__(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
191 192 193 194 195
        return self.name

    class Meta:
        ordering = ['ordering']

196

197
@add_metaclass(models.SubfieldBase)
Jeremy Kerr's avatar
Jeremy Kerr committed
198
class HashField(models.CharField):
Jeremy Kerr's avatar
Jeremy Kerr committed
199

200
    def __init__(self, algorithm='sha1', *args, **kwargs):
Jeremy Kerr's avatar
Jeremy Kerr committed
201
        self.algorithm = algorithm
Nate Case's avatar
Nate Case committed
202 203
        try:
            import hashlib
204 205

            def _construct(string=''):
206 207
                if isinstance(string, six.text_type):
                    string = string.encode('utf-8')
Jeremy Kerr's avatar
Jeremy Kerr committed
208 209
                return hashlib.new(self.algorithm, string)
            self.construct = _construct
210
            self.n_bytes = len(hashlib.new(self.algorithm).hexdigest())
Nate Case's avatar
Nate Case committed
211
        except ImportError:
212
            modules = {'sha1': 'sha', 'md5': 'md5'}
Jeremy Kerr's avatar
Jeremy Kerr committed
213

214
            if algorithm not in modules:
Nate Case's avatar
Nate Case committed
215
                raise NameError("Unknown algorithm '%s'" % algorithm)
Jeremy Kerr's avatar
Jeremy Kerr committed
216 217 218 219

            self.construct = __import__(modules[algorithm]).new

        self.n_bytes = len(self.construct().hexdigest())
Jeremy Kerr's avatar
Jeremy Kerr committed
220

221
        kwargs['max_length'] = self.n_bytes
Jeremy Kerr's avatar
Jeremy Kerr committed
222 223
        super(HashField, self).__init__(*args, **kwargs)

224
    def db_type(self, connection=None):
225
        return 'char(%d)' % self.n_bytes
Jeremy Kerr's avatar
Jeremy Kerr committed
226

227

228
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
229 230 231
class Tag(models.Model):
    name = models.CharField(max_length=20)
    pattern = models.CharField(max_length=50,
232 233 234
                               help_text='A simple regex to match the tag in the content of '
                               'a message. Will be used with MULTILINE and IGNORECASE '
                               'flags. eg. ^Acked-by:')
Jeremy Kerr's avatar
Jeremy Kerr committed
235
    abbrev = models.CharField(max_length=2, unique=True,
236 237
                              help_text='Short (one-or-two letter) abbreviation for the tag, '
                              'used in table column headers')
Jeremy Kerr's avatar
Jeremy Kerr committed
238 239 240 241 242

    @property
    def attr_name(self):
        return 'tag_%d_count' % self.id

243 244 245
    def __str__(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
246 247 248
    class Meta:
        ordering = ['abbrev']

249

Jeremy Kerr's avatar
Jeremy Kerr committed
250 251 252 253 254 255 256 257
class PatchTag(models.Model):
    patch = models.ForeignKey('Patch')
    tag = models.ForeignKey('Tag')
    count = models.IntegerField(default=1)

    class Meta:
        unique_together = [('patch', 'tag')]

258

259 260 261
def get_default_initial_patch_state():
    return State.objects.get(ordering=0)

262

Jeremy Kerr's avatar
Jeremy Kerr committed
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277
class PatchQuerySet(models.query.QuerySet):

    def with_tag_counts(self, project):
        if not project.use_tags:
            return self

        # We need the project's use_tags field loaded for Project.tags().
        # Using prefetch_related means we'll share the one instance of
        # Project, and share the project.tags cache between all patch.project
        # references.
        qs = self.prefetch_related('project')
        select = OrderedDict()
        select_params = []
        for tag in project.tags:
            select[tag.attr_name] = ("coalesce("
278 279 280
                                     "(SELECT count FROM patchwork_patchtag "
                                     "WHERE patchwork_patchtag.patch_id=patchwork_patch.id "
                                     "AND patchwork_patchtag.tag_id=%s), 0)")
Jeremy Kerr's avatar
Jeremy Kerr committed
281 282 283 284
            select_params.append(tag.id)

        return qs.extra(select=select, select_params=select_params)

285

Jeremy Kerr's avatar
Jeremy Kerr committed
286 287 288 289 290 291 292 293 294
class PatchManager(models.Manager):
    use_for_related_fields = True

    def get_queryset(self):
        return PatchQuerySet(self.model, using=self.db)

    def with_tag_counts(self, project):
        return self.get_queryset().with_tag_counts(project)

295

296 297 298 299 300
def filename(name, ext):
    fname_re = re.compile('[^-_A-Za-z0-9\.]+')
    str = fname_re.sub('-', name)
    return str.strip('-') + ext

301

302
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
303 304
class Patch(models.Model):
    project = models.ForeignKey(Project)
305
    msgid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
306 307 308
    name = models.CharField(max_length=255)
    date = models.DateTimeField(default=datetime.datetime.now)
    submitter = models.ForeignKey(Person)
309
    delegate = models.ForeignKey(User, blank=True, null=True)
310
    state = models.ForeignKey(State, null=True)
311 312 313 314 315 316
    archived = models.BooleanField(default=False)
    headers = models.TextField(blank=True)
    content = models.TextField(null=True, blank=True)
    pull_url = models.CharField(max_length=255, null=True, blank=True)
    commit_ref = models.CharField(max_length=255, null=True, blank=True)
    hash = HashField(null=True, blank=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
317 318 319
    tags = models.ManyToManyField(Tag, through=PatchTag)

    objects = PatchManager()
Jeremy Kerr's avatar
Jeremy Kerr committed
320

321 322 323 324 325 326 327 328
    def commit_message(self):
        """Retrieves the commit message"""
        return Comment.objects.filter(patch=self, msgid=self.msgid)

    def answers(self):
        """Retrieves the answers (ie all comments but the commit message)"""
        return Comment.objects.filter(Q(patch=self) & ~Q(msgid=self.msgid))

Jeremy Kerr's avatar
Jeremy Kerr committed
329
    def comments(self):
330 331
        """Retrieves all comments of this patch ie. the commit message and the
           answers"""
332
        return Comment.objects.filter(patch=self)
Jeremy Kerr's avatar
Jeremy Kerr committed
333

Jeremy Kerr's avatar
Jeremy Kerr committed
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351
    def _set_tag(self, tag, count):
        if count == 0:
            self.patchtag_set.filter(tag=tag).delete()
            return
        (patchtag, _) = PatchTag.objects.get_or_create(patch=self, tag=tag)
        if patchtag.count != count:
            patchtag.count = count
            patchtag.save()

    def refresh_tag_counts(self):
        tags = self.project.tags
        counter = Counter()
        for comment in self.comment_set.all():
            counter = counter + extract_tags(comment.content, tags)

        for tag in tags:
            self._set_tag(tag, counter[tag])

Jeremy Kerr's avatar
Jeremy Kerr committed
352
    def save(self):
353 354
        if not hasattr(self, 'state') or not self.state:
            self.state = get_default_initial_patch_state()
Jeremy Kerr's avatar
Jeremy Kerr committed
355

356
        if self.hash is None and self.content is not None:
Jeremy Kerr's avatar
Jeremy Kerr committed
357
            self.hash = hash_patch(self.content).hexdigest()
Jeremy Kerr's avatar
Jeremy Kerr committed
358

Jeremy Kerr's avatar
Jeremy Kerr committed
359 360 361 362 363 364 365 366 367
        super(Patch, self).save()

    def is_editable(self, user):
        if not user.is_authenticated():
            return False

        if self.submitter.user == user or self.delegate == user:
            return True

368
        return self.project.is_editable(user)
Jeremy Kerr's avatar
Jeremy Kerr committed
369 370

    def filename(self):
371
        return filename(self.name, '.patch')
Jeremy Kerr's avatar
Jeremy Kerr committed
372 373 374 375 376

    @models.permalink
    def get_absolute_url(self):
        return ('patchwork.views.patch.patch', (), {'patch_id': self.id})

377 378 379
    def __str__(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
380 381 382
    class Meta:
        verbose_name_plural = 'Patches'
        ordering = ['date']
383
        unique_together = [('msgid', 'project')]
Jeremy Kerr's avatar
Jeremy Kerr committed
384

385

Jeremy Kerr's avatar
Jeremy Kerr committed
386 387
class Comment(models.Model):
    patch = models.ForeignKey(Patch)
388
    msgid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
389
    submitter = models.ForeignKey(Person)
390 391
    date = models.DateTimeField(default=datetime.datetime.now)
    headers = models.TextField(blank=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
392 393
    content = models.TextField()

394 395 396
    response_re = re.compile(
        '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
        re.M | re.I)
397 398

    def patch_responses(self):
399 400
        return ''.join([match.group(0) + '\n' for match in
                        self.response_re.finditer(self.content)])
401

Jeremy Kerr's avatar
Jeremy Kerr committed
402 403 404 405 406 407 408 409
    def save(self, *args, **kwargs):
        super(Comment, self).save(*args, **kwargs)
        self.patch.refresh_tag_counts()

    def delete(self, *args, **kwargs):
        super(Comment, self).delete(*args, **kwargs)
        self.patch.refresh_tag_counts()

Jeremy Kerr's avatar
Jeremy Kerr committed
410 411
    class Meta:
        ordering = ['date']
412
        unique_together = [('msgid', 'patch')]
Jeremy Kerr's avatar
Jeremy Kerr committed
413

414

Jeremy Kerr's avatar
Jeremy Kerr committed
415 416 417
class Bundle(models.Model):
    owner = models.ForeignKey(User)
    project = models.ForeignKey(Project)
418 419 420
    name = models.CharField(max_length=50, null=False, blank=False)
    patches = models.ManyToManyField(Patch, through='BundlePatch')
    public = models.BooleanField(default=False)
Jeremy Kerr's avatar
Jeremy Kerr committed
421 422 423 424

    def n_patches(self):
        return self.patches.all().count()

Jeremy Kerr's avatar
Jeremy Kerr committed
425
    def ordered_patches(self):
Guilherme Salgado's avatar
Guilherme Salgado committed
426
        return self.patches.order_by('bundlepatch__order')
Jeremy Kerr's avatar
Jeremy Kerr committed
427

428 429
    def append_patch(self, patch):
        # todo: use the aggregate queries in django 1.1
430 431
        orders = BundlePatch.objects.filter(bundle=self).order_by('-order') \
            .values('order')
Jeremy Kerr's avatar
Jeremy Kerr committed
432 433 434 435 436 437 438

        if len(orders) > 0:
            max_order = orders[0]['order']
        else:
            max_order = 0

        # see if the patch is already in this bundle
439
        if BundlePatch.objects.filter(bundle=self, patch=patch).count():
Jeremy Kerr's avatar
Jeremy Kerr committed
440
            raise Exception("patch is already in bundle")
441

442 443
        bp = BundlePatch.objects.create(bundle=self, patch=patch,
                                        order=max_order + 1)
444 445
        bp.save()

Jeremy Kerr's avatar
Jeremy Kerr committed
446 447 448 449 450
    def public_url(self):
        if not self.public:
            return None
        site = Site.objects.get_current()
        return 'http://%s%s' % (site.domain,
451 452 453 454 455
                                reverse('patchwork.views.bundle.bundle',
                                        kwargs={
                                            'username': self.owner.username,
                                            'bundlename': self.name
                                        }))
Jeremy Kerr's avatar
Jeremy Kerr committed
456

457 458 459
    @models.permalink
    def get_absolute_url(self):
        return ('patchwork.views.bundle.bundle', (), {
460 461 462
            'username': self.owner.username,
            'bundlename': self.name,
        })
463

464 465 466
    class Meta:
        unique_together = [('owner', 'name')]

467

468 469 470 471 472 473
class BundlePatch(models.Model):
    patch = models.ForeignKey(Patch)
    bundle = models.ForeignKey(Bundle)
    order = models.IntegerField()

    class Meta:
Jeremy Kerr's avatar
Jeremy Kerr committed
474 475
        unique_together = [('bundle', 'patch')]
        ordering = ['order']
476

Damien Lespiau's avatar
Damien Lespiau committed
477 478
SERIES_DEFAULT_NAME = "Series without cover letter"

479 480 481 482 483 484 485 486 487 488 489 490 491 492

class TestState:
    STATE_PENDING = 0
    STATE_SUCCESS = 1
    STATE_WARNING = 2
    STATE_FAILURE = 3
    STATE_CHOICES = (
        (STATE_PENDING, 'pending'),
        (STATE_SUCCESS, 'success'),
        (STATE_WARNING, 'warning'),
        (STATE_FAILURE, 'failure'),
    )


Damien Lespiau's avatar
Damien Lespiau committed
493 494
# This Model represents the "top level" Series, an object that doesn't change
# with the various versions of patches sent to the mailing list.
495
@python_2_unicode_compatible
Damien Lespiau's avatar
Damien Lespiau committed
496 497 498 499 500 501 502 503
class Series(models.Model):
    project = models.ForeignKey(Project)
    name = models.CharField(max_length=200, default=SERIES_DEFAULT_NAME)
    submitter = models.ForeignKey(Person, related_name='submitters')
    reviewer = models.ForeignKey(User, related_name='reviewers', null=True,
                                 blank=True)
    submitted = models.DateTimeField(default=datetime.datetime.now)
    last_updated = models.DateTimeField(auto_now=True)
504 505 506 507
    # direct access to the latest revision so we can get the latest revision
    # information with a JOIN
    last_revision = models.OneToOneField('SeriesRevision', null=True,
                                         related_name='+')
Damien Lespiau's avatar
Damien Lespiau committed
508 509 510 511 512 513 514 515

    def revisions(self):
        return SeriesRevision.objects.filter(series=self)

    def latest_revision(self):
        return self.revisions().reverse()[0]

    def get_absolute_url(self):
516
        return reverse('series', kwargs={'series': self.pk})
Damien Lespiau's avatar
Damien Lespiau committed
517 518 519 520 521

    def dump(self):
        print('')
        print('===')
        print('Series: %s' % self)
522 523
        print('    version: %d' % self.version)
        print('    n_patches: %d' % self.n_patches)
Damien Lespiau's avatar
Damien Lespiau committed
524 525 526 527 528 529 530 531 532
        for rev in self.revisions():
            print('    rev %d:' % rev.version)
            i = 1
            for patch in rev.ordered_patches():
                print('        patch %d:' % i)
                print('            subject: %s' % patch.name)
                print('            msgid  : %s' % patch.msgid)
                i += 1

533 534 535
    def filename(self):
        return filename(self.name, '.mbox')

536 537 538
    def __str__(self):
        return self.name

539 540 541 542
# Signal one can listen to to know when a revision is complete (ie. has all of
# its patches)
series_revision_complete = django.dispatch.Signal(providing_args=["revision"])

Damien Lespiau's avatar
Damien Lespiau committed
543 544
# A 'revision' of a series. Resending a new version of a patch or a full new
# iteration of a series will create a new revision.
545 546


547
@python_2_unicode_compatible
Damien Lespiau's avatar
Damien Lespiau committed
548 549 550 551
class SeriesRevision(models.Model):
    series = models.ForeignKey(Series)
    version = models.IntegerField(default=1)
    root_msgid = models.CharField(max_length=255)
552
    cover_letter = models.TextField(null=True, blank=True)
553
    n_patches = models.IntegerField(default=0)
554
    patches = models.ManyToManyField(Patch, through='SeriesRevisionPatch')
555 556
    test_state = models.SmallIntegerField(choices=TestState.STATE_CHOICES,
                                          null=True)
Damien Lespiau's avatar
Damien Lespiau committed
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574

    class Meta:
        unique_together = [('series', 'version')]
        ordering = ['version']

    def ordered_patches(self):
        return self.patches.order_by('seriesrevisionpatch__order')

    def add_patch(self, patch, order):
        # see if the patch is already in this revision
        if SeriesRevisionPatch.objects.filter(revision=self,
                                              patch=patch).count():
            raise Exception("patch is already in revision")

        sp = SeriesRevisionPatch.objects.create(revision=self, patch=patch,
                                                order=order)
        sp.save()

575
        revision_complete = self.patches.count() == self.n_patches
576 577 578
        if revision_complete:
            series_revision_complete.send(sender=self.__class__, revision=self)

579 580 581 582 583
    def duplicate_meta(self):
        new = SeriesRevision.objects.get(pk=self.pk)
        new.pk = None
        new.cover_letter = None
        new.version = self.version + 1
584
        new.test_state = None
585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602
        new.save()

        return new

    def duplicate(self, exclude_patches=()):
        """Create a new revision based on 'self', incrementing the version
           and populating the new revision with all 'self' patches.
           exclude_patch (a list of 'order's) can be used to exclude
           patches from the operation"""
        new = self.duplicate_meta()
        order = 0
        for p in self.ordered_patches():
            order += 1
            if order in exclude_patches:
                continue
            new.add_patch(p, order)
        return new

603 604 605 606 607
    def refresh_test_state(self):
        results = TestResult.objects.filter(revision=self)
        if results.count() > 0:
            self.test_state = max([r.state for r in results])
            self.save()
608
            self.series.save()
609

610
    def __str__(self):
611
        return "Revision " + str(self.version)
Damien Lespiau's avatar
Damien Lespiau committed
612

613

Damien Lespiau's avatar
Damien Lespiau committed
614 615 616 617 618 619 620 621 622
class SeriesRevisionPatch(models.Model):
    patch = models.ForeignKey(Patch)
    revision = models.ForeignKey(SeriesRevision)
    order = models.IntegerField()

    class Meta:
        unique_together = [('revision', 'patch'), ('revision', 'order')]
        ordering = ['order']

623

624 625 626
class Event(models.Model):
    name = models.CharField(max_length=20)

627

628 629 630 631 632
class EventLog(models.Model):
    event = models.ForeignKey(Event)
    event_time = models.DateTimeField(auto_now=True)
    series = models.ForeignKey(Series)
    user = models.ForeignKey(User, null=True)
633
    parameters = jsonfield.JSONField(null=True)
634 635 636 637

    class Meta:
        ordering = ['-event_time']

638

639
@python_2_unicode_compatible
640
class Test(models.Model):
641 642 643 644 645 646 647
    # no mail, default so test systems/scripts can have a grace period to
    # settle down and give useful results
    RECIPIENT_NONE = 0
    # send mail only to submitter
    RECIPIENT_SUBMITTER = 1
    # send mail to submitter and mailing-list in Cc
    RECIPIENT_MAILING_LIST = 2
648 649
    # send mail to the addresses listed in the mail_to_list field only
    RECIPIENT_TO_LIST = 3
650 651 652 653
    RECIPIENT_CHOICES = (
        (RECIPIENT_NONE, 'none'),
        (RECIPIENT_SUBMITTER, 'submitter'),
        (RECIPIENT_MAILING_LIST, 'mailing list'),
654
        (RECIPIENT_TO_LIST, 'recipient list'),
655 656 657 658 659 660 661 662 663 664 665
    )

    # send result mail on any state (but pending)
    CONDITION_ALWAYS = 0
    # only send result on warning/failure
    CONDITION_ON_FAILURE = 1
    CONDITION_CHOICES = (
        (CONDITION_ALWAYS, 'always'),
        (CONDITION_ON_FAILURE, 'on failure'),
    )

666 667
    project = models.ForeignKey(Project)
    name = models.CharField(max_length=255)
668 669
    mail_recipient = models.SmallIntegerField(choices=RECIPIENT_CHOICES,
                                              default=RECIPIENT_NONE)
670 671 672
    # email addresses in these lists are always added to the To: and Cc:fields,
    # unless we don't want to send any email at all.
    mail_to_list = models.CharField(max_length=255, blank=True, null=True,
673
                                    help_text='Comma separated list of emails')
674
    mail_cc_list = models.CharField(max_length=255, blank=True, null=True,
675
                                    help_text='Comma separated list of emails')
676 677
    mail_condition = models.SmallIntegerField(choices=CONDITION_CHOICES,
                                              default=CONDITION_ALWAYS)
678 679 680 681

    class Meta:
        unique_together = [('project', 'name')]

682 683 684 685 686 687
    def get_to_list(self):
        return get_comma_separated_field(self.mail_to_list)

    def get_cc_list(self):
        return get_comma_separated_field(self.mail_cc_list)

688
    def __str__(self):
689 690
        return self.name

691

692
@python_2_unicode_compatible
693 694 695 696 697 698
class TestResult(models.Model):

    test = models.ForeignKey(Test)
    revision = models.ForeignKey(SeriesRevision, blank=True, null=True)
    patch = models.ForeignKey(Patch, blank=True, null=True)
    user = models.ForeignKey(User)
699
    date = models.DateTimeField(auto_now=True)
700
    state = models.SmallIntegerField(choices=TestState.STATE_CHOICES)
701 702 703
    url = models.URLField(blank=True, null=True)
    summary = models.TextField(blank=True, null=True)

704 705 706
    def __str__(self):
        return self.get_state_display()

707 708 709
    class Meta:
        unique_together = [('test', 'revision'), ('test', 'patch')]

710

711
class EmailConfirmation(models.Model):
712 713 714 715 716 717 718 719
    validity = datetime.timedelta(days=settings.CONFIRMATION_VALIDITY_DAYS)
    type = models.CharField(max_length=20, choices=[
        ('userperson', 'User-Person association'),
        ('registration', 'Registration'),
        ('optout', 'Email opt-out'),
    ])
    email = models.CharField(max_length=200)
    user = models.ForeignKey(User, null=True)
720
    key = HashField()
721 722
    date = models.DateTimeField(default=datetime.datetime.now)
    active = models.BooleanField(default=True)
723

724
    def deactivate(self):
725
        self.active = False
726
        self.save()
727

728 729 730
    def is_valid(self):
        return self.date + self.validity > datetime.datetime.now()

731 732 733 734 735
    def save(self):
        max = 1 << 32
        if self.key == '':
            str = '%s%s%d' % (self.user, self.email, random.randint(0, max))
            self.key = self._meta.get_field('key').construct(str).hexdigest()
736
        super(EmailConfirmation, self).save()
737

738

739
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
740
class EmailOptout(models.Model):
741
    email = models.CharField(max_length=200, primary_key=True)
742

Jeremy Kerr's avatar
Jeremy Kerr committed
743 744 745
    @classmethod
    def is_optout(cls, email):
        email = email.lower().strip()
746
        return cls.objects.filter(email=email).count() > 0
Jeremy Kerr's avatar
Jeremy Kerr committed
747

748 749 750
    def __str__(self):
        return self.email

751

752
class PatchChangeNotification(models.Model):
753 754
    patch = models.OneToOneField(Patch, primary_key=True)
    last_modified = models.DateTimeField(default=datetime.datetime.now)
755 756
    orig_state = models.ForeignKey(State)

757

758 759 760 761 762 763 764 765 766
def _patch_change_callback(sender, instance, **kwargs):
    # we only want notification of modified patches
    if instance.pk is None:
        return

    if instance.project is None or not instance.project.send_notifications:
        return

    try:
767
        orig_patch = Patch.objects.get(pk=instance.pk)
768 769 770 771 772 773 774 775 776 777
    except Patch.DoesNotExist:
        return

    # If there's no interesting changes, abort without creating the
    # notification
    if orig_patch.state == instance.state:
        return

    notification = None
    try:
778
        notification = PatchChangeNotification.objects.get(patch=instance)
779 780 781 782
    except PatchChangeNotification.DoesNotExist:
        pass

    if notification is None:
783 784
        notification = PatchChangeNotification(patch=instance,
                                               orig_state=orig_patch.state)
785 786 787 788 789 790 791 792 793

    elif notification.orig_state == instance.state:
        # If we're back at the original state, there is no need to notify
        notification.delete()
        return

    notification.last_modified = datetime.datetime.now()
    notification.save()

794 795
models.signals.pre_save.connect(_patch_change_callback, sender=Patch)

796 797

def _on_revision_complete(sender, revision, **kwargs):
798 799 800 801 802 803 804
    series = revision.series

    # update series.last_revision
    series.last_revision = series.latest_revision()
    series.save()

    # log event
805
    new_revision = Event.objects.get(name='series-new-revision')
806 807
    log = EventLog(event=new_revision, series=series,
                   user=series.submitter.user,
808
                   parameters={'revision': revision.version})
809 810 811
    log.save()

series_revision_complete.connect(_on_revision_complete)