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

Damien Lespiau's avatar
Damien Lespiau committed
21 22 23 24 25 26 27 28
from collections import Counter, OrderedDict
import datetime
import jsonfield
import random
import re
import threadlocalrequest

from django.conf import settings
29
from django.contrib import auth
Jeremy Kerr's avatar
Jeremy Kerr committed
30 31
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
Damien Lespiau's avatar
Damien Lespiau committed
32
from django.core.urlresolvers import reverse
33 34 35 36
from django.db import models
from django.db.models import Q
import django.dispatch
from django.utils.encoding import python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
37
from django.utils.functional import cached_property
38
from django.utils.six.moves import filter
Jeremy Kerr's avatar
Jeremy Kerr committed
39

40
from patchwork.fields import HashField
41 42
from patchwork.parser import hash_patch, extract_tags

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
    def email_name(self):
        if (self.name):
59
            return "\"%s\" <%s>" % (self.name, self.email)
60 61 62
        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 get_listemail_tag(self):
        return self.listemail.split("@")[0]

115 116 117
    def __str__(self):
        return self.name

118 119 120
    class Meta:
        ordering = ['linkname']

121

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

128

129 130
auth.models.User.add_to_class('name', user_name)

131

132 133
@python_2_unicode_compatible
class DelegationRule(models.Model):
134
    user = models.ForeignKey(User, on_delete=models.CASCADE)
135
    path = models.CharField(max_length=255)
136
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
137 138 139 140 141 142 143 144 145 146
    priority = models.IntegerField(default=0)

    def __str__(self):
        return self.path

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


147
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
148
class UserProfile(models.Model):
149 150 151 152
    user = models.OneToOneField(User, unique=True, related_name='profile',
                                on_delete=models.CASCADE)
    primary_project = models.ForeignKey(Project, null=True, blank=True,
                                        on_delete=models.CASCADE)
Jeremy Kerr's avatar
Jeremy Kerr committed
153
    maintainer_projects = models.ManyToManyField(Project,
154
             related_name='maintainer_project', blank=True)
155
    send_email = models.BooleanField(default=False,
156
             help_text='Selecting this option allows patchwork to send '
157 158 159 160
             '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
161 162

    def name(self):
163
        return user_name(self.user)
Jeremy Kerr's avatar
Jeremy Kerr committed
164 165

    def contributor_projects(self):
166 167 168 169
        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
170 171 172 173

    def sync_person(self):
        pass

174 175
    def n_todo(self):
        return self.todo_patches().count() + self.todo_series().count()
Jeremy Kerr's avatar
Jeremy Kerr committed
176

177
    def todo_patches(self, project=None):
Jeremy Kerr's avatar
Jeremy Kerr committed
178 179 180

        # filter on project, if necessary
        if project:
181
            qs = Patch.objects.filter(project=project)
Jeremy Kerr's avatar
Jeremy Kerr committed
182 183 184
        else:
            qs = Patch.objects

185 186 187 188
        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
189 190
        return qs

191 192 193 194 195 196 197
    def todo_series(self, project=None):
        # filter on project, if necessary
        if project:
            qs = Series.objects.filter(project=project)
        else:
            qs = Series.objects

198 199
        qs = qs.filter(Q(reviewer=self.user),
                       ~Q(last_revision__state=RevisionState.DONE))
200 201
        return qs

202
    def __str__(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
203 204
        return self.name()

205

206 207
def _user_saved_callback(sender, created, instance, **kwargs):
    try:
208
        profile = instance.profile
209
    except UserProfile.DoesNotExist:
210
        profile = UserProfile(user=instance)
211 212
    profile.save()

213

214 215
models.signals.post_save.connect(_user_saved_callback, sender=User)

216

217
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
218
class State(models.Model):
219 220 221
    name = models.CharField(max_length=100)
    ordering = models.IntegerField(unique=True)
    action_required = models.BooleanField(default=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
222

223 224 225 226
    @classmethod
    def from_string(cls, name):
        return State.objects.get(name__iexact=name)

227
    def __str__(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
228 229 230 231 232
        return self.name

    class Meta:
        ordering = ['ordering']

233

234
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
235 236 237
class Tag(models.Model):
    name = models.CharField(max_length=20)
    pattern = models.CharField(max_length=50,
238 239 240
            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
241
    abbrev = models.CharField(max_length=2, unique=True,
242 243
            help_text='Short (one-or-two letter) abbreviation for the tag, '
                       'used in table column headers')
Jeremy Kerr's avatar
Jeremy Kerr committed
244 245 246 247 248

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

249 250 251
    def __str__(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
252 253 254
    class Meta:
        ordering = ['abbrev']

255

Jeremy Kerr's avatar
Jeremy Kerr committed
256
class PatchTag(models.Model):
257 258
    patch = models.ForeignKey('Patch', on_delete=models.CASCADE)
    tag = models.ForeignKey('Tag', on_delete=models.CASCADE)
Jeremy Kerr's avatar
Jeremy Kerr committed
259 260 261 262 263
    count = models.IntegerField(default=1)

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

264

265 266 267
def get_default_initial_patch_state():
    return State.objects.get(ordering=0)

268

Jeremy Kerr's avatar
Jeremy Kerr committed
269 270 271 272 273 274 275 276 277 278 279 280 281 282
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:
283 284 285 286 287
            select[tag.attr_name] = (
                "coalesce("
                     "(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
288 289 290 291
            select_params.append(tag.id)

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

292

Jeremy Kerr's avatar
Jeremy Kerr committed
293 294 295 296 297 298 299 300 301
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)

302

303 304 305 306 307
def filename(name, ext):
    fname_re = re.compile('[^-_A-Za-z0-9\.]+')
    str = fname_re.sub('-', name)
    return str.strip('-') + ext

308

309
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
310
class Patch(models.Model):
311
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
312
    msgid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
313 314
    name = models.CharField(max_length=255)
    date = models.DateTimeField(default=datetime.datetime.now)
315
    last_updated = models.DateTimeField(auto_now=True)
316 317 318 319
    submitter = models.ForeignKey(Person, on_delete=models.CASCADE)
    delegate = models.ForeignKey(User, blank=True, null=True,
                                 on_delete=models.CASCADE)
    state = models.ForeignKey(State, null=True, on_delete=models.CASCADE)
320 321 322 323 324 325
    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
326 327 328
    tags = models.ManyToManyField(Tag, through=PatchTag)

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

330 331 332 333 334 335 336 337
    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
338
    def comments(self):
339 340
        """Retrieves all comments of this patch ie. the commit message and the
           answers"""
341
        return Comment.objects.filter(patch=self)
Jeremy Kerr's avatar
Jeremy Kerr committed
342

343 344 345 346
    def series(self):
        try:
            rev = SeriesRevisionPatch.objects.filter(patch=self)[0].revision
            return rev.series
347
        except Exception:
348 349
            return None

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

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

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

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

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

389 390 391
    def human_name(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
392 393
    @models.permalink
    def get_absolute_url(self):
Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
394
        return ('patch', (), {'patch_id': self.id})
Jeremy Kerr's avatar
Jeremy Kerr committed
395

396 397 398
    def __str__(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
399 400 401
    class Meta:
        verbose_name_plural = 'Patches'
        ordering = ['date']
402
        unique_together = [('msgid', 'project')]
Jeremy Kerr's avatar
Jeremy Kerr committed
403

404

Jeremy Kerr's avatar
Jeremy Kerr committed
405
class Comment(models.Model):
406
    patch = models.ForeignKey(Patch, on_delete=models.CASCADE)
407
    msgid = models.CharField(max_length=255)
408
    submitter = models.ForeignKey(Person, on_delete=models.CASCADE)
409 410
    date = models.DateTimeField(default=datetime.datetime.now)
    headers = models.TextField(blank=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
411 412
    content = models.TextField()

413
    response_re = re.compile(
414
        '^((Tested|Reviewed|Acked|Signed-off|Nacked|Reported)-by|Fixes): .*$',
415
        re.M | re.I)
416 417

    def patch_responses(self):
418 419
        return ''.join([match.group(0) + '\n' for match in
                        self.response_re.finditer(self.content)])
420

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

433

Jeremy Kerr's avatar
Jeremy Kerr committed
434
class Bundle(models.Model):
435 436
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
437 438 439
    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
440 441 442 443

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

Jeremy Kerr's avatar
Jeremy Kerr committed
444
    def ordered_patches(self):
Guilherme Salgado's avatar
Guilherme Salgado committed
445
        return self.patches.order_by('bundlepatch__order')
Jeremy Kerr's avatar
Jeremy Kerr committed
446

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

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

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

461 462
        bp = BundlePatch.objects.create(bundle=self, patch=patch,
                                        order=max_order + 1)
463 464
        bp.save()

Jeremy Kerr's avatar
Jeremy Kerr committed
465 466 467 468 469
    def public_url(self):
        if not self.public:
            return None
        site = Site.objects.get_current()
        return 'http://%s%s' % (site.domain,
Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
470
                                reverse('bundle',
471 472 473 474
                                        kwargs={
                                            'username': self.owner.username,
                                            'bundlename': self.name
                                        }))
Jeremy Kerr's avatar
Jeremy Kerr committed
475

476 477
    @models.permalink
    def get_absolute_url(self):
Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
478
        return ('bundle', (), {
479 480 481
            'username': self.owner.username,
            'bundlename': self.name,
        })
482

483 484 485
    class Meta:
        unique_together = [('owner', 'name')]

486

487
class BundlePatch(models.Model):
488 489
    patch = models.ForeignKey(Patch, on_delete=models.CASCADE)
    bundle = models.ForeignKey(Bundle, on_delete=models.CASCADE)
490 491 492
    order = models.IntegerField()

    class Meta:
Jeremy Kerr's avatar
Jeremy Kerr committed
493 494
        unique_together = [('bundle', 'patch')]
        ordering = ['order']
495

496

Damien Lespiau's avatar
Damien Lespiau committed
497 498
SERIES_DEFAULT_NAME = "Series without cover letter"

499 500 501

class TestState:
    STATE_PENDING = 0
502 503 504 505
    STATE_INFO = 1
    STATE_SUCCESS = 2
    STATE_WARNING = 3
    STATE_FAILURE = 4
506 507
    STATE_CHOICES = (
        (STATE_PENDING, 'pending'),
508
        (STATE_INFO, 'info'),
509 510 511 512 513
        (STATE_SUCCESS, 'success'),
        (STATE_WARNING, 'warning'),
        (STATE_FAILURE, 'failure'),
    )

514 515 516 517 518
    @classmethod
    def from_string(cls, s):
        s2i = {s: i for i, s in cls.STATE_CHOICES}
        return s2i[s]

519

Damien Lespiau's avatar
Damien Lespiau committed
520 521
# This Model represents the "top level" Series, an object that doesn't change
# with the various versions of patches sent to the mailing list.
522
@python_2_unicode_compatible
Damien Lespiau's avatar
Damien Lespiau committed
523
class Series(models.Model):
524
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
525
    name = models.CharField(max_length=200, default=SERIES_DEFAULT_NAME)
526 527
    submitter = models.ForeignKey(Person, related_name='submitters',
                                  on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
528
    reviewer = models.ForeignKey(User, related_name='reviewers', null=True,
529
                                 blank=True, on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
530 531
    submitted = models.DateTimeField(default=datetime.datetime.now)
    last_updated = models.DateTimeField(auto_now=True)
532 533 534
    # direct access to the latest revision so we can get the latest revision
    # information with a JOIN
    last_revision = models.OneToOneField('SeriesRevision', null=True,
535 536
                                         related_name='+',
                                         on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
537 538 539 540 541 542 543 544

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

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

    def get_absolute_url(self):
545
        return reverse('series', kwargs={'series': self.pk})
Damien Lespiau's avatar
Damien Lespiau committed
546 547 548 549 550

    def dump(self):
        print('')
        print('===')
        print('Series: %s' % self)
551 552
        print('    version: %d' % self.version)
        print('    n_patches: %d' % self.n_patches)
Damien Lespiau's avatar
Damien Lespiau committed
553 554 555 556 557 558 559 560 561
        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

562 563 564
    def filename(self):
        return filename(self.name, '.mbox')

565
    def human_name(self):
566 567 568 569 570 571 572 573
        if self.name == SERIES_DEFAULT_NAME:
            if self.last_revision:
                ordered_patches = self.last_revision.ordered_patches()
                if ordered_patches:
                    return "series starting with " + ordered_patches[0].name
            return "Incomplete Series"
        else:
            return self.name
574

575 576 577
    def __str__(self):
        return self.name

578 579
    class Meta:
        verbose_name_plural = 'Series'
580
        ordering = ["-id"]
581

582

583 584 585 586
# 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"])

587 588 589 590 591 592 593 594 595 596 597 598 599

class RevisionState:
    INCOMPLETE = 0
    INITIAL = 1
    IN_PROGRESS = 2
    DONE = 3
    CHOICES = (
        (INCOMPLETE, 'incomplete'),
        (INITIAL, 'initial'),
        (IN_PROGRESS, 'in progress'),
        (DONE, 'done'),
    )

600 601 602 603 604 605
    i2s = dict(CHOICES)

    @classmethod
    def to_string(cls, i):
        return cls.i2s[i]

606 607 608 609 610
    @classmethod
    def from_string(cls, s):
        s2i = {s: i for i, s in cls.CHOICES}
        return s2i[s]

Damien Lespiau's avatar
Damien Lespiau committed
611 612
# 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.
613 614


615
@python_2_unicode_compatible
Damien Lespiau's avatar
Damien Lespiau committed
616
class SeriesRevision(models.Model):
617
    series = models.ForeignKey(Series, on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
618 619
    version = models.IntegerField(default=1)
    root_msgid = models.CharField(max_length=255)
620
    cover_letter = models.TextField(null=True, blank=True)
621
    n_patches = models.IntegerField(default=0)
622
    patches = models.ManyToManyField(Patch, through='SeriesRevisionPatch')
623 624 625
    state = models.SmallIntegerField(choices=RevisionState.CHOICES,
                                     default=RevisionState.INCOMPLETE)
    state_summary = jsonfield.JSONField(null=True)
626
    test_state = models.SmallIntegerField(choices=TestState.STATE_CHOICES,
627
                                          null=True, blank=True)
Damien Lespiau's avatar
Damien Lespiau committed
628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645

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

646
        revision_complete = self.patches.count() == self.n_patches
647 648 649
        if revision_complete:
            series_revision_complete.send(sender=self.__class__, revision=self)

650 651 652 653 654
    def duplicate_meta(self):
        new = SeriesRevision.objects.get(pk=self.pk)
        new.pk = None
        new.cover_letter = None
        new.version = self.version + 1
655
        new.test_state = None
656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673
        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

674 675 676 677
    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])
678 679 680 681
        else:
            self.test_state = None
        self.save()
        self.series.save()
682

683 684 685 686 687 688 689 690
    def human_name(self):
        name = self.series.name
        if name == SERIES_DEFAULT_NAME:
            name = "series starting with " + self.ordered_patches()[0].name
        if self.version > 1:
            name += " (rev%d)" % self.version
        return name

691
    def __str__(self):
692
        return "Revision " + str(self.version)
Damien Lespiau's avatar
Damien Lespiau committed
693

694

Damien Lespiau's avatar
Damien Lespiau committed
695
class SeriesRevisionPatch(models.Model):
696 697
    patch = models.ForeignKey(Patch, on_delete=models.CASCADE)
    revision = models.ForeignKey(SeriesRevision, on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
698 699 700 701 702 703
    order = models.IntegerField()

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

704

705 706 707
class Event(models.Model):
    name = models.CharField(max_length=20)

708

709
class EventLog(models.Model):
710
    event = models.ForeignKey(Event, on_delete=models.CASCADE)
711
    event_time = models.DateTimeField(auto_now=True)
712 713
    series = models.ForeignKey(Series, null=True, on_delete=models.CASCADE)
    user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
714
    parameters = jsonfield.JSONField(null=True)
715
    patch = models.ForeignKey(Patch, null=True, on_delete=models.CASCADE)
716 717 718 719

    class Meta:
        ordering = ['-event_time']

720

721
@python_2_unicode_compatible
722
class Test(models.Model):
723 724 725 726 727 728 729
    # 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
730 731
    # send mail to the addresses listed in the mail_to_list field only
    RECIPIENT_TO_LIST = 3
732 733 734 735
    RECIPIENT_CHOICES = (
        (RECIPIENT_NONE, 'none'),
        (RECIPIENT_SUBMITTER, 'submitter'),
        (RECIPIENT_MAILING_LIST, 'mailing list'),
736
        (RECIPIENT_TO_LIST, 'recipient list'),
737 738 739 740
    )

    # send result mail on any state (but pending)
    CONDITION_ALWAYS = 0
741 742 743 744
    # send result mail on warning or failure
    CONDITION_ON_WARNING = 1
    # send result mail on error
    CONDITION_ON_FAILURE = 2
745 746
    CONDITION_CHOICES = (
        (CONDITION_ALWAYS, 'always'),
747
        (CONDITION_ON_WARNING, 'on warning/failure'),
748 749 750
        (CONDITION_ON_FAILURE, 'on failure'),
    )

751
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
752
    name = models.CharField(max_length=255)
753 754
    mail_recipient = models.SmallIntegerField(choices=RECIPIENT_CHOICES,
                                              default=RECIPIENT_NONE)
755 756 757
    # 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,
758
                                    help_text='Comma separated list of emails')
759
    mail_cc_list = models.CharField(max_length=255, blank=True, null=True,
760
                                    help_text='Comma separated list of emails')
761 762
    mail_condition = models.SmallIntegerField(choices=CONDITION_CHOICES,
                                              default=CONDITION_ALWAYS)
763 764 765 766

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

767 768 769 770 771 772
    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)

773
    def __str__(self):
774 775
        return self.name

776

777
@python_2_unicode_compatible
778 779
class TestResult(models.Model):

780 781 782 783 784 785
    test = models.ForeignKey(Test, on_delete=models.CASCADE)
    revision = models.ForeignKey(SeriesRevision, blank=True, null=True,
                                 on_delete=models.CASCADE)
    patch = models.ForeignKey(Patch, blank=True, null=True,
                              on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.CASCADE)
786
    date = models.DateTimeField(auto_now=True)
787
    state = models.SmallIntegerField(choices=TestState.STATE_CHOICES)
788 789 790
    url = models.URLField(blank=True, null=True)
    summary = models.TextField(blank=True, null=True)

791 792 793
    def __str__(self):
        return self.get_state_display()

794 795 796
    class Meta:
        unique_together = [('test', 'revision'), ('test', 'patch')]

797

798
class EmailConfirmation(models.Model):
799 800 801 802 803 804 805
    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)
806
    user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
807
    key = HashField()
808 809
    date = models.DateTimeField(default=datetime.datetime.now)
    active = models.BooleanField(default=True)
810

811
    def deactivate(self):
812
        self.active = False
813
        self.save()
814

815 816 817
    def is_valid(self):
        return self.date + self.validity > datetime.datetime.now()

818 819 820 821 822
    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()
823
        super(EmailConfirmation, self).save()
824

825

826
@python_2_unicode_compatible
Jeremy Kerr's avatar
Jeremy Kerr committed
827
class EmailOptout(models.Model):
828
    email = models.CharField(max_length=200, primary_key=True)
829

Jeremy Kerr's avatar
Jeremy Kerr committed
830 831 832
    @classmethod
    def is_optout(cls, email):
        email = email.lower().strip()
833
        return cls.objects.filter(email=email).count() > 0
Jeremy Kerr's avatar
Jeremy Kerr committed
834

835 836 837
    def __str__(self):
        return self.email

838

839
class PatchChangeNotification(models.Model):
840 841
    patch = models.OneToOneField(Patch, primary_key=True,
                                 on_delete=models.CASCADE)
842
    last_modified = models.DateTimeField(default=datetime.datetime.now)
843
    orig_state = models.ForeignKey(State, on_delete=models.CASCADE)
844

845

846
def _patch_change_log_event(old_patch, new_patch):
847 848 849
    # If state changed, log the event
    event_state_change = Event.objects.get(name='patch-state-change')
    curr_user = threadlocalrequest.get_current_user()
850 851
    previous_state = str(old_patch.state)
    new_state = str(new_patch.state)
852 853 854

    # Do not log patch-state-change events for Patches that are not part of a
    # Series (ie patches older than the introduction of Series)
855
    series = old_patch.series()
856 857
    if series:
        log = EventLog(event=event_state_change,
858 859
                       user=curr_user,
                       series_id=series.id,
860
                       patch=old_patch,
861 862 863
                       parameters={'previous_state': previous_state,
                                   'new_state': new_state,
                                  })
864 865
        log.save()

866 867 868

def _patch_change_send_notification(old_patch, new_patch):
    if not new_patch.project.send_notifications:
869 870
        return

871 872
    notification = None
    try:
873
        notification = PatchChangeNotification.objects.get(patch=new_patch)
874 875 876 877
    except PatchChangeNotification.DoesNotExist:
        pass

    if notification is None: