models.py 26.8 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

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

507 508 509 510 511
    @classmethod
    def from_string(cls, s):
        s2i = {s: i for i, s in cls.STATE_CHOICES}
        return s2i[s]

512

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

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

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

    def get_absolute_url(self):
536
        return reverse('series', kwargs={'series': self.pk})
Damien Lespiau's avatar
Damien Lespiau committed
537 538 539 540 541

    def dump(self):
        print('')
        print('===')
        print('Series: %s' % self)
542 543
        print('    version: %d' % self.version)
        print('    n_patches: %d' % self.n_patches)
Damien Lespiau's avatar
Damien Lespiau committed
544 545 546 547 548 549 550 551 552
        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

553 554 555
    def filename(self):
        return filename(self.name, '.mbox')

556 557 558
    def __str__(self):
        return self.name

559 560 561 562
# 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
563 564
# 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.
565 566


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

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

595
        revision_complete = self.patches.count() == self.n_patches
596 597 598
        if revision_complete:
            series_revision_complete.send(sender=self.__class__, revision=self)

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

623 624 625 626 627
    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()
628
            self.series.save()
629

630
    def __str__(self):
631
        return "Revision " + str(self.version)
Damien Lespiau's avatar
Damien Lespiau committed
632

633

Damien Lespiau's avatar
Damien Lespiau committed
634 635 636 637 638 639 640 641 642
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']

643

644 645 646
class Event(models.Model):
    name = models.CharField(max_length=20)

647

648 649 650 651 652
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)
653
    parameters = jsonfield.JSONField(null=True)
654 655 656 657

    class Meta:
        ordering = ['-event_time']

658

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

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

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

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

702 703 704 705 706 707
    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)

708
    def __str__(self):
709 710
        return self.name

711

712
@python_2_unicode_compatible
713 714 715 716 717 718
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)
719
    date = models.DateTimeField(auto_now=True)
720
    state = models.SmallIntegerField(choices=TestState.STATE_CHOICES)
721 722 723
    url = models.URLField(blank=True, null=True)
    summary = models.TextField(blank=True, null=True)

724 725 726
    def __str__(self):
        return self.get_state_display()

727 728 729
    class Meta:
        unique_together = [('test', 'revision'), ('test', 'patch')]

730

731
class EmailConfirmation(models.Model):
732 733 734 735 736 737 738 739
    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)
740
    key = HashField()
741 742
    date = models.DateTimeField(default=datetime.datetime.now)
    active = models.BooleanField(default=True)
743

744
    def deactivate(self):
745
        self.active = False
746
        self.save()
747

748 749 750
    def is_valid(self):
        return self.date + self.validity > datetime.datetime.now()

751 752 753 754 755
    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()
756
        super(EmailConfirmation, self).save()
757

758

759
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
760
class EmailOptout(models.Model):
761
    email = models.CharField(max_length=200, primary_key=True)
762

Jeremy Kerr's avatar
Jeremy Kerr committed
763 764 765
    @classmethod
    def is_optout(cls, email):
        email = email.lower().strip()
766
        return cls.objects.filter(email=email).count() > 0
Jeremy Kerr's avatar
Jeremy Kerr committed
767

768 769 770
    def __str__(self):
        return self.email

771

772
class PatchChangeNotification(models.Model):
773 774
    patch = models.OneToOneField(Patch, primary_key=True)
    last_modified = models.DateTimeField(default=datetime.datetime.now)
775 776
    orig_state = models.ForeignKey(State)

777

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

    if notification is None:
803 804
        notification = PatchChangeNotification(patch=instance,
                                               orig_state=orig_patch.state)
805 806 807 808 809 810 811 812 813

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

814 815
models.signals.pre_save.connect(_patch_change_callback, sender=Patch)

816 817

def _on_revision_complete(sender, revision, **kwargs):
818 819 820 821 822 823 824
    series = revision.series

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

    # log event
825
    new_revision = Event.objects.get(name='series-new-revision')
826 827
    log = EventLog(event=new_revision, series=series,
                   user=series.submitter.user,
828
                   parameters={'revision': revision.version})
829 830 831
    log.save()

series_revision_complete.connect(_on_revision_complete)