diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index b5256b14f8..cdf70ac8e8 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -232,9 +232,9 @@ def confirm_account(request, auth): if not person: name = form.cleaned_data["name"] ascii = form.cleaned_data["ascii"] - person = Person.objects.create(user=user, - name=name, - ascii=ascii) + + person = Person.objects.create(user=user, name=name, ascii=ascii) + person.uuids.create(person=person) for name in set([ person.name, person.ascii, person.plain_name(), person.plain_ascii(), ]): Alias.objects.create(person=person, name=name) diff --git a/ietf/person/admin.py b/ietf/person/admin.py index f46edcf8ae..bccfd8a9d7 100644 --- a/ietf/person/admin.py +++ b/ietf/person/admin.py @@ -4,7 +4,8 @@ from django import forms -from ietf.person.models import Email, Alias, Person, PersonalApiKey, PersonEvent, PersonApiKeyEvent, PersonExtResource +from ietf.person.models import Email, Alias, Person, PersonalApiKey, PersonEvent, \ + PersonApiKeyEvent, PersonExtResource, PersonUUID from ietf.person.name import name_parts from ietf.utils.admin import SaferStackedInline, SaferTabularInline @@ -29,6 +30,18 @@ class AliasAdmin(admin.ModelAdmin): class AliasInline(SaferStackedInline): model = Alias + +class PersonUUIDAdmin(admin.ModelAdmin): + list_display = ["uuid"] + raw_id_fields = ["person"] +admin.site.register(PersonUUID, PersonUUIDAdmin) + + +class PersonUUIDInline(SaferStackedInline): + model = PersonUUID + extra = 0 + + class PersonAdmin(simple_history.admin.SimpleHistoryAdmin): def plain_name(self, obj): if obj.plain: @@ -41,7 +54,7 @@ def plain_name(self, obj): readonly_fields = ("name_from_draft", ) search_fields = ["name", "ascii"] raw_id_fields = ["user"] - inlines = [ EmailInline, AliasInline, ] + inlines = [ EmailInline, AliasInline, PersonUUIDInline] # actions = None admin.site.register(Person, PersonAdmin) diff --git a/ietf/person/factories.py b/ietf/person/factories.py index 98756f26c8..fd94e953ba 100644 --- a/ietf/person/factories.py +++ b/ietf/person/factories.py @@ -20,7 +20,8 @@ import debug # pyflakes:ignore -from ietf.person.models import Person, Alias, Email, PersonalApiKey, PersonApiKeyEvent, PERSON_API_KEY_ENDPOINTS +from ietf.person.models import Person, Alias, Email, PersonalApiKey, PersonApiKeyEvent, \ + PERSON_API_KEY_ENDPOINTS, PersonUUID from ietf.person.name import normalize_name, unidecode_name @@ -64,12 +65,21 @@ def set_password(obj, create, extracted, **kwargs): # pylint: disable=no-self-ar obj.set_password( '%s+password' % obj.username ) # pylint: disable=no-value-for-parameter obj.save() + +class PersonUUIDFactory(factory.django.DjangoModelFactory): + person = factory.SubFactory("ietf.person.factories.PersonFactory") + + class Meta: + model = PersonUUID + + class PersonFactory(factory.django.DjangoModelFactory): class Meta: model = Person skip_postgeneration_save = True user = factory.SubFactory(UserFactory) + uuid = factory.RelatedFactory("ietf.person.factories.PersonUUIDFactory", "person") name = factory.LazyAttribute(lambda p: normalize_name('%s %s'%(p.user.first_name, p.user.last_name))) # Some i18n names, e.g., "शिला के.सी." have a dot at the end that is also part of the ASCII, e.g., "Shilaa Kesii." # That trailing dot breaks extract_authors(). Avoid this issue by stripping the dot from the ASCII. diff --git a/ietf/person/migrations/0006_personuuid.py b/ietf/person/migrations/0006_personuuid.py new file mode 100644 index 0000000000..a9465279d6 --- /dev/null +++ b/ietf/person/migrations/0006_personuuid.py @@ -0,0 +1,47 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from django.db import migrations, models +import django.db.models.deletion +import ietf.person.models + + +def forward(apps, schema_editor): + Person = apps.get_model("person", "Person") + for person in Person.objects.all(): + person.uuids.create(person=person) + + +def reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("person", "0005_alter_historicalperson_pronouns_selectable_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PersonUUID", + fields=[ + ( + "uuid", + models.UUIDField( + default=ietf.person.models.unused_person_uuid, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "person", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="uuids", + to="person.person", + ), + ), + ], + ), + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/person/models.py b/ietf/person/models.py index 3ab89289a6..2789ff7476 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -44,6 +44,26 @@ def name_character_validator(value): ) +def unused_person_uuid(): + MAX_ATTEMPTS = 50 # ludicrously large + for _ in range(MAX_ATTEMPTS): + candidate = uuid.uuid4() + if not PersonUUID.objects.filter(uuid=candidate).exists(): + return candidate + raise RuntimeError("Unable to generate unused UUID") + + +class PersonUUID(models.Model): + """Surrogate key for a Person""" + uuid = models.UUIDField(primary_key=True, editable=False, default=unused_person_uuid) + person = models.ForeignKey( + "person.Person", related_name="uuids", on_delete=models.PROTECT + ) + + def __str__(self): + return str(self.uuid) + + class Person(models.Model): history = HistoricalRecords() user = OneToOneField(User, blank=True, null=True, on_delete=models.SET_NULL) diff --git a/ietf/person/tests.py b/ietf/person/tests.py index f55d8b8a34..eb848b34e3 100644 --- a/ietf/person/tests.py +++ b/ietf/person/tests.py @@ -114,6 +114,31 @@ def test_person_profile_without_email(self): r = self.client.get(url) self.assertContains(r, person.name, status_code=200) + def test_person_profile_by_uuid(self): + person_a = PersonFactory(name="A Fine Person") + uuid_a = person_a.uuids.first() + url_a = urlreverse("ietf.person.views.profile_by_uuid", kwargs={"uuid": uuid_a.uuid}) + + person_b = PersonFactory(name="Brilliant Person") + uuid_b = person_b.uuids.first() + url_b = urlreverse("ietf.person.views.profile_by_uuid", kwargs={"uuid": uuid_b.uuid}) + + r = self.client.get(url_a) + self.assertContains(r, person_a.name) + self.assertNotContains(r, person_b.name) + + r = self.client.get(url_b) + self.assertNotContains(r, person_a.name) + self.assertContains(r, person_b.name) + + # move the UUID from b to a... + uuid_b.person = person_a + uuid_b.save() + # ... and see that the view returns the other person's values + r = self.client.get(url_a) + self.assertContains(r, person_a.name) + self.assertNotContains(r, person_b.name) + def test_case_insensitive(self): # Case insensitive seach person = PersonFactory(name="Test Person") @@ -392,9 +417,12 @@ def test_merge_persons(self): request = HttpRequest() request.user = user source = PersonFactory() + source.uuids.create() # give them an extra target = PersonFactory() mars = RoleFactory(name_id='chair',group__acronym='mars').group source_id = source.pk + source_uuids = set(source.uuids.values_list("uuid", flat=True)) + target_uuids = set(target.uuids.values_list("uuid", flat=True)) source_email = source.email_set.first() source_alias = source.alias_set.first() source_user = source.user @@ -413,6 +441,10 @@ def test_merge_persons(self): self.assertIn(nomination, target.nomination_set.all()) self.assertFalse(Person.objects.filter(id=source_id)) self.assertFalse(source_user.is_active) + self.assertEqual( + set(target.uuids.values_list("uuid", flat=True)), + source_uuids | target_uuids, + ) def test_merge_persons_reviewer_settings(self): secretariat_role = RoleFactory(group__acronym='secretariat', name_id='secr') diff --git a/ietf/person/urls.py b/ietf/person/urls.py index f3eccd04b7..52aa6748d4 100644 --- a/ietf/person/urls.py +++ b/ietf/person/urls.py @@ -9,6 +9,7 @@ url(r'^merge/send_request/?$', views.send_merge_request), url(r'^search/(?P(person|email))/$', views.ajax_select2_search), url(r'^(?P[0-9]+)/email.json$', ajax.person_email_json), + url(r'^(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$', views.profile_by_uuid), url(r'^(?P[^/]+)$', views.profile), url(r'^(?P[^/]+)/photo/?$', views.photo), ] diff --git a/ietf/person/utils.py b/ietf/person/utils.py index 5ed90591f9..5a9eae2ef9 100755 --- a/ietf/person/utils.py +++ b/ietf/person/utils.py @@ -52,6 +52,9 @@ def merge_persons(request, source, target, file=sys.stdout, verbose=False): move_related_objects(source, target, file=file, verbose=verbose) dedupe_aliases(target) + # move UUIDs + source.uuids.update(person=target) + # copy other attributes for field in ('ascii','ascii_short', 'biography', 'photo', 'photo_thumb', 'name_from_draft'): if getattr(source,field) and not getattr(target,field): diff --git a/ietf/person/views.py b/ietf/person/views.py index d0b5912431..ca147ca0ce 100644 --- a/ietf/person/views.py +++ b/ietf/person/views.py @@ -9,7 +9,7 @@ from django.contrib import messages from django.db.models import Q from django.http import HttpResponse, Http404 -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 from django.template.loader import render_to_string from django.utils import timezone @@ -77,6 +77,13 @@ def profile(request, email_or_name): return render(request, 'person/profile.html', {'persons': persons, 'today': timezone.now()}) +def profile_by_uuid(request, uuid): + person = get_object_or_404(Person, uuids=uuid) + return render( + request, "person/profile.html", {"persons": [person], "today": timezone.now()} + ) + + def photo(request, email_or_name): persons = lookup_persons(email_or_name) if len(persons) > 1: