391 lines
16 KiB
Python
391 lines
16 KiB
Python
# Copyright The IETF Trust 2010-2019, All Rights Reserved
|
|
|
|
import datetime
|
|
import email.utils
|
|
import email.header
|
|
import six
|
|
import uuid
|
|
|
|
from hashids import Hashids
|
|
from urllib.parse import urljoin
|
|
|
|
from django.conf import settings
|
|
|
|
from django.core.validators import validate_email
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.db import models
|
|
from django.contrib.auth.models import User
|
|
from django.template.loader import render_to_string
|
|
from django.utils.encoding import smart_bytes
|
|
from django.utils.text import slugify
|
|
from simple_history.models import HistoricalRecords
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from ietf.person.name import name_parts, initials, plain_name
|
|
from ietf.utils.mail import send_mail_preformatted
|
|
from ietf.utils.storage import NoLocationMigrationFileSystemStorage
|
|
from ietf.utils.mail import formataddr
|
|
from ietf.person.name import unidecode_name
|
|
from ietf.utils import log
|
|
from ietf.utils.models import ForeignKey, OneToOneField
|
|
|
|
|
|
class Person(models.Model):
|
|
history = HistoricalRecords()
|
|
user = OneToOneField(User, blank=True, null=True, on_delete=models.SET_NULL)
|
|
time = models.DateTimeField(default=datetime.datetime.now) # When this Person record entered the system
|
|
# The normal unicode form of the name. This must be
|
|
# set to the same value as the ascii-form if equal.
|
|
name = models.CharField("Full Name (Unicode)", max_length=255, db_index=True, help_text="Preferred form of name.")
|
|
# The normal ascii-form of the name.
|
|
ascii = models.CharField("Full Name (ASCII)", max_length=255, help_text="Name as rendered in ASCII (Latin, unaccented) characters.")
|
|
# The short ascii-form of the name. Also in alias table if non-null
|
|
ascii_short = models.CharField("Abbreviated Name (ASCII)", max_length=32, null=True, blank=True, help_text="Example: A. Nonymous. Fill in this with initials and surname only if taking the initials and surname of the ASCII name above produces an incorrect initials-only form. (Blank is OK).")
|
|
biography = models.TextField(blank=True, help_text="Short biography for use on leadership pages. Use plain text or reStructuredText markup.")
|
|
photo = models.ImageField(storage=NoLocationMigrationFileSystemStorage(), upload_to=settings.PHOTOS_DIRNAME, blank=True, default=None)
|
|
photo_thumb = models.ImageField(storage=NoLocationMigrationFileSystemStorage(), upload_to=settings.PHOTOS_DIRNAME, blank=True, default=None)
|
|
name_from_draft = models.CharField("Full Name (from submission)", null=True, max_length=255, editable=False, help_text="Name as found in a draft submission.")
|
|
consent = models.NullBooleanField("I hereby give my consent to the use of the personal details I have provided (photo, bio, name, email) within the IETF Datatracker", null=True, default=None)
|
|
|
|
def __str__(self):
|
|
return self.plain_name()
|
|
def name_parts(self):
|
|
return name_parts(self.name)
|
|
def ascii_parts(self):
|
|
return name_parts(self.ascii)
|
|
def short(self):
|
|
if self.ascii_short:
|
|
return self.ascii_short
|
|
else:
|
|
prefix, first, middle, last, suffix = self.ascii_parts()
|
|
return (first and first[0]+"." or "")+(middle or "")+" "+last+(suffix and " "+suffix or "")
|
|
def plain_name(self):
|
|
if not hasattr(self, '_cached_plain_name'):
|
|
self._cached_plain_name = plain_name(self.name)
|
|
return self._cached_plain_name
|
|
def ascii_name(self):
|
|
if not hasattr(self, '_cached_ascii_name'):
|
|
if self.ascii:
|
|
# It's possibly overkill with unidecode() here, but needed until
|
|
# we're validating the content of the ascii field, and have
|
|
# verified that the field is ascii clean in the database:
|
|
if not all(ord(c) < 128 for c in self.ascii):
|
|
self._cached_ascii_name = unidecode_name(self.ascii)
|
|
else:
|
|
self._cached_ascii_name = self.ascii
|
|
else:
|
|
self._cached_ascii_name = unidecode_name(self.plain_name())
|
|
return self._cached_ascii_name
|
|
def plain_ascii(self):
|
|
if not hasattr(self, '_cached_plain_ascii'):
|
|
if self.ascii:
|
|
ascii = unidecode_name(self.ascii)
|
|
else:
|
|
ascii = unidecode_name(self.name)
|
|
prefix, first, middle, last, suffix = name_parts(ascii)
|
|
self._cached_plain_ascii = " ".join([first, last])
|
|
return self._cached_plain_ascii
|
|
def initials(self):
|
|
return initials(self.ascii or self.name)
|
|
def last_name(self):
|
|
return name_parts(self.name)[3]
|
|
def first_name(self):
|
|
return name_parts(self.name)[1]
|
|
def role_email(self, role_name, group=None):
|
|
"""Lookup email for role for person, optionally on group which
|
|
may be an object or the group acronym."""
|
|
if group:
|
|
from ietf.group.models import Group
|
|
if isinstance(group, str) or isinstance(group, str):
|
|
group = Group.objects.get(acronym=group)
|
|
e = Email.objects.filter(person=self, role__group=group, role__name=role_name)
|
|
else:
|
|
e = Email.objects.filter(person=self, role__group__state="active", role__name=role_name)
|
|
if e:
|
|
return e[0]
|
|
# no cigar, try the complete set before giving up
|
|
e = self.email_set.order_by("-active", "-time")
|
|
if e:
|
|
return e[0]
|
|
return None
|
|
def email(self):
|
|
if not hasattr(self, '_cached_email'):
|
|
e = self.email_set.filter(primary=True).first()
|
|
if not e:
|
|
e = self.email_set.filter(active=True).order_by("-time").first()
|
|
self._cached_email = e
|
|
return self._cached_email
|
|
def email_address(self):
|
|
e = self.email()
|
|
if e:
|
|
return e.address
|
|
else:
|
|
return ""
|
|
def formatted_ascii_email(self):
|
|
e = self.email_set.filter(primary=True).first()
|
|
if not e or not e.active:
|
|
e = self.email_set.order_by("-active", "-time").first()
|
|
if e:
|
|
return e.formatted_ascii_email()
|
|
else:
|
|
return ""
|
|
def formatted_email(self):
|
|
e = self.email_set.filter(primary=True).first()
|
|
if not e or not e.active:
|
|
e = self.email_set.order_by("-active", "-time").first()
|
|
if e:
|
|
return e.formatted_email()
|
|
else:
|
|
return ""
|
|
def full_name_as_key(self):
|
|
# this is mostly a remnant from the old views, needed in the menu
|
|
return self.plain_name().lower().replace(" ", ".")
|
|
|
|
def photo_name(self,thumb=False):
|
|
hasher = Hashids(salt='Person photo name salt',min_length=5)
|
|
_, first, _, last, _ = name_parts(self.ascii)
|
|
return '%s-%s%s' % ( slugify("%s %s" % (first, last)), hasher.encode(self.id), '-th' if thumb else '' )
|
|
|
|
def has_drafts(self):
|
|
from ietf.doc.models import Document
|
|
return Document.objects.filter(documentauthor__person=self, type='draft').exists()
|
|
|
|
def rfcs(self):
|
|
from ietf.doc.models import Document
|
|
rfcs = list(Document.objects.filter(documentauthor__person=self, type='draft', states__slug='rfc'))
|
|
rfcs.sort(key=lambda d: d.canonical_name() )
|
|
return rfcs
|
|
|
|
def active_drafts(self):
|
|
from ietf.doc.models import Document
|
|
return Document.objects.filter(documentauthor__person=self, type='draft', states__slug='active').order_by('-time')
|
|
|
|
def expired_drafts(self):
|
|
from ietf.doc.models import Document
|
|
return Document.objects.filter(documentauthor__person=self, type='draft', states__slug__in=['repl', 'expired', 'auth-rm', 'ietf-rm']).order_by('-time')
|
|
|
|
def needs_consent(self):
|
|
"""
|
|
Returns an empty list or a list of fields which holds information that
|
|
requires consent to be given.
|
|
"""
|
|
needs_consent = []
|
|
if self.name != self.name_from_draft:
|
|
needs_consent.append("full name")
|
|
if self.ascii != self.name_from_draft:
|
|
needs_consent.append("ascii name")
|
|
if self.biography and not (self.role_set.exists() or self.rolehistory_set.exists()):
|
|
needs_consent.append("biography")
|
|
if self.user_id:
|
|
needs_consent.append("login")
|
|
try:
|
|
if self.user.communitylist_set.exists():
|
|
needs_consent.append("draft notification subscription(s)")
|
|
except ObjectDoesNotExist:
|
|
pass
|
|
for email in self.email_set.all():
|
|
if not email.origin.split(':')[0] in ['author', 'role', 'reviewer', 'liaison', 'shepherd', ]:
|
|
needs_consent.append("email address(es)")
|
|
break
|
|
return needs_consent
|
|
|
|
def save(self, *args, **kwargs):
|
|
created = not self.pk
|
|
super(Person, self).save(*args, **kwargs)
|
|
if created:
|
|
if Person.objects.filter(name=self.name).count() > 1 :
|
|
msg = render_to_string('person/mail/possible_duplicates.txt',
|
|
dict(name=self.name,
|
|
persons=Person.objects.filter(name=self.name),
|
|
settings=settings
|
|
))
|
|
send_mail_preformatted(None, msg)
|
|
if not self.name in [ a.name for a in self.alias_set.filter(name=self.name) ]:
|
|
self.alias_set.create(name=self.name)
|
|
if self.ascii and self.name != self.ascii:
|
|
if not self.ascii in [ a.name for a in self.alias_set.filter(name=self.ascii) ]:
|
|
self.alias_set.create(name=self.ascii)
|
|
|
|
#this variable, if not None, may be used by url() to keep the sitefqdn.
|
|
default_hostscheme = None
|
|
|
|
@property
|
|
def defurl(self):
|
|
return urljoin(self.default_hostscheme,self.json_url())
|
|
|
|
def json_url(self):
|
|
return "/person/%s.json" % (self.id, )
|
|
|
|
# return info about the person
|
|
def json_dict(self, hostscheme):
|
|
ct1 = dict()
|
|
ct1['person_id'] = self.id
|
|
ct1['href'] = urljoin(hostscheme, self.json_url())
|
|
ct1['name'] = self.name
|
|
ct1['ascii'] = self.ascii
|
|
return ct1
|
|
|
|
class Alias(models.Model):
|
|
"""This is used for alternative forms of a name. This is the
|
|
primary lookup point for names, and should always contain the
|
|
unicode form (and ascii form, if different) of a name which is
|
|
recorded in the Person record.
|
|
"""
|
|
person = ForeignKey(Person)
|
|
name = models.CharField(max_length=255, db_index=True)
|
|
|
|
def save(self, *args, **kwargs):
|
|
created = not self.pk
|
|
super(Alias, self).save(*args, **kwargs)
|
|
if created:
|
|
if Alias.objects.filter(name=self.name).exclude(person=self.person).count() > 0 :
|
|
msg = render_to_string('person/mail/possible_duplicates.txt',
|
|
dict(name=self.name,
|
|
persons=Person.objects.filter(alias__name=self.name).distinct(),
|
|
settings=settings
|
|
))
|
|
send_mail_preformatted(None, msg)
|
|
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
class Meta:
|
|
verbose_name_plural = "Aliases"
|
|
|
|
class Email(models.Model):
|
|
history = HistoricalRecords()
|
|
address = models.CharField(max_length=64, primary_key=True, validators=[validate_email])
|
|
person = ForeignKey(Person, null=True)
|
|
time = models.DateTimeField(auto_now_add=True)
|
|
primary = models.BooleanField(default=False)
|
|
origin = models.CharField(max_length=150, blank=False, help_text="The origin of the address: the user's email address, or 'author: DRAFTNAME' if a draft, or 'role: GROUP/ROLE' if a role.") # User.username or Document.name
|
|
active = models.BooleanField(default=True) # Old email addresses are *not* purged, as history
|
|
# information points to persons through these
|
|
|
|
def __str__(self):
|
|
return self.address or "Email object with id: %s"%self.pk
|
|
|
|
def get_name(self):
|
|
return self.person.plain_name() if self.person else self.address
|
|
|
|
def formatted_ascii_email(self):
|
|
if self.person:
|
|
return email.utils.formataddr((self.person.plain_ascii(), self.address))
|
|
else:
|
|
return self.address
|
|
|
|
def name_and_email(self):
|
|
"""
|
|
Returns name and email, e.g.: u'Ano Nymous <ano@nymous.org>'
|
|
Is intended for display use, not in email context.
|
|
Use self.formatted_email() for that.
|
|
"""
|
|
if self.person:
|
|
return "%s <%s>" % (self.person.plain_name(), self.address)
|
|
else:
|
|
return "<%s>" % self.address
|
|
|
|
def formatted_email(self):
|
|
"""
|
|
Similar to name_and_email(), but with email header-field
|
|
encoded words (RFC 2047) and quotes as needed.
|
|
"""
|
|
if self.person:
|
|
return formataddr((self.person.plain_name(), self.address))
|
|
else:
|
|
return self.address
|
|
|
|
def email_address(self):
|
|
"""Get valid, current email address; in practise, for active,
|
|
non-invalid addresses it is just the address field. In other
|
|
cases, we default to person's email address."""
|
|
if not self.active:
|
|
if self.person:
|
|
return self.person.email_address()
|
|
return
|
|
return self.address
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.origin:
|
|
log.assertion('self.origin')
|
|
super(Email, self).save(*args, **kwargs)
|
|
|
|
# "{key.id}{salt}{hash}
|
|
KEY_STRUCT = "i12s32s"
|
|
|
|
def salt():
|
|
return uuid.uuid4().bytes[:12]
|
|
|
|
# Manual maintenance: List all endpoints that use @require_api_key here
|
|
PERSON_API_KEY_ENDPOINTS = [
|
|
("/api/iesg/position", "/api/iesg/position"),
|
|
("/api/v2/person/person", "/api/v2/person/person"),
|
|
("/api/meeting/session/video/url", "/api/meeting/session/video/url"),
|
|
]
|
|
|
|
class PersonalApiKey(models.Model):
|
|
person = ForeignKey(Person, related_name='apikeys')
|
|
endpoint = models.CharField(max_length=128, null=False, blank=False, choices=PERSON_API_KEY_ENDPOINTS)
|
|
created = models.DateTimeField(default=datetime.datetime.now, null=False)
|
|
valid = models.BooleanField(default=True)
|
|
salt = models.BinaryField(default=salt, max_length=12, null=False, blank=False)
|
|
count = models.IntegerField(default=0, null=False, blank=False)
|
|
latest = models.DateTimeField(blank=True, null=True)
|
|
|
|
@classmethod
|
|
def validate_key(cls, s):
|
|
import struct, hashlib, base64
|
|
try:
|
|
key = base64.urlsafe_b64decode(s)
|
|
except TypeError:
|
|
return None
|
|
|
|
id, salt, hash = struct.unpack(KEY_STRUCT, key)
|
|
k = cls.objects.filter(id=id)
|
|
if not k.exists():
|
|
return None
|
|
k = k.first()
|
|
check = hashlib.sha256()
|
|
for v in (str(id), str(k.person.id), k.created.isoformat(), k.endpoint, str(k.valid), salt, settings.SECRET_KEY):
|
|
v = smart_bytes(v)
|
|
check.update(v)
|
|
return k if check.digest() == hash else None
|
|
|
|
def hash(self):
|
|
import struct, hashlib, base64
|
|
if not hasattr(self, '_cached_hash'):
|
|
hash = hashlib.sha256()
|
|
# Hash over: ( id, person, created, endpoint, valid, salt, secret )
|
|
for v in (str(self.id), str(self.person.id), self.created.isoformat(), self.endpoint, str(self.valid), self.salt, settings.SECRET_KEY):
|
|
v = smart_bytes(v)
|
|
hash.update(v)
|
|
key = struct.pack(KEY_STRUCT, self.id, six.binary_type(self.salt), hash.digest())
|
|
self._cached_hash = base64.urlsafe_b64encode(key).decode('ascii')
|
|
return self._cached_hash
|
|
|
|
def __str__(self):
|
|
return "%s (%s): %s ..." % (self.endpoint, self.created.strftime("%Y-%m-%d %H:%M"), self.hash()[:16])
|
|
|
|
PERSON_EVENT_CHOICES = [
|
|
("apikey_login", "API key login"),
|
|
("gdpr_notice_email", "GDPR consent request email sent"),
|
|
("email_address_deactivated", "Email address deactivated"),
|
|
]
|
|
|
|
class PersonEvent(models.Model):
|
|
person = ForeignKey(Person)
|
|
time = models.DateTimeField(default=datetime.datetime.now, help_text="When the event happened")
|
|
type = models.CharField(max_length=50, choices=PERSON_EVENT_CHOICES)
|
|
desc = models.TextField()
|
|
|
|
def __str__(self):
|
|
return "%s %s at %s" % (self.person.plain_name(), self.get_type_display().lower(), self.time)
|
|
|
|
class Meta:
|
|
ordering = ['-time', '-id']
|
|
|
|
class PersonApiKeyEvent(PersonEvent):
|
|
key = ForeignKey(PersonalApiKey)
|
|
|