diff --git a/changelog b/changelog index f2488326a..35077bc53 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,67 @@ +ietfdb (6.81.0) ietf; urgency=medium + + This release contains code and schema changes necessary for compliance + with the European GDPR (General Data Protection Regulation). It provides + necessary GUI and internal changes, but lacks some admin utilities, and + also data migration routines which takes some time to run, and therefore + will be packaged in a separate release and applied separately. + + From the commit log: + + * Updated the personal information page with reviewed text from legal + counsel. Fixes issue #2503. + + * Removed the Person.address field, which is not being used. This was a + legacy from the 2001 perl-based datatracker tables. Fixes issue #2504. + + * Added django-simple-history and replaced the old (and unused) + PersonHistory class with a history=HistoricalRecords() field on Person. + Added the needed migrations and changes to admin, resources, and settings. + Related to issues #2505 and #2507. + + * Added a new field name_from_draft to Person, to hold the name field + equivalent as captured from drafts, in case name has been modified by the + user and we're asked to remove that info under GDPR. Added history for + Email, and also an origin field to capture from where we got an email + address (draft name, username, meeting registration, etc.) Added a + log.assertion() to Email.save() in order to ensure we don't create any + email without setting origin. + + * Added origin information to all places where we create email address + entries. + + * Removed all references to the removed Person.affiliation field. + + * Added email origin information to some function calls that needed it. + + * Overwrite earlier email origin when we've picked up the address from a + submission. + + * Added a consent field to the Person model. + + * Disallow profile changes without consent given. Together with previous + commits this fixes issues #2505 and #2507. + + * Added another category of personal information to the + personal-information page, after review of personal information in the + code. Completes issue #2501. + + * Added a dagger at the end of some fields in the account data forms to + signify consent-based fields, and made the consent field required. + + * Simplified the email.origin assignment code for outgoing liaisons. + + * Fixed a long-standing bug in the liaison.name() code. + + * Added assingment of the person.name_from_draft field on draft + submission. + + + * Updated the edit_profile template with information about consent-based + fields. Fixes issue #2502. + + -- Henrik Levkowetz 28 May 2018 11:15:34 +0000 + ietfdb (6.80.1) ietf; urgency=medium This is a bugfix release which also clears the slate for the upcoming diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index e0b039e4f..ed5b057ad 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -1001,7 +1001,7 @@ class IndividualInfoFormsTests(TestCase): doc.shepherd = Email.objects.get(person__user__username="plain") doc.save_with_history([DocEvent.objects.create(doc=doc, rev=doc.rev, type="changed_shepherd", by=Person.objects.get(user__username="secretary"), desc="Test")]) - new_email = Email.objects.create(address="anotheremail@example.com", person=doc.shepherd.person) + new_email = Email.objects.create(address="anotheremail@example.com", person=doc.shepherd.person, origin=doc.shepherd.person.user.username) r = self.client.get(url) self.assertEqual(r.status_code, 200) @@ -1435,8 +1435,8 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() + datetime.timedelta(days=settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) - p = Person.objects.create(address="basea_author") - e = Email.objects.create(address="basea_author@example.com", person=p) + p = PersonFactory(name=u"basea_author") + e = Email.objects.create(address="basea_author@example.com", person=p, origin=p.user.username) self.basea.documentauthor_set.create(person=p, email=e, order=1) self.baseb = Document.objects.create( @@ -1448,8 +1448,8 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() - datetime.timedelta(days = 365 - settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) - p = Person.objects.create(name="baseb_author") - e = Email.objects.create(address="baseb_author@example.com", person=p) + p = PersonFactory(name=u"baseb_author") + e = Email.objects.create(address="baseb_author@example.com", person=p, origin=p.user.username) self.baseb.documentauthor_set.create(person=p, email=e, order=1) self.replacea = Document.objects.create( @@ -1461,8 +1461,8 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() + datetime.timedelta(days = settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) - p = Person.objects.create(name="replacea_author") - e = Email.objects.create(address="replacea_author@example.com", person=p) + p = PersonFactory(name=u"replacea_author") + e = Email.objects.create(address="replacea_author@example.com", person=p, origin=p.user.username) self.replacea.documentauthor_set.create(person=p, email=e, order=1) self.replaceboth = Document.objects.create( @@ -1474,8 +1474,8 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() + datetime.timedelta(days = settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) - p = Person.objects.create(name="replaceboth_author") - e = Email.objects.create(address="replaceboth_author@example.com", person=p) + p = PersonFactory(name=u"replaceboth_author") + e = Email.objects.create(address="replaceboth_author@example.com", person=p, origin=p.user.username) self.replaceboth.documentauthor_set.create(person=p, email=e, order=1) self.basea.set_state(State.objects.get(used=True, type="draft", slug="active")) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index f1d56ca18..fdaea6713 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -954,6 +954,9 @@ def edit_shepherd(request, name): events = [] doc.shepherd = form.cleaned_data['shepherd'] + if doc.shepherd and not doc.shepherd.origin: + doc.shepherd.origin = 'shepherd: %s' % doc.name + doc.shepherd.save() c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "Document shepherd changed to "+ (doc.shepherd.person.name if doc.shepherd else "(None)") diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index 59d141f78..d87fca329 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -175,7 +175,6 @@ def retrieve_search_results(form, all_types=False): if by == "author": docs = docs.filter( Q(documentauthor__person__alias__name__icontains=query["author"]) | - Q(documentauthor__person__affiliation__icontains=query["author"]) | Q(documentauthor__person__email__address__icontains=query["author"]) ) elif by == "group": diff --git a/ietf/group/views.py b/ietf/group/views.py index 417adf1fd..54c9990e9 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -958,6 +958,10 @@ def edit(request, group_type=None, acronym=None, action="edit", field=None): group.role_set.filter(name=slug).delete() for e in new: Role.objects.get_or_create(name_id=slug, email=e, group=group, person=e.person) + if not e.origin or e.origin == e.person.user.username: + e.origin = "role: %s %s" % (group.acronym, slug) + e.save() + added = set(new) - set(old) deleted = set(old) - set(new) if added: @@ -1206,6 +1210,9 @@ def stream_edit(request, acronym): group.role_set.filter(name=slug).delete() for e in new: Role.objects.get_or_create(name_id=slug, email=e, group=group, person=e.person) + if not e.origin or e.origin == e.person.user.username: + e.origin = "role: %s %s" % (group.acronym, slug) + e.save() return redirect("ietf.group.views.streams") else: diff --git a/ietf/help/tests_views.py b/ietf/help/tests_views.py index 2d6207ea2..714dab39e 100644 --- a/ietf/help/tests_views.py +++ b/ietf/help/tests_views.py @@ -7,7 +7,7 @@ import debug # pyflakes:ignore from ietf.utils.test_utils import TestCase from ietf.doc.models import StateType -class StateHelpTest(TestCase): +class HelpPageTests(TestCase): def test_state_index(self): url = reverse('ietf.help.views.state_index') @@ -21,3 +21,7 @@ class StateHelpTest(TestCase): self.assertIn(name, content) + def test_personal_information_help(self): + r = self.client.get('/help/personal-information') + self.assertContains(r, 'personal information') + self.assertContains(r, 'GDPR') diff --git a/ietf/help/urls.py b/ietf/help/urls.py index 26b65528d..9c0bfb157 100644 --- a/ietf/help/urls.py +++ b/ietf/help/urls.py @@ -1,3 +1,6 @@ +# Copyright The IETF Trust 2013-2018, All Rights Reserved + +from django.views.generic import TemplateView from ietf.help import views from ietf.utils.urls import url @@ -6,5 +9,6 @@ urlpatterns = [ url(r'^state/(?P[-\w]+)/(?P[-\w]+)/?$', views.state), url(r'^state/(?P[-\w]+)/?$', views.state), url(r'^state/?$', views.state_index), + url(r'^personal-information/?$', TemplateView.as_view(template_name='help/personal-information.html'), name='personal-information'), ] diff --git a/ietf/ietfauth/forms.py b/ietf/ietfauth/forms.py index 918c8a239..07e78f077 100644 --- a/ietf/ietfauth/forms.py +++ b/ietf/ietfauth/forms.py @@ -1,3 +1,7 @@ +# Copyright The IETF Trust 2016, All Rights Reserved +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, print_function + import re from unidecode import unidecode @@ -94,6 +98,9 @@ def get_person_form(*args, **kwargs): class Meta: model = Person exclude = exclude_list + widgets = { + 'consent': forms.widgets.CheckboxInput, + } def __init__(self, *args, **kwargs): super(PersonForm, self).__init__(*args, **kwargs) @@ -105,6 +112,12 @@ 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', ]: + if f in self.fields: + self.fields[f].label += ' \u2020' + + self.fields["consent"].required = True + self.unidecoded_ascii = False if self.data and not self.data.get("ascii", "").strip(): @@ -135,6 +148,11 @@ def get_person_form(*args, **kwargs): prevent_system_name(name) return ascii_cleaner(name) + def clean_consent(self): + consent = self.cleaned_data.get('consent') + if consent == False: + raise forms.ValidationError("In order to modify your profile data, you must permit the IETF to use the uploaded data.") + return PersonForm(*args, **kwargs) diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index fba73e529..8380deb68 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -207,9 +207,9 @@ class IetfAuthTests(TestCase): "name": u"Test Nãme", "ascii": u"Test Name", "ascii_short": u"T. Name", - "address": "Test address", "affiliation": "Test Org", "active_emails": email_address, + "consent": True, } # edit details - faulty ASCII @@ -309,7 +309,7 @@ class IetfAuthTests(TestCase): user.set_password("forgotten") user.save() p = Person.objects.create(name="Some One", ascii="Some One", user=user) - Email.objects.create(address=user.username, person=p) + Email.objects.create(address=user.username, person=p, origin=user.username) # get r = self.client.get(url) @@ -419,7 +419,7 @@ class IetfAuthTests(TestCase): user.set_password("password") user.save() p = Person.objects.create(name="Some One", ascii="Some One", user=user) - Email.objects.create(address=user.username, person=p) + Email.objects.create(address=user.username, person=p, origin=user.username) # log in r = self.client.post(redir_url, {"username":user.username, "password":"password"}) @@ -466,8 +466,8 @@ class IetfAuthTests(TestCase): user.set_password("password") user.save() p = Person.objects.create(name="Some One", ascii="Some One", user=user) - Email.objects.create(address=user.username, person=p) - Email.objects.create(address="othername@example.org", person=p) + Email.objects.create(address=user.username, person=p, origin=user.username) + Email.objects.create(address="othername@example.org", person=p, origin=user.username) # log in r = self.client.post(redir_url, {"username":user.username, "password":"password"}) diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index e3f549279..c71f44f35 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -172,7 +172,7 @@ def confirm_account(request, auth): Alias.objects.create(person=person, name=name) if not email_obj: - email_obj = Email.objects.create(address=email, person=person) + email_obj = Email.objects.create(address=email, person=person, origin=user.username) else: if not email_obj.person: email_obj.person = person @@ -251,6 +251,8 @@ def profile(request): email.primary = email.address == primary_email if email.primary and not email.active: email.active = True + if not email.origin: + email.origin = person.user.username email.save() # Make sure the alias table contains any new and/or old names. @@ -293,7 +295,7 @@ def confirm_new_email(request, auth): can_confirm = form.is_valid() and email new_email_obj = None if request.method == 'POST' and can_confirm and request.POST.get("action") == "confirm": - new_email_obj = Email.objects.create(address=email, person=person) + new_email_obj = Email.objects.create(address=email, person=person, origin=username) return render(request, 'registration/confirm_new_email.html', { 'username': username, diff --git a/ietf/liaisons/forms.py b/ietf/liaisons/forms.py index 3acebfa09..2eb16eb07 100644 --- a/ietf/liaisons/forms.py +++ b/ietf/liaisons/forms.py @@ -266,8 +266,12 @@ class LiaisonModelForm(BetterModelForm): def clean_from_contact(self): contact = self.cleaned_data.get('from_contact') + from_groups = self.cleaned_data.get('from_groups') try: email = Email.objects.get(address=contact) + if not email.origin: + email.origin = "liaison: %s" % (','.join([ g.acronym for g in from_groups.all() ])) + email.save() except ObjectDoesNotExist: raise forms.ValidationError('Email address does not exist') return email @@ -500,6 +504,7 @@ class OutgoingLiaisonForm(LiaisonModelForm): if has_role(self.user, "Liaison Manager"): self.fields['to_groups'].initial = [queryset.first()] + class EditLiaisonForm(LiaisonModelForm): def __init__(self, *args, **kwargs): super(EditLiaisonForm, self).__init__(*args, **kwargs) diff --git a/ietf/liaisons/models.py b/ietf/liaisons/models.py index 37c24d230..b42d72044 100644 --- a/ietf/liaisons/models.py +++ b/ietf/liaisons/models.py @@ -84,11 +84,11 @@ class LiaisonStatement(models.Model): if self.from_groups.count(): frm = ', '.join([i.acronym or i.name for i in self.from_groups.all()]) else: - frm = self.from_name + frm = self.from_contact.person.name if self.to_groups.count(): to = ', '.join([i.acronym or i.name for i in self.to_groups.all()]) else: - to = self.to_name + to = self.to_contacts return slugify("liaison" + " " + self.submitted.strftime("%Y-%m-%d") + " " + frm[:50] + " " + to[:50] + " " + self.title[:115]) @property diff --git a/ietf/nomcom/management/commands/make_dummy_nomcom.py b/ietf/nomcom/management/commands/make_dummy_nomcom.py index b91b91ee5..a6724a326 100644 --- a/ietf/nomcom/management/commands/make_dummy_nomcom.py +++ b/ietf/nomcom/management/commands/make_dummy_nomcom.py @@ -39,18 +39,18 @@ class Command(BaseCommand): populate_personnel=False, populate_positions=False)) - e = EmailFactory(person__name=u'Dummy Chair',address=u'dummychair@example.com',person__user__username=u'dummychair',person__default_emails=False) + e = EmailFactory(person__name=u'Dummy Chair', address=u'dummychair@example.com', person__user__username=u'dummychair', person__default_emails=False, origin='dummychair') e.person.user.set_password('password') e.person.user.save() nc.group.role_set.create(name_id=u'chair',person=e.person,email=e) - e = EmailFactory(person__name=u'Dummy Member',address=u'dummymember@example.com',person__user__username=u'dummymember',person__default_emails=False) + e = EmailFactory(person__name=u'Dummy Member', address=u'dummymember@example.com', person__user__username=u'dummymember', person__default_emails=False, origin='dummymember') e.person.user.set_password('password') e.person.user.save() nc.group.role_set.create(name_id=u'member',person=e.person,email=e) - e = EmailFactory(person__name=u'Dummy Candidate',address=u'dummycandidate@example.com',person__user__username=u'dummycandidate',person__default_emails=False) + e = EmailFactory(person__name=u'Dummy Candidate', address=u'dummycandidate@example.com', person__user__username=u'dummycandidate', person__default_emails=False, origin='dummycandidate') e.person.user.set_password('password') e.person.user.save() NomineePositionFactory(nominee__nomcom=nc, nominee__person=e.person, diff --git a/ietf/nomcom/test_data.py b/ietf/nomcom/test_data.py index 759fe1e39..c04621c91 100644 --- a/ietf/nomcom/test_data.py +++ b/ietf/nomcom/test_data.py @@ -123,7 +123,9 @@ def nomcom_test_data(): u.set_password(COMMUNITY_USER+"+password") u.save() plainman, _ = Person.objects.get_or_create(name="Plain Man", ascii="Plain Man", user=u) - email, _ = Email.objects.get_or_create(address="plain@example.com", person=plainman) + email = Email.objects.filter(address="plain@example.com", person=plainman).first() + if not email: + email = Email.objects.create(address="plain@example.com", person=plainman, origin=u.username) nominee, _ = Nominee.objects.get_or_create(email=email, nomcom=nomcom) # positions diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index 671fdce43..ca406663f 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -576,7 +576,7 @@ class NomcomViewsTest(TestCase): if not searched_email: searched_email = Email.objects.filter(address=nominee_email).first() if not searched_email: - searched_email = EmailFactory(address=nominee_email,primary=True) + searched_email = EmailFactory(address=nominee_email, primary=True, origin='test') if not searched_email.person: searched_email.person = PersonFactory() searched_email.save() @@ -694,6 +694,7 @@ class NomcomViewsTest(TestCase): # check objects email = Email.objects.get(address=candidate_email) + Person.objects.get(name=candidate_name) nominee = Nominee.objects.get(email=email) NomineePosition.objects.get(position=position, nominee=nominee) feedback = Feedback.objects.filter(positions__in=[position], @@ -966,8 +967,8 @@ class ReminderTest(TestCase): today = datetime.date.today() t_minus_3 = today - datetime.timedelta(days=3) t_minus_4 = today - datetime.timedelta(days=4) - e1 = EmailFactory(address="nominee1@example.org",person=PersonFactory(name=u"Nominee 1")) - e2 = EmailFactory(address="nominee2@example.org",person=PersonFactory(name=u"Nominee 2")) + e1 = EmailFactory(address="nominee1@example.org", person=PersonFactory(name=u"Nominee 1"), origin='test') + e2 = EmailFactory(address="nominee2@example.org", person=PersonFactory(name=u"Nominee 2"), origin='test') n = make_nomineeposition(self.nomcom,e1.person,gen,None) np = n.nomineeposition_set.get(position=gen) np.time = t_minus_3 @@ -1715,7 +1716,7 @@ Junk body for testing def test_edit_nominee(self): nominee = self.nc.nominee_set.order_by('pk').first() - new_email = EmailFactory(person=nominee.person) + new_email = EmailFactory(person=nominee.person, origin='test') url = reverse('ietf.nomcom.views.edit_nominee',kwargs={'year':self.nc.year(),'nominee_id':nominee.id}) login_testing_unauthorized(self,self.chair.user.username,url) response = self.client.get(url) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index 4d999da9b..c51360e1e 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -372,7 +372,7 @@ def make_nomineeposition(nomcom, candidate, position, author): def make_nomineeposition_for_newperson(nomcom, candidate_name, candidate_email, position, author): # This is expected to fail if called with an existing email address - email = Email.objects.create(address=candidate_email) + email = Email.objects.create(address=candidate_email, origin="nominee: %s" % nomcom.group.acronym) person = Person.objects.create(name=candidate_name, ascii=unidecode_name(candidate_name), ) diff --git a/ietf/person/admin.py b/ietf/person/admin.py index 86c0c851f..864141cc1 100644 --- a/ietf/person/admin.py +++ b/ietf/person/admin.py @@ -1,11 +1,11 @@ from django.contrib import admin +import simple_history - -from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent +from ietf.person.models import Email, Alias, Person, PersonalApiKey, PersonEvent, PersonApiKeyEvent from ietf.person.name import name_parts -class EmailAdmin(admin.ModelAdmin): - list_display = ["address", "person", "time", "active", ] +class EmailAdmin(simple_history.admin.SimpleHistoryAdmin): + list_display = ["address", "person", "time", "active", "origin"] raw_id_fields = ["person", ] search_fields = ["address", "person__name", ] admin.site.register(Email, EmailAdmin) @@ -22,7 +22,7 @@ admin.site.register(Alias, AliasAdmin) class AliasInline(admin.StackedInline): model = Alias -class PersonAdmin(admin.ModelAdmin): +class PersonAdmin(simple_history.admin.SimpleHistoryAdmin): def plain_name(self, obj): prefix, first, middle, last, suffix = name_parts(obj.name) return "%s %s" % (first, last) @@ -33,16 +33,6 @@ class PersonAdmin(admin.ModelAdmin): # actions = None admin.site.register(Person, PersonAdmin) -class PersonHistoryAdmin(admin.ModelAdmin): - def plain_name(self, obj): - prefix, first, middle, last, suffix = name_parts(obj.name) - return "%s %s" % (first, last) - list_display = ['name', 'short', 'plain_name', 'time', 'user', 'person', ] - list_filter = ['time'] - raw_id_fields = ['person', 'user'] - search_fields = ['name', 'ascii'] -admin.site.register(PersonHistory, PersonHistoryAdmin) - class PersonalApiKeyAdmin(admin.ModelAdmin): list_display = ['id', 'person', 'created', 'endpoint', 'valid', 'count', 'latest', ] list_filter = ['endpoint', 'created', ] @@ -61,3 +51,5 @@ class PersonApiKeyEventAdmin(admin.ModelAdmin): search_fields = ["person__name", ] raw_id_fields = ['person', ] admin.site.register(PersonApiKeyEvent, PersonApiKeyEventAdmin) + + diff --git a/ietf/person/factories.py b/ietf/person/factories.py index a9c02c5fb..0c7888185 100644 --- a/ietf/person/factories.py +++ b/ietf/person/factories.py @@ -71,7 +71,7 @@ class PersonFactory(factory.DjangoModelFactory): extracted = True if create and extracted: make_email = getattr(EmailFactory, 'create' if create else 'build') - make_email(person=obj,address=obj.user.email) + make_email(person=obj, address=obj.user.email) @factory.post_generation def default_photo(obj, create, extracted, **kwargs): # pylint: disable=no-self-argument @@ -131,4 +131,4 @@ class EmailFactory(factory.DjangoModelFactory): active = True primary = False - + origin = factory.LazyAttribute(lambda obj: obj.person.user.username if obj.person.user else '') diff --git a/ietf/person/migrations/0003_auto_20180504_1519.py b/ietf/person/migrations/0003_auto_20180504_1519.py new file mode 100644 index 000000000..7abd048e2 --- /dev/null +++ b/ietf/person/migrations/0003_auto_20180504_1519.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-04 15:19 +from __future__ import unicode_literals + +import datetime +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import ietf.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('person', '0002_auto_20180330_0808'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalEmail', + fields=[ + ('address', models.CharField(db_index=True, max_length=64, validators=[django.core.validators.EmailValidator()])), + ('time', models.DateTimeField(blank=True, editable=False)), + ('primary', models.BooleanField(default=False)), + ('origin', models.CharField(default=b'', editable=False, max_length=150)), + ('active', models.BooleanField(default=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical email', + }, + ), + migrations.CreateModel( + name='HistoricalPerson', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('time', models.DateTimeField(default=datetime.datetime.now)), + ('name', models.CharField(db_index=True, help_text=b'Preferred form of name.', max_length=255, verbose_name=b'Full Name (Unicode)')), + ('ascii', models.CharField(help_text=b'Name as rendered in ASCII (Latin, unaccented) characters.', max_length=255, verbose_name=b'Full Name (ASCII)')), + ('ascii_short', models.CharField(blank=True, help_text=b'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).', max_length=32, null=True, verbose_name=b'Abbreviated Name (ASCII)')), + ('biography', models.TextField(blank=True, help_text=b'Short biography for use on leadership pages. Use plain text or reStructuredText markup.')), + ('photo', models.TextField(blank=True, default=None, max_length=100)), + ('photo_thumb', models.TextField(blank=True, default=None, max_length=100)), + ('name_from_draft', models.CharField(editable=False, help_text=b'Name as found in a draft submission.', max_length=255, null=True, verbose_name=b'Full Name (from submission)')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical person', + }, + ), + migrations.RemoveField( + model_name='personhistory', + name='person', + ), + migrations.RemoveField( + model_name='personhistory', + name='user', + ), + migrations.RemoveField( + model_name='person', + name='address', + ), + migrations.RemoveField( + model_name='person', + name='affiliation', + ), + migrations.AddField( + model_name='email', + name='origin', + field=models.CharField(default=b'', editable=False, max_length=150), + ), + migrations.AddField( + model_name='person', + name='name_from_draft', + field=models.CharField(editable=False, help_text=b'Name as found in a draft submission.', max_length=255, null=True, verbose_name=b'Full Name (from submission)'), + ), + migrations.DeleteModel( + name='PersonHistory', + ), + migrations.AddField( + model_name='historicalemail', + name='person', + field=ietf.utils.models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='person.Person'), + ), + migrations.AddField( + model_name='historicalperson', + name='consent', + field=models.NullBooleanField(default=None, verbose_name=b'I hereby give my consent to the use of the personal details I have provided (photo, bio, name, email) within the IETF Datatracker'), + ), + migrations.AddField( + model_name='person', + name='consent', + field=models.NullBooleanField(default=None, verbose_name=b'I hereby give my consent to the use of the personal details I have provided (photo, bio, name, email) within the IETF Datatracker'), + ), + ] diff --git a/ietf/person/models.py b/ietf/person/models.py index 886802b83..40b80c3f6 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -16,6 +16,7 @@ from django.db import models from django.contrib.auth.models import User from django.template.loader import render_to_string from django.utils.text import slugify +from simple_history.models import HistoricalRecords import debug # pyflakes:ignore @@ -24,10 +25,13 @@ 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 PersonInfo(models.Model): +class Person(models.Model): + history = HistoricalRecords() + user = OneToOneField(User, blank=True, null=True) 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. @@ -36,11 +40,11 @@ class PersonInfo(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).") - affiliation = models.CharField(max_length=255, blank=True, help_text="Employer, university, sponsor, etc.") - address = models.TextField(max_length=255, blank=True, help_text="Postal mailing address.") 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 __unicode__(self): return self.plain_name() @@ -144,24 +148,21 @@ class PersonInfo(models.Model): 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') - class Meta: - abstract = True - -class Person(PersonInfo): - user = OneToOneField(User, blank=True, null=True) - def save(self, *args, **kwargs): created = not self.pk super(Person, self).save(*args, **kwargs) @@ -196,13 +197,8 @@ class Person(PersonInfo): ct1['href'] = urljoin(hostscheme, self.json_url()) ct1['name'] = self.name ct1['ascii'] = self.ascii - ct1['affiliation']= self.affiliation return ct1 -class PersonHistory(PersonInfo): - person = ForeignKey(Person, related_name="history_set") - user = ForeignKey(User, blank=True, null=True) - 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 @@ -231,12 +227,15 @@ class Alias(models.Model): 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, default='', editable=False) # User.username or Document.name active = models.BooleanField(default=True) # Old email addresses are *not* purged, as history - # information points to persons through these + # information points to persons through these + def __unicode__(self): return self.address or "Email object with id: %s"%self.pk @@ -280,6 +279,10 @@ class Email(models.Model): 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" diff --git a/ietf/person/resources.py b/ietf/person/resources.py index 1e842a69f..c5b51e6c4 100644 --- a/ietf/person/resources.py +++ b/ietf/person/resources.py @@ -6,7 +6,7 @@ from tastypie.cache import SimpleCache from ietf import api -from ietf.person.models import (Person, Email, Alias, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent) +from ietf.person.models import (Person, Email, Alias, PersonalApiKey, PersonEvent, PersonApiKeyEvent, HistoricalPerson, HistoricalEmail) from ietf.utils.resources import UserResource @@ -24,7 +24,6 @@ class PersonResource(ModelResource): "name": ALL, "ascii": ALL, "ascii_short": ALL, - "address": ALL, "affiliation": ALL, "photo": ALL, "biography": ALL, @@ -61,29 +60,6 @@ class AliasResource(ModelResource): } api.person.register(AliasResource()) -from ietf.utils.resources import UserResource -class PersonHistoryResource(ModelResource): - person = ToOneField(PersonResource, 'person') - user = ToOneField(UserResource, 'user', null=True) - class Meta: - cache = SimpleCache() - queryset = PersonHistory.objects.all() - serializer = api.Serializer() - #resource_name = 'personhistory' - filtering = { - "id": ALL, - "time": ALL, - "name": ALL, - "ascii": ALL, - "ascii_short": ALL, - "address": ALL, - "affiliation": ALL, - "person": ALL_WITH_RELATIONS, - "user": ALL_WITH_RELATIONS, - } -api.person.register(PersonHistoryResource()) - - class PersonalApiKeyResource(ModelResource): person = ToOneField(PersonResource, 'person') class Meta: @@ -141,3 +117,57 @@ class PersonApiKeyEventResource(ModelResource): "key": ALL_WITH_RELATIONS, } api.person.register(PersonApiKeyEventResource()) + + +from ietf.utils.resources import UserResource +class HistoricalPersonResource(ModelResource): + user = ToOneField(UserResource, 'user', null=True) + history_user = ToOneField(UserResource, 'history_user', null=True) + class Meta: + queryset = HistoricalPerson.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'historicalperson' + filtering = { + "id": ALL, + "time": ALL, + "name": ALL, + "ascii": ALL, + "ascii_short": ALL, + "affiliation": ALL, + "biography": ALL, + "photo": ALL, + "photo_thumb": ALL, + "history_id": ALL, + "history_date": ALL, + "history_change_reason": ALL, + "history_type": ALL, + "user": ALL_WITH_RELATIONS, + "history_user": ALL_WITH_RELATIONS, + } +api.person.register(HistoricalPersonResource()) + + +from ietf.utils.resources import UserResource +class HistoricalEmailResource(ModelResource): + person = ToOneField(PersonResource, 'person', null=True) + history_user = ToOneField(UserResource, 'history_user', null=True) + class Meta: + queryset = HistoricalEmail.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'historicalemail' + filtering = { + "address": ALL, + "time": ALL, + "primary": ALL, + "origin": ALL, + "active": ALL, + "history_id": ALL, + "history_date": ALL, + "history_change_reason": ALL, + "history_type": ALL, + "person": ALL_WITH_RELATIONS, + "history_user": ALL_WITH_RELATIONS, + } +api.person.register(HistoricalEmailResource()) diff --git a/ietf/person/tests.py b/ietf/person/tests.py index caf32beb4..7fd961c17 100644 --- a/ietf/person/tests.py +++ b/ietf/person/tests.py @@ -42,9 +42,9 @@ class PersonTests(TestCase): def test_default_email(self): person = PersonFactory() - primary = EmailFactory(person=person,primary=True,active=True) - EmailFactory(person=person,primary=False,active=True) - EmailFactory(person=person,primary=False,active=False) + primary = EmailFactory(person=person, primary=True, active=True) + EmailFactory(person=person, primary=False, active=True) + EmailFactory(person=person, primary=False, active=False) self.assertTrue(primary.address in person.formatted_email()) def test_profile(self): diff --git a/ietf/person/utils.py b/ietf/person/utils.py index 5909eacf7..51bae8a21 100755 --- a/ietf/person/utils.py +++ b/ietf/person/utils.py @@ -34,7 +34,7 @@ def merge_persons(source, target, file=sys.stdout, verbose=False): dedupe_aliases(target) # copy other attributes - for field in ('ascii','ascii_short','address','affiliation'): + for field in ('ascii','ascii_short', 'biography', 'photo', 'photo_thumb', 'name_from_draft'): if getattr(source,field) and not getattr(target,field): setattr(target,field,getattr(source,field)) target.save() diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 93635d813..4e1488d2c 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -101,7 +101,7 @@ with db_con.cursor() as c: for name in new_aliases: Alias.objects.create(person=person, name=name) - email, created = Email.objects.get_or_create(address=row.email, person=person) + email, created = Email.objects.get_or_create(address=row.email, person=person, origin="import: %s" % __name__) if created: print "created email", email diff --git a/ietf/secr/areas/views.py b/ietf/secr/areas/views.py index 1099303ce..ca0bcb0a8 100644 --- a/ietf/secr/areas/views.py +++ b/ietf/secr/areas/views.py @@ -214,6 +214,10 @@ def people(request, name): # create role Role.objects.create(name_id='pre-ad',group=area,email=email,person=person) + if not email.origin or email.origin == person.user.username: + email.origin = "role: %s %s" % (area.acronym, 'pre-ad') + email.save() + messages.success(request, 'New Area Director added successfully!') return redirect('ietf.secr.areas.views.view', name=name) else: diff --git a/ietf/secr/drafts/forms.py b/ietf/secr/drafts/forms.py index 6d1dbe7f8..623e7ca19 100644 --- a/ietf/secr/drafts/forms.py +++ b/ietf/secr/drafts/forms.py @@ -178,6 +178,12 @@ class EditModelForm(forms.ModelForm): else: m.tags.remove('rfc-rev') + if 'shepherd' in self.changed_data: + email = self.cleaned_data.get('shepherd') + if email and not email.origin: + email.origin = 'shepherd: %s' % m.name + email.save() + # handle replaced by return m diff --git a/ietf/secr/groups/forms.py b/ietf/secr/groups/forms.py index 0d1d72cb5..c002b3b30 100644 --- a/ietf/secr/groups/forms.py +++ b/ietf/secr/groups/forms.py @@ -193,6 +193,9 @@ class RoleForm(forms.Form): name = cleaned_data['name'] group_acronym = cleaned_data['group_acronym'] + if email.person != person: + raise forms.ValidationError('ERROR: The person associated with the chosen email address is different from the chosen person') + if Role.objects.filter(name=name,group=self.group,person=person,email=email): raise forms.ValidationError('ERROR: This is a duplicate entry') diff --git a/ietf/secr/groups/views.py b/ietf/secr/groups/views.py index 956978853..6c0d46cb3 100644 --- a/ietf/secr/groups/views.py +++ b/ietf/secr/groups/views.py @@ -347,6 +347,10 @@ def people(request, acronym): email=email, group=group) + if not email.origin or email.origin == person.user.username: + email.origin = "role: %s %s" % (group.acronym, name.slug) + email.save() + messages.success(request, 'New %s added successfully!' % name) return redirect('ietf.secr.groups.views.people', acronym=group.acronym) else: diff --git a/ietf/secr/roles/views.py b/ietf/secr/roles/views.py index cd53b55e8..1063b9271 100644 --- a/ietf/secr/roles/views.py +++ b/ietf/secr/roles/views.py @@ -93,6 +93,10 @@ def main(request): email=email, group=group) + if not email.origin or email.origin == person.user.username: + email.origin = "role: %s %s" % (group.acronym, name.slug) + email.save() + messages.success(request, 'New %s added successfully!' % name) url = reverse('ietf.secr.roles.views.main') + '?group=%s' % group.acronym return HttpResponseRedirect(url) diff --git a/ietf/secr/rolodex/tests.py b/ietf/secr/rolodex/tests.py index 468526dd3..a4df74453 100644 --- a/ietf/secr/rolodex/tests.py +++ b/ietf/secr/rolodex/tests.py @@ -41,7 +41,6 @@ class RolodexTestCase(TestCase): 'ascii': 'Joe Smith', 'ascii_short': 'Joe S', 'affiliation': 'IETF', - 'address': '100 First Ave', 'email': 'joes@exanple.com', 'submit': 'Submit', } @@ -62,8 +61,6 @@ class RolodexTestCase(TestCase): 'name': person.name, 'ascii': person.ascii, 'ascii_short': person.ascii_short, - 'affiliation': person.affiliation, - 'address': person.address, 'user': user.username, 'email-0-person':person.pk, 'email-0-address': person.email_address(), diff --git a/ietf/secr/rolodex/views.py b/ietf/secr/rolodex/views.py index 03f8fc48a..be3cd5b02 100644 --- a/ietf/secr/rolodex/views.py +++ b/ietf/secr/rolodex/views.py @@ -86,7 +86,9 @@ def add_proceed(request): # save email Email.objects.create(address=email, - person=person) + person=person, + origin=request.user.username, + ) # in theory a user record could exist which wasn't associated with a Person try: diff --git a/ietf/secr/templates/includes/search_results_table.html b/ietf/secr/templates/includes/search_results_table.html index dc23455fc..97abd16f3 100644 --- a/ietf/secr/templates/includes/search_results_table.html +++ b/ietf/secr/templates/includes/search_results_table.html @@ -2,7 +2,6 @@ Name - Company Email ID @@ -11,7 +10,6 @@ {% for item in results %} {{item.name}} - {{item.person.affiliation}} {{item.person.email_address}} {{item.person.id}} diff --git a/ietf/secr/templates/rolodex/view.html b/ietf/secr/templates/rolodex/view.html index 32eb4d506..6bfa0b45f 100644 --- a/ietf/secr/templates/rolodex/view.html +++ b/ietf/secr/templates/rolodex/view.html @@ -18,8 +18,6 @@ Ascii Name:{{ person.ascii }} Short Name:{{ person.ascii_short }} Aliases:{% for alias in person.alias_set.all %}{% if not forloop.first %}, {% endif %}{{ alias.name }}{% endfor %} - Address:{{ person.address }} - Affiliation:{{ person.affiliation }} User:{{ person.user }} {% for email in person.emails %} diff --git a/ietf/settings.py b/ietf/settings.py index 9819c802f..caabf0550 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -349,6 +349,7 @@ MIDDLEWARE = ( 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.http.ConditionalGetMiddleware', + 'simple_history.middleware.HistoryRequestMiddleware', # 'ietf.middleware.sql_log_middleware', 'ietf.middleware.SMTPExceptionMiddleware', 'ietf.middleware.Utf8ExceptionMiddleware', @@ -385,6 +386,7 @@ INSTALLED_APPS = ( 'django_password_strength', 'djangobwr', 'form_utils', + 'simple_history', 'tastypie', 'widget_tweaks', # IETF apps diff --git a/ietf/stats/utils.py b/ietf/stats/utils.py index 85a11135e..a7ecc0065 100644 --- a/ietf/stats/utils.py +++ b/ietf/stats/utils.py @@ -299,18 +299,16 @@ def get_meeting_registration_data(meeting): person = Person.objects.create( name=regname, ascii=ascii_name, - affiliation=affiliation, user=user, ) # Create an associated Email address for this Person - email, __ = Email.objects.get_or_create( - person=person, - address=address[:64], - ) + try: + email = Email.objects.get(person=person, address=address[:64]) + except Email.DoesNotExist: + email = Email.objects.create(person=person, address=address[:64], origin='registration: ietf-%s'%meeting.number) if email.address != address: debug.say("Truncated address: %s --> %s" % (address, email.address)) - # If this is the only email address, set primary to true. # If the person already existed (found through Alias) and diff --git a/ietf/submit/tests.py b/ietf/submit/tests.py index d51573ce0..9470e3d86 100644 --- a/ietf/submit/tests.py +++ b/ietf/submit/tests.py @@ -324,7 +324,7 @@ class SubmitTests(TestCase): prev_author = draft.documentauthor_set.all()[0] if change_authors: # Make it such that one of the previous authors has an invalid email address - bogus_person, bogus_email = ensure_person_email_info_exists(u'Bogus Person',None) + bogus_person, bogus_email = ensure_person_email_info_exists(u'Bogus Person', None, draft.name) DocumentAuthor.objects.create(document=draft, person=bogus_person, email=bogus_email, order=draft.documentauthor_set.latest('order').order+1) # pretend IANA reviewed it diff --git a/ietf/submit/utils.py b/ietf/submit/utils.py index c40c8aa2d..192b3401e 100644 --- a/ietf/submit/utils.py +++ b/ietf/submit/utils.py @@ -136,7 +136,7 @@ def docevent_from_submission(request, submission, desc, who=None): else: submitter_parsed = submission.submitter_parsed() if submitter_parsed["name"] and submitter_parsed["email"]: - by, _ = ensure_person_email_info_exists(submitter_parsed["name"], submitter_parsed["email"]) + by, _ = ensure_person_email_info_exists(submitter_parsed["name"], submitter_parsed["email"], submission.name) else: by = system @@ -190,7 +190,7 @@ def post_submission(request, submission, approvedDesc): system = Person.objects.get(name="(System)") submitter_parsed = submission.submitter_parsed() if submitter_parsed["name"] and submitter_parsed["email"]: - submitter, _ = ensure_person_email_info_exists(submitter_parsed["name"], submitter_parsed["email"]) + submitter, _ = ensure_person_email_info_exists(submitter_parsed["name"], submitter_parsed["email"], submission.name) submitter_info = u'%s <%s>' % (submitter_parsed["name"], submitter_parsed["email"]) else: submitter = system @@ -428,7 +428,7 @@ def get_person_from_name_email(name, email): return None -def ensure_person_email_info_exists(name, email): +def ensure_person_email_info_exists(name, email, docname): addr = email email = None person = get_person_from_name_email(name, addr) @@ -437,9 +437,12 @@ def ensure_person_email_info_exists(name, email): if not person: person = Person() person.name = name + person.name_from_draft = name log.assertion('isinstance(person.name, six.text_type)') person.ascii = unidecode_name(person.name).decode('ascii') person.save() + else: + person.name_from_draft = name # make sure we have an email address if addr and (addr.startswith('unknown-email-') or is_valid_email(addr)): @@ -452,6 +455,8 @@ def ensure_person_email_info_exists(name, email): try: email = person.email_set.get(address=addr) + email.origin = "author: %s" % docname # overwrite earlier origin + email.save() except Email.DoesNotExist: try: # An Email object pointing to some other person will not exist @@ -463,10 +468,10 @@ def ensure_person_email_info_exists(name, email): # most likely we just need to create it email = Email(address=addr) email.active = active - email.person = person if email.time is None: email.time = datetime.datetime.now() + email.origin = "author: %s" % docname email.save() return person, email @@ -474,7 +479,7 @@ def ensure_person_email_info_exists(name, email): def update_authors(draft, submission): persons = [] for order, author in enumerate(submission.authors): - person, email = ensure_person_email_info_exists(author["name"], author.get("email")) + person, email = ensure_person_email_info_exists(author["name"], author.get("email"), submission.name) a = DocumentAuthor.objects.filter(document=draft, person=person).first() if not a: diff --git a/ietf/templates/base/menu_user.html b/ietf/templates/base/menu_user.html index 10fbcd5e1..cde9e31cd 100644 --- a/ietf/templates/base/menu_user.html +++ b/ietf/templates/base/menu_user.html @@ -26,6 +26,7 @@
  • Password reset
  • Preferences
  • {% endif %} +
  • Handling of personal information
  • {% endif %} {% if not request.user.is_authenticated %} diff --git a/ietf/templates/help/personal-information.html b/ietf/templates/help/personal-information.html new file mode 100644 index 000000000..5c02d2588 --- /dev/null +++ b/ietf/templates/help/personal-information.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2018-2018, All Rights Reserved #} +{% load origin %} + +{% block title %}Personal Information in the Datatracker{% endblock %} + +{% block content %} + {% origin %} +
    +

    Personal Information in the Datatracker

    +

    + + RFC 3935, "A Mission Statement for the IETF" lays out + the goal and the mission of the IETF as follows: +

    + + The goal of the IETF is to make the Internet work better. + + The mission of the IETF is to produce high quality, relevant + technical and engineering documents that influence the way people + design, use, and manage the Internet in such a way as to make the + Internet work better. These documents include protocol standards, + best current practices, and informational documents of various kinds. + + + + +

    + + In order to fulfil its mission, the IETF provides various ways to distribute + and discuss Internet-Drafts, and otherwise process them until there is + consensus that they are ready for publication. + +

    +

    + + This makes the information in the content of the draft documents, as well + as contributions related to the draft documents and their processing as + laid out in the "Note + Well" statement, of legitimate interest to the IETF when it pursues + its mission; not only in general terms, but specifically under Article + 6(1) f) of + + EU's General Data Protection Regulation . + +

    +

    + + The datatracker treats all personal information derived from draft documents and + documents published as RFC, as well as personal information derived from + processing draft documents through the IETF procedures, in accordance with + applicable law, including the GDPR. This includes author names, + email addresses and other address information provided in draft documents or as + contact information for IETF roles such as Working Group chairs, secretaries, + Area Directors and other roles. + +

    +

    + + There is however additional personal information held in the datatracker that + is used for other purposes. This information requires the consent of the + individuals whose information is stored and processed which IETF secures + through various means, and the person it relates to may at any time request + its correction or removal. This includes: + +

    + +
      +
    • Personal photograph or other likeness;
    • +
    • Personal biography;
    • +
    • Personal email addresses not derived from IETF contributions; and
    • +
    • Personal account login information
    • +
    • Personal notification subscriptions
    • +
    + +

    + + Most of this information can be edited on the individual's + Account Info page by anybody with an + account. If the datatracker holds such information about a person, and + they don't have an account, a request to the IETF secretariat to change or + remove the information will be honoured to the extent feasible and legally + permitted. + +

    + + +
    +{% endblock %} diff --git a/ietf/templates/person/person_info.html b/ietf/templates/person/person_info.html index c1a800bc4..fc47969e7 100644 --- a/ietf/templates/person/person_info.html +++ b/ietf/templates/person/person_info.html @@ -2,12 +2,6 @@
    Name:
    {{ person.name }}
    -
    -
    Address:
    {{ person.address }}
    -
    -
    -
    Affiliation:
    {{ person.affiliation}}
    -
    Login:
    {% if person.user %}{{ person.user }} (last used: {% if person.user.last_login %}{{ person.user.last_login|date:"Y-m-d" }}{% else %}never{% endif %}){% endif %}
    @@ -19,4 +13,4 @@
    Role{{ person.role_set.count|pluralize }}:
    {% for role in person.role_set.all %}{{ role.name }} {{ role.group.acronym }}{% if not forloop.last %}, {% endif %}{% endfor %}
    - \ No newline at end of file + diff --git a/ietf/templates/registration/edit_profile.html b/ietf/templates/registration/edit_profile.html index 16dcece35..8c5079ce0 100644 --- a/ietf/templates/registration/edit_profile.html +++ b/ietf/templates/registration/edit_profile.html @@ -12,22 +12,57 @@ {% origin %}

    Profile for {{ user.username }}

    +

    + Personal information in the datatracker which is derived from your contributions + to the IETF standards development process is covered by the EU General Data Protection + Regulation's + Article 6(1) (f) + covering IETF's Legitimate Interest due to the IETF's mission of developing standards + for the internet. See also the page on handling + of personal information. + +

    +

    + + Personal information which is not derived from your contributions is covered by the EU + GDPR Article 6(1) (a) + regarding consent. All such information is visible on this page, and is shown with the + dagger symbol † next to it. Most of this + information can be edited or removed on this page. There are some exceptions, such + as photos, which currently require an email to the Secretariat + if you wish to update or remove the information. + +

    +
    +
    {% csrf_token %} - {% bootstrap_form_errors person_form 'non_fields' %} + {% bootstrap_form_errors person_form %} + {% for f in new_email_forms %} + {% bootstrap_form_errors f %} + {% endfor %} +
    - +
    -

    {{ user.username }}

    +

    + {{ user.username }} +   +

    +
    - +
    @@ -78,7 +113,12 @@ {% bootstrap_field role.email_form.email layout="horizontal" show_label=False %} {% endfor %} - {% bootstrap_form person_form layout="horizontal" %} + {% bootstrap_field person_form.name layout="horizontal" %} + {% bootstrap_field person_form.ascii layout="horizontal" %} + {% if roles %} + {% bootstrap_field person_form.biography layout="horizontal" %} + {% endif %} + {% bootstrap_field person_form.consent layout="horizontal" %}
    diff --git a/ietf/templates/utils/merge_person_records.txt b/ietf/templates/utils/merge_person_records.txt index 2673a1181..69c8e7bdd 100644 --- a/ietf/templates/utils/merge_person_records.txt +++ b/ietf/templates/utils/merge_person_records.txt @@ -7,8 +7,6 @@ The merged record is: Name: {{ person.plain_name }} Aliases: {{ person.alias_set.all|join:", " }} -Address: {{ person.address }} -Affiliation: {{ person.affiliation }} User (login): {{ person.user.username }} Emails: diff --git a/ietf/utils/history.py b/ietf/utils/history.py index 3cb33f5db..85e65024d 100644 --- a/ietf/utils/history.py +++ b/ietf/utils/history.py @@ -1,6 +1,6 @@ def find_history_active_at(obj, time): """Assumes obj has a corresponding history model (e.g. obj could - be Person with a corresponding PersonHistory model), then either + be Document with a corresponding DocHistory model), then either returns the object itself if it was active at time, or the history object active at time, or None if time predates the object and its history (assuming history is complete). diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index a8a56a8d4..072e3ffd5 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -38,7 +38,7 @@ def create_person(group, role_name, name=None, username=None, email_address=None user.set_password(password) user.save() person = Person.objects.create(name=name, ascii=unidecode_name(smart_text(name)), user=user) - email = Email.objects.create(address=email_address, person=person) + email = Email.objects.create(address=email_address, person=person, origin=user.username) Role.objects.create(group=group, name_id=role_name, person=person, email=email) return person @@ -61,7 +61,7 @@ def make_immutable_base_data(): # system system_person = Person.objects.create(name="(System)", ascii="(System)") - Email.objects.create(address="", person=system_person) + Email.objects.create(address="", person=system_person, origin='test') # high-level groups ietf = create_group(name="IETF", acronym="ietf", type_id="ietf") @@ -112,7 +112,7 @@ def make_immutable_base_data(): for i in range(1, 10): u = User.objects.create(username="ad%s" % i) p = Person.objects.create(name="Ad No%s" % i, ascii="Ad No%s" % i, user=u) - email = Email.objects.create(address="ad%s@ietf.org" % i, person=p) + email = Email.objects.create(address="ad%s@ietf.org" % i, person=p, origin=u.username) if i < 6: # active Role.objects.create(name_id="ad", group=area, person=p, email=email) @@ -232,7 +232,7 @@ def make_test_data(): u.set_password("plain+password") u.save() plainman = Person.objects.create(name="Plain Man", ascii="Plain Man", user=u) - email = Email.objects.create(address="plain@example.com", person=plainman) + email = Email.objects.create(address="plain@example.com", person=plainman, origin=u.username) # group personnel create_person(mars_wg, "chair", name="WG Cháir Man", username="marschairman") @@ -473,7 +473,7 @@ def make_review_data(doc): u.set_password("reviewer+password") u.save() reviewer = Person.objects.create(name=u"Some Réviewer", ascii="Some Reviewer", user=u) - email = Email.objects.create(address="reviewer@example.com", person=reviewer) + email = Email.objects.create(address="reviewer@example.com", person=reviewer, origin=u.username) for team in (team1, team2, team3): Role.objects.create(name_id="reviewer", person=reviewer, email=email, group=team) @@ -496,14 +496,14 @@ def make_review_data(doc): u.set_password("reviewsecretary+password") u.save() reviewsecretary = Person.objects.create(name=u"Réview Secretary", ascii="Review Secretary", user=u) - reviewsecretary_email = Email.objects.create(address="reviewsecretary@example.com", person=reviewsecretary) + reviewsecretary_email = Email.objects.create(address="reviewsecretary@example.com", person=reviewsecretary, origin=u.username) Role.objects.create(name_id="secr", person=reviewsecretary, email=reviewsecretary_email, group=team1) u = User.objects.create(username="reviewsecretary3") u.set_password("reviewsecretary3+password") u.save() reviewsecretary3 = Person.objects.create(name=u"Réview Secretary3", ascii="Review Secretary3", user=u) - reviewsecretary3_email = Email.objects.create(address="reviewsecretary3@example.com", person=reviewsecretary) + reviewsecretary3_email = Email.objects.create(address="reviewsecretary3@example.com", person=reviewsecretary, origin=u.username) Role.objects.create(name_id="secr", person=reviewsecretary3, email=reviewsecretary3_email, group=team3) return review_req diff --git a/ietf/utils/tests.py b/ietf/utils/tests.py index 865856bec..b2e02f9c9 100644 --- a/ietf/utils/tests.py +++ b/ietf/utils/tests.py @@ -367,8 +367,11 @@ class AdminTestCase(TestCase): # model_list = apps.get_app_config(name).get_models() for model in model_list: - self.assertContains(r, model._meta.model_name, - msg_prefix="There doesn't seem to be any admin API for model %s.models.%s"%(app.__name__,model.__name__,)) + if model.__name__.startswith('Historical') and hasattr(model, "get_history_type_display"): + continue + else: + self.assertContains(r, model._meta.model_name, + msg_prefix="There doesn't seem to be any admin API for model %s.models.%s"%(app.__name__,model.__name__,)) ## One might think that the code below would work, but it doesn't ... diff --git a/requirements.txt b/requirements.txt index 0b6ff25cb..98ef5c19c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ django-bootstrap3>=8.2.1,<9.0.0 django-formtools>=1.0 # instead of django.contrib.formtools in 1.8 django-markup>=1.1 django-password-strength>=1.2.1 +django-simple-history>=2.0 django-tastypie>=0.13.2 django-widget-tweaks>=1.3 docutils>=0.12