feat: add pronouns to Person and oidc claims. Fixes #4043. (#4059)

* feat: add pronouns

* fix: include migrations

* fix: correct daggers on person form.

* fix: clean pronouns

* feat: add choices to pronouns

* feat: show pronouns on public profile

* feat: add pronouns to oidc userinfo

* fix: move pronouns to new claim. Add tests.

* fix: improve html generated by new widget

* feat: use a MultiWidget for pronouns

* refactor: use two fields on Person for the two types of pronoun entry.

* chore: update copyrights
This commit is contained in:
Robert Sparks 2022-06-21 15:02:02 -05:00 committed by GitHub
parent 63d80bff4c
commit 8b90ecd4aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 126 additions and 16 deletions

View file

@ -1,4 +1,4 @@
# Copyright The IETF Trust 2011-2020, All Rights Reserved
# Copyright The IETF Trust 2011-2022, All Rights Reserved
# -*- coding: utf-8 -*-
@ -118,9 +118,11 @@ def get_person_form(*args, **kwargs):
if self.initial.get("ascii") == self.initial.get("name"):
self.initial["ascii"] = ""
for f in ['name', 'ascii', 'ascii_short', 'biography', 'photo', 'photo_thumb', ]:
self.fields['pronouns_selectable'] = forms.MultipleChoiceField(label='Pronouns', choices = [(option, option) for option in ["he/him", "she/her", "they/them"]], widget=forms.CheckboxSelectMultiple, required=False)
for f in ['name', 'ascii', 'ascii_short', 'biography', 'photo', 'photo_thumb', 'pronouns_selectable']:
if f in self.fields:
self.fields[f].label += ' \u2020'
self.fields[f].label = mark_safe(self.fields[f].label + ' <a href="#pi" aria-label="!"><i class="bi bi-exclamation-circle"></i></a>')
self.unidecoded_ascii = False
@ -131,6 +133,7 @@ def get_person_form(*args, **kwargs):
self.data["ascii"] = reconstructed_name
self.unidecoded_ascii = name != reconstructed_name
def clean_name(self):
name = self.cleaned_data.get("name") or ""
prevent_at_symbol(name)
@ -158,11 +161,17 @@ def get_person_form(*args, **kwargs):
self.cleaned_data.get('name') != person.name_from_draft
or self.cleaned_data.get('ascii') != person.name_from_draft
or self.cleaned_data.get('biography')
or self.cleaned_data.get('pronouns_selectable')
or self.cleaned_data.get('pronouns_freetext')
)
if consent == False and require_consent:
raise forms.ValidationError("In order to modify your profile with data that require consent, you must permit the IETF to use the uploaded data.")
return consent
def clean(self):
if self.cleaned_data.get("pronouns_selectable") and self.cleaned_data.get("pronouns_freetext"):
self.add_error("pronouns_freetext", "Either select from the pronoun checkboxes or provide a custom value, but not both")
return PersonForm(*args, **kwargs)

View file

@ -1,4 +1,4 @@
# Copyright The IETF Trust 2009-2020, All Rights Reserved
# Copyright The IETF Trust 2009-2022, All Rights Reserved
# -*- coding: utf-8 -*-
@ -248,6 +248,7 @@ class IetfAuthTests(TestCase):
"plain": "",
"ascii": "Test Name",
"ascii_short": "T. Name",
"pronouns_freetext": "foo/bar",
"affiliation": "Test Org",
"active_emails": email_address,
"consent": True,
@ -319,6 +320,40 @@ class IetfAuthTests(TestCase):
q = PyQuery(r.content)
self.assertEqual(len(q('[name="action"][value="confirm"]')), 0)
pronoundish = base_data.copy()
pronoundish["pronouns_freetext"] = "baz/boom"
r = self.client.post(url, pronoundish)
self.assertEqual(r.status_code, 200)
person = Person.objects.get(user__username=username)
self.assertEqual(person.pronouns_freetext,"baz/boom")
pronoundish["pronouns_freetext"]=""
r = self.client.post(url, pronoundish)
self.assertEqual(r.status_code, 200)
person = Person.objects.get(user__username=username)
self.assertEqual(person.pronouns_freetext, None)
pronoundish = base_data.copy()
del pronoundish["pronouns_freetext"]
pronoundish["pronouns_selectable"] = []
r = self.client.post(url, pronoundish)
self.assertEqual(r.status_code, 200)
person = Person.objects.get(user__username=username)
self.assertEqual(person.pronouns_selectable,[])
pronoundish["pronouns_selectable"] = ['he/him','she/her']
r = self.client.post(url, pronoundish)
self.assertEqual(r.status_code, 200)
person = Person.objects.get(user__username=username)
self.assertEqual(person.pronouns_selectable,['he/him','she/her'])
self.assertEqual(person.pronouns(),"he/him, she/her")
# Can't have both selectables and freetext
pronoundish["pronouns_freetext"] = "foo/bar/baz"
r = self.client.post(url, pronoundish)
self.assertContains(r, 'but not both' ,status_code=200)
q = PyQuery(r.content)
self.assertTrue(len(q("form div.invalid-feedback")) == 1)
# change role email
role = Role.objects.create(
person=Person.objects.get(user__username=username),
@ -854,7 +889,7 @@ class OpenIDConnectTests(TestCase):
client.store_registration_info(client_reg)
# Get a user for which we want to get access
person = PersonFactory(with_bio=True)
person = PersonFactory(with_bio=True, pronouns_freetext="foo/bar")
active_group = RoleFactory(name_id='chair', person=person).group
closed_group = RoleFactory(name_id='chair', person=person, group__state_id='conclude').group
# an additional email
@ -872,7 +907,7 @@ class OpenIDConnectTests(TestCase):
session["nonce"] = rndstr()
args = {
"response_type": "code",
"scope": ['openid', 'profile', 'email', 'roles', 'registration', 'dots' ],
"scope": ['openid', 'profile', 'email', 'roles', 'registration', 'dots', 'pronouns' ],
"nonce": session["nonce"],
"redirect_uri": redirect_uris[0],
"state": session["state"]
@ -920,7 +955,7 @@ class OpenIDConnectTests(TestCase):
# Get userinfo, check keys present
userinfo = client.do_user_info_request(state=params["state"], scope=args['scope'])
for key in [ 'email', 'family_name', 'given_name', 'meeting', 'name', 'roles',
for key in [ 'email', 'family_name', 'given_name', 'meeting', 'name', 'pronouns', 'roles',
'ticket_type', 'reg_type', 'affiliation', 'picture', 'dots', ]:
self.assertIn(key, userinfo)
self.assertTrue(userinfo[key])
@ -957,4 +992,4 @@ class OpenIDConnectTests(TestCase):
# logging.debug() instead of logger.debug(), which results in setting a root
# handler, causing later logging to become visible even if that wasn't intended.
# Fail here if that happens.
self.assertEqual(logging.root.handlers, [])
self.assertEqual(logging.root.handlers, [])

View file

@ -258,6 +258,9 @@ class OidcExtraScopeClaims(oidc_provider.lib.claims.ScopeClaims):
dots = get_dots(self.user.person)
return { 'dots': dots }
def scope_pronouns(self):
return { 'pronouns': self.user.person.pronouns() }
info_registration = (
"IETF Meeting Registration Info",
"Access to public IETF meeting registration information for the current meeting. "

View file

@ -1,4 +1,4 @@
# Copyright The IETF Trust 2007-2020, All Rights Reserved
# Copyright The IETF Trust 2007-2022, All Rights Reserved
# -*- coding: utf-8 -*-
#
# Portions Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies).

View file

@ -1,3 +1,4 @@
# Copyright The IETF Trust 2022, All Rights Reserved
from django.contrib import admin
import simple_history
@ -35,7 +36,7 @@ class PersonAdmin(simple_history.admin.SimpleHistoryAdmin):
prefix, first, middle, last, suffix = name_parts(obj.name)
return "%s %s" % (first, last)
list_display = ["name", "short", "plain_name", "time", "user", ]
fields = ("user", "time", "name", "plain", "name_from_draft", "ascii", "ascii_short", "biography", "photo", "photo_thumb", "consent",)
fields = ("user", "time", "name", "plain", "name_from_draft", "ascii", "ascii_short", "pronouns_selectable", "pronouns_freetext", "biography", "photo", "photo_thumb", "consent",)
readonly_fields = ("name_from_draft", )
search_fields = ["name", "ascii"]
raw_id_fields = ["user"]

View file

@ -0,0 +1,45 @@
# Copyright The IETF Trust 2022, All Rights Reserved
# Generated by Django 2.2.28 on 2022-06-17 15:09
from django.db import migrations, models
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('person', '0022_auto_20220513_1456'),
]
operations = [
migrations.AddField(
model_name='historicalperson',
name='pronouns_freetext',
field=models.CharField(blank=True, help_text='Optionally provide your personal pronouns. These will be displayed on your public profile page and alongside your name in Meetecho and, in future, other systems. Select any number of the checkboxes OR provide a custom string up to 30 characters.', max_length=30, null=True, verbose_name=' '),
),
migrations.AddField(
model_name='historicalperson',
name='pronouns_selectable',
field=jsonfield.fields.JSONCharField(blank=True, default=list, max_length=120, null=True, verbose_name='Pronouns'),
),
migrations.AddField(
model_name='person',
name='pronouns_freetext',
field=models.CharField(blank=True, help_text='Optionally provide your personal pronouns. These will be displayed on your public profile page and alongside your name in Meetecho and, in future, other systems. Select any number of the checkboxes OR provide a custom string up to 30 characters.', max_length=30, null=True, verbose_name=' '),
),
migrations.AddField(
model_name='person',
name='pronouns_selectable',
field=jsonfield.fields.JSONCharField(blank=True, default=list, max_length=120, null=True, verbose_name='Pronouns'),
),
migrations.AlterField(
model_name='historicalperson',
name='consent',
field=models.BooleanField(default=None, null=True, verbose_name='I hereby give my consent to the use of the personal details I have provided (photo, bio, name, pronouns, email) within the IETF Datatracker'),
),
migrations.AlterField(
model_name='person',
name='consent',
field=models.BooleanField(default=None, null=True, verbose_name='I hereby give my consent to the use of the personal details I have provided (photo, bio, name, pronouns, email) within the IETF Datatracker'),
),
]

View file

@ -1,10 +1,11 @@
# Copyright The IETF Trust 2010-2020, All Rights Reserved
# Copyright The IETF Trust 2010-2022, All Rights Reserved
# -*- coding: utf-8 -*-
import datetime
import email.utils
import email.header
import jsonfield
import uuid
from hashids import Hashids
@ -46,11 +47,13 @@ class Person(models.Model):
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).")
pronouns_selectable = jsonfield.JSONCharField("Pronouns", max_length=120, blank=True, null=True, default=list )
pronouns_freetext = models.CharField(" ", max_length=30, null=True, blank=True, help_text="Optionally provide your personal pronouns. These will be displayed on your public profile page and alongside your name in Meetecho and, in future, other systems. Select any number of the checkboxes OR provide a custom string up to 30 characters.")
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.BooleanField("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)
consent = models.BooleanField("I hereby give my consent to the use of the personal details I have provided (photo, bio, name, pronouns, email) within the IETF Datatracker", null=True, default=None)
def __str__(self):
return self.plain_name()
@ -106,6 +109,13 @@ class Person(models.Model):
return name_parts(self.name)[1]
def aliases(self):
return [ str(a) for a in self.alias_set.all() ]
def pronouns(self):
if self.pronouns_selectable:
return ", ".join(self.pronouns_selectable)
else:
return self.pronouns_freetext
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."""
@ -202,6 +212,8 @@ class Person(models.Model):
if not email.origin.split(':')[0] in ['author', 'role', 'reviewer', 'liaison', 'shepherd', ]:
needs_consent.append("email address(es)")
break
if self.pronouns_freetext or self.pronouns_selectable:
needs_consent.append("pronouns")
return needs_consent
def save(self, *args, **kwargs):

View file

@ -1,4 +1,4 @@
# Copyright The IETF Trust 2014-2020, All Rights Reserved
# Copyright The IETF Trust 2014-2022, All Rights Reserved
# -*- coding: utf-8 -*-
@ -80,7 +80,7 @@ class PersonTests(TestCase):
self.assertTrue(primary.address in person.formatted_email())
def test_person_profile(self):
person = PersonFactory(with_bio=True)
person = PersonFactory(with_bio=True,pronouns_freetext="foo/bar")
self.assertTrue(person.photo is not None)
self.assertTrue(person.photo.name is not None)
@ -91,6 +91,7 @@ class PersonTests(TestCase):
#debug.show('person.plain_name()')
#debug.show('person.photo_name()')
self.assertContains(r, person.photo_name(), status_code=200)
self.assertContains(r, "foo/bar")
q = PyQuery(r.content)
self.assertIn("Photo of %s"%person.name, q("div.bio-text img").attr("alt"))

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{# Copyright The IETF Trust 2015-2022, All Rights Reserved #}
{% load origin %}
{% load markup_tags %}
{% load static %}
@ -22,7 +22,11 @@
{{ person.name }}
{% if person.ascii != person.name %}
<br>
<small class="text-muted">({{ person.ascii }})</small>
<span class="text-muted fs-2">({{ person.ascii }})</span>
{% endif %}
{% if person.pronouns %}
<br>
<span class="text-muted fs-3">Pronouns: {{person.pronouns}}</span>
{% endif %}
</h1>
<div class="bio-text">