Skip to content

feat: more flexible liaison stmt "From Contact" #8958

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ietf/liaisons/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class LiaisonStatementAdmin(admin.ModelAdmin):
list_display = ['id', 'title', 'submitted', 'from_groups_short_display', 'purpose', 'related_to']
list_display_links = ['id', 'title']
ordering = ('title', )
raw_id_fields = ('from_contact', 'attachments', 'from_groups', 'to_groups')
raw_id_fields = ('attachments', 'from_groups', 'to_groups')
#filter_horizontal = ('from_groups', 'to_groups')
inlines = [ RelatedLiaisonStatementInline, LiaisonStatementAttachmentInline ]

Expand All @@ -50,4 +50,4 @@ class LiaisonStatementEventAdmin(admin.ModelAdmin):
raw_id_fields = ["statement", "by"]

admin.site.register(LiaisonStatement, LiaisonStatementAdmin)
admin.site.register(LiaisonStatementEvent, LiaisonStatementEventAdmin)
admin.site.register(LiaisonStatementEvent, LiaisonStatementEventAdmin)
2 changes: 1 addition & 1 deletion ietf/liaisons/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class Meta:
skip_postgeneration_save = True

title = factory.Faker('sentence')
from_contact = factory.SubFactory('ietf.person.factories.EmailFactory')
from_contact = factory.Faker('email')
purpose_id = 'comment'
body = factory.Faker('paragraph')
state_id = 'posted'
Expand Down
85 changes: 38 additions & 47 deletions ietf/liaisons/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,33 @@


import io
import os
import operator

import os
from email.utils import parseaddr
from functools import reduce
from typing import Union, Optional # pyflakes:ignore

from django import forms
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.forms.utils import ErrorList
from django.db.models import Q, QuerySet
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import Q, QuerySet
from django.forms.utils import ErrorList
from django_stubs_ext import QuerySetAny

from ietf.doc.models import Document
from ietf.group.models import Group
from ietf.ietfauth.utils import has_role
from ietf.name.models import DocRelationshipName
from ietf.liaisons.fields import SearchableLiaisonStatementsField
from ietf.liaisons.models import (LiaisonStatement,
LiaisonStatementEvent, LiaisonStatementAttachment, LiaisonStatementPurposeName)
from ietf.liaisons.utils import get_person_for_user, is_authorized_individual, OUTGOING_LIAISON_ROLES, \
INCOMING_LIAISON_ROLES
from ietf.liaisons.widgets import ButtonWidget,ShowAttachmentsWidget
from ietf.liaisons.models import (LiaisonStatement,
LiaisonStatementEvent,LiaisonStatementAttachment,LiaisonStatementPurposeName)
from ietf.liaisons.fields import SearchableLiaisonStatementsField
from ietf.group.models import Group
from ietf.person.models import Email, Person
from ietf.person.fields import SearchableEmailField
from ietf.doc.models import Document
from ietf.liaisons.widgets import ButtonWidget, ShowAttachmentsWidget
from ietf.name.models import DocRelationshipName
from ietf.person.models import Person
from ietf.utils.fields import DatepickerDateField, ModelMultipleChoiceField
from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO
from functools import reduce

'''
NOTES:
Expand Down Expand Up @@ -212,7 +210,7 @@ def get_results(self):
query = self.cleaned_data.get('text')
if query:
q = (Q(title__icontains=query) |
Q(from_contact__address__icontains=query) |
Q(from_contact__icontains=query) |
Q(to_contacts__icontains=query) |
Q(other_identifiers__icontains=query) |
Q(body__icontains=query) |
Expand Down Expand Up @@ -274,7 +272,6 @@ class LiaisonModelForm(forms.ModelForm):
'''Specify fields which require a custom widget or that are not part of the model.
'''
from_groups = ModelMultipleChoiceField(queryset=Group.objects.all(),label='Groups',required=False)
from_contact = forms.EmailField() # type: Union[forms.EmailField, SearchableEmailField]
to_contacts = forms.CharField(label="Contacts", widget=forms.Textarea(attrs={'rows':'3', }), strip=False)
to_groups = ModelMultipleChoiceField(queryset=Group.objects,label='Groups',required=False)
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Deadline', required=True)
Expand Down Expand Up @@ -309,7 +306,7 @@ def __init__(self, user, *args, **kwargs):
self.fields["other_identifiers"].widget.attrs["rows"] = 2

# add email validators
for field in ['from_contact','to_contacts','technical_contacts','action_holder_contacts','cc_contacts']:
for field in ['to_contacts','technical_contacts','action_holder_contacts','cc_contacts']:
if field in self.fields:
self.fields[field].validators.append(validate_emails)

Expand All @@ -328,18 +325,6 @@ def clean_to_groups(self):
raise forms.ValidationError('You must specify a To Group')
return to_groups

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

# Note to future person: This is the wrong place to fix the new lines
# in cc_contacts and to_contacts. Those belong in the save function.
# Or at least somewhere other than here.
Expand Down Expand Up @@ -516,7 +501,7 @@ def set_from_fields(self):
or has_role(self.user, "Liaison Coordinator")
):
self.fields["from_contact"].initial = (
self.person.role_set.filter(group=qs[0]).first().email.address
self.person.role_set.filter(group=qs[0]).first().email.formatted_email()
)
self.fields["from_contact"].widget.attrs["disabled"] = True

Expand All @@ -537,21 +522,11 @@ class Meta:
def is_approved(self):
return self.cleaned_data['approved']

@staticmethod
def from_contact_queryset(person):
if person.role_set.filter(name='liaiman',group__state='active'):
email = person.role_set.filter(name='liaiman',group__state='active').first().email
elif person.role_set.filter(name__in=('ad','chair'),group__state='active'):
email = person.role_set.filter(name__in=('ad','chair'),group__state='active').first().email
else:
email = person.email()
return Email.objects.filter(pk=email)

def set_from_fields(self):
"""Configure from "From" fields based on user roles"""
self.set_from_groups_field()
self.set_from_contact_field()

def set_from_groups_field(self):
"""Configure the from_groups field based on roles"""
grouped_choices = choices_from_group_queryset(internal_groups_for_person(self.person))
Expand All @@ -564,13 +539,29 @@ def set_from_groups_field(self):

def set_from_contact_field(self):
"""Configure the from_contact field based on user roles"""
# Secretariat can set this to any valid address but gets no default
if has_role(self.user, "Secretariat"):
self.fields['from_contact'] = SearchableEmailField(only_users=True) # secretariat can edit this field!
return
elif has_role(self.user, ["IAB Chair", "Liaison Coordinator"]):
self.fields["from_contact"].initial = "IAB Chair <iab-chair@iab.org>"
return
elif has_role(self.user, "IETF Chair"):
self.fields["from_contact"].initial = "IETF Chair <chair@ietf.org>"
return
# ... others have it set to the correct value and cannot change it
self.fields['from_contact'].disabled = True
# Set up the querysets we might use - only evaluated as needed
liaison_manager_role = self.person.role_set.filter(name="liaiman", group__state="active")
chair_or_ad_role = self.person.role_set.filter(
name__in=("ad", "chair"), group__state="active"
)
if liaison_manager_role.exists():
from_contact_email = liaison_manager_role.first().email
elif chair_or_ad_role.exists():
from_contact_email = chair_or_ad_role.first().email
else:
# Non-secretariat user cannot change the from_contact field. Fill in its value.
allowed_from_emails = self.from_contact_queryset(self.person)
self.fields['from_contact'].disabled = True
self.fields['from_contact'].initial = allowed_from_emails.first().address # todo actually allow choice
from_contact_email = self.person.email()
self.fields['from_contact'].initial = from_contact_email.formatted_email()

def set_to_fields(self):
"""Configure the "To" fields based on user roles"""
Expand Down
22 changes: 22 additions & 0 deletions ietf/liaisons/migrations/0003_liaisonstatement_from_contact_tmp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright The IETF Trust 2025 All Rights Reserved
from django.db import migrations, models
import ietf.utils.validators


class Migration(migrations.Migration):
dependencies = [
("liaisons", "0002_alter_liaisonstatement_response_contacts"),
]

operations = [
migrations.AddField(
model_name="liaisonstatement",
name="from_contact_tmp",
field=models.CharField(
blank=True,
help_text="Address of the formal sender of the statement",
max_length=512,
validators=[ietf.utils.validators.validate_mailbox_address],
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright The IETF Trust 2025, All Rights Reserved
from itertools import islice

from django.db import migrations

from ietf.person.name import plain_name
from ietf.utils.mail import formataddr
from ietf.utils.validators import validate_mailbox_address


def forward(apps, schema_editor):
def _formatted_email(email):
"""Format an email address to match Email.formatted_email()"""
person = email.person
if person:
return formataddr(
(
# inlined Person.plain_name(), minus the caching
person.plain if person.plain else plain_name(person.name),
email.address,
)
)
return email.address

def _batched(iterable, n):
"""Split an iterable into lists of length <= n

(based on itertools example code for batched(), which is added in py312)
"""
iterator = iter(iterable)
batch = list(islice(iterator, n)) # consumes first n iterations
while batch:
yield batch
batch = list(islice(iterator, n)) # consumes next n iterations

LiaisonStatement = apps.get_model("liaisons", "LiaisonStatement")
LiaisonStatement.objects.update(from_contact_tmp="") # ensure they're all blank
for batch in _batched(
LiaisonStatement.objects.exclude(from_contact=None).select_related(
"from_contact"
),
100,
):
for ls in batch:
ls.from_contact_tmp = _formatted_email(ls.from_contact)
validate_mailbox_address(
ls.from_contact_tmp
) # be sure it's permitted before we accept it

LiaisonStatement.objects.bulk_update(batch, fields=["from_contact_tmp"])


class Migration(migrations.Migration):
dependencies = [
("liaisons", "0003_liaisonstatement_from_contact_tmp"),
]

operations = [
migrations.RunPython(forward),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright The IETF Trust 2025 All Rights Reserved
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("liaisons", "0004_populate_liaisonstatement_from_contact_tmp"),
]

operations = [
migrations.RemoveField(
model_name="liaisonstatement",
name="from_contact",
),
migrations.RenameField(
model_name="liaisonstatement",
old_name="from_contact_tmp",
new_name="from_contact",
),
]
12 changes: 9 additions & 3 deletions ietf/liaisons/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
from django.db import models
from django.utils.text import slugify

from ietf.person.models import Email, Person
from ietf.person.models import Person
from ietf.name.models import (LiaisonStatementPurposeName, LiaisonStatementState,
LiaisonStatementEventTypeName, LiaisonStatementTagName,
DocRelationshipName)
from ietf.doc.models import Document
from ietf.group.models import Group
from ietf.utils.models import ForeignKey
from ietf.utils.validators import validate_mailbox_address

# maps (previous state id, new state id) to event type id
STATE_EVENT_MAPPING = {
Expand All @@ -29,7 +30,12 @@
class LiaisonStatement(models.Model):
title = models.CharField(max_length=255)
from_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_from_set')
from_contact = ForeignKey(Email, blank=True, null=True)
from_contact = models.CharField(
blank=True,
max_length=512,
help_text="Address of the formal sender of the statement",
validators=(validate_mailbox_address,)
)
to_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_to_set')
to_contacts = models.CharField(max_length=2000, help_text="Contacts at recipient group")

Expand Down Expand Up @@ -85,7 +91,7 @@ def name(self):
if self.from_groups.count():
frm = ', '.join([i.acronym or i.name for i in self.from_groups.all()])
else:
frm = self.from_contact.person.name
frm = self.from_contact
if self.to_groups.count():
to = ', '.join([i.acronym or i.name for i in self.to_groups.all()])
else:
Expand Down
6 changes: 1 addition & 5 deletions ietf/liaisons/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@
RelatedLiaisonStatement)


from ietf.person.resources import EmailResource
from ietf.group.resources import GroupResource
from ietf.name.resources import LiaisonStatementPurposeNameResource, LiaisonStatementTagNameResource, LiaisonStatementStateResource
from ietf.doc.resources import DocumentResource
class LiaisonStatementResource(ModelResource):
from_contact = ToOneField(EmailResource, 'from_contact', null=True)
purpose = ToOneField(LiaisonStatementPurposeNameResource, 'purpose')
state = ToOneField(LiaisonStatementStateResource, 'state')
from_groups = ToManyField(GroupResource, 'from_groups', null=True)
Expand All @@ -36,6 +34,7 @@ class Meta:
filtering = {
"id": ALL,
"title": ALL,
"from_contact": ALL,
"to_contacts": ALL,
"response_contacts": ALL,
"technical_contacts": ALL,
Expand All @@ -44,9 +43,6 @@ class Meta:
"deadline": ALL,
"other_identifiers": ALL,
"body": ALL,
"from_name": ALL,
"to_name": ALL,
"from_contact": ALL_WITH_RELATIONS,
"purpose": ALL_WITH_RELATIONS,
"state": ALL_WITH_RELATIONS,
"from_groups": ALL_WITH_RELATIONS,
Expand Down
6 changes: 3 additions & 3 deletions ietf/liaisons/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ def test_add_incoming_liaison(self):

l = LiaisonStatement.objects.all().order_by("-id")[0]
self.assertEqual(l.from_groups.count(),2)
self.assertEqual(l.from_contact.address, submitter.email_address())
self.assertEqual(l.from_contact, submitter.email_address())
self.assertSequenceEqual(l.to_groups.all(),[to_group])
self.assertEqual(l.technical_contacts, "technical_contact@example.com")
self.assertEqual(l.action_holder_contacts, "action_holder_contacts@example.com")
Expand Down Expand Up @@ -845,7 +845,7 @@ def test_add_outgoing_liaison(self):

l = LiaisonStatement.objects.all().order_by("-id")[0]
self.assertSequenceEqual(l.from_groups.all(), [from_group])
self.assertEqual(l.from_contact.address, submitter.email_address())
self.assertEqual(l.from_contact, submitter.email_address())
self.assertSequenceEqual(l.to_groups.all(), [to_group])
self.assertEqual(l.to_contacts, "to_contacts@example.com")
self.assertEqual(l.technical_contacts, "technical_contact@example.com")
Expand Down Expand Up @@ -921,7 +921,7 @@ def test_liaison_add_attachment(self):
file.name = "upload.txt"
post_data = dict(
from_groups = ','.join([ str(x.pk) for x in liaison.from_groups.all() ]),
from_contact = liaison.from_contact.address,
from_contact = liaison.from_contact,
to_groups = ','.join([ str(x.pk) for x in liaison.to_groups.all() ]),
to_contacts = 'to_contacts@example.com',
purpose = liaison.purpose.slug,
Expand Down
2 changes: 1 addition & 1 deletion ietf/templates/liaisons/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ <h1>
{% if liaison.from_contact %}
<tr>
<th scope="row">From Contact</th>
<td>{% person_link liaison.from_contact.person %}</td>
<td>{{ liaison.from_contact }}</td>
</tr>
{% endif %}
<tr>
Expand Down
Loading