models.py 26.7 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 129 130 131 132 133 134 135 136 137 138 139 140 141 142
@python_2_unicode_compatible
class DelegationRule(models.Model):
    user = models.ForeignKey(User)
    path = models.CharField(max_length=255)
    project = models.ForeignKey(Project)
    priority = models.IntegerField(default=0)

    def __str__(self):
        return self.path

    class Meta:
        ordering = ['-priority', 'path']
        unique_together = (('path', 'project'))


143
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
144
class UserProfile(models.Model):
145 146
    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
147
    maintainer_projects = models.ManyToManyField(Project,
148 149 150 151 152 153 154
                                                 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
155 156

    def name(self):
157
        return user_name(self.user)
Jeremy Kerr's avatar
Jeremy Kerr committed
158 159

    def contributor_projects(self):
160 161 162 163
        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
164 165 166 167 168 169 170

    def sync_person(self):
        pass

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

171
    def todo_patches(self, project=None):
Jeremy Kerr's avatar
Jeremy Kerr committed
172 173 174

        # filter on project, if necessary
        if project:
175
            qs = Patch.objects.filter(project=project)
Jeremy Kerr's avatar
Jeremy Kerr committed
176 177 178
        else:
            qs = Patch.objects

179 180 181 182
        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
183 184
        return qs

185
    def __str__(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
186 187
        return self.name()

188

189 190
def _user_saved_callback(sender, created, instance, **kwargs):
    try:
191
        profile = instance.profile
192
    except UserProfile.DoesNotExist:
193
        profile = UserProfile(user=instance)
194 195
    profile.save()

196 197
models.signals.post_save.connect(_user_saved_callback, sender=User)

198

199
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
200
class State(models.Model):
201 202 203
    name = models.CharField(max_length=100)
    ordering = models.IntegerField(unique=True)
    action_required = models.BooleanField(default=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
204

205
    def __str__(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
206 207 208 209 210
        return self.name

    class Meta:
        ordering = ['ordering']

211

212
@add_metaclass(models.SubfieldBase)
Jeremy Kerr's avatar
Jeremy Kerr committed
213
class HashField(models.CharField):
Jeremy Kerr's avatar
Jeremy Kerr committed
214

215
    def __init__(self, algorithm='sha1', *args, **kwargs):
Jeremy Kerr's avatar
Jeremy Kerr committed
216
        self.algorithm = algorithm
Nate Case's avatar
Nate Case committed
217 218
        try:
            import hashlib
219 220

            def _construct(string=''):
221 222
                if isinstance(string, six.text_type):
                    string = string.encode('utf-8')
Jeremy Kerr's avatar
Jeremy Kerr committed
223 224
                return hashlib.new(self.algorithm, string)
            self.construct = _construct
225
            self.n_bytes = len(hashlib.new(self.algorithm).hexdigest())
Nate Case's avatar
Nate Case committed
226
        except ImportError:
227
            modules = {'sha1': 'sha', 'md5': 'md5'}
Jeremy Kerr's avatar
Jeremy Kerr committed
228

229
            if algorithm not in modules:
Nate Case's avatar
Nate Case committed
230
                raise NameError("Unknown algorithm '%s'" % algorithm)
Jeremy Kerr's avatar
Jeremy Kerr committed
231 232 233 234

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

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

236
        kwargs['max_length'] = self.n_bytes
Jeremy Kerr's avatar
Jeremy Kerr committed
237 238
        super(HashField, self).__init__(*args, **kwargs)

239
    def db_type(self, connection=None):
240
        return 'char(%d)' % self.n_bytes
Jeremy Kerr's avatar
Jeremy Kerr committed
241

242

243
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
244 245 246
class Tag(models.Model):
    name = models.CharField(max_length=20)
    pattern = models.CharField(max_length=50,
247 248 249
                               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
250
    abbrev = models.CharField(max_length=2, unique=True,
251 252
                              help_text='Short (one-or-two letter) abbreviation for the tag, '
                              'used in table column headers')
Jeremy Kerr's avatar
Jeremy Kerr committed
253 254 255 256 257

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

258 259 260
    def __str__(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
261 262 263
    class Meta:
        ordering = ['abbrev']

264

Jeremy Kerr's avatar
Jeremy Kerr committed
265 266 267 268 269 270 271 272
class PatchTag(models.Model):
    patch = models.ForeignKey('Patch')
    tag = models.ForeignKey('Tag')
    count = models.IntegerField(default=1)

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

273

274 275 276
def get_default_initial_patch_state():
    return State.objects.get(ordering=0)

277

Jeremy Kerr's avatar
Jeremy Kerr committed
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292
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("
293 294 295
                                     "(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
296 297 298 299
            select_params.append(tag.id)

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

300

Jeremy Kerr's avatar
Jeremy Kerr committed
301 302 303 304 305 306 307 308 309
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)

310

311 312 313 314 315
def filename(name, ext):
    fname_re = re.compile('[^-_A-Za-z0-9\.]+')
    str = fname_re.sub('-', name)
    return str.strip('-') + ext

316

317
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
318 319
class Patch(models.Model):
    project = models.ForeignKey(Project)
320
    msgid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
321 322 323
    name = models.CharField(max_length=255)
    date = models.DateTimeField(default=datetime.datetime.now)
    submitter = models.ForeignKey(Person)
324
    delegate = models.ForeignKey(User, blank=True, null=True)
325
    state = models.ForeignKey(State, null=True)
326 327 328 329 330 331
    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
332 333 334
    tags = models.ManyToManyField(Tag, through=PatchTag)

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

336 337 338 339 340 341 342 343
    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
344
    def comments(self):
345 346
        """Retrieves all comments of this patch ie. the commit message and the
           answers"""
347
        return Comment.objects.filter(patch=self)
Jeremy Kerr's avatar
Jeremy Kerr committed
348

Jeremy Kerr's avatar
Jeremy Kerr committed
349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366
    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
367
    def save(self):
368 369
        if not hasattr(self, 'state') or not self.state:
            self.state = get_default_initial_patch_state()
Jeremy Kerr's avatar
Jeremy Kerr committed
370

371
        if self.hash is None and self.content is not None:
Jeremy Kerr's avatar
Jeremy Kerr committed
372
            self.hash = hash_patch(self.content).hexdigest()
Jeremy Kerr's avatar
Jeremy Kerr committed
373

Jeremy Kerr's avatar
Jeremy Kerr committed
374 375 376 377 378 379 380 381 382
        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

383
        return self.project.is_editable(user)
Jeremy Kerr's avatar
Jeremy Kerr committed
384 385

    def filename(self):
386
        return filename(self.name, '.patch')
Jeremy Kerr's avatar
Jeremy Kerr committed
387 388 389 390 391

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

392 393 394
    def __str__(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
395 396 397
    class Meta:
        verbose_name_plural = 'Patches'
        ordering = ['date']
398
        unique_together = [('msgid', 'project')]
Jeremy Kerr's avatar
Jeremy Kerr committed
399

400

Jeremy Kerr's avatar
Jeremy Kerr committed
401 402
class Comment(models.Model):
    patch = models.ForeignKey(Patch)
403
    msgid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
404
    submitter = models.ForeignKey(Person)
405 406
    date = models.DateTimeField(default=datetime.datetime.now)
    headers = models.TextField(blank=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
407 408
    content = models.TextField()

409 410 411
    response_re = re.compile(
        '^(Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by: .*$',
        re.M | re.I)
412 413

    def patch_responses(self):
414 415
        return ''.join([match.group(0) + '\n' for match in
                        self.response_re.finditer(self.content)])
416

Jeremy Kerr's avatar
Jeremy Kerr committed
417 418 419 420 421 422 423 424
    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
425 426
    class Meta:
        ordering = ['date']
427
        unique_together = [('msgid', 'patch')]
Jeremy Kerr's avatar
Jeremy Kerr committed
428

429

Jeremy Kerr's avatar
Jeremy Kerr committed
430 431 432
class Bundle(models.Model):
    owner = models.ForeignKey(User)
    project = models.ForeignKey(Project)
433 434 435
    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
436 437 438 439

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

Jeremy Kerr's avatar
Jeremy Kerr committed
440
    def ordered_patches(self):
Guilherme Salgado's avatar
Guilherme Salgado committed
441
        return self.patches.order_by('bundlepatch__order')
Jeremy Kerr's avatar
Jeremy Kerr committed
442

443 444
    def append_patch(self, patch):
        # todo: use the aggregate queries in django 1.1
445 446
        orders = BundlePatch.objects.filter(bundle=self).order_by('-order') \
            .values('order')
Jeremy Kerr's avatar
Jeremy Kerr committed
447 448 449 450 451 452 453

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

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

457 458
        bp = BundlePatch.objects.create(bundle=self, patch=patch,
                                        order=max_order + 1)
459 460
        bp.save()

Jeremy Kerr's avatar
Jeremy Kerr committed
461 462 463 464 465
    def public_url(self):
        if not self.public:
            return None
        site = Site.objects.get_current()
        return 'http://%s%s' % (site.domain,
466 467 468 469 470
                                reverse('patchwork.views.bundle.bundle',
                                        kwargs={
                                            'username': self.owner.username,
                                            'bundlename': self.name
                                        }))
Jeremy Kerr's avatar
Jeremy Kerr committed
471

472 473 474
    @models.permalink
    def get_absolute_url(self):
        return ('patchwork.views.bundle.bundle', (), {
475 476 477
            'username': self.owner.username,
            'bundlename': self.name,
        })
478

479 480 481
    class Meta:
        unique_together = [('owner', 'name')]

482

483 484 485 486 487 488
class BundlePatch(models.Model):
    patch = models.ForeignKey(Patch)
    bundle = models.ForeignKey(Bundle)
    order = models.IntegerField()

    class Meta:
Jeremy Kerr's avatar
Jeremy Kerr committed
489 490
        unique_together = [('bundle', 'patch')]
        ordering = ['order']
491

Damien Lespiau's avatar
Damien Lespiau committed
492 493
SERIES_DEFAULT_NAME = "Series without cover letter"

494 495 496 497 498 499 500 501 502 503 504 505 506 507

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
508 509
# This Model represents the "top level" Series, an object that doesn't change
# with the various versions of patches sent to the mailing list.
510
@python_2_unicode_compatible
Damien Lespiau's avatar
Damien Lespiau committed
511 512 513 514 515 516 517 518
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)
519 520 521 522
    # 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
523 524 525 526 527 528 529 530

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

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

    def get_absolute_url(self):
531
        return reverse('series', kwargs={'series': self.pk})
Damien Lespiau's avatar
Damien Lespiau committed
532 533 534 535 536

    def dump(self):
        print('')
        print('===')
        print('Series: %s' % self)
537 538
        print('    version: %d' % self.version)
        print('    n_patches: %d' % self.n_patches)
Damien Lespiau's avatar
Damien Lespiau committed
539 540 541 542 543 544 545 546 547
        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

548 549 550
    def filename(self):
        return filename(self.name, '.mbox')

551 552 553
    def __str__(self):
        return self.name

554 555 556 557
# 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
558 559
# 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.
560 561


562
@python_2_unicode_compatible
Damien Lespiau's avatar
Damien Lespiau committed
563 564 565 566
class SeriesRevision(models.Model):
    series = models.ForeignKey(Series)
    version = models.IntegerField(default=1)
    root_msgid = models.CharField(max_length=255)
567
    cover_letter = models.TextField(null=True, blank=True)
568
    n_patches = models.IntegerField(default=0)
569
    patches = models.ManyToManyField(Patch, through='SeriesRevisionPatch')
570 571
    test_state = models.SmallIntegerField(choices=TestState.STATE_CHOICES,
                                          null=True)
Damien Lespiau's avatar
Damien Lespiau committed
572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589

    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()

590
        revision_complete = self.patches.count() == self.n_patches
591 592 593
        if revision_complete:
            series_revision_complete.send(sender=self.__class__, revision=self)

594 595 596 597 598
    def duplicate_meta(self):
        new = SeriesRevision.objects.get(pk=self.pk)
        new.pk = None
        new.cover_letter = None
        new.version = self.version + 1
599
        new.test_state = None
600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617
        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

618 619 620 621 622
    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()
623
            self.series.save()
624

625
    def __str__(self):
626
        return "Revision " + str(self.version)
Damien Lespiau's avatar
Damien Lespiau committed
627

628

Damien Lespiau's avatar
Damien Lespiau committed
629 630 631 632 633 634 635 636 637
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']

638

639 640 641
class Event(models.Model):
    name = models.CharField(max_length=20)

642

643 644 645 646 647
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)
648
    parameters = jsonfield.JSONField(null=True)
649 650 651 652

    class Meta:
        ordering = ['-event_time']

653

654
@python_2_unicode_compatible
655
class Test(models.Model):
656 657 658 659 660 661 662
    # 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
663 664
    # send mail to the addresses listed in the mail_to_list field only
    RECIPIENT_TO_LIST = 3
665 666 667 668
    RECIPIENT_CHOICES = (
        (RECIPIENT_NONE, 'none'),
        (RECIPIENT_SUBMITTER, 'submitter'),
        (RECIPIENT_MAILING_LIST, 'mailing list'),
669
        (RECIPIENT_TO_LIST, 'recipient list'),
670 671 672 673 674 675 676 677 678 679 680
    )

    # 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'),
    )

681 682
    project = models.ForeignKey(Project)
    name = models.CharField(max_length=255)
683 684
    mail_recipient = models.SmallIntegerField(choices=RECIPIENT_CHOICES,
                                              default=RECIPIENT_NONE)
685 686 687
    # 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,
688
                                    help_text='Comma separated list of emails')
689
    mail_cc_list = models.CharField(max_length=255, blank=True, null=True,
690
                                    help_text='Comma separated list of emails')
691 692
    mail_condition = models.SmallIntegerField(choices=CONDITION_CHOICES,
                                              default=CONDITION_ALWAYS)
693 694 695 696

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

697 698 699 700 701 702
    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)

703
    def __str__(self):
704 705
        return self.name

706

707
@python_2_unicode_compatible
708 709 710 711 712 713
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)
714
    date = models.DateTimeField(auto_now=True)
715
    state = models.SmallIntegerField(choices=TestState.STATE_CHOICES)
716 717 718
    url = models.URLField(blank=True, null=True)
    summary = models.TextField(blank=True, null=True)

719 720 721
    def __str__(self):
        return self.get_state_display()

722 723 724
    class Meta:
        unique_together = [('test', 'revision'), ('test', 'patch')]

725

726
class EmailConfirmation(models.Model):
727 728 729 730 731 732 733 734
    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)
735
    key = HashField()
736 737
    date = models.DateTimeField(default=datetime.datetime.now)
    active = models.BooleanField(default=True)
738

739
    def deactivate(self):
740
        self.active = False
741
        self.save()
742

743 744 745
    def is_valid(self):
        return self.date + self.validity > datetime.datetime.now()

746 747 748 749 750
    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()
751
        super(EmailConfirmation, self).save()
752

753

754
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
755
class EmailOptout(models.Model):
756
    email = models.CharField(max_length=200, primary_key=True)
757

Jeremy Kerr's avatar
Jeremy Kerr committed
758 759 760
    @classmethod
    def is_optout(cls, email):
        email = email.lower().strip()
761
        return cls.objects.filter(email=email).count() > 0
Jeremy Kerr's avatar
Jeremy Kerr committed
762

763 764 765
    def __str__(self):
        return self.email

766

767
class PatchChangeNotification(models.Model):
768 769
    patch = models.OneToOneField(Patch, primary_key=True)
    last_modified = models.DateTimeField(default=datetime.datetime.now)
770 771
    orig_state = models.ForeignKey(State)

772

773 774 775 776 777 778 779 780 781
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:
782
        orig_patch = Patch.objects.get(pk=instance.pk)
783 784 785 786 787 788 789 790 791 792
    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:
793
        notification = PatchChangeNotification.objects.get(patch=instance)
794 795 796 797
    except PatchChangeNotification.DoesNotExist:
        pass

    if notification is None:
798 799
        notification = PatchChangeNotification(patch=instance,
                                               orig_state=orig_patch.state)
800 801 802 803 804 805 806 807 808

    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()

809 810
models.signals.pre_save.connect(_patch_change_callback, sender=Patch)

811 812

def _on_revision_complete(sender, revision, **kwargs):
813 814 815 816 817 818 819
    series = revision.series

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

    # log event
820
    new_revision = Event.objects.get(name='series-new-revision')
821 822
    log = EventLog(event=new_revision, series=series,
                   user=series.submitter.user,
823
                   parameters={'revision': revision.version})
824 825 826
    log.save()

series_revision_complete.connect(_on_revision_complete)