diff --git a/ietf/api/tests.py b/ietf/api/tests.py index f7fa1aff02c..75147d679a4 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -1072,10 +1072,10 @@ def test_role_holder_addresses(self): ) @override_settings( - APP_API_TOKENS={"ietf.api.views.rfc_authors": ["valid-token"]} + APP_API_TOKENS={"ietf.api.views.rfc_author_survey_recipients": ["valid-token"]} ) - def test_rfc_authors(self): - url = urlreverse("ietf.api.views.rfc_authors") + def test_rfc_author_survey_recipients(self): + url = urlreverse("ietf.api.views.rfc_author_survey_recipients") # auth and method checks self.assertEqual( self.client.get(url, headers={}).status_code, 403, "No api token, no access" @@ -1179,18 +1179,30 @@ def test_rfc_authors(self): self.assertEqual(rows[0]["name"], "Jane Q. Author") # If in test mode and testaddr parameters are present, records for those - # addresses should be returned with a fake RFC. + # addresses should be returned with a fake RFC. Also exercise specifying + # recipient type, including that author is the default. r = self.client.get( - url + "?testing&testaddr=fake@a.example.com&testaddr=phony@b.example.com", + url + + ( + "?testing" + "&testaddr=fake@a.example.com" + "&testaddr=phony@b.example.com;shepherd" + "&testaddr=ersatz@c.example.com;shepherd,author" + ), headers={"X-Api-Key": "valid-token"}, ) self.assertEqual(r.status_code, 200) rows = json.loads(r.content) - self.assertEqual(len(rows), 3) + self.assertEqual(len(rows), 4) fake_author_addr = author.email().address.split("@", 1)[0] + "@fake.example.com" self.assertCountEqual( - [fake_author_addr, "fake@a.example.com", "phony@b.example.com"], - [r["email"] for r in rows], + [ + (fake_author_addr, "author"), + ("fake@a.example.com", "author"), + ("phony@b.example.com", "shepherd"), + ("ersatz@c.example.com", "author/shepherd"), + ], + [(r["email"], r["type"]) for r in rows], ) # Can only use testaddr when testing is also present @@ -1210,6 +1222,90 @@ def test_rfc_authors(self): ) self.assertEqual(r.status_code, 400, "out-of-order from/to parameters") + @override_settings( + APP_API_TOKENS={"ietf.api.views.rfc_author_survey_recipients": ["valid-token"]} + ) + def test_rfc_author_survey_recipients_with_shepherd(self): + url = urlreverse("ietf.api.views.rfc_author_survey_recipients") + now = timezone.now() + one_day_ago = now - datetime.timedelta(days=1) + # A recently published RFC with a known author and shepherd + author = PersonFactory(name="Jane Q. Author") + recent_rfc = WgRfcFactory(title="A Recently Published RFC") + DocEventFactory(doc=recent_rfc, type="published_rfc", time=one_day_ago) + RfcAuthorFactory(document=recent_rfc, person=author) + shepherd_email = EmailFactory(person__name="Alicia Shepherd") + shepherd = shepherd_email.person + recent_rfc_draft = WgDraftFactory(shepherd_id=shepherd_email.pk) + recent_rfc_draft.relateddocument_set.create( + relationship_id="became_rfc", target=recent_rfc + ) + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["Content-Type"], "application/json") + rows = json.loads(r.content) + expected_rows = [ + { + "name": shepherd.name, + "email": shepherd.email_address(), + "type": "shepherd", + "rfc_number": str(recent_rfc.rfc_number), + "rfc_name": recent_rfc.name, + "rfc_number_and_title": ( + f"RFC {recent_rfc.rfc_number}: {recent_rfc.title}" + ), + "rfc_title": recent_rfc.title, + "published_date": str(recent_rfc.pub_date()), + }, + { + "name": author.name, + "email": author.email_address(), + "type": "author", + "rfc_number": str(recent_rfc.rfc_number), + "rfc_name": recent_rfc.name, + "rfc_title": recent_rfc.title, + "rfc_number_and_title": ( + f"RFC {recent_rfc.rfc_number}: {recent_rfc.title}" + ), + "published_date": str(recent_rfc.pub_date()), + }, + ] + self.assertCountEqual( + rows, + expected_rows, + ) + + # Now give the shepherd an RFC as author + other_rfc = WgRfcFactory(title="Other Published RFC") + DocEventFactory(doc=other_rfc, type="published_rfc", time=one_day_ago) + RfcAuthorFactory(document=other_rfc, person=shepherd) + + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["Content-Type"], "application/json") + rows = json.loads(r.content) + # update expected rows + expected_rows[0]["type"] = "author/shepherd" + expected_rows[0]["rfc_number"] = ( + f"{recent_rfc.rfc_number}, {other_rfc.rfc_number}" + ) + expected_rows[0]["rfc_name"] = f"{recent_rfc.name}, {other_rfc.name}" + expected_rows[0]["rfc_title"] = f"{recent_rfc.title}, {other_rfc.title}" + expected_rows[0]["rfc_number_and_title"] = ( + f"RFC {recent_rfc.rfc_number}: {recent_rfc.title}, " + f"RFC {other_rfc.rfc_number}: {other_rfc.title}" + ) + expected_rows[0]["published_date"] = ( + f"{recent_rfc.pub_date()}, {other_rfc.pub_date()}" + ) + # and compare + self.assertCountEqual( + rows, + expected_rows, + ) + + + @override_settings( APP_API_TOKENS={"ietf.api.views.ingest_email": "valid-token", "ietf.api.views.ingest_email_test": "test-token"} ) diff --git a/ietf/api/urls.py b/ietf/api/urls.py index dc1ddb6d0ca..d2dc774efb6 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -44,8 +44,10 @@ # --- Custom API endpoints, sorted alphabetically --- # Email alias information for drafts url(r'^doc/draft-aliases/$', api_views.draft_aliases), - # Authors of recently published RFCs, as CSV - url(r'^doc/rfc-authors/$', api_views.rfc_authors), + # Recipients for author survey for recently published RFCs + url( + r'^doc/rfc-author-survey-recipients/$', api_views.rfc_author_survey_recipients + ), # email ingestor url(r'email/$', api_views.ingest_email), # email ingestor diff --git a/ietf/api/views.py b/ietf/api/views.py index 9c4743c152a..5d574ac4e60 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -566,27 +566,39 @@ def role_holder_addresses(request): @requires_api_token @csrf_exempt -def rfc_authors(request): - """Return authors of published RFCs as a JSON list of objects. +def rfc_author_survey_recipients(request): + """Return recipients of the RFC Author Survey for RFCs in a time range - Finds authors by date, though this could be extended to other selection + Returns the authors and (if present) document shepherd for RFCs as a JSON + structure. This is intended for generation of the post-publication Author + Survey and is not likely to be useful elsewhere. + + Finds RFCs by date, though this could be extended to other selection criteria in the future. Specify the range as ?from=&to=. Each is an ISO-8601 timestamp, treated as UTC if it does not include time zone information. Defaults to `to`=now, `from`=14 days before `to` Each author appears once, with their RFC numbers, names, titles, and publication dates accumulated across every RFC they published in the window. + Authors have `"type": "author"` in their records. + + If an RFC has a shepherd assigned to its originating draft, that shepherd is + included in the list of recipients alongside the authors. Shepherds have + `"type": "shepherd"` in their records. If a shepherd is also an author, then + `"type": "author/shepherd"`. When the ?testing query parameter is supplied, each author's real email address is replaced with a fake one that keeps the original mailbox but uses the "fake.example.com" domain, so the output can be shared without exposing - real addresses. + real addresses. When the ?testing query parameter is supplied, one or more testaddr=ADDRESS query parameters can also be specified. The value of each parameter is an email address. When these parameters are present, the response data will include an - entry for each address as though it belonged to the author of a recently published - RFC. + entry for each address as though it belonged to an author or shepherd of a recently + published RFC. To specify the recipient type, append ";author", ";shepherd", + or ";author,shepherd" to the end of the email address. E.g., + "?testing&testaddr=somebody@example.com;author,shepherd" """ if request.method != "GET": return HttpResponse(status=405) @@ -634,56 +646,99 @@ def rfc_authors(request): docevent__type="published_rfc", docevent__time__gte=time_range_start, docevent__time__lt=time_range_end, - ).distinct() + ).order_by("rfc_number").distinct() # Collect per-author data keyed by email so each author gets one row. # Values accumulate RFC numbers, names, titles, and dates across all RFCs. - author_data = {} + recipient_data = {} for rfc in rfcs: # RfcAuthor is the authoritative source for RFC authors. Documents of # type "rfc" always have an rfcauthor_set, so no fallback is needed. - authors = [ + recipients = [ { "name": a.person.name if a.person else a.titlepage_name, "email": a.person.email().address if (a.person and a.person.email()) else None, + "type": "author", # distinguishes authors from the shepherd entry added below } for a in rfc.rfcauthor_set.select_related("person").order_by("order") ] - for author in authors: - if not author["email"]: + # As of Aug 2026, the rfc Document doesn't carry its own shepherd - it's only + # set on the draft it came from. If the shepherd changes, the value on the draft + # will be kept up to date. + # + # came_from_draft() can return None (e.g. very old RFCs with no tracked + # originating draft, April 1 RFCs, and other special cases), so guard for that. + # Also, shepherd.person should always be set (see assertion in views_doc.py) + # but skip rather than crash if that invariant is ever violated. + originating_draft = rfc.came_from_draft() + if ( + originating_draft + and originating_draft.shepherd is not None + and originating_draft.shepherd.person is not None + ): + # Use email_address() to find an active address if this one is stale + shepherd_email = originating_draft.shepherd.email_address() + shepherd = originating_draft.shepherd.person + if shepherd_email is not None: + recipients.append({ + "name": shepherd.name, + "email": shepherd_email, + "type": "shepherd", + }) + else: + log.log( + f"rfc_authors(): shepherd for {rfc.name}, has no active email " + f"address, omitting shepherd ({shepherd.name})" + ) + + for recipient in recipients: + if not recipient["email"]: continue if testing: - mailbox = author["email"].split("@", 1)[0] + mailbox = recipient["email"].split("@", 1)[0] email = f"{mailbox}@fake.example.com" else: - email = author["email"] + email = recipient["email"] - if email not in author_data: - author_data[email] = { - "name": author["name"], + if email not in recipient_data: + recipient_data[email] = { + "name": recipient["name"], "email": email, + "types": set(), "rfc_numbers": [], "rfc_names": [], "rfc_titles": [], "published_dates": [], } - author_data[email]["rfc_numbers"].append(str(rfc.rfc_number)) - author_data[email]["rfc_names"].append(rfc.name) - author_data[email]["rfc_titles"].append(rfc.title) - author_data[email]["published_dates"].append(str(rfc.pub_date())) + recipient_data[email]["rfc_numbers"].append(str(rfc.rfc_number)) + recipient_data[email]["rfc_names"].append(rfc.name) + recipient_data[email]["rfc_titles"].append(rfc.title) + recipient_data[email]["published_dates"].append(str(rfc.pub_date())) + recipient_data[email]["types"].add(recipient["type"]) if testing: for n, email in enumerate(test_addresses): - if email in author_data: - continue # author will already be included - author_data[email] = { + if email in recipient_data: + continue # recipient will already be included + # see if a type list was included + if ";" in email: + email, types = email.rsplit(";", 1) + types = {type_.strip() for type_ in types.split(",")} + if len(types.difference({"author", "shepherd"})) != 0: + return HttpResponseBadRequest( + f"Invalid types for testaddr={email}" + ) + else: + types = {"author"} + recipient_data[email] = { "name": f"Test Author {n + 1}", "email": email, + "types": types, "rfc_numbers": ["99999"], "rfc_names": ["rfc99999"], "rfc_titles": ["A Fake RFC for Testing"], @@ -691,11 +746,13 @@ def rfc_authors(request): } rows = [] - for entry in author_data.values(): + for entry in recipient_data.values(): + entry_type = "/".join(sorted(entry["types"])) rows.append( { "name": entry["name"], "email": entry["email"], + "type": entry_type, # "author", "shepherd", or "author/shepherd" "rfc_number": ", ".join(entry["rfc_numbers"]), "rfc_name": ", ".join(entry["rfc_names"]), "rfc_title": ", ".join(entry["rfc_titles"]), diff --git a/ietf/doc/forms.py b/ietf/doc/forms.py index 768d6f96af2..82076d31733 100644 --- a/ietf/doc/forms.py +++ b/ietf/doc/forms.py @@ -5,8 +5,10 @@ import datetime import debug #pyflakes:ignore from django import forms +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import validate_email +from django.db.models.functions import Collate from ietf.doc.fields import SearchableDocumentField, SearchableDocumentsField from ietf.doc.models import RelatedDocument, DocExtResource, State @@ -63,8 +65,14 @@ class DocAuthorChangeBasisForm(forms.Form): help_text='What is the source or reasoning for the changes to the author list?') class AdForm(forms.Form): - ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type='area').order_by('name'), - label="Shepherding AD", empty_label="(None)", required=True) + ad = forms.ModelChoiceField( + Person.objects.filter( + role__name="ad", role__group__state="active", role__group__type="area" + ).order_by(Collate("name", settings.PREFERRED_COLLATION)), + label="Shepherding AD", + empty_label="(None)", + required=True, + ) def __init__(self, *args, **kwargs): super(self.__class__, self).__init__(*args, **kwargs) diff --git a/ietf/doc/views_charter.py b/ietf/doc/views_charter.py index e899f592271..b2f400d6d6f 100644 --- a/ietf/doc/views_charter.py +++ b/ietf/doc/views_charter.py @@ -20,6 +20,7 @@ from django.utils import timezone from django.utils.encoding import force_str from django.utils.html import escape +from django.db.models.functions import Collate import debug # pyflakes:ignore @@ -305,8 +306,14 @@ def change_title(request, name, option=None): )) class AdForm(forms.Form): - ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type="area").order_by('name'), - label="Responsible AD", empty_label="(None)", required=True) + ad = forms.ModelChoiceField( + Person.objects.filter( + role__name="ad", role__group__state="active", role__group__type="area" + ).order_by(Collate("name", settings.PREFERRED_COLLATION)), + label="Responsible AD", + empty_label="(None)", + required=True, + ) def __init__(self, *args, **kwargs): super(self.__class__, self).__init__(*args, **kwargs) diff --git a/ietf/doc/views_conflict_review.py b/ietf/doc/views_conflict_review.py index 159f1340a49..495958133ad 100644 --- a/ietf/doc/views_conflict_review.py +++ b/ietf/doc/views_conflict_review.py @@ -14,6 +14,7 @@ from django.template.loader import render_to_string from django.conf import settings from django.utils.html import escape +from django.db.models.functions import Collate import debug # pyflakes:ignore @@ -404,8 +405,14 @@ class SimpleStartReviewForm(forms.Form): ) class StartReviewForm(forms.Form): - ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active",role__group__type='area').order_by('name'), - label="Shepherding AD", empty_label="(None)", required=True) + ad = forms.ModelChoiceField( + Person.objects.filter( + role__name="ad", role__group__state="active", role__group__type="area" + ).order_by(Collate("name", settings.PREFERRED_COLLATION)), + label="Shepherding AD", + empty_label="(None)", + required=True, + ) create_in_state = forms.ModelChoiceField(State.objects.filter(used=True, type="conflrev", slug__in=("needshep", "adrev")), empty_label=None, required=False) notify = forms.CharField( widget=forms.Textarea, diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index a64d0a53fe9..a0904afa0bc 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -20,6 +20,8 @@ from django.forms.utils import ErrorList from django.template.defaultfilters import pluralize from django.utils import timezone +from django.db.models.functions import Collate + import debug # pyflakes:ignore @@ -1130,7 +1132,7 @@ class AdForm(forms.Form): role__name__in=("ad", "pre-ad"), role__group__state="active", role__group__type="area", - ).order_by('name'), + ).order_by(Collate('name', settings.PREFERRED_COLLATION)), label="Shepherding AD", empty_label="(None)", required=False, diff --git a/ietf/doc/views_status_change.py b/ietf/doc/views_status_change.py index 2bccc213c40..a5db6e4bdd0 100644 --- a/ietf/doc/views_status_change.py +++ b/ietf/doc/views_status_change.py @@ -18,6 +18,7 @@ from django.conf import settings from django.utils.encoding import force_str from django.utils.html import escape +from django.db.models.functions import Collate import debug # pyflakes:ignore from ietf.doc.mails import email_ad_approved_status_change @@ -484,8 +485,14 @@ def clean(self): class StartStatusChangeForm(forms.Form): document_name = forms.CharField(max_length=255, label="Document name", help_text="A descriptive name such as status-change-md2-to-historic is better than status-change-rfc1319.", required=True) title = forms.CharField(max_length=255, label="Title", required=True) - ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active",role__group__type='area').order_by('name'), - label="Shepherding AD", empty_label="(None)", required=False) + ad = forms.ModelChoiceField( + Person.objects.filter( + role__name="ad", role__group__state="active", role__group__type="area" + ).order_by(Collate("name", settings.PREFERRED_COLLATION)), + label="Shepherding AD", + empty_label="(None)", + required=False, + ) create_in_state = forms.ModelChoiceField(State.objects.filter(type="statchg", slug__in=("needshep", "adrev")), empty_label=None, required=False) notify = forms.CharField( widget=forms.Textarea, diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 8cf386abf29..bf0c4dabd12 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1243,6 +1243,12 @@ def can_manage_materials(self, user): def is_material_submission_cutoff(self): return date_today(datetime.UTC) > self.meeting.get_submission_correction_date() + def is_past(self): + timeslotassignment = self.official_timeslotassignment() + if timeslotassignment is None: + return False # if it's not scheduled, it's not past + return timezone.now() > timeslotassignment.timeslot.end_time() + def joint_with_groups_acronyms(self): return [group.acronym for group in self.joint_with_groups.all()] diff --git a/ietf/meeting/tests_session_requests.py b/ietf/meeting/tests_session_requests.py index 42dbee5f230..1ea7d42f22a 100644 --- a/ietf/meeting/tests_session_requests.py +++ b/ietf/meeting/tests_session_requests.py @@ -12,6 +12,7 @@ from ietf.group.factories import GroupFactory, RoleFactory from ietf.meeting.models import Session, ResourceAssociation, SchedulingEvent, Constraint from ietf.meeting.factories import MeetingFactory, SessionFactory +from ietf.meeting.views_session_request import get_requester_text from ietf.name.models import ConstraintName, TimerangeName from ietf.person.factories import PersonFactory from ietf.person.models import Person @@ -724,6 +725,70 @@ def test_request_notification_msg(self): get_payload_text(msg), ) + def test_wg_request_notification_msg(self): + to = "" + subject = "Dummy subject" + template = "meeting/session_request_notification.txt" + header = "A new" + meeting = MeetingFactory(type_id="ietf", date=date_today()) + area = GroupFactory(type_id='area') + mars = GroupFactory(parent=area, acronym='mars') + secretariat_role = RoleFactory(group__acronym='secretariat', name_id='secr') + requester = get_requester_text(secretariat_role.person, mars) + context = {"header": header, "meeting": meeting, "requester": requester} + cc = "cc.a@example.com, cc.b@example.com" + bcc = "bcc@example.com" + + msg = send_mail( + None, + to, + None, + subject, + template, + context, + cc=cc, + bcc=bcc, + ) + # Undo the text wrapping for simple checking + payload = get_payload_text(msg).replace("\n"," ") + self.assertIn( + f"{header} meeting session request has just been submitted by {requester}", + payload, + ) + self.assertIn("MARS Working Group", payload) + + def test_bof_request_notification_msg(self): + to = "" + subject = "Dummy subject" + template = "meeting/session_request_notification.txt" + header = "A new" + meeting = MeetingFactory(type_id="ietf", date=date_today()) + bof = RoleFactory(group__type_id="wg", group__state_id="bof", name_id="chair").group + secretariat_role = RoleFactory(group__acronym='secretariat', name_id='secr') + requester = get_requester_text(secretariat_role.person, bof) + context = {"header": header, "meeting": meeting, "requester": requester} + cc = "cc.a@example.com, cc.b@example.com" + bcc = "bcc@example.com" + + msg = send_mail( + None, + to, + None, + subject, + template, + context, + cc=cc, + bcc=bcc, + ) + # Undo the text wrapping for simple checking + payload = get_payload_text(msg).replace("\n"," ") + self.assertIn( + f"{header} meeting session request has just been submitted by {requester}", + payload, + ) + self.assertIn("BOF", payload) + self.assertNotIn("Working Group", payload) + def test_request_notification_third_session(self): meeting = MeetingFactory(type_id='ietf', date=date_today()) ad = Person.objects.get(user__username='ad') diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index beaaf8da8a7..9daf3c19157 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -7090,7 +7090,8 @@ def test_remove_sessionpresentation(self, mock_slides_manager_cls): def test_propose_session_slides(self): for type_id in ['ietf','interim']: - session = SessionFactory(meeting__type_id=type_id) + # create session in a meeting in the near future, not the past + session = SessionFactory(meeting__type_id=type_id, meeting__date=date_today() + datetime.timedelta(days=3)) chair = RoleFactory(group=session.group,name_id='chair').person session.meeting.importantdate_set.create(name_id='revsub',date=date_today() + datetime.timedelta(days=20)) newperson = PersonFactory() @@ -7169,6 +7170,33 @@ def test_propose_session_slides(self): self.assertEqual(len(q('.uploadslidelist p')), 0) self.client.logout() + # once the session is past, participants can no longer propose slides + timeslot = session.official_timeslotassignment().timeslot + timeslot.time = ( + timezone.now() - timeslot.duration - datetime.timedelta(seconds=1) + ) + timeslot.save() + + self.client.login( + username=newperson.user.username, + password=newperson.user.username + "+password", + ) + r = self.client.get(session_overview_url) + self.assertEqual(r.status_code,200) + q = PyQuery(r.content) + self.assertFalse(q('.proposeslides')) + r = self.client.get(upload_url) + self.assertEqual(r.status_code,403) + self.client.logout() + + # but a chair still can + self.client.login( + username=chair.user.username, password=chair.user.username + "+password" + ) + r = self.client.get(upload_url) + self.assertEqual(r.status_code,200) + self.client.logout() + def test_disapprove_proposed_slides(self): submission = SlideSubmissionFactory() submission.session.meeting.importantdate_set.create(name_id='revsub',date=date_today() + datetime.timedelta(days=20)) @@ -7291,7 +7319,8 @@ def test_approve_proposed_slides_multisession_apply_all(self, mock_slides_manage @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls @patch("ietf.meeting.views.SlidesManager") def test_submit_and_approve_multiple_versions(self, mock_slides_manager_cls): - session = SessionFactory(meeting__type_id='ietf') + # create session in a meeting in the near future, not the past + session = SessionFactory(meeting__type_id='ietf', meeting__date=date_today() + datetime.timedelta(days=3)) chair = RoleFactory(group=session.group,name_id='chair').person session.meeting.importantdate_set.create(name_id='revsub',date=date_today()+datetime.timedelta(days=20)) newperson = PersonFactory() @@ -7624,6 +7653,41 @@ def test_handles_notes_server_failure(self): class SessionTests(TestCase): + def test_is_past(self): + now = timezone.now() + delta_t = datetime.timedelta(minutes=1) # long compared to test duration + duration = datetime.timedelta(minutes=30) + + for type_id in ["ietf", "interim"]: + # Create an ongoing meeting. The date of the meeting is not really important, + # and it's not realistic for an interim, but it gets the job done. + meeting = MeetingFactory( + type_id=type_id, date=date_today() - datetime.timedelta(days=1), days=7 + ) + # and schedule past and future sessions + past_session = SessionFactory(meeting=meeting, add_to_schedule=False) + # significant moment is the _end_ of the session + past_timeslot = TimeSlotFactory( + meeting=meeting, time=now - duration - delta_t, duration=duration + ) + SchedTimeSessAssignment.objects.create( + timeslot=past_timeslot, session=past_session, schedule=meeting.schedule + ) + future_session = SessionFactory(meeting=meeting, add_to_schedule=False) + future_timeslot = TimeSlotFactory( + meeting=meeting, time=now - duration + delta_t, duration=duration + ) + SchedTimeSessAssignment.objects.create( + timeslot=future_timeslot, session=future_session, schedule=meeting.schedule + ) + # Unscheduled sessions are arbitrarily declared not to be past + unscheduled_session = SessionFactory(meeting=meeting, add_to_schedule=False) + # and, finally, assert the expected behavior + self.assertTrue(past_session.is_past()) + self.assertFalse(future_session.is_past()) + self.assertFalse(unscheduled_session.is_past()) + + def test_get_summary_by_area(self): meeting = make_meeting_test_data(meeting=MeetingFactory(type_id='ietf', number='100')) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index e2a15d3e8aa..3e5002a466f 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1931,7 +1931,11 @@ def api_get_session_materials(request, session_id=None): minutes = session.minutes() slides_actions = [] - if can_manage_session_materials(request.user, session.group, session) or not session.is_material_submission_cutoff(): + if ( + has_role(request.user, "Secretariat") + or (not session.is_material_submission_cutoff() and session.can_manage_materials(request.user)) + or not session.is_past() + ): slides_actions.append( { "label": "Upload slides", @@ -3550,6 +3554,12 @@ def upload_session_slides(request, session_id, num, name=None): "The materials cutoff for this session has passed. Contact the secretariat for further action.", ) + if session.is_past() and not can_manage: + permission_denied( + request, + "This meeting has already occurred. Contact a chair or the secretariat for further action.", + ) + session_number = None sessions = get_sessions(session.meeting.number, session.group.acronym) show_apply_to_all_checkbox = ( diff --git a/ietf/meeting/views_session_request.py b/ietf/meeting/views_session_request.py index a1ef74f1b88..924249e9d4c 100644 --- a/ietf/meeting/views_session_request.py +++ b/ietf/meeting/views_session_request.py @@ -200,6 +200,9 @@ def get_requester_text(person, group): in the session request notification email, ie. Joe Smith, a Chair of the ancp working group """ + group_type = group.type.verbose_name + if group_type == "Working Group" and group.state_id == "bof": + group_type = group.state_id.upper() roles = group.role_set.filter(name__in=("chair", "secr", "ad"), person=person) if roles: rolename = str(roles[0].name) @@ -207,13 +210,13 @@ def get_requester_text(person, group): person.name, inflect.engine().a(rolename), group.acronym.upper(), - group.type.verbose_name, + group_type, ) if person.role_set.filter(name="secr", group__acronym="secretariat"): return "%s, on behalf of the %s %s" % ( person.name, group.acronym.upper(), - group.type.verbose_name, + group_type, ) diff --git a/ietf/settings.py b/ietf/settings.py index d2a622d63e6..483b08bff47 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -121,6 +121,14 @@ } +# Collation that we wish we were using. The production database is currently using +# the C collation, which does not handle accented characters. Do not change this +# without confirming that the production and dev databases support the new collation. +# This setting and places we use it can go away if we switch the production database +# collation. That requires creating and populating a new database, it cannot be done +# on an existing one. +PREFERRED_COLLATION = "en-US-x-icu" + # Local time zone for this installation. Choices can be found here: # http://www.postgresql.org/docs/8.1/static/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE # although not all variations may be possible on all operating systems. diff --git a/ietf/templates/group/review_requests_history.html b/ietf/templates/group/review_requests_history.html index 1b1fb4d2636..34e12ad0fa1 100644 --- a/ietf/templates/group/review_requests_history.html +++ b/ietf/templates/group/review_requests_history.html @@ -18,7 +18,7 @@

Review requests history

-
diff --git a/ietf/templates/meeting/session_details_panel.html b/ietf/templates/meeting/session_details_panel.html index 7c52ac0b4ab..62fda934705 100644 --- a/ietf/templates/meeting/session_details_panel.html +++ b/ietf/templates/meeting/session_details_panel.html @@ -185,7 +185,7 @@

Slides

href="{% url 'ietf.meeting.views.upload_session_slides' session_id=session.pk num=session.meeting.number %}"> Upload new slides - {% elif request.user.is_authenticated and not session.is_material_submission_cutoff %} + {% elif request.user.is_authenticated and not session.is_past %} Propose slides diff --git a/ietf/templates/meeting/session_request_info.txt b/ietf/templates/meeting/session_request_info.txt index 2e96efb31f1..99502a5c573 100644 --- a/ietf/templates/meeting/session_request_info.txt +++ b/ietf/templates/meeting/session_request_info.txt @@ -1,7 +1,7 @@ {# Copyright The IETF Trust 2025, All Rights Reserved #} {% load ams_filters %} --------------------------------------------------------- -Working Group Name: {{ group.name }} +Group Name: {{ group.name }} Area Name: {{ group.parent }} Session Requester: {{ login }} {% if session.joint_with_groups %}{{ session.joint_for_session_display }} joint with: {{ session.joint_with_groups }}{% endif %} diff --git a/ietf/templates/meeting/session_request_view_table.html b/ietf/templates/meeting/session_request_view_table.html index a5cb85c2520..a5939f4755b 100644 --- a/ietf/templates/meeting/session_request_view_table.html +++ b/ietf/templates/meeting/session_request_view_table.html @@ -3,7 +3,7 @@
- Working Group Name + Group Name
{{ group.name }} ({{ group.acronym }})