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

Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
552 553 554 555 556
    def __hash__(self):
        if self.pk is None:
            raise TypeError("Model instances without pk are unhashable")
        return hash(self.pk)

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

560 561
    class Meta:
        verbose_name_plural = 'Series'
562
        ordering = ["-id"]
563

564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581
    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

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


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 621
    raw_cover_letter = models.TextField(null=True, blank=True)
    raw_cover_letter_headers = models.TextField(null=True, blank=True)
622
    n_patches = models.IntegerField(default=0)
623
    patches = models.ManyToManyField(Patch, through='SeriesRevisionPatch')
624 625 626
    state = models.SmallIntegerField(choices=RevisionState.CHOICES,
                                     default=RevisionState.INCOMPLETE)
    state_summary = jsonfield.JSONField(null=True)
627
    test_state = models.SmallIntegerField(choices=TestState.STATE_CHOICES,
628
                                          null=True, blank=True)
629
    is_rerun = models.BooleanField(default=False)
Damien Lespiau's avatar
Damien Lespiau committed
630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647

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

648
        if self.patches_count == self.n_patches:
649 650
            series_revision_complete.send(sender=self.__class__, revision=self)

651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670
    @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):
671
            match = re.search(r"\[[^]]*?(\d+)/(\d+).*\]", name)
672 673 674 675 676 677 678
            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

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

705 706 707 708
    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])
709 710 711 712
        else:
            self.test_state = None
        self.save()
        self.series.save()
713

714 715 716 717 718 719 720 721
    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

722
    def __str__(self):
723
        return "Revision " + str(self.version)
Damien Lespiau's avatar
Damien Lespiau committed
724

725

Damien Lespiau's avatar
Damien Lespiau committed
726
class SeriesRevisionPatch(models.Model):
727 728
    patch = models.ForeignKey(Patch, on_delete=models.CASCADE)
    revision = models.ForeignKey(SeriesRevision, on_delete=models.CASCADE)
Damien Lespiau's avatar
Damien Lespiau committed
729 730 731 732 733 734
    order = models.IntegerField()

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

735

736 737 738
class Event(models.Model):
    name = models.CharField(max_length=20)

739

740
class EventLog(models.Model):
741
    event = models.ForeignKey(Event, on_delete=models.CASCADE)
742
    event_time = models.DateTimeField(auto_now=True)
743 744
    series = models.ForeignKey(Series, null=True, on_delete=models.CASCADE)
    user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
745
    parameters = jsonfield.JSONField(null=True)
746
    patch = models.ForeignKey(Patch, null=True, on_delete=models.CASCADE)
747 748 749 750

    class Meta:
        ordering = ['-event_time']

751

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

    # send result mail on any state (but pending)
    CONDITION_ALWAYS = 0
774 775 776 777
    # send result mail on warning or failure
    CONDITION_ON_WARNING = 1
    # send result mail on error
    CONDITION_ON_FAILURE = 2
778 779
    CONDITION_CHOICES = (
        (CONDITION_ALWAYS, 'always'),
780
        (CONDITION_ON_WARNING, 'on warning/failure'),
781 782 783
        (CONDITION_ON_FAILURE, 'on failure'),
    )

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

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

800 801 802 803 804 805
    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)

806
    def __str__(self):
807 808
        return self.name

809

810 811
class TestResult(models.Model):

812 813 814 815 816 817
    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)
818
    date = models.DateTimeField(auto_now=True)
819
    state = models.SmallIntegerField(choices=TestState.STATE_CHOICES)
820 821 822
    url = models.URLField(blank=True, null=True)
    summary = models.TextField(blank=True, null=True)

823 824 825
    def __str__(self):
        return self.get_state_display()

826 827 828
    class Meta:
        unique_together = [('test', 'revision'), ('test', 'patch')]

829

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

843
    def deactivate(self):
844
        self.active = False
845
        self.save()
846

847 848 849
    def is_valid(self):
        return self.date + self.validity > datetime.datetime.now()

850 851 852 853 854
    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()
855
        super(EmailConfirmation, self).save()
856

857

Jeremy Kerr's avatar
Jeremy Kerr committed
858
class EmailOptout(models.Model):
859
    email = models.CharField(max_length=200, primary_key=True)
860

Jeremy Kerr's avatar
Jeremy Kerr committed
861 862 863
    @classmethod
    def is_optout(cls, email):
        email = email.lower().strip()
864
        return cls.objects.filter(email=email).count() > 0
Jeremy Kerr's avatar
Jeremy Kerr committed
865

866 867 868
    def __str__(self):
        return self.email

869

870
class PatchChangeNotification(models.Model):
871 872
    patch = models.OneToOneField(Patch, primary_key=True,
                                 on_delete=models.CASCADE)
873
    last_modified = models.DateTimeField(default=datetime.datetime.now)
874
    orig_state = models.ForeignKey(State, on_delete=models.CASCADE)
875

876

877
def _patch_change_log_event(old_patch, new_patch):
878 879 880
    # If state changed, log the event
    event_state_change = Event.objects.get(name='patch-state-change')
    curr_user = threadlocalrequest.get_current_user()
881 882
    previous_state = str(old_patch.state)
    new_state = str(new_patch.state)
883 884 885

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