Skip to content

Commit 4fba531

Browse files
committed
Merged in the latest GDPR changes. This refines the handling of the consent checkbox on the account page; refines the Consent Needed warning given on login if consent is needed; tweaks several models to set the on_deletion fields for FK to User and Person appropriately; adds a Person.needs_consent() method to capture the logic of which fields require consent; refines the Person.plain_name() method and the user.log.log() function; and adds 2 management commands to send out consent requests and delete non-consent information, respectively.
- Legacy-Id: 15464
2 parents a389e24 + b85e1c4 commit 4fba531

11 files changed

Lines changed: 357 additions & 12 deletions

File tree

ietf/ietfauth/forms.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,6 @@ def __init__(self, *args, **kwargs):
116116
if f in self.fields:
117117
self.fields[f].label += ' \u2020'
118118

119-
self.fields["consent"].required = True
120-
121119
self.unidecoded_ascii = False
122120

123121
if self.data and not self.data.get("ascii", "").strip():
@@ -150,8 +148,13 @@ def clean_ascii_short(self):
150148

151149
def clean_consent(self):
152150
consent = self.cleaned_data.get('consent')
153-
if consent == False:
154-
raise forms.ValidationError("In order to modify your profile data, you must permit the IETF to use the uploaded data.")
151+
require_consent = (
152+
self.cleaned_data.get('name') != person.name_from_draft
153+
or self.cleaned_data.get('ascii') != person.name_from_draft
154+
or self.cleaned_data.get('biography')
155+
)
156+
if consent == False and require_consent:
157+
raise forms.ValidationError("In order to modify your profile with data that require consent, you must permit the IETF to use the uploaded data.")
155158
return consent
156159

157160
return PersonForm(*args, **kwargs)

ietf/ietfauth/views.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -580,12 +580,14 @@ def login(request, extra_context=None):
580580
which is not recognized as a valid password hash.
581581
"""
582582

583+
require_consent = []
583584
if request.method == "POST":
584585
form = AuthenticationForm(request, data=request.POST)
585586
username = form.data.get('username')
586587
user = User.objects.filter(username=username).first()
587588
#
588-
require_consent = []
589+
if user.person and not user.person.consent:
590+
require_consent = user.person.needs_consent()
589591
if user:
590592
if hasattr(user, 'person') and not user.person.consent:
591593
person = user.person
@@ -618,8 +620,10 @@ def login(request, extra_context=None):
618620
You have personal information associated with your account which is not
619621
derived from draft submissions or other ietf work, namely: %s. Please go
620622
to your <a href='/accounts/profile'>account profile</a> and review your
621-
personal information, and confirm that it may be used and displayed
622-
within the IETF datatracker.
623+
personal information, then scoll to the bottom and check the 'confirm'
624+
checkbox and submit the form, in order to to indicate that that the
625+
provided personal information may be used and displayed within the IETF
626+
datatracker.
623627
624628
""" % ', '.join(require_consent)))
625629
return response

ietf/nomcom/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ class Nomination(models.Model):
9090
nominee = ForeignKey('Nominee')
9191
comments = ForeignKey('Feedback')
9292
nominator_email = models.EmailField(verbose_name='Nominator Email', blank=True)
93-
user = ForeignKey(User, editable=False)
93+
user = ForeignKey(User, editable=False, null=True, on_delete=models.SET_NULL)
9494
time = models.DateTimeField(auto_now_add=True)
9595
share_nominator = models.BooleanField(verbose_name='Share nominator name with candidate', default=False,
9696
help_text='Check this box to allow the NomCom to let the '
@@ -247,7 +247,7 @@ class Feedback(models.Model):
247247
subject = models.TextField(verbose_name='Subject', blank=True)
248248
comments = EncryptedTextField(verbose_name='Comments')
249249
type = ForeignKey(FeedbackTypeName, blank=True, null=True)
250-
user = ForeignKey(User, editable=False, blank=True, null=True)
250+
user = ForeignKey(User, editable=False, blank=True, null=True, on_delete=models.SET_NULL)
251251
time = models.DateTimeField(auto_now_add=True)
252252

253253
objects = FeedbackManager()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.11.13 on 2018-09-10 07:19
3+
from __future__ import unicode_literals
4+
5+
from django.conf import settings
6+
from django.db import migrations
7+
import django.db.models.deletion
8+
import ietf.utils.models
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
dependencies = [
14+
('person', '0005_populate_person_name_from_draft'),
15+
]
16+
17+
operations = [
18+
migrations.AlterField(
19+
model_name='person',
20+
name='user',
21+
field=ietf.utils.models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
22+
),
23+
]

ietf/person/models.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.conf import settings
1313

1414
from django.core.validators import validate_email
15+
from django.core.exceptions import ObjectDoesNotExist
1516
from django.db import models
1617
from django.contrib.auth.models import User
1718
from django.template.loader import render_to_string
@@ -31,7 +32,7 @@
3132

3233
class Person(models.Model):
3334
history = HistoricalRecords()
34-
user = OneToOneField(User, blank=True, null=True)
35+
user = OneToOneField(User, blank=True, null=True, on_delete=models.SET_NULL)
3536
time = models.DateTimeField(default=datetime.datetime.now) # When this Person record entered the system
3637
# The normal unicode form of the name. This must be
3738
# set to the same value as the ascii-form if equal.
@@ -163,6 +164,31 @@ def expired_drafts(self):
163164
from ietf.doc.models import Document
164165
return Document.objects.filter(documentauthor__person=self, type='draft', states__slug__in=['repl', 'expired', 'auth-rm', 'ietf-rm']).order_by('-time')
165166

167+
def needs_consent(self):
168+
"""
169+
Returns an empty list or a list of fields which holds information that
170+
requires consent to be given.
171+
"""
172+
needs_consent = []
173+
if self.name != self.name_from_draft:
174+
needs_consent.append("full name")
175+
if self.ascii != self.name_from_draft:
176+
needs_consent.append("ascii name")
177+
if self.biography and not (self.role_set.exists() or self.rolehistory_set.exists()):
178+
needs_consent.append("biography")
179+
if self.user_id:
180+
needs_consent.append("login")
181+
try:
182+
if self.user.communitylist_set.exists():
183+
needs_consent.append("draft notification subscription(s)")
184+
except ObjectDoesNotExist:
185+
pass
186+
for email in self.email_set.all():
187+
if not email.origin.split(':')[0] in ['author', 'role', 'reviewer', 'liaison', 'shepherd', ]:
188+
needs_consent.append("email address(es)")
189+
break
190+
return needs_consent
191+
166192
def save(self, *args, **kwargs):
167193
created = not self.pk
168194
super(Person, self).save(*args, **kwargs)

ietf/person/name.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def initials(name):
7171

7272
def plain_name(name):
7373
prefix, first, middle, last, suffix = name_parts(name)
74-
return u" ".join([first, last])
74+
return u" ".join( n for n in (first, last) if n)
7575

7676
def capfirst(s):
7777
# Capitalize the first word character, skipping non-word characters and
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{% load ietf_filters %}{% filter wordwrap:78 %}
2+
Dear {{ person.plain_name }},
3+
4+
This email is regarding some of the personal information stored in your IETF datatracker
5+
profile; information for which we require your consent for storage and use.
6+
7+
If you do nothing in response to this email, the information in your profile
8+
that requires consent ({{ fields|safe }} and login) will be deleted {{ days }}
9+
days from now, on {{ date }}. If you later wish to create a new login, you can
10+
do so at {{ settings.IDTRACKER_BASE_URL }}{% url 'ietf.ietfauth.views.create_account' %}.
11+
12+
If you would like to keep the information that requires consent available, please go to
13+
{{ settings.IDTRACKER_BASE_URL }}{% url 'ietf.ietfauth.views.profile' %}, and review and
14+
edit the information as desired. When ready, please check the 'Consent' checkbox found
15+
at the bottom of the page and submit the form.
16+
17+
For information on how personal information is handled in the datatracker, please see
18+
{{ settings.IDTRACKER_BASE_URL }}/help/personal-information.
19+
20+
21+
Thank You,
22+
The IETF Secretariat
23+
{% endfilter %}

ietf/utils/log.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def _flushfunc():
4343
return
4444
elif settings.DEBUG == True:
4545
_logfunc = debug.say
46-
_flushfunc = sys.stdout.flush
46+
_flushfunc = sys.stdout.flush # pyflakes:ignore (intentional redefinition)
4747
if isinstance(msg, unicode):
4848
msg = msg.encode('unicode_escape')
4949
try:
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Copyright The IETF Trust 2016, All Rights Reserved
2+
# -*- coding: utf-8 -*-
3+
from __future__ import unicode_literals, print_function
4+
5+
import datetime
6+
from tqdm import tqdm
7+
8+
from django.conf import settings
9+
from django.contrib.admin.utils import NestedObjects
10+
from django.contrib.auth.models import User
11+
from django.core.management.base import BaseCommand
12+
from django.db.models import F
13+
14+
import debug # pyflakes:ignore
15+
16+
from ietf.community.models import SearchRule
17+
from ietf.person.models import Person, Alias, PersonalApiKey, Email
18+
from ietf.person.name import unidecode_name
19+
from ietf.utils.log import log
20+
21+
class Command(BaseCommand):
22+
help = (u"""
23+
24+
Delete data for which consent to store the data has not been given,
25+
where the data does not fall under the GDPR Legitimate Interest clause
26+
for the IETF. This includes full name, ascii name, bio, login,
27+
notification subscriptions and email addresses that are not derived from
28+
published drafts or ietf roles.
29+
30+
""")
31+
32+
def add_arguments(self, parser):
33+
parser.add_argument('-n', '--dry-run', action='store_true', default=False,
34+
help="Don't delete anything, just list what would be done.")
35+
# parser.add_argument('-d', '--date', help="Date of deletion (mentioned in message)")
36+
parser.add_argument('-m', '--minimum-response-time', metavar='TIME', type=int, default=14,
37+
help="Minimum response time, default: %(default)s days. Persons to whom a "
38+
"consent request email has been sent more recently than this will not "
39+
"be affected by the run.")
40+
# parser.add_argument('-r', '--rate', type=float, default=1.0,
41+
# help='Rate of sending mail, default: %(default)s/s')
42+
# parser.add_argument('user', nargs='*')
43+
44+
45+
def handle(self, *args, **options):
46+
dry_run = options['dry_run']
47+
verbosity = int(options['verbosity'])
48+
event_type = 'gdpr_notice_email'
49+
settings.DEBUG = False # don't log to console
50+
51+
# users
52+
users = User.objects.filter(person__isnull=True, username__contains='@')
53+
self.stdout.write("Found %d users without associated person records" % (users.count(), ))
54+
emails = Email.objects.filter(address__in=users.values_list('username', flat=True))
55+
# fix up users that don't have person records, but have a username matching a nown email record
56+
self.stdout.write("Checking usernames against email records ...")
57+
for email in tqdm(emails):
58+
user = users.get(username=email.address)
59+
if email.person.user_id:
60+
if dry_run:
61+
self.stdout.write("Would delete user #%-6s (%s) %s" % (user.id, user.last_login, user.username))
62+
else:
63+
log("Deleting user #%-6s (%s) %s: no person record, matching email has other user" % (user.id, user.last_login, user.username))
64+
user_id = user.id
65+
user.delete()
66+
Person.history.filter(user_id=user_id).delete()
67+
Email.history.filter(history_user=user_id).delete()
68+
else:
69+
if dry_run:
70+
self.stdout.write("Would connect user #%-6s %s to person #%-6s %s" % (user.id, user.username, email.person.id, email.person.ascii_name()))
71+
else:
72+
log("Connecting user #%-6s %s to person #%-6s %s" % (user.id, user.username, email.person.id, email.person.ascii_name()))
73+
email.person.user_id = user.id
74+
email.person.save()
75+
# delete users without person records
76+
users = users.exclude(username__in=emails.values_list('address', flat=True))
77+
if dry_run:
78+
self.stdout.write("Would delete %d users without associated person records" % (users.count(), ))
79+
else:
80+
if users.count():
81+
log("Deleting %d users without associated person records" % (users.count(), ))
82+
assert not users.filter(person__isnull=False).exists()
83+
user_ids = users.values_list('id', flat=True)
84+
users.delete()
85+
assert not Person.history.filter(user_id__in=user_ids).exists()
86+
87+
88+
# persons
89+
self.stdout.write('Querying the database for person records without given consent ...')
90+
notification_cutoff = datetime.datetime.now() - datetime.timedelta(days=options['minimum_response_time'])
91+
persons = Person.objects.exclude(consent=True)
92+
persons = persons.exclude(id=1) # make sure we don't delete System ;-)
93+
self.stdout.write("Found %d persons with information for which we don't have consent." % (persons.count(), ))
94+
95+
# Narrow to persons we don't have Legitimate Interest in, and delete those fully
96+
persons = persons.exclude(docevent__by=F('pk'))
97+
persons = persons.exclude(documentauthor__person=F('pk')).exclude(dochistoryauthor__person=F('pk'))
98+
persons = persons.exclude(email__liaisonstatement__from_contact__person=F('pk'))
99+
persons = persons.exclude(email__reviewrequest__reviewer__person=F('pk'))
100+
persons = persons.exclude(email__shepherd_dochistory_set__shepherd__person=F('pk'))
101+
persons = persons.exclude(email__shepherd_document_set__shepherd__person=F('pk'))
102+
persons = persons.exclude(iprevent__by=F('pk'))
103+
persons = persons.exclude(meetingregistration__person=F('pk'))
104+
persons = persons.exclude(message__by=F('pk'))
105+
persons = persons.exclude(name_from_draft='')
106+
persons = persons.exclude(personevent__time__gt=notification_cutoff, personevent__type=event_type)
107+
persons = persons.exclude(reviewrequest__requested_by=F('pk'))
108+
persons = persons.exclude(role__person=F('pk')).exclude(rolehistory__person=F('pk'))
109+
persons = persons.exclude(session__requested_by=F('pk'))
110+
persons = persons.exclude(submissionevent__by=F('pk'))
111+
self.stdout.write("Found %d persons with information for which we neither have consent nor legitimate interest." % (persons.count(), ))
112+
if persons.count() > 0:
113+
self.stdout.write("Deleting records for persons for which we have with neither consent nor legitimate interest ...")
114+
for person in (persons if dry_run else tqdm(persons)):
115+
if dry_run:
116+
self.stdout.write(("Would delete record #%-6d: (%s) %-32s %-48s" % (person.pk, person.time, person.ascii_name(), "<%s>"%person.email())).encode('utf8'))
117+
else:
118+
if verbosity > 1:
119+
# development aids
120+
collector = NestedObjects(using='default')
121+
collector.collect([person,])
122+
objects = collector.nested()
123+
related = [ o for o in objects[-1] if not isinstance(o, (Alias, Person, SearchRule, PersonalApiKey)) ]
124+
if len(related) > 0:
125+
self.stderr.write("Person record #%-6s %s has unexpected related records" % (person.pk, person.ascii_name()))
126+
127+
# Historical records using simple_history has on_delete=DO_NOTHING, so
128+
# we have to do explicit deletions:
129+
id = person.id
130+
person.delete()
131+
Person.history.filter(id=id).delete()
132+
Email.history.filter(person_id=id).delete()
133+
134+
# Deal with remaining persons (lacking consent, but with legitimate interest)
135+
persons = Person.objects.exclude(consent=True)
136+
persons = persons.exclude(id=1)
137+
self.stdout.write("Found %d remaining persons with information for which we don't have consent." % (persons.count(), ))
138+
if persons.count() > 0:
139+
self.stdout.write("Removing personal information requiring consent ...")
140+
for person in (persons if dry_run else tqdm(persons)):
141+
fields = ', '.join(person.needs_consent())
142+
if dry_run:
143+
self.stdout.write(("Would remove info for #%-6d: (%s) %-32s %-48s %s" % (person.pk, person.time, person.ascii_name(), "<%s>"%person.email(), fields)).encode('utf8'))
144+
else:
145+
if person.name_from_draft:
146+
log("Using name info from draft for #%-6d %s: no consent, no roles" % (person.pk, person))
147+
person.name = person.name_from_draft
148+
person.ascii = unidecode_name(person.name_from_draft)
149+
if person.biography:
150+
log("Deleting biography for #%-6d %s: no consent, no roles" % (person.pk, person))
151+
person.biography = ''
152+
person.save()
153+
if person.user_id:
154+
if User.objects.filter(id=person.user_id).exists():
155+
log("Deleting communitylist for #%-6d %s: no consent, no roles" % (person.pk, person))
156+
person.user.communitylist_set.all().delete()
157+
for email in person.email_set.all():
158+
if not email.origin.split(':')[0] in ['author', 'role', 'reviewer', 'liaison', 'shepherd', ]:
159+
log("Deleting email <%s> for #%-6d %s: no consent, no roles" % (email.address, person.pk, person))
160+
address = email.address
161+
email.delete()
162+
Email.history.filter(address=address).delete()
163+
164+
emails = Email.objects.filter(origin='', person__consent=False)
165+
self.stdout.write("Found %d emails without origin for which we lack consent." % (emails.count(), ))
166+
if dry_run:
167+
self.stdout.write("Would delete %d email records without origin and consent" % (emails.count(), ))
168+
else:
169+
if emails.count():
170+
log("Deleting %d email records without origin and consent" % (emails.count(), ))
171+
addresses = emails.values_list('address', flat=True)
172+
emails.delete()
173+
Email.history.filter(address__in=addresses).delete()
174+

0 commit comments

Comments
 (0)