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

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

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
32
from django.urls import reverse
33 34 35
from django.db import models
from django.db.models import Q
import django.dispatch
Jeremy Kerr's avatar
Jeremy Kerr committed
36
from django.utils.functional import cached_property
Jeremy Kerr's avatar
Jeremy Kerr committed
37

38
from patchwork.fields import HashField
39 40
from patchwork.parser import hash_patch, extract_tags

41

Jeremy Kerr's avatar
Jeremy Kerr committed
42
class Person(models.Model):
43 44 45 46
    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
47

48
    def display_name(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
49
        if self.name:
50
            return self.name
Jeremy Kerr's avatar
Jeremy Kerr committed
51 52 53
        else:
            return self.email

54 55
    def email_name(self):
        if (self.name):
56
            return "\"%s\" <%s>" % (self.name, self.email)
57 58 59
        else:
            return self.email

Jeremy Kerr's avatar
Jeremy Kerr committed
60
    def link_to_user(self, user):
61
        self.name = user.profile.name()
Jeremy Kerr's avatar
Jeremy Kerr committed
62 63
        self.user = user

64 65 66
    def __str__(self):
        return self.display_name()

Jeremy Kerr's avatar
Jeremy Kerr committed
67 68 69
    class Meta:
        verbose_name_plural = 'People'

70

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

78

Jeremy Kerr's avatar
Jeremy Kerr committed
79 80 81
class Project(models.Model):
    linkname = models.CharField(max_length=255, unique=True)
    name = models.CharField(max_length=255, unique=True)
82
    description = models.TextField(blank=True, null=True)
83
    listid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
84
    listemail = models.CharField(max_length=200)
Simo Sorce's avatar
Simo Sorce committed
85 86 87
    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
88
    send_notifications = models.BooleanField(default=False)
Jeremy Kerr's avatar
Jeremy Kerr committed
89
    use_tags = models.BooleanField(default=True)
90
    git_send_email_only = models.BooleanField(default=False)
91
    subject_prefix_tags = models.CharField(max_length=255, blank=True,
92
               help_text='Comma separated list of tags')
Jeremy Kerr's avatar
Jeremy Kerr committed
93

Jeremy Kerr's avatar
Jeremy Kerr committed
94 95 96 97 98 99
    @cached_property
    def tags(self):
        if not self.use_tags:
            return []
        return list(Tag.objects.all())

100
    def get_subject_prefix_tags(self):
101
        return get_comma_separated_field(self.subject_prefix_tags)
102

103 104 105
    def get_listemail_tag(self):
        return self.listemail.split("@")[0]

106 107 108
    def __str__(self):
        return self.name

109 110 111
    class Meta:
        ordering = ['linkname']

112

113 114
def user_name(user):
    if user.first_name or user.last_name:
115
        names = list(filter(bool, [user.first_name, user.last_name]))
116 117
        return u' '.join(names)
    return user.username
118

119

120 121
auth.models.User.add_to_class('name', user_name)

122

123
class DelegationRule(models.Model):
124
    user = models.ForeignKey(User, on_delete=models.CASCADE)
125
    path = models.CharField(max_length=255)
126
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
127 128 129 130 131 132 133 134 135 136
    priority = models.IntegerField(default=0)

    def __str__(self):
        return self.path

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


Jeremy Kerr's avatar
Jeremy Kerr committed
137
class UserProfile(models.Model):
138 139 140 141
    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
142
    maintainer_projects = models.ManyToManyField(Project,
143
             related_name='maintainer_project', blank=True)
144
    send_email = models.BooleanField(default=False,
145
             help_text='Selecting this option allows patchwork to send '
146 147 148 149
             '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
150 151

    def name(self):
152
        return user_name(self.user)
Jeremy Kerr's avatar
Jeremy Kerr committed
153 154

    def contributor_projects(self):
155 156 157 158
        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
159 160 161 162

    def sync_person(self):
        pass

163 164
    def n_todo(self):
        return self.todo_patches().count() + self.todo_series().count()
Jeremy Kerr's avatar
Jeremy Kerr committed
165

166
    def todo_patches(self, project=None):
Jeremy Kerr's avatar
Jeremy Kerr committed
167 168 169

        # filter on project, if necessary
        if project:
170
            qs = Patch.objects.filter(project=project)
Jeremy Kerr's avatar
Jeremy Kerr committed
171 172 173
        else:
            qs = Patch.objects

174 175 176 177
        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
178 179
        return qs

180 181 182 183 184 185 186
    def todo_series(self, project=None):
        # filter on project, if necessary
        if project:
            qs = Series.objects.filter(project=project)
        else:
            qs = Series.objects

187 188
        qs = qs.filter(Q(reviewer=self.user),
                       ~Q(last_revision__state=RevisionState.DONE))
189 190
        return qs

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

194

195 196
def _user_saved_callback(sender, created, instance, **kwargs):
    try:
197
        profile = instance.profile
198
    except UserProfile.DoesNotExist:
199
        profile = UserProfile(user=instance)
200 201
    profile.save()

202

203 204
models.signals.post_save.connect(_user_saved_callback, sender=User)

205

Jeremy Kerr's avatar
Jeremy Kerr committed
206
class State(models.Model):
207 208 209
    name = models.CharField(max_length=100)
    ordering = models.IntegerField(unique=True)
    action_required = models.BooleanField(default=True)
Jeremy Kerr's avatar
Jeremy Kerr committed
210

211 212 213 214
    @classmethod
    def from_string(cls, name):
        return State.objects.get(name__iexact=name)

215
    def __str__(self):
Jeremy Kerr's avatar
Jeremy Kerr committed
216 217 218 219 220
        return self.name

    class Meta:
        ordering = ['ordering']

221

Jeremy Kerr's avatar
Jeremy Kerr committed
222 223 224
class Tag(models.Model):
    name = models.CharField(max_length=20)
    pattern = models.CharField(max_length=50,
225 226 227
            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
228
    abbrev = models.CharField(max_length=2, unique=True,
229 230
            help_text='Short (one-or-two letter) abbreviation for the tag, '
                       'used in table column headers')
Jeremy Kerr's avatar
Jeremy Kerr committed
231 232 233 234 235

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

236 237 238
    def __str__(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
239 240 241
    class Meta:
        ordering = ['abbrev']

242

Jeremy Kerr's avatar
Jeremy Kerr committed
243
class PatchTag(models.Model):
244 245
    patch = models.ForeignKey('Patch', on_delete=models.CASCADE)
    tag = models.ForeignKey('Tag', on_delete=models.CASCADE)
Jeremy Kerr's avatar
Jeremy Kerr committed
246 247 248 249 250
    count = models.IntegerField(default=1)

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

251

252 253 254
def get_default_initial_patch_state():
    return State.objects.get(ordering=0)

255

Jeremy Kerr's avatar
Jeremy Kerr committed
256 257 258 259 260 261 262 263 264 265 266 267 268 269
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:
270 271 272 273 274
            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
275 276 277 278
            select_params.append(tag.id)

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

279

Jeremy Kerr's avatar
Jeremy Kerr committed
280 281 282 283 284 285 286
class PatchManager(models.Manager):
    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)

287

288
def filename(name, ext):
289
    fname_re = re.compile(r'[^-_A-Za-z0-9\.]+')
290 291 292
    str = fname_re.sub('-', name)
    return str.strip('-') + ext

293

Jeremy Kerr's avatar
Jeremy Kerr committed
294
class Patch(models.Model):
295
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
296
    msgid = models.CharField(max_length=255)
Jeremy Kerr's avatar
Jeremy Kerr committed
297 298
    name = models.CharField(max_length=255)
    date = models.DateTimeField(default=datetime.datetime.now)
299
    last_updated = models.DateTimeField(auto_now=True)
300 301 302 303
    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)
304 305 306 307 308 309
    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
310 311 312
    tags = models.ManyToManyField(Tag, through=PatchTag)

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

314 315 316 317 318 319 320 321
    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
322
    def comments(self):
323 324
        """Retrieves all comments of this patch ie. the commit message and the
           answers"""
325
        return Comment.objects.filter(patch=self)
Jeremy Kerr's avatar
Jeremy Kerr committed
326

327 328 329 330
    def series(self):
        try:
            rev = SeriesRevisionPatch.objects.filter(patch=self)[0].revision
            return rev.series
331
        except Exception:
332 333
            return None

334 335 336 337 338
    def all_revisions(self):
        srp = SeriesRevisionPatch.objects.filter(patch=self)
        revs = [x.revision for x in srp]
        return revs

Jeremy Kerr's avatar
Jeremy Kerr committed
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
    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
357
    def save(self):
358 359
        if not hasattr(self, 'state') or not self.state:
            self.state = get_default_initial_patch_state()
Jeremy Kerr's avatar
Jeremy Kerr committed
360

361
        if self.hash is None and self.content is not None:
Jeremy Kerr's avatar
Jeremy Kerr committed
362
            self.hash = hash_patch(self.content).hexdigest()
Jeremy Kerr's avatar
Jeremy Kerr committed
363

Jeremy Kerr's avatar
Jeremy Kerr committed
364 365 366
        super(Patch, self).save()

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

369 370 371
    def human_name(self):
        return self.name

Jeremy Kerr's avatar
Jeremy Kerr committed
372
    def get_absolute_url(self):
373
        return reverse('patch', kwargs={'patch_id': self.id})
Jeremy Kerr's avatar
Jeremy Kerr committed
374

375 376 377
    def __str__(self):
        return self.name

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

383

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

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

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

Jeremy Kerr's avatar
Jeremy Kerr committed
400 401 402 403 404 405 406 407
    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
408 409
    class Meta:
        ordering = ['date']
410
        unique_together = [('msgid', 'patch')]
Jeremy Kerr's avatar
Jeremy Kerr committed
411

412

Jeremy Kerr's avatar
Jeremy Kerr committed
413
class Bundle(models.Model):
414 415
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
416 417 418
    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
419 420 421 422

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

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

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

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

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

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

Jeremy Kerr's avatar
Jeremy Kerr committed
444 445 446 447 448
    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
449
                                reverse('bundle',
450 451 452 453
                                        kwargs={
                                            'username': self.owner.username,
                                            'bundlename': self.name
                                        }))
Jeremy Kerr's avatar
Jeremy Kerr committed
454

455
    def get_absolute_url(self):
456
        return reverse('bundle', kwargs={
457 458 459
            'username': self.owner.username,
            'bundlename': self.name,
        })
460

461 462 463
    class Meta:
        unique_together = [('owner', 'name')]

464

465
class BundlePatch(models.Model):
466 467
    patch = models.ForeignKey(Patch, on_delete=models.CASCADE)
    bundle = models.ForeignKey(Bundle, on_delete=models.CASCADE)
468 469 470
    order = models.IntegerField()

    class Meta:
Jeremy Kerr's avatar
Jeremy Kerr committed
471 472
        unique_together = [('bundle', 'patch')]
        ordering = ['order']
473

474

Damien Lespiau's avatar
Damien Lespiau committed
475 476
SERIES_DEFAULT_NAME = "Series without cover letter"

477 478 479

class TestState:
    STATE_PENDING = 0
480 481 482 483
    STATE_INFO = 1
    STATE_SUCCESS = 2
    STATE_WARNING = 3
    STATE_FAILURE = 4
484 485
    STATE_CHOICES = (
        (STATE_PENDING, 'pending'),
486
        (STATE_INFO, 'info'),
487 488 489 490 491
        (STATE_SUCCESS, 'success'),
        (STATE_WARNING, 'warning'),
        (STATE_FAILURE, 'failure'),
    )

492 493 494 495 496
    @classmethod
    def from_string(cls, s):
        s2i = {s: i for i, s in cls.STATE_CHOICES}
        return s2i[s]

497

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

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

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

    def get_absolute_url(self):
522
        return reverse('series', kwargs={'series': self.pk})
Damien Lespiau's avatar
Damien Lespiau committed
523 524 525 526 527

    def dump(self):
        print('')
        print('===')
        print('Series: %s' % self)
528 529
        print('    version: %d' % self.version)
        print('    n_patches: %d' % self.n_patches)
Damien Lespiau's avatar
Damien Lespiau committed
530 531 532 533 534 535 536 537 538
        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

539 540 541
    def filename(self):
        return filename(self.name, '.mbox')

542
    def human_name(self):
543 544 545 546 547 548 549 550
        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
551

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

555 556
    class Meta:
        verbose_name_plural = 'Series'
557
        ordering = ["-id"]
558

559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576
    def __lt__(self, other):
        return self.pk < other.pk

    def __le__(self, other):
        return self.pk <= other.pk

    def __eq__(self, other):
        return self.pk == other.pk

    def __ne__(self, other):
        return self.pk != other.pk

    def __gt__(self, other):
        return self.pk > other.pk

    def __ge__(self, other):
        return self.pk >= other.pk

577

578 579 580 581
# 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"])

582 583 584 585 586 587 588 589 590 591 592 593 594

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

595 596 597 598 599 600
    i2s = dict(CHOICES)

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

601 602 603 604 605
    @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
606 607
# 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.
608 609


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

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

643
        if self.patches_count == self.n_patches:
644 645
            series_revision_complete.send(sender=self.__class__, revision=self)

646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665
    @property
    def patches_count(self):
        return self.patches.count()

    @property
    def is_complete(self):
        return self.patches_count >= self.n_patches

    @property
    def is_strange(self):
        if self.patches_count > self.n_patches:
            return True

        names = [patch.name for patch in self.ordered_patches()]

        # one patch "series", not much to break
        if len(names) == 1:
            return False

        for index, name in enumerate(names):
666
            match = re.search(r"\[[^]]*?(\d+)/(\d+).*\]", name)
667 668 669 670 671 672 673
            if not match:  # we have a patch without proper numbering
                return True
            if (index + 1) != int(match.group(1)):  # numbering is off
                return True

        return False

674 675 676
    def duplicate_meta(self):
        new = SeriesRevision.objects.get(pk=self.pk)
        new.pk = None
677 678 679
        new.cover_letter = self.cover_letter
        new.raw_cover_letter = self.raw_cover_letter
        new.raw_cover_letter_headers = self.raw_cover_letter_headers
680
        new.version = self.version + 1
681
        new.test_state = None
682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699
        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

700 701 702 703
    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])
704 705 706 707
        else:
            self.test_state = None
        self.save()
        self.series.save()
708

709 710 711 712 713 714 715 716
    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

717
    def __str__(self):
718
        return "Revision " + str(self.version)
Damien Lespiau's avatar
Damien Lespiau committed
719

720

Damien Lespiau's avatar
Damien Lespiau committed
721
class SeriesRevisionPatch(models.Model):
722 723
    patch = models.ForeignKey(Patch, on_delete=models.CASCADE)
    revision = models.ForeignKey(SeriesRevision, on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
724 725 726 727 728 729
    order = models.IntegerField()

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

730

731 732 733
class Event(models.Model):
    name = models.CharField(max_length=20)

734

735
class EventLog(models.Model):
736
    event = models.ForeignKey(Event, on_delete=models.CASCADE)
737
    event_time = models.DateTimeField(auto_now=True)
738 739
    series = models.ForeignKey(Series, null=True, on_delete=models.CASCADE)
    user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
740
    parameters = jsonfield.JSONField(null=True)
741
    patch = models.ForeignKey(Patch, null=True, on_delete=models.CASCADE)
742 743 744 745

    class Meta:
        ordering = ['-event_time']

746

747
class Test(models.Model):
748 749 750 751 752 753 754
    # 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
755 756
    # send mail to the addresses listed in the mail_to_list field only
    RECIPIENT_TO_LIST = 3
757 758
    # send mail to mailing list only
    RECIPIENT_MAILING_LIST_ONLY = 4
759 760 761
    RECIPIENT_CHOICES = (
        (RECIPIENT_NONE, 'none'),
        (RECIPIENT_SUBMITTER, 'submitter'),
762
        (RECIPIENT_MAILING_LIST, 'submitter and mailing list'),
763
        (RECIPIENT_TO_LIST, 'recipient list'),
764
        (RECIPIENT_MAILING_LIST_ONLY, 'mailing list only'),
765 766 767 768
    )

    # send result mail on any state (but pending)
    CONDITION_ALWAYS = 0
769 770 771 772
    # send result mail on warning or failure
    CONDITION_ON_WARNING = 1
    # send result mail on error
    CONDITION_ON_FAILURE = 2
773 774
    CONDITION_CHOICES = (
        (CONDITION_ALWAYS, 'always'),
775
        (CONDITION_ON_WARNING, 'on warning/failure'),
776 777 778
        (CONDITION_ON_FAILURE, 'on failure'),
    )

779
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
780
    name = models.CharField(max_length=255)
781 782
    mail_recipient = models.SmallIntegerField(choices=RECIPIENT_CHOICES,
                                              default=RECIPIENT_NONE)
783 784 785
    # 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,
786
                                    help_text='Comma separated list of emails')
787
    mail_cc_list = models.CharField(max_length=255, blank=True, null=True,
788
                                    help_text='Comma separated list of emails')
789 790
    mail_condition = models.SmallIntegerField(choices=CONDITION_CHOICES,
                                              default=CONDITION_ALWAYS)
791 792 793 794

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

795 796 797 798 799 800
    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)

801
    def __str__(self):
802 803
        return self.name

804

805 806
class TestResult(models.Model):

807 808 809 810 811 812
    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)
813
    date = models.DateTimeField(auto_now=True)
814
    state = models.SmallIntegerField(choices=TestState.STATE_CHOICES)
815 816 817
    url = models.URLField(blank=True, null=True)
    summary = models.TextField(blank=True, null=True)

818 819 820
    def __str__(self):
        return self.get_state_display()

821 822 823
    class Meta:
        unique_together = [('test', 'revision'), ('test', 'patch')]

824

825
class EmailConfirmation(models.Model):
826 827 828 829 830 831 832
    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)
833
    user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
834
    key = HashField()
835 836
    date = models.DateTimeField(default=datetime.datetime.now)
    active = models.BooleanField(default=True)
837

838
    def deactivate(self):
839
        self.active = False
840
        self.save()
841

842 843 844
    def is_valid(self):
        return self.date + self.validity > datetime.datetime.now()

845 846 847 848 849
    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()
850
        super(EmailConfirmation, self).save()
851

852

Jeremy Kerr's avatar
Jeremy Kerr committed
853
class EmailOptout(models.Model):
854
    email = models.CharField(max_length=200, primary_key=True)
855

Jeremy Kerr's avatar
Jeremy Kerr committed
856 857 858
    @classmethod
    def is_optout(cls, email):
        email = email.lower().strip()
859
        return cls.objects.filter(email=email).count() > 0
Jeremy Kerr's avatar
Jeremy Kerr committed
860

861 862 863
    def __str__(self):
        return self.email

864

865
class PatchChangeNotification(models.Model):
866 867
    patch = models.OneToOneField(Patch, primary_key=True,
                                 on_delete=models.CASCADE)
868
    last_modified = models.DateTimeField(default=datetime.datetime.now)
869
    orig_state = models.ForeignKey(State, on_delete=models.CASCADE)
870

871

872
def _patch_change_log_event(old_patch, new_patch):
873 874 875
    # If state changed, log the event
    event_state_change = Event.objects.get(name='patch-state-change')
    curr_user = threadlocalrequest.get_current_user()
876 877
    previous_state = str(old_patch.state)
    new_state = str(new_patch.state)
878 879 880

    # Do not log patch-state-change events for Patches that are not part of a
    # Series (ie patches older than the introduction of Series)
881
    series = old_patch.series()
882 883
    if series:
        log = EventLog(event=event_state_change,
884 885
                       user=curr_user,
                       series_id=series.id,
886
                       patch=old_patch,
887 888 889
                       parameters={'previous_state': previous_state,
                                   'new_state': new_state,
                                  })
890 891
        log.save()

892 893 894

def _patch_change_send_notification(old_patch