models.py 28.1 KB
Newer Older
Enrico Zini's avatar
Enrico Zini committed
1
from __future__ import annotations
2
from typing import Tuple
3
from django.utils.translation import gettext_lazy as _
4
from django.utils.timezone import now
5
from django.conf import settings
6
from django.urls import reverse
7
from django.db import models, transaction
8
from nm2.lib.email import build_python_message
9
10
import backend.models as bmodels
from backend import const
11
from keyring import utils as kutils
Enrico Zini's avatar
Enrico Zini committed
12
import datetime
13
import os
Enrico Zini's avatar
Enrico Zini committed
14
from collections import namedtuple
15
from . import permissions
Enrico Zini's avatar
Enrico Zini committed
16

Enrico Zini's avatar
Enrico Zini committed
17
18
RequirementType = namedtuple(
    "RequirementType", ("tag", "sdesc", "desc", "sort_order"))
19
20

REQUIREMENT_TYPES = (
21
22
23
24
25
    RequirementType("intent", "Intent", _("Declaration of intent"), 0),
    RequirementType("sc_dmup", "SC/DMUP", _("SC/DFSG/DMUP agreement"), 1),
    RequirementType("advocate", "Advocate", _("Advocate"), 2),
    RequirementType("keycheck", "Keycheck", _("Key consistency checks"), 3),
    RequirementType("am_ok", "AM report", _("Application Manager report"), 4),
26
    RequirementType("approval", "Approval", _("Front Desk or DAM approval"), 5),
27
28
)

Enrico Zini's avatar
Enrico Zini committed
29
30
REQUIREMENT_TYPES_CHOICES = [(x.tag, x.desc) for x in REQUIREMENT_TYPES]

Enrico Zini's avatar
Enrico Zini committed
31
REQUIREMENT_TYPES_DICT = {x.tag: x for x in REQUIREMENT_TYPES}
Enrico Zini's avatar
Enrico Zini committed
32

Enrico Zini's avatar
Enrico Zini committed
33

34
class ProcessManager(models.Manager):
Enrico Zini's avatar
Enrico Zini committed
35
    def compute_requirements(self, status, applying_for):
36
37
38
        """
        Compute the process requirements for person applying for applying_for
        """
Enrico Zini's avatar
Enrico Zini committed
39
        if status == applying_for:
Enrico Zini's avatar
Enrico Zini committed
40
41
            raise RuntimeError("Invalid applying_for value {} for a person with status {}".format(
                applying_for, status))
42
43
44

        requirements = ["intent", "sc_dmup"]
        if applying_for == const.STATUS_DC_GA:
Enrico Zini's avatar
Enrico Zini committed
45
            if status != const.STATUS_DC:
Enrico Zini's avatar
Enrico Zini committed
46
47
                raise RuntimeError("Invalid applying_for value {} for a person with status {}".format(
                    applying_for, status))
48
49
            requirements.append("advocate")
        elif applying_for == const.STATUS_DM:
Enrico Zini's avatar
Enrico Zini committed
50
            if status != const.STATUS_DC:
Enrico Zini's avatar
Enrico Zini committed
51
52
                raise RuntimeError("Invalid applying_for value {} for a person with status {}".format(
                    applying_for, status))
53
54
55
            requirements.append("advocate")
            requirements.append("keycheck")
        elif applying_for == const.STATUS_DM_GA:
Enrico Zini's avatar
Enrico Zini committed
56
            if status == const.STATUS_DC_GA:
57
58
                requirements.append("advocate")
                requirements.append("keycheck")
Enrico Zini's avatar
Enrico Zini committed
59
            elif status == const.STATUS_DM:
60
61
62
63
                # No extra requirement: the declaration of intents is
                # sufficient
                pass
            else:
Enrico Zini's avatar
Enrico Zini committed
64
65
                raise RuntimeError("Invalid applying_for value {} for a person with status {}".format(
                    applying_for, status))
66
        elif applying_for in (const.STATUS_DD_U, const.STATUS_DD_NU):
67
            if status == const.STATUS_DD_U:
68
69
                # crossing from DD_U to DD_NU only needs intent:
                requirements = ["intent"]
70
            elif status == const.STATUS_DD_NU:
Enrico Zini's avatar
Enrico Zini committed
71
                requirements = ["intent", "advocate", 'am_ok']
72
            else:
73
74
                requirements.append("keycheck")
                requirements.append("am_ok")
75
76
                if status not in (const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD):
                    requirements.append("advocate")
77
        elif applying_for in (const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD):
78
            if status not in (const.STATUS_DD_NU, const.STATUS_DD_U):
Enrico Zini's avatar
Enrico Zini committed
79
80
                raise RuntimeError("Invalid applying_for value {} for a person with status {}".format(
                    applying_for, status))
81
            # Only intent is required to become emeritus or removed
82
            requirements = ["intent"]
83
        else:
Enrico Zini's avatar
Enrico Zini committed
84
85
            raise RuntimeError(
                "Invalid applying_for value {}".format(applying_for))
86
        requirements.append("approval")
87
88
89
90

        return requirements

    @transaction.atomic
91
    def create(self, person, applying_for, skip_requirements=False, **kw):
92
93
94
        """
        Create a new process and all its requirements
        """
Enrico Zini's avatar
Enrico Zini committed
95
96
        # Forbid pending persons to start processes
        if person.pending:
Enrico Zini's avatar
Enrico Zini committed
97
98
            raise RuntimeError(
                "Invalid applying_for value {} for a person whose account is still pending".format(applying_for))
Enrico Zini's avatar
Enrico Zini committed
99

100
        # Check that no active process of the same kind exists
101
        conflicts_with = [applying_for]
Enrico Zini's avatar
Enrico Zini committed
102
103
104
105
        if applying_for == const.STATUS_EMERITUS_DD:
            conflicts_with.append(const.STATUS_REMOVED_DD)
        if applying_for == const.STATUS_REMOVED_DD:
            conflicts_with.append(const.STATUS_EMERITUS_DD)
106
        if self.filter(person=person, applying_for__in=conflicts_with, closed_time__isnull=True).exists():
Enrico Zini's avatar
Enrico Zini committed
107
108
            raise RuntimeError("there is already an active process for {} to become {}".format(
                person, applying_for))
109
110

        # Compute requirements
111
112
113
        if skip_requirements:
            requirements = []
        else:
Enrico Zini's avatar
Enrico Zini committed
114
115
            requirements = self.compute_requirements(
                person.status, applying_for)
116
117

        # Create the new process
Enrico Zini's avatar
Enrico Zini committed
118
        res = self.model(person=person, applying_for=applying_for, **kw)
119
120
121
122
123
        # Save it to get an ID
        res.save(using=self._db)

        # Create the requirements
        for req in requirements:
Enrico Zini's avatar
Enrico Zini committed
124
            Requirement.objects.create(process=res, type=req)
125
126
127

        return res

128
129
130
131
132
    def in_early_stage(self):
        """
        Return processes that are in an early stage, that is, that still have
        an unapproved intent, sc_dmup or advocate requirement.
        """
Enrico Zini's avatar
Enrico Zini committed
133
        reqs = Requirement.objects.filter(type__in=("intent", "sc_dmup", "advocate"), approved_by__isnull=True).exclude(
Enrico Zini's avatar
Enrico Zini committed
134
135
136
137
            process__applying_for__in=(const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD))
        return self.get_queryset().filter(
                closed_time__isnull=True, frozen_by__isnull=True,
                approved_by__isnull=True, requirements__in=reqs).distinct()
138

139
140

class Process(models.Model):
Enrico Zini's avatar
Enrico Zini committed
141
    person = models.ForeignKey(bmodels.Person, related_name="processes", on_delete=models.CASCADE)
Enrico Zini's avatar
Enrico Zini committed
142
143
144
145
146
147
    applying_for = models.CharField("target status", max_length=20, null=False, choices=[
                                    x[1:3] for x in const.ALL_STATUS])
    started = models.DateTimeField(
        default=now, verbose_name='process started')
    frozen_by = models.ForeignKey(
            bmodels.Person, related_name="+", blank=True, null=True,
Enrico Zini's avatar
Enrico Zini committed
148
            on_delete=models.PROTECT,
149
            help_text=_("Person who froze this process for review, or NULL if it is still being worked on"))
Enrico Zini's avatar
Enrico Zini committed
150
151
152
153
154
    frozen_time = models.DateTimeField(
            null=True, blank=True,
            help_text=_("Date the process was frozen for review, or NULL if it is still being worked on"))
    approved_by = models.ForeignKey(
            bmodels.Person, related_name="+", blank=True, null=True,
Enrico Zini's avatar
Enrico Zini committed
155
            on_delete=models.PROTECT,
156
            help_text=_("Person who reviewed this process and considered it complete, or NULL if not yet reviewed"))
Enrico Zini's avatar
Enrico Zini committed
157
158
159
160
161
    approved_time = models.DateTimeField(
            null=True, blank=True,
            help_text=_("Date the process was reviewed and considered complete, or NULL if not yet reviewed"))
    closed_by = models.ForeignKey(
            bmodels.Person, related_name="+", blank=True, null=True,
162
            help_text=_("Person who closed this process, or NULL if still open"), on_delete=models.PROTECT)
Enrico Zini's avatar
Enrico Zini committed
163
164
165
166
    closed_time = models.DateTimeField(null=True, blank=True, help_text=_(
        "Date the process was closed, or NULL if still open"))
    fd_comment = models.TextField(
        "Front Desk comments", blank=True, default="")
Enrico Zini's avatar
Enrico Zini committed
167
    rt_request = models.TextField("RT request text", blank=True, default="")
Enrico Zini's avatar
Enrico Zini committed
168
    rt_ticket = models.IntegerField("RT request ticket", null=True, blank=True)
Enrico Zini's avatar
Enrico Zini committed
169
170
    hide_until = models.DateTimeField(null=True, blank=True, help_text=_(
        "Hide this process from the AM dashboard until the given date"))
171
172
173

    objects = ProcessManager()

Enrico Zini's avatar
Enrico Zini committed
174
    def __str__(self):
Enrico Zini's avatar
Enrico Zini committed
175
        return "{} to become {}".format(self.person, self.applying_for)
176

Enrico Zini's avatar
Enrico Zini committed
177
    def get_absolute_url(self):
Enrico Zini's avatar
Enrico Zini committed
178
        return reverse("process_show", args=[self.pk])
Enrico Zini's avatar
Enrico Zini committed
179

180
181
182
    def get_admin_url(self):
        return reverse("admin:process_process_change", args=[self.pk])

183
184
185
186
    @property
    def frozen(self):
        return self.frozen_by is not None

187
188
189
190
191
    @property
    def approved(self):
        return self.approved_by is not None

    @property
192
193
    def closed(self):
        return self.closed_by is not None
194

195
196
197
198
199
200
201
202
    @property
    def a_link(self):
        from django.utils.safestring import mark_safe
        from django.utils.html import conditional_escape
        return mark_safe("<a href='{}'>→ {}</a>".format(
            conditional_escape(self.get_absolute_url()),
            conditional_escape(const.ALL_STATUS_DESCS[self.applying_for])))

Enrico Zini's avatar
Enrico Zini committed
203
204
205
206
    @property
    def can_advocate_self(self):
        return self.applying_for == const.STATUS_DM_GA and self.person.status == const.STATUS_DM

Enrico Zini's avatar
Enrico Zini committed
207
208
209
210
211
212
213
    @property
    def current_am_assignment(self):
        """
        Return the current Application Manager assignment for this process, or
        None if there is none.
        """
        try:
214
            return self.ams.select_related("am", "am__person").get(unassigned_by__isnull=True)
Enrico Zini's avatar
Enrico Zini committed
215
216
217
        except AMAssignment.DoesNotExist:
            return None

218
    def has_dam_approval(self) -> bool:
219
220
221
222
223
224
225
226
227
228
229
230
        """
        Returns True if a DAM member has approved the process
        """

        # This first block is for backward compatibility. Any process should
        # have an approval requirement now, but older ones don't.
        approved_by = self.approved_by
        if approved_by:
            am = approved_by.am_or_none
            if am is not None and am.is_dam:
                return True

231
        # TODO: This should probably be .get() instead of .filter().first(), to get an error if we have two
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
        appr = self.requirements.filter(type="approval").first()

        if appr is None:
            return False

        for statement in appr.statements.all():
            am = statement.fpr.person.am_or_none
            if am is None:
                continue

            if am.is_dam:
                return True

        return False

247
    def latest_approval_activities(self) -> Tuple[datetime.datetime, datetime.datetime]:
248
249
250
251
252
253
254
255
256
        """
        Compute the latest approval statement and the latest approval comment
        posterior to this approval statement. Used to determine if a FD
        approval activates a RT ticket when one hasn't been sent yet.
        """

        latest_comment_date = None
        first_next_statement_date = None

257
        # TODO: This should probably be .get() instead of .filter().first(), to get an error if we have two
258
259
260
261
        appr = self.requirements.filter(type="approval").first()
        if appr is None or not appr.approved_by:
            return (None, None)

262
        latest_log_comment = self.log.filter(
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
            changed_by__status__in=[
                const.STATUS_DD_U, const.STATUS_DD_NU
            ],
            action="",
        ).order_by('-logdate').first()

        if latest_log_comment:
            latest_comment_date = latest_log_comment.logdate
            latest_statement = appr.statements.filter(
                uploaded_time__gt=latest_comment_date
            ).order_by('uploaded_time').first()

            if latest_statement:
                first_next_statement_date = latest_statement.uploaded_time
        else:
            for statement in appr.statements.order_by('uploaded_time'):
                am = statement.fpr.person.am_or_none
                if am is None:
                    continue
                elif am.is_dam or am.is_fd:
                    first_next_statement_date = statement.uploaded_time

        return (latest_comment_date, first_next_statement_date)

Enrico Zini's avatar
Enrico Zini committed
287
288
289
290
    def permissions_of(self, visitor):
        """
        Compute which ProcessVisitorPermissions \a visitor has over this process
        """
291
292
293
294
        if visitor.is_authenticated:
            return permissions.ProcessVisitorPermissions(self, visitor)
        else:
            return permissions.ProcessVisitorPermissions(self, None)
Enrico Zini's avatar
Enrico Zini committed
295

296
    def add_log(self, changed_by, logtext, is_public=False, action="", logdate=None):
Enrico Zini's avatar
Enrico Zini committed
297
298
299
        """
        Add a log entry for this process
        """
Enrico Zini's avatar
Enrico Zini committed
300
301
        if logdate is None:
            logdate = now()
Enrico Zini's avatar
Enrico Zini committed
302
303
304
        return Log.objects.create(
                changed_by=changed_by, process=self, is_public=is_public,
                logtext=logtext, action=action, logdate=logdate)
Enrico Zini's avatar
Enrico Zini committed
305

306
    def get_statements_as_mbox(self, for_user):
307

308
309
310
        # Generating mailboxes in python2 is surprisingly difficult and painful.
        # A lot of this code has been put together thanks to:
        # http://wordeology.com/computer/how-to-send-good-unicode-email-with-python.html
311
312
        import mailbox
        import tempfile
313
314

        with tempfile.NamedTemporaryFile(mode="wb+") as outfile:
315
316
317
            mbox = mailbox.mbox(path=outfile.name, create=True)

            for req in self.requirements.all():
318
319
320
                perms = req.permissions_of(for_user)
                if "req_view" not in perms:
                    continue
321
                for stm in req.statements.all():
322
323
324
325
326
327
                    msg = build_python_message(
                        stm.uploaded_by,
                        subject="Signed statement for " + req.get_type_display(),
                        date=stm.uploaded_time,
                        body=stm.statement,
                        factory=mailbox.Message)
328
329
330
331
332
333
334
                    mbox.add(msg)

            mbox.close()

            outfile.seek(0)
            return outfile.read()

335
336
337
338
339
340
341
342
343
    @property
    def archive_email(self):
        return "archive-{}@nm.debian.org".format(self.pk)

    @property
    def mailbox_file(self):
        """
        The pathname of the archival mailbox, or None if it does not exist
        """
Enrico Zini's avatar
Enrico Zini committed
344
345
346
347
        PROCESS_MAILBOX_DIR = getattr(
            settings, "PROCESS_MAILBOX_DIR", "/srv/nm.debian.org/mbox/processes/")
        fname = os.path.join(PROCESS_MAILBOX_DIR,
                             "process-{}.mbox".format(self.pk))
348
349
        if os.path.exists(fname):
            return fname
Enrico Zini's avatar
Enrico Zini committed
350
351
352
353
        if os.path.exists(fname + ".gz"):
            return fname + ".gz"
        if os.path.exists(fname + ".xz"):
            return fname + ".xz"
354
355
356
357
358
359
360
361
        return None

    @property
    def mailbox_mtime(self):
        """
        The mtime of the archival mailbox, or None if it does not exist
        """
        fname = self.mailbox_file
Enrico Zini's avatar
Enrico Zini committed
362
363
        if fname is None:
            return None
364
365
        return datetime.datetime.fromtimestamp(os.path.getmtime(fname))

366
367

class Requirement(models.Model):
Enrico Zini's avatar
Enrico Zini committed
368
    process = models.ForeignKey(Process, related_name="requirements", on_delete=models.CASCADE)
Enrico Zini's avatar
Enrico Zini committed
369
370
371
    type = models.CharField(verbose_name=_(
        "Requirement type"), max_length=16, choices=REQUIREMENT_TYPES_CHOICES)
    approved_by = models.ForeignKey(bmodels.Person, null=True, blank=True, help_text=_(
372
        "Set to the person who reviewed and approved this requirement"), on_delete=models.PROTECT)
Enrico Zini's avatar
Enrico Zini committed
373
374
    approved_time = models.DateTimeField(
        null=True, blank=True, help_text=_("When the requirement has been approved"))
375

Enrico Zini's avatar
Enrico Zini committed
376
377
    class Meta:
        unique_together = ("process", "type")
378
        ordering = ["type"]
Enrico Zini's avatar
Enrico Zini committed
379

Enrico Zini's avatar
Enrico Zini committed
380
    def __str__(self):
381
        return f"{self.process.pk}: {self.type_desc}"
382
383
384

    @property
    def type_desc(self):
Enrico Zini's avatar
Enrico Zini committed
385
        res = REQUIREMENT_TYPES_DICT.get(self.type, None)
Enrico Zini's avatar
Enrico Zini committed
386
387
        if res is None:
            return self.type
Enrico Zini's avatar
Enrico Zini committed
388
        return res.desc
389

Enrico Zini's avatar
Enrico Zini committed
390
391
392
    @property
    def type_sdesc(self):
        res = REQUIREMENT_TYPES_DICT.get(self.type, None)
Enrico Zini's avatar
Enrico Zini committed
393
394
        if res is None:
            return self.type
Enrico Zini's avatar
Enrico Zini committed
395
396
        return res.sdesc

397
    def get_absolute_url(self):
398
        return reverse("process_req_" + self.type, args=[self.process_id])
399

400
401
402
    def get_admin_url(self):
        return reverse("admin:process_requirement_change", args=[self.pk])

403
404
405
406
407
408
    @property
    def a_link(self):
        from django.utils.safestring import mark_safe
        from django.utils.html import conditional_escape
        return mark_safe("<a href='{}'>{}</a>".format(
            conditional_escape(self.get_absolute_url()),
409
            conditional_escape(REQUIREMENT_TYPES_DICT[self.type].desc)))
410

411
412
413
414
    def permissions_of(self, visitor):
        """
        Compute which permissions \a visitor has over this requirement
        """
415
416
417
418
        if visitor.is_authenticated:
            return permissions.RequirementVisitorPermissions(self, visitor)
        else:
            return permissions.RequirementVisitorPermissions(self, None)
419

Enrico Zini's avatar
Enrico Zini committed
420
    def add_log(self, changed_by, logtext, is_public=False, action="", logdate=None):
421
422
423
        """
        Add a log entry for this requirement
        """
Enrico Zini's avatar
Enrico Zini committed
424
425
426
        if logdate is None:
            logdate = now()
        return Log.objects.create(
Enrico Zini's avatar
Enrico Zini committed
427
428
429
            changed_by=changed_by, process=self.process,
            requirement=self, is_public=is_public, logtext=logtext,
            action=action, logdate=logdate)
430

431
432
433
434
435
436
437
438
439
440
441
    def compute_status(self):
        """
        Return a dict describing the status of this requirement.

        The dict can contain:
        {
            "satisfied": bool,
            "notes": [ ("class", "text") ],
        }
        """
        meth = getattr(self, "compute_status_" + self.type, None)
Enrico Zini's avatar
Enrico Zini committed
442
443
        if meth is None:
            return {}
444
445
        return meth()

446
447
448
449
450
    def _compute_warnings_own_statement(self, notes):
        """
        Check that the statement is signed with the current active key of the
        process' person
        """
451
        satisfied = False
452
        for s in self.statements.all().select_related("uploaded_by"):
453
            if s.uploaded_by != self.process.person:
Enrico Zini's avatar
Enrico Zini committed
454
455
                notes.append(("warn", _("statement of intent uploaded by {} instead of the applicant").format(
                    s.uploaded_by.lookup_key)))
456
            if not s.fpr:
Enrico Zini's avatar
Enrico Zini committed
457
                notes.append(("warn", _("statement of intent not signed")))
458
            elif s.fpr.person != self.process.person:
Enrico Zini's avatar
Enrico Zini committed
459
460
                notes.append(("warn", _("statement of intent signed by {} instead of the applicant").format(
                    s.fpr.person.lookup_key)))
461
            elif not s.fpr.is_active:
Enrico Zini's avatar
Enrico Zini committed
462
463
464
                notes.append(
                    ("warn", _("statement of intent signed with key {} instead of the current active key").format(
                        s.fpr.fpr)))
Enrico Zini's avatar
Enrico Zini committed
465
            satisfied = True
466
467
468
469
470
        return satisfied

    def compute_status_intent(self):
        notes = []
        satisfied = self._compute_warnings_own_statement(notes)
471
472
473
474
475
476
477
        return {
            "satisfied": satisfied,
            "notes": notes,
        }

    def compute_status_sc_dmup(self):
        notes = []
478
        satisfied = self._compute_warnings_own_statement(notes)
479
480
481
482
483
        return {
            "satisfied": satisfied,
            "notes": notes,
        }

Enrico Zini's avatar
Enrico Zini committed
484
485
486
487
    def compute_status_advocate(self):
        notes = []
        satisfied_count = 0
        can_advocate_self = self.process.can_advocate_self
488
        for s in self.statements.all().select_related("uploaded_by"):
Enrico Zini's avatar
Enrico Zini committed
489
            if not can_advocate_self and s.uploaded_by == self.process.person:
490
                notes.append(("warn", _("statement signed by the applicant")))
Enrico Zini's avatar
Enrico Zini committed
491
492
493
494
            else:
                satisfied_count += 1
        if self.process.applying_for in (const.STATUS_DD_U, const.STATUS_DD_NU):
            if satisfied_count == 1:
Enrico Zini's avatar
Enrico Zini committed
495
496
                notes.append(
                    ("warn", _("if possible, have more than 1 advocate")))
Enrico Zini's avatar
Enrico Zini committed
497
498
499
500
501
        return {
            "satisfied": satisfied_count > 0,
            "notes": notes,
        }

Enrico Zini's avatar
Enrico Zini committed
502
503
504
505
506
507
508
509
510
511
    def compute_status_am_ok(self):
        # Compute the latest AM
        latest_am = self.process.current_am_assignment
        if latest_am is None:
            try:
                latest_am = self.process.ams.order_by("-unassigned_time")[0]
            except IndexError:
                latest_am = None
        notes = []
        satisfied = False
512
        for s in self.statements.all().select_related("uploaded_by"):
Enrico Zini's avatar
Enrico Zini committed
513
            if latest_am is None:
Enrico Zini's avatar
Enrico Zini committed
514
515
                notes.append(("warn", "statement of intent signed by {} but no AMs have been assigned".format(
                    s.uploaded_by.lookup_key)))
Enrico Zini's avatar
Enrico Zini committed
516
            elif s.uploaded_by != latest_am.am.person:
Enrico Zini's avatar
Enrico Zini committed
517
518
                notes.append(("warn", "statement of intent signed by {} instead of {} as the last assigned AM".format(
                    s.uploaded_by.lookup_key, latest_am.am.person.lookup_key)))
Enrico Zini's avatar
Enrico Zini committed
519
520
521
522
523
524
            satisfied = True
        return {
            "satisfied": satisfied,
            "notes": notes,
        }

525
526
527
528
529
530
    def compute_status_keycheck(self):
        notes = []
        satisfied = True
        keycheck_results = None

        if not self.process.person.fpr:
Enrico Zini's avatar
Enrico Zini committed
531
532
            notes.append(("error", "no key is configured for {}".format(
                self.process.person.lookup_key)))
533
534
535
536
537
538
539
540
541
542
543
            satisfied = False
        else:
            from keyring.models import Key
            try:
                key = Key.objects.get_or_download(self.process.person.fpr)
            except RuntimeError as e:
                key = None
                notes.append(("error", "cannot run keycheck: " + str(e)))
                satisfied = False

            if key is not None:
544
545
546
547
                try:
                    keycheck = key.keycheck()
                except RuntimeError as e:
                    notes.append(("error", "cannot run keycheck: " + str(e)))
548
                    satisfied = False
549
550
551
                else:
                    uids = []
                    has_good_uid = False
552
553
554
555
556
557
558
559
560
561
562

                    # The requirement in terms of web of trust to consider the keycheck
                    # as ok depends on whether the applicant is applying for DM or for
                    # DD.
                    #
                    # Note that this is not really elegant to have such constants hard
                    # coded in the code, it should probably go in backend/const.py
                    sigs_ok_req = 2
                    if self.process.applying_for in [const.STATUS_DM, const.STATUS_DM_GA]:
                        sigs_ok_req = 1

563
564
565
566
567
568
569
570
                    for ku in keycheck.uids:
                        uids.append({
                            "name": ku.uid.name.replace("@", ", "),
                            "remarks": " ".join(sorted(ku.errors)) if ku.errors else "ok",
                            "sigs_ok": ku.sigs_ok,
                            "sigs_no_key": len(ku.sigs_no_key),
                            "sigs_bad": len(ku.sigs_bad)
                        })
571
                        if not ku.errors and len(ku.sigs_ok) >= sigs_ok_req:
572
573
574
                            has_good_uid = True

                    if not has_good_uid:
Enrico Zini's avatar
Enrico Zini committed
575
576
                        notes.append(
                            ("warn", "no UID found that fully satisfies requirements"))
577
578
579
580
581
582
583
584
585
586
587
                        satisfied = False

                    keycheck_results = {
                        "main": {
                            "remarks": " ".join(sorted(keycheck.errors)) if keycheck.errors else "ok",
                        },
                        "uids": uids,
                        "updated": key.check_sigs_updated,
                    }

                    if keycheck.errors:
Enrico Zini's avatar
Enrico Zini committed
588
589
                        notes.append(("warn", "key has issues " +
                                      keycheck_results["main"]["remarks"]))
590
                        satisfied = False
591
592
593
594
595
596

        return {
            "satisfied": satisfied,
            "notes": notes,
            "keycheck": keycheck_results,
        }
Enrico Zini's avatar
Enrico Zini committed
597

598
599
600
601
602
603
604
605
606
607
608
609
    def compute_status_approval(self):
        # Compute the latest AM
        notes = []
        have_one_statement = False
        satisfied = False
        for s in self.statements.all().select_related("uploaded_by"):
            have_one_statement = True
            if not s.fpr:
                notes.append(("warn", _("statement uploaded by {} is not signed and therefore invalid").format(
                    s.upload_by.lookup_key)))
                continue
            elif not s.fpr.is_active:
Enrico Zini's avatar
Enrico Zini committed
610
611
                notes.append(("warn", _("statement uploaded by {} is not signed by their active key"
                                        " and therefore is invalid").format(
612
613
614
615
616
                    s.fpr.person.lookup_key)))
                continue

            am = s.fpr.person.am_or_none
            if am is None or (am is not None and not (am.is_fd or am.is_dam)):
Enrico Zini's avatar
Enrico Zini committed
617
                notes.append(("warn", _("statement uploaded by {}"
618
                                        " who has no right to file an approval requirement").format(
619
620
621
622
623
624
625
626
627
628
629
630
                    s.fpr.person.lookup_key)))
                continue

            satisfied = True

        if have_one_statement and not satisfied:
            notes.append(("warn", _("all statements here are invalid.")))
        return {
            "satisfied": satisfied,
            "notes": notes,
        }

Enrico Zini's avatar
Enrico Zini committed
631
632
633
634
635

class AMAssignment(models.Model):
    """
    AM assignment on a process
    """
Enrico Zini's avatar
Enrico Zini committed
636
637
    process = models.ForeignKey(Process, related_name="ams", on_delete=models.CASCADE)
    am = models.ForeignKey(bmodels.AM, related_name="+", on_delete=models.PROTECT)
Enrico Zini's avatar
Enrico Zini committed
638
639
640
    paused = models.BooleanField(default=False, help_text=_(
        "Whether this process is paused and the AM is free to take another applicant in the meantime"))
    assigned_by = models.ForeignKey(
Enrico Zini's avatar
Enrico Zini committed
641
        bmodels.Person, related_name="+", help_text=_("Person who did the assignment"), on_delete=models.PROTECT)
Enrico Zini's avatar
Enrico Zini committed
642
643
644
    assigned_time = models.DateTimeField(
        help_text=_("When the assignment happened"))
    unassigned_by = models.ForeignKey(bmodels.Person, related_name="+",
Enrico Zini's avatar
Enrico Zini committed
645
646
                                      blank=True, null=True, on_delete=models.PROTECT,
                                      help_text=_("Person who did the unassignment"))
Enrico Zini's avatar
Enrico Zini committed
647
648
    unassigned_time = models.DateTimeField(
        blank=True, null=True, help_text=_("When the unassignment happened"))
Enrico Zini's avatar
Enrico Zini committed
649
650
651

    class Meta:
        ordering = ["-assigned_by"]
652

653
654
655
    def get_admin_url(self):
        return reverse("admin:process_amassignment_change", args=[self.pk])

656
657
658
659
660

class Statement(models.Model):
    """
    A signed statement
    """
Enrico Zini's avatar
Enrico Zini committed
661
    requirement = models.ForeignKey(Requirement, related_name="statements", on_delete=models.CASCADE)
Enrico Zini's avatar
Enrico Zini committed
662
    fpr = models.ForeignKey(bmodels.Fingerprint, related_name="+",
Enrico Zini's avatar
Enrico Zini committed
663
664
                            null=True, on_delete=models.PROTECT,
                            help_text=_("Fingerprint used to verify the statement"))
Enrico Zini's avatar
Enrico Zini committed
665
666
667
    statement = models.TextField(
        verbose_name=_("Signed statement"), blank=True)
    uploaded_by = models.ForeignKey(
Enrico Zini's avatar
Enrico Zini committed
668
        bmodels.Person, related_name="+", help_text=_("Person who uploaded the statement"), on_delete=models.PROTECT)
Enrico Zini's avatar
Enrico Zini committed
669
670
    uploaded_time = models.DateTimeField(
        help_text=_("When the statement has been uploaded"))
671

Enrico Zini's avatar
Enrico Zini committed
672
    def __str__(self):
673
        return "{}:{}".format(self.fpr, self.requirement)
674
675
676
677
678

    def get_key(self):
        from keyring.models import Key
        return Key.objects.get_or_download(self.fpr.fpr)

679
680
681
682
683
    @property
    def statement_clean(self):
        """
        Return the statement without the OpenPGP wrapping
        """
684
        return kutils.cleaned_text(self.statement)
685

686
687
688
689
690
    @property
    def rfc3156(self):
        """
        If the statement is an email, parse it as rfc3156, else return None
        """
691
        return kutils.rfc3156(self.statement)
692

693
694
695
696
697

class Log(models.Model):
    """
    A log entry about anything that happened during a process
    """
Enrico Zini's avatar
Enrico Zini committed
698
699
    changed_by = models.ForeignKey(bmodels.Person, related_name="+", null=True, on_delete=models.CASCADE)
    process = models.ForeignKey(Process, related_name="log", on_delete=models.CASCADE)
Enrico Zini's avatar
Enrico Zini committed
700
    requirement = models.ForeignKey(
Enrico Zini's avatar
Enrico Zini committed
701
        Requirement, related_name="log", null=True, blank=True, on_delete=models.CASCADE)
Enrico Zini's avatar
Enrico Zini committed
702
703
    is_public = models.BooleanField(default=False)
    logdate = models.DateTimeField(default=now)
Enrico Zini's avatar
Enrico Zini committed
704
705
    action = models.CharField(max_length=16, blank=True, help_text=_(
        "Action performed with this log entry, if any"))
Enrico Zini's avatar
Enrico Zini committed
706
    logtext = models.TextField(blank=True, default="")
707

708
709
710
    class Meta:
        ordering = ["-logdate"]

Enrico Zini's avatar
Enrico Zini committed
711
    def __str__(self):
Enrico Zini's avatar
Enrico Zini committed
712
        return "{}: {}".format(self.logdate, self.logtext)
713
714
715
716
717
718
719
720
721
722

    @property
    def previous(self):
        """
        Return the previous log entry for this process.
        """
        try:
            return Log.objects.filter(logdate__lt=self.logdate, process=self.process).order_by("-logdate")[0]
        except IndexError:
            return None