models.py 33.6 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 394 395
    @models.permalink
    def get_absolute_url(self):
        return ('patchwork.views.patch.patch', (), {'patch_id': self.id})

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,
470 471 472 473 474
                                reverse('patchwork.views.bundle.bundle',
                                        kwargs={
                                            'username': self.owner.username,
                                            'bundlename': self.name
                                        }))
Jeremy Kerr's avatar
Jeremy Kerr committed
475

476 477 478
    @models.permalink
    def get_absolute_url(self):
        return ('patchwork.views.bundle.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 580
    class Meta:
        verbose_name_plural = 'Series'

581

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

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

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

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

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

605 606 607 608 609
    @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
610 611
# 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.
612 613


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

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

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

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

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

682 683 684 685 686 687 688 689
    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

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

693

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

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

703

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

707

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

    class Meta:
        ordering = ['-event_time']

719

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

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

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

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

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

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

775

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

779 780 781 782 783 784
    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)
785
    date = models.DateTimeField(auto_now=True)
786
    state = models.SmallIntegerField(choices=TestState.STATE_CHOICES)
787 788 789
    url = models.URLField(blank=True, null=True)
    summary = models.TextField(blank=True, null=True)

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

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

796

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

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

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

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

824

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

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

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

837

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

844

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

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

865 866 867

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

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

    if notification is None:
877 878
        notification = PatchChangeNotification(patch=new_patch,
                                               orig_state=old_patch.state)
879

880
    elif notification.orig_state == new_patch.state:
881 882 883 884 885