models.py 28.3 KB
Newer Older
1
2
3
"""
Core models of the New Member site
"""
Enrico Zini's avatar
Enrico Zini committed
4
from __future__ import annotations
Enrico Zini's avatar
Enrico Zini committed
5
from django.utils.translation import ugettext_lazy as _
6
from django.core.exceptions import ObjectDoesNotExist
7
from django.utils.timezone import now
8
from django.db import models
Enrico Zini's avatar
Enrico Zini committed
9
from django.conf import settings
10
from django.urls import reverse
Enrico Zini's avatar
Enrico Zini committed
11
from django.contrib.auth.models import BaseUserManager, PermissionsMixin
12
from django.forms.models import model_to_dict
13
from . import const
14
from . import permissions
15
from .fields import FingerprintField
16
from .utils import cached_property
17
import datetime
Enrico Zini's avatar
Enrico Zini committed
18
19
20
import urllib.request
import urllib.parse
import urllib.error
Enrico Zini's avatar
Enrico Zini committed
21
import re
22
import json
23

24
DM_IMPORT_DATE = getattr(settings, "DM_IMPORT_DATE", None)
Enrico Zini's avatar
Enrico Zini committed
25

26

Enrico Zini's avatar
Enrico Zini committed
27
28
29
30
class PersonManager(BaseUserManager):
    def create_user(self, email, **other_fields):
        if not email:
            raise ValueError('Users must have an email address')
31
32
33
        audit_author = other_fields.pop("audit_author", None)
        audit_notes = other_fields.pop("audit_notes", None)
        audit_skip = other_fields.pop("audit_skip", False)
34
        fpr = other_fields.pop("fpr", None)
35
36
37
        if "fullname" not in other_fields:
            other_fields["fullname"] = _build_fullname(
                    other_fields.get("cn"), other_fields.get("mn"), other_fields.get("sn"))
Enrico Zini's avatar
Enrico Zini committed
38
39
40
41
        user = self.model(
            email=self.normalize_email(email),
            **other_fields
        )
42
        user.save(using=self._db, audit_author=audit_author, audit_notes=audit_notes, audit_skip=audit_skip)
43
        if fpr:
Enrico Zini's avatar
Enrico Zini committed
44
            Fingerprint.objects.create(fpr=fpr, person=user, is_active=True,
45
46
47
                                       audit_author=audit_author,
                                       audit_notes=audit_notes,
                                       audit_skip=audit_skip)
Enrico Zini's avatar
Enrico Zini committed
48
49
50
51
        return user

    def create_superuser(self, email, **other_fields):
        other_fields["is_superuser"] = True
52
        other_fields["is_staff"] = True
Enrico Zini's avatar
Enrico Zini committed
53
54
        return self.create_user(email, **other_fields)

55
56
57
58
59
60
61
62
63
64
    def get_or_none(self, *args, **kw):
        """
        Same as get(), but returns None instead of raising DoesNotExist if the
        object cannot be found
        """
        try:
            return self.get(*args, **kw)
        except self.model.DoesNotExist:
            return None

65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
    def get_housekeeper(self):
        """
        Return the housekeeping person, creating it if it does not exist yet
        """
        # Ensure that there is a __housekeeping__ user
        try:
            return self.get(email="nm@debian.org")
        except self.model.DoesNotExist:
            from dsa.models import LDAPFields
            res = self.create_user(
                is_staff=False,
                fullname="nm.debian.org Housekeeping Robot",
                email="nm@debian.org",
                bio="I am the robot that runs the automated tasks in the site",
                status=const.STATUS_DC,
                audit_skip=True)
            LDAPFields.objects.create(person=res, audit_skip=True)
            return res

Enrico Zini's avatar
Enrico Zini committed
84
    def get_from_other_db(
85
            self, other_db_name, uid=None, email=None, fpr=None, format_person=lambda x: str(x)):
86
87
88
89
        """
        Get one Person entry matching the informations that another database
        has about a person.

90
        One or more of uid, email, or fpr must be provided, and the
91
92
93
94
95
96
97
98
99
100
101
        function will ensure consistency in the results. That is, only one
        person will be returned, and it will raise an exception if the data
        provided match different Person entries in our database.

        other_db_name is the name of the database where the parameters come
        from, to use in generating exception messages.

        It returns None if nothing is matched.
        """
        candidates = []
        if uid is not None:
Enrico Zini's avatar
Enrico Zini committed
102
            p = self.get_or_none(ldap_fields__uid=uid)
103
104
105
106
107
108
109
            if p is not None:
                candidates.append((p, "uid", uid))
        if email is not None:
            p = self.get_or_none(email=email)
            if p is not None:
                candidates.append((p, "email", email))
        if fpr is not None:
110
            p = self.get_or_none(fprs__fpr=fpr)
111
112
113
114
115
116
117
118
119
120
121
122
123
            if p is not None:
                candidates.append((p, "fingerprint", fpr))

        # No candidates, nothing was found
        if not candidates:
            return None

        candidate = candidates[0]

        # Check for conflicts in the database
        for person, match_type, match_value in candidates[1:]:
            if candidate[0].pk != person.pk:
                raise self.model.MultipleObjectsReturned(
Enrico Zini's avatar
Enrico Zini committed
124
125
                    "{} has {} {}, which corresponds to two different users in our db: "
                    "{} (by {} {}) and {} (by {} {})".format(
126
127
128
129
130
131
                        other_db_name, match_type, match_value,
                        format_person(candidate[0]), candidate[1], candidate[2],
                        format_person(person), match_type, match_value))

        return candidate[0]

Enrico Zini's avatar
Enrico Zini committed
132

133
134
135
136
137
138
139
140
141
142
143
144
145
def _build_fullname(cn, mn, sn):
    if not mn:
        if not sn:
            return cn
        else:
            return "{} {}".format(cn, sn)
    else:
        if not sn:
            return "{} {}".format(cn, mn)
        else:
            return "{} {} {}".format(cn, mn, sn)


Enrico Zini's avatar
Enrico Zini committed
146
class Person(PermissionsMixin, models.Model):
147
148
149
150
151
    """
    A person (DM, DD, AM, applicant, FD member, DAM, anything)
    """
    class Meta:
        db_table = "person"
152
        ordering = ("fullname",)
153

Enrico Zini's avatar
Enrico Zini committed
154
155
    objects = PersonManager()

156
    # Standard Django user fields
Enrico Zini's avatar
Enrico Zini committed
157
158
159
    last_login = models.DateTimeField(_('last login'), default=now)
    date_joined = models.DateTimeField(_('date joined'), default=now)
    is_staff = models.BooleanField(default=False)
Enrico Zini's avatar
Enrico Zini committed
160
    # is_active = True
161
    fullname = models.CharField(_("full name"), max_length=255)
Enrico Zini's avatar
Enrico Zini committed
162
163
164

    #  enrico> For people like Wookey, do you prefer we use only cn or only sn?
    #          "sn" is used currently, and "cn" has a dash, but rather than
Enrico Zini's avatar
Enrico Zini committed
165
    #          cargo-culting that in the new NM double check it with you
Enrico Zini's avatar
Enrico Zini committed
166
167
168
169
170
171
172
173
174
    # @sgran> cn would be more usual
    # @sgran> cn is the "whole name" and you can split it up into givenName + sn if you like
    #  phil> Except that in Debian LDAP it isn't.
    #  enrico> sgran: ok. should I use 'cn' for potential new cases then?
    # @sgran> phil: indeed
    # @sgran> but if we keep doing it the other way, we'll never be in a position to change
    # @sgran> enrico: please
    #  enrico> sgran: ack

175
176
    # Most user fields mirror Debian LDAP fields

Enrico Zini's avatar
Enrico Zini committed
177
    # First/Given name, or only name in case of only one name
178
179
    email = models.EmailField(_("email address"), null=False, unique=True)
    bio = models.TextField(_("short biography"), blank=True, null=False, default="",
Enrico Zini's avatar
Enrico Zini committed
180
                           help_text=_("Please enter here a short biographical information"))
181
182

    # Membership status
183
    status = models.CharField(_("current status in the project"), max_length=20, null=False,
184
                              choices=[(x.tag, x.ldesc) for x in const.ALL_STATUS])
185
186
    status_changed = models.DateTimeField(_("when the status last changed"), null=False, default=now)
    fd_comment = models.TextField(_("Front Desk comments"), null=False, blank=True, default="")
Enrico Zini's avatar
Enrico Zini committed
187
    # null=True because we currently do not have the info for old entries
188
    created = models.DateTimeField(_("Person record created"), null=True, default=now)
Enrico Zini's avatar
Enrico Zini committed
189
190
    expires = models.DateField(
            _("Expiration date for the account"), null=True, blank=True, default=None,
191
            help_text=_("This person will be deleted after this date if the status is still {} and"
Enrico Zini's avatar
Enrico Zini committed
192
                        " no Process has started").format(const.STATUS_DC))
193
    pending = models.CharField(_("Nonce used to confirm this pending record"), max_length=255, unique=False, blank=True)
194
    last_vote = models.DateField(null=True, blank=True, help_text=_("date of the last vote done with this uid"))
195

Enrico Zini's avatar
Enrico Zini committed
196
197
198
199
    status_description = models.CharField(
            max_length=128, null=False, blank=True,
            help_text=_("Override for the status description for when the default is not enough"))

Enrico Zini's avatar
Enrico Zini committed
200
201
202
203
    def get_full_name(self):
        return self.fullname

    def get_short_name(self):
204
        return self.ldap_fields.cn
Enrico Zini's avatar
Enrico Zini committed
205
206

    def get_username(self):
207
        return self.email
Enrico Zini's avatar
Enrico Zini committed
208

Enrico Zini's avatar
Enrico Zini committed
209
    @property
Enrico Zini's avatar
Enrico Zini committed
210
211
212
    def is_anonymous(self):
        return False

Enrico Zini's avatar
Enrico Zini committed
213
    @property
Enrico Zini's avatar
Enrico Zini committed
214
215
216
    def is_authenticated(self):
        return True

Enrico Zini's avatar
Enrico Zini committed
217
218
219
    def is_active(self):
        return True

Enrico Zini's avatar
Enrico Zini committed
220
221
222
223
224
225
226
227
228
229
230
231
    def set_password(self, raw_password):
        pass

    def check_password(self, raw_password):
        return False

    def set_unusable_password(self):
        pass

    def has_usable_password(self):
        return False

232
233
234
235
236
237
238
239
240
241
242
243
244
    @property
    def fingerprint(self):
        """
        Return the Fingerprint associated to this person, or None if there is
        none
        """
        # If there is more than one active fingerprint, return a random one.
        # This should not happen, and a nightly maintenance task will warn if
        # it happens.
        for f in self.fprs.filter(is_active=True):
            return f
        return None

245
246
247
248
249
    @property
    def fpr(self):
        """
        Return the current fingerprint for this Person
        """
250
        f = self.fingerprint
Enrico Zini's avatar
Enrico Zini committed
251
252
        if f is not None:
            return f.fpr
253
254
        return None

255
256
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ["cn", "status"]
Enrico Zini's avatar
Enrico Zini committed
257

258
259
260
261
262
263
264
    @property
    def person(self):
        """
        Allow to call foo.person to get a Person record, regardless if foo is a Person or an AM
        """
        return self

265
266
267
268
269
270
271
272
273
274
275
276
    @cached_property
    def perms(self):
        """
        Get permission tags for this user
        """
        res = set()
        is_dd = self.status in (const.STATUS_DD_U, const.STATUS_DD_NU)

        if is_dd:
            res.add("dd")
            am = self.am_or_none
            if am:
277
278
279
280
281
                if am.is_am:
                    res.add("am")
                if am.is_admin:
                    res.add("am")
                    res.add("admin")
282
283
284
285
286
            else:
                res.add("am_candidate")

        return frozenset(res)

Enrico Zini's avatar
Enrico Zini committed
287
288
    @property
    def is_dd(self):
289
        return "dd" in self.perms
Enrico Zini's avatar
Enrico Zini committed
290

291
    @property
Enrico Zini's avatar
Enrico Zini committed
292
    def is_am(self):
293
        return "am" in self.perms
Enrico Zini's avatar
Enrico Zini committed
294

295
296
    @property
    def is_admin(self):
297
298
299
300
301
302
303
        return "admin" in self.perms

    def can_become_am(self):
        """
        Check if the person can become an AM
        """
        return "am_candidate" in self.perms
304

305
306
307
308
309
310
311
    @property
    def am_or_none(self):
        try:
            return self.am
        except AM.DoesNotExist:
            return None

312
313
    @property
    def changed_before_data_import(self):
314
        return DM_IMPORT_DATE is not None and self.status in (
Enrico Zini's avatar
Enrico Zini committed
315
                const.STATUS_DM, const.STATUS_DM_GA) and self.status_changed <= DM_IMPORT_DATE
316

Enrico Zini's avatar
Enrico Zini committed
317
    def permissions_of(self, visitor):
318
        """
Enrico Zini's avatar
Enrico Zini committed
319
        Compute which PersonVisitorPermissions the given person has over this person
320
        """
321
        return permissions.PersonVisitorPermissions(self, visitor)
322

323
324
325
326
327
328
    @property
    def preferred_email(self):
        """
        Return uid@debian.org if the person is a DD, else return the email
        field.
        """
329
        if self.status in (const.STATUS_DD_U, const.STATUS_DD_NU):
330
            return "{}@debian.org".format(self.ldap_fields.uid)
331
332
333
        else:
            return self.email

Enrico Zini's avatar
Enrico Zini committed
334
    def __str__(self):
Enrico Zini's avatar
Enrico Zini committed
335
        return "{} <{}>".format(self.fullname, self.email)
336
337

    def __repr__(self):
338
        return "{} <{}> [uid:{}, status:{}]".format(
Enrico Zini's avatar
Enrico Zini committed
339
                self.fullname, self.email, self.get_ldap_uid(), self.status)
340

341
    def get_absolute_url(self):
Enrico Zini's avatar
Enrico Zini committed
342
        return reverse("person", kwargs=dict(key=self.lookup_key))
343

344
345
346
    def get_admin_url(self):
        return reverse("admin:backend_person_change", args=[self.pk])

347
348
349
350
351
352
353
354
355
    def get_picture_url(self):
        """
        Return a URL to a picture for the person, if available, else None
        """
        for identity in self.identities.all():
            if identity.picture:
                return identity.picture
        return None

356
357
358
359
360
361
    def get_ldap_uid(self):
        try:
            return self.ldap_fields.uid
        except ObjectDoesNotExist:
            return None

362
363
364
365
366
367
368
369
    @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(self.lookup_key)))

Enrico Zini's avatar
Enrico Zini committed
370
    def get_ddpo_url(self):
Enrico Zini's avatar
Enrico Zini committed
371
        return "http://qa.debian.org/developer.php?{}".format(urllib.parse.urlencode(dict(login=self.preferred_email)))
Enrico Zini's avatar
Enrico Zini committed
372

Enrico Zini's avatar
Enrico Zini committed
373
    def get_portfolio_url(self):
Enrico Zini's avatar
Enrico Zini committed
374
        parms = dict(
375
            email=self.preferred_email,
376
            name=self.fullname,
Enrico Zini's avatar
Enrico Zini committed
377
378
379
380
381
382
383
384
            gpgfp="",
            username="",
            nonddemail=self.email,
            wikihomepage="",
            forumsid=""
        )
        if self.fpr:
            parms["gpgfp"] = self.fpr
385
386
        if self.get_ldap_uid():
            parms["username"] = self.get_ldap_uid()
Enrico Zini's avatar
Enrico Zini committed
387
        return "http://portfolio.debian.net/result?" + urllib.parse.urlencode(parms)
Enrico Zini's avatar
Enrico Zini committed
388

389
    def get_contributors_url(self):
390
        from signon.models import Identity
391
        if self.is_dd:
392
            return "https://contributors.debian.org/contributor/{}@debian".format(self.ldap_fields.uid)
393
394
395
396
397
398
399
400
401

        try:
            debsso = self.identities.get(issuer="debsso")
            if debsso.subject.endswith("@users.alioth.debian.org"):
                return "https://contributors.debian.org/contributor/{}@alioth".format(debsso.subject[:-24])
        except Identity.DoesNotExist:
            pass

        return None
402

403
404
405
    _new_status_table = {
        const.STATUS_DC: [const.STATUS_DC_GA, const.STATUS_DM, const.STATUS_DD_U, const.STATUS_DD_NU],
        const.STATUS_DC_GA: [const.STATUS_DM_GA, const.STATUS_DD_U, const.STATUS_DD_NU],
Enrico Zini's avatar
Enrico Zini committed
406
407
        const.STATUS_DM: [const.STATUS_DM_GA, const.STATUS_DD_NU, const.STATUS_DD_U],
        const.STATUS_DM_GA: [const.STATUS_DD_NU, const.STATUS_DD_U],
408
        const.STATUS_DD_NU: [const.STATUS_DD_U, const.STATUS_EMERITUS_DD],
409
        const.STATUS_DD_U: [const.STATUS_DD_NU, const.STATUS_EMERITUS_DD],
410
411
412
413
        const.STATUS_EMERITUS_DD: [const.STATUS_DD_U, const.STATUS_DD_NU],
        const.STATUS_REMOVED_DD: [const.STATUS_DD_U, const.STATUS_DD_NU],
    }

414
    @property
415
416
417
418
419
    def possible_new_statuses(self):
        """
        Return a list of possible new statuses that can be requested for the
        person
        """
Enrico Zini's avatar
Enrico Zini committed
420
421
        if self.pending:
            return []
422

423
        statuses = list(self._new_status_table.get(self.status, []))
Enrico Zini's avatar
Enrico Zini committed
424

425
        # Compute statuses one is already applying for in active processes
426
        blacklist = []
427
428
        if statuses:
            import process.models as pmodels
429
            for proc in pmodels.Process.objects.filter(person=self, closed_time__isnull=True):
430
                blacklist.append(proc.applying_for)
Enrico Zini's avatar
Enrico Zini committed
431
432
433
434
        if const.STATUS_DD_U in blacklist:
            blacklist.append(const.STATUS_DD_NU)
        if const.STATUS_DD_NU in blacklist:
            blacklist.append(const.STATUS_DD_U)
435
436
437
        if const.STATUS_EMERITUS_DD in blacklist:
            blacklist.append(const.STATUS_REMOVED_DD)
            blacklist.append(const.STATUS_DD_U)
438
            blacklist.append(const.STATUS_DD_NU)
439
440
        if const.STATUS_REMOVED_DD in blacklist:
            blacklist.append(const.STATUS_EMERITUS_DD)
441
442
            blacklist.append(const.STATUS_DD_U)
            blacklist.append(const.STATUS_DD_NU)
Enrico Zini's avatar
Enrico Zini committed
443

444
        for status in blacklist:
Enrico Zini's avatar
Enrico Zini committed
445
446
447
448
449
            try:
                statuses.remove(status)
            except ValueError:
                pass

450
451
        return statuses

452
453
454
455
456
457
    def make_pending(self, days_valid=30):
        """
        Make this person a pending person.

        It does not automatically save the Person.
        """
458
        from django.utils.crypto import get_random_string
459
460
461
        self.pending = get_random_string(length=12,
                                         allowed_chars='abcdefghijklmnopqrstuvwxyz'
                                         'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
462
463
        self.expires = now().date() + datetime.timedelta(days=days_valid)

464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
    def save(self, *args, **kw):
        """
        Save, and add an entry to the Person audit log.

        Extra arguments that can be passed:

            audit_author: Person instance of the person doing the change
            audit_notes: free form text annotations for this change
            audit_skip: skip audit logging, used only for tests

        """
        # Extract our own arguments, so that they are not passed to django
        author = kw.pop("audit_author", None)
        notes = kw.pop("audit_notes", "")
        audit_skip = kw.pop("audit_skip", False)

        if audit_skip:
            changes = None
        else:
            # Get the previous version of the Person object, so that PersonAuditLog
            # can compute differences
            if self.pk:
                old_person = Person.objects.get(pk=self.pk)
            else:
                old_person = None

490
            changes = Person.diff(old_person, self)
491
492
493
494
495
496
497
498
499
            if changes and not author:
                raise RuntimeError("Cannot save a Person instance without providing Author information")

        # Perform the save; if we are creating a new person, this will also
        # fill in the id/pk field, so that PersonAuditLog can link to us
        super(Person, self).save(*args, **kw)

        # Finally, create the audit log entry
        if changes:
Enrico Zini's avatar
Enrico Zini committed
500
501
            PersonAuditLog.objects.create(
                    person=self, author=author, notes=notes, changes=PersonAuditLog.serialize_changes(changes))
502

503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
    @classmethod
    def diff(cls, old_person: "Person", new_person: "Person"):
        """
        Compute the changes between two different instances of a Person model
        """
        exclude = ["last_login", "date_joined", "groups", "user_permissions"]
        changes = {}
        if old_person is None:
            for k, nv in list(model_to_dict(new_person, exclude=exclude).items()):
                changes[k] = [None, nv]
        else:
            old = model_to_dict(old_person, exclude=exclude)
            new = model_to_dict(new_person, exclude=exclude)
            for k, nv in list(new.items()):
                ov = old.get(k, None)
                # Also ignore changes like None -> ""
                if ov != nv and (ov or nv):
                    changes[k] = [ov, nv]
        return changes

523
524
525
526
527
528
529
530
    @property
    def lookup_key(self):
        """
        Return a key that can be used to look up this person in the database
        using Person.lookup.

        Currently, this is the uid if available, else the email.
        """
531
532
533
        uid = self.get_ldap_uid()
        if uid:
            return uid
534
        elif self.email:
535
            return self.email
536
537
        else:
            return self.fpr
538
539
540

    @classmethod
    def lookup(cls, key):
541
542
543
        try:
            if "@" in key:
                return cls.objects.get(email=key)
544
545
            elif re.match(r"^[0-9A-Fa-f]{32,40}$", key):
                return cls.objects.get(fpr=key.upper())
546
            else:
547
                return cls.objects.get(ldap_fields__uid=key)
548
549
        except cls.DoesNotExist:
            return None
550

551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
    @classmethod
    def lookup_by_email(cls, addr):
        """
        Return the person corresponding to an email address, or None if no such
        person has been found.
        """
        try:
            return cls.objects.get(email=addr)
        except cls.DoesNotExist:
            pass
        if not addr.endswith("@debian.org"):
            return None
        try:
            return cls.objects.get(uid=addr[:-11])
        except cls.DoesNotExist:
            return None

568
569
570
571
572
573
574
    @classmethod
    def lookup_or_404(cls, key):
        from django.http import Http404
        res = cls.lookup(key)
        if res is not None:
            return res
        raise Http404
575

576

Enrico Zini's avatar
Enrico Zini committed
577
class FingerprintManager(BaseUserManager):
578
    def create(self, **fields):
Enrico Zini's avatar
Enrico Zini committed
579
580
581
        audit_author = fields.pop("audit_author", None)
        audit_notes = fields.pop("audit_notes", None)
        audit_skip = fields.pop("audit_skip", False)
Enrico Zini's avatar
Enrico Zini committed
582
        fields["fpr"] = fields["fpr"].replace(" ", "")
Enrico Zini's avatar
Enrico Zini committed
583
584
585
586
587
        res = self.model(**fields)
        res.save(using=self._db, audit_author=audit_author, audit_notes=audit_notes, audit_skip=audit_skip)
        return res


Enrico Zini's avatar
Enrico Zini committed
588
589
590
591
592
593
594
class Fingerprint(models.Model):
    """
    A fingerprint for a person
    """
    class Meta:
        db_table = "fingerprints"

Enrico Zini's avatar
Enrico Zini committed
595
596
    objects = FingerprintManager()

Enrico Zini's avatar
Enrico Zini committed
597
    person = models.ForeignKey(Person, related_name="fprs", on_delete=models.CASCADE)
598
599
    fpr = FingerprintField(verbose_name=_("OpenPGP key fingerprint"), max_length=40, unique=True)
    is_active = models.BooleanField(default=False, help_text=_("whether this key is curently in use"))
Enrico Zini's avatar
Enrico Zini committed
600
601
    last_upload = models.DateField(
            null=True, blank=True, help_text=_("date of the last ftp-master upload done with this key"))
Enrico Zini's avatar
Enrico Zini committed
602

Enrico Zini's avatar
Enrico Zini committed
603
    def __str__(self):
604
605
        return self.fpr

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

Enrico Zini's avatar
Enrico Zini committed
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
    def save(self, *args, **kw):
        """
        Save, and add an entry to the Person audit log.

        Extra arguments that can be passed:

            audit_author: Person instance of the person doing the change
            audit_notes: free form text annotations for this change
            audit_skip: skip audit logging, used only for tests

        """
        # Extract our own arguments, so that they are not passed to django
        author = kw.pop("audit_author", None)
        notes = kw.pop("audit_notes", "")
        audit_skip = kw.pop("audit_skip", False)

        if audit_skip:
            changes = None
        else:
            # Get the previous version of the Fingerprint object, so that
            # PersonAuditLog can compute differences
            if self.pk:
                existing_fingerprint = Fingerprint.objects.get(pk=self.pk)
            else:
                existing_fingerprint = None

            changes = PersonAuditLog.diff_fingerprint(existing_fingerprint, self)
            if changes and not author:
638
                raise RuntimeError("Cannot save a Fingerprint instance without providing Author information")
Enrico Zini's avatar
Enrico Zini committed
639
640
641
642
643
644
645

        # Perform the save; if we are creating a new person, this will also
        # fill in the id/pk field, so that PersonAuditLog can link to us
        super(Fingerprint, self).save(*args, **kw)

        # Finally, create the audit log entry
        if changes:
646
            if existing_fingerprint is not None and existing_fingerprint.person.pk != self.person.pk:
Enrico Zini's avatar
Enrico Zini committed
647
648
649
650
651
                PersonAuditLog.objects.create(
                        person=existing_fingerprint.person, author=author, notes=notes,
                        changes=PersonAuditLog.serialize_changes(changes))
            PersonAuditLog.objects.create(
                    person=self.person, author=author, notes=notes, changes=PersonAuditLog.serialize_changes(changes))
Enrico Zini's avatar
Enrico Zini committed
652

653
654
655
656
657
658
        # If we are saving an active fingerprint, make all others inactive
        if self.is_active:
            for fpr in Fingerprint.objects.filter(person=self.person, is_active=True).exclude(pk=self.pk):
                fpr.is_active = False
                fpr.save(audit_notes=notes, audit_author=author, audit_skip=audit_skip)

Enrico Zini's avatar
Enrico Zini committed
659

660
class PersonAuditLog(models.Model):
Enrico Zini's avatar
Enrico Zini committed
661
    person = models.ForeignKey(Person, related_name="audit_log", on_delete=models.CASCADE)
662
    logdate = models.DateTimeField(null=False, auto_now_add=True)
Enrico Zini's avatar
Enrico Zini committed
663
    author = models.ForeignKey(Person, related_name="+", null=False, on_delete=models.CASCADE)
664
665
666
    notes = models.TextField(null=False, default="")
    changes = models.TextField(null=False, default="{}")

667
    def __str__(self):
Enrico Zini's avatar
Enrico Zini committed
668
669
        return "{:%Y-%m-%d %H:%S}:{}: {}:{}".format(
                self.logdate, self.person.lookup_key, self.author.lookup_key, self.notes)
670

Enrico Zini's avatar
Enrico Zini committed
671
672
673
674
675
676
677
678
    @classmethod
    def diff_fingerprint(cls, existing_fpr, new_fpr):
        """
        Compute the changes between two different instances of a Fingerprint model
        """
        exclude = []
        changes = {}
        if existing_fpr is None:
Enrico Zini's avatar
Enrico Zini committed
679
            for k, nv in list(model_to_dict(new_fpr, exclude=exclude).items()):
Enrico Zini's avatar
Enrico Zini committed
680
681
682
683
                changes["fpr:{}:{}".format(new_fpr.fpr, k)] = [None, nv]
        else:
            old = model_to_dict(existing_fpr, exclude=exclude)
            new = model_to_dict(new_fpr, exclude=exclude)
Enrico Zini's avatar
Enrico Zini committed
684
            for k, nv in list(new.items()):
Enrico Zini's avatar
Enrico Zini committed
685
686
687
                ov = old.get(k, None)
                # Also ignore changes like None -> ""
                if ov != nv and (ov or nv):
688
                    changes["fpr:{}:{}".format(existing_fpr.fpr, k)] = [ov, nv]
Enrico Zini's avatar
Enrico Zini committed
689
690
        return changes

691
692
693
694
695
696
697
698
699
700
701
702
703
    @classmethod
    def serialize_changes(cls, changes):
        class Serializer(json.JSONEncoder):
            def default(self, o):
                if isinstance(o, datetime.datetime):
                    return o.strftime("%Y-%m-%d %H:%M:%S")
                elif isinstance(o, datetime.date):
                    return o.strftime("%Y-%m-%d")
                else:
                    return json.JSONEncoder.default(self, o)
        return json.dumps(changes, cls=Serializer)


704
705
706
707
708
709
710
class AM(models.Model):
    """
    Extra info for people who are or have been AMs, FD members, or DAMs
    """
    class Meta:
        db_table = "am"

Enrico Zini's avatar
Enrico Zini committed
711
    person = models.OneToOneField(Person, related_name="am", on_delete=models.CASCADE)
712
    slots = models.IntegerField(null=False, default=1)
713
714
715
    is_am = models.BooleanField(_("Active AM"), null=False, default=True)
    is_fd = models.BooleanField(_("FD member"), null=False, default=False)
    is_dam = models.BooleanField(_("DAM"), null=False, default=False)
716
717
    # Automatically computed as true if any applicant was approved in the last
    # 6 months
718
    is_am_ctte = models.BooleanField(_("NM CTTE member"), null=False, default=False)
Enrico Zini's avatar
Enrico Zini committed
719
    # null=True because we currently do not have the info for old entries
720
721
    created = models.DateTimeField(_("AM record created"), null=True, default=now)
    fd_comment = models.TextField(_("Front Desk comments"), null=False, blank=True, default="")
722

Enrico Zini's avatar
Enrico Zini committed
723
    def __str__(self):
Enrico Zini's avatar
Enrico Zini committed
724
725
        return "%s %c%c%c" % (
            str(self.person),
Enrico Zini's avatar
Enrico Zini committed
726
727
728
729
730
            "a" if self.is_am else "-",
            "f" if self.is_fd else "-",
            "d" if self.is_dam else "-",
        )

731
732
733
734
735
736
737
738
    def __repr__(self):
        return "%s %c%c%c slots:%d" % (
            repr(self.person),
            "a" if self.is_am else "-",
            "f" if self.is_fd else "-",
            "d" if self.is_dam else "-",
            self.slots)

739
    def get_absolute_url(self):
Enrico Zini's avatar
Enrico Zini committed
740
        return reverse("person", kwargs=dict(key=self.person.lookup_key))
741

742
743
744
745
    @property
    def is_admin(self):
        return self.is_fd or self.is_dam

Enrico Zini's avatar
Enrico Zini committed
746
    @classmethod
747
    def list_available(cls, free_only=False):
Enrico Zini's avatar
Enrico Zini committed
748
749
750
751
752
753
        """
        Get a list of active AMs with free slots, ordered by uid.

        Each AM is annotated with stats_active, stats_held and stats_free, with
        the number of NMs, held NMs and free slots.
        """
754
        import process.models as pmodels
Enrico Zini's avatar
Enrico Zini committed
755

756
        ams = {}
757
        for am in AM.objects.all():
758
759
760
761
            am.proc_active = []
            am.proc_held = []
            ams[am] = am

Enrico Zini's avatar
Enrico Zini committed
762
763
764
765
        for p in pmodels.AMAssignment.objects.filter(
                unassigned_by__isnull=True, process__frozen_by__isnull=True,
                process__approved_by__isnull=True,
                process__closed_time__isnull=True).select_related("am"):
766
767
768
769
770
            am = ams[p.am]
            if p.paused:
                am.proc_held.append(p)
            else:
                am.proc_active.append(p)
771

772
        res = []
Enrico Zini's avatar
Enrico Zini committed
773
        for am in list(ams.values()):
774
775
            am.stats_active = len(am.proc_active)
            am.stats_held = len(am.proc_held)
776
777
778
            am.stats_free = am.slots - am.stats_active

            if free_only and am.stats_free <= 0:
Enrico Zini's avatar
Enrico Zini committed
779
                continue
780
781

            res.append(am)
782
        res.sort(key=lambda x: (-x.stats_free, x.stats_active))
Enrico Zini's avatar
Enrico Zini committed
783
784
        return res

785
786
787
788
789
790
791
792
793
794
795
796
797
    @property
    def lookup_key(self):
        """
        Return a key that can be used to look up this manager in the database
        using AM.lookup.

        Currently, this is the lookup key of the person.
        """
        return self.person.lookup_key

    @classmethod
    def lookup(cls, key):
        p = Person.lookup(key)
Enrico Zini's avatar
Enrico Zini committed
798
799
        if p is None:
            return None
800
801
        return p.am_or_none

802
803
804
805
806
807
808
    @classmethod
    def lookup_or_404(cls, key):
        from django.http import Http404
        res = cls.lookup(key)
        if res is not None:
            return res
        raise Http404