From ef4d61d0d1d4b7be9be8f1577b2ddaa5091febf4 Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Sat, 19 Jul 2025 17:06:15 +0200 Subject: [PATCH 01/12] feat: use icalendar instead manual template --- ietf/meeting/views.py | 126 +++++++++++++++++++++++++++++++++++++++--- requirements.txt | 1 + 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 8bd70a3733f..0fa58ddff24 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -118,6 +118,10 @@ UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm, UploadNarrativeMinutesForm) +from icalendar import Calendar, Event, vText +import pytz +from ietf.doc.templatetags.ietf_filters import absurl, ics_esc + request_summary_exclude_group_types = ['team'] @@ -2103,6 +2107,114 @@ def ical_session_status(assignment): else: return "CONFIRMED" +def generate_agenda_ical(schedule, assignments): + """Generate iCalendar using the icalendar library""" + + # Create calendar + cal = Calendar() + cal.add('prodid', '-//IETF//datatracker.ietf.org ical agenda//EN') + cal.add('version', '2.0') + cal.add('method', 'PUBLISH') + + # Process each assignment/session + for item in assignments: + event = Event() + + # UID: ietf-{meeting_number}-{timeslot_pk}-{group_acronym} + uid = f"ietf-{schedule.meeting.number}-{item.timeslot.pk}-{item.session.group.acronym}" + event.add('uid', uid) + + # SUMMARY: session name or group acronym and name + if item.session.name: + summary = item.session.name + else: + group = item.session.group_at_the_time() + summary = f"{group.acronym.upper()} - {group.name}" + + # Add agenda note if present + if item.session.agenda_note: + summary += f" ({item.session.agenda_note})" + + event.add('summary', vText(summary)) + + # LOCATION: only if location should be shown + if item.timeslot.show_location and item.timeslot.get_location(): + event.add('location', vText(item.timeslot.get_location())) + + status = ical_session_status(item) + event.add('status', status) + + event.add('class', 'PUBLIC') + + start_time = item.timeslot.local_start_time() + end_time = item.timeslot.local_end_time() + + # Convert to UTC for maximum compatibility + event.add('dtstart', start_time.astimezone(pytz.UTC)) + event.add('dtend', end_time.astimezone(pytz.UTC)) + + # DTSTAMP: when the event was last modified (in UTC) + dtstamp = item.timeslot.modified.astimezone(pytz.UTC) + event.add('dtstamp', dtstamp) + + # URL: link to session agenda if available + if item.session.agenda and hasattr(item.session.agenda, 'get_versionless_href'): + event.add('url', item.session.agenda.get_versionless_href()) + + # DESCRIPTION: build comprehensive description + description_parts = [ics_esc(item.timeslot.name)] + + if item.session.agenda_note: + description_parts.append(f"Note: {ics_esc(item.session.agenda_note)}") + + if hasattr(item.session, 'onsite_tool_url') and callable(item.session.onsite_tool_url): + onsite_url = item.session.onsite_tool_url() + if onsite_url: + description_parts.append(f"Onsite tool: {onsite_url}") + + if hasattr(item.session, 'video_stream_url') and callable(item.session.video_stream_url): + video_url = item.session.video_stream_url() + if video_url: + description_parts.append(f"Meetecho: {video_url}") + + if item.timeslot.location and hasattr(item.timeslot.location, 'webex_url') and callable(item.timeslot.location.webex_url): + description_parts.append(f"Webex: {item.timeslot.location.webex_url()}") + + if item.session.remote_instructions: + description_parts.append(f"Remote instructions: {item.session.remote_instructions}") + + if item.session.agenda and hasattr(item.session.agenda, 'get_versionless_href'): + agenda = item.session.agenda + agenda_url = agenda.get_versionless_href() + if agenda_url: + description_parts.append(f"{agenda.type} {agenda_url}") + + # Add session materials link + try: + materials_url = absurl('ietf.meeting.views.session_details', + num=schedule.meeting.number, + acronym=item.session.group.acronym) + description_parts.append(f"Session materials: {materials_url}") + except: + pass + + # Add schedule link for IETF meetings + if hasattr(schedule.meeting, 'get_number') and schedule.meeting.get_number() is not None: + try: + agenda_url = absurl('agenda', num=schedule.meeting.number) + description_parts.append(f"See in schedule: {agenda_url}#row-{item.slug()}") + except: + pass + + # Join all description parts with 2 newlines (not escaped) + description = "\n\n".join(description_parts) + event.add('description', vText(description)) + + # Add event to calendar + cal.add_component(event) + + return cal.to_ical().decode('utf-8') + def parse_agenda_filter_params(querydict): """Parse agenda filter parameters from a request""" if len(querydict) == 0: @@ -2180,15 +2292,11 @@ def agenda_ical(request, num=None, acronym=None, session_id=None): elif session_id: assignments = [ a for a in assignments if a.session_id == int(session_id) ] - for a in assignments: - if a.session: - a.session.ical_status = ical_session_status(a) - - return render(request, "meeting/agenda.ics", { - "schedule": schedule, - "assignments": assignments, - "updated": updated - }, content_type="text/calendar") + # Try to use the icalendar library first + ical_content = generate_agenda_ical(schedule, assignments) + # Normalize line endings to RFC 5545 standard (CRLF) + ical_content = re.sub(r"\r(?!\n)|(?=20.1.0 hashids>=1.3.1 html2text>=2020.1.16 # Used only to clean comment field of secr/sreq html5lib>=1.1 # Only used in tests +icalendar>=5.0.0 inflect>= 6.0.2 jsonfield>=3.1.0 # for SubmissionCheck. This is https://github.com/bradjasper/django-jsonfield/. jsonschema[format]>=4.2.1 From 70de1585e958dc43a29c9537f614336cfb8d8eda Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Sat, 19 Jul 2025 17:32:55 +0200 Subject: [PATCH 02/12] avoid code duplication --- ietf/meeting/views.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 0fa58ddff24..0af3ebd844b 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -141,6 +141,10 @@ def send_interim_change_notice(request, meeting): message.related_groups.add(group) send_mail_message(request, message) +def parse_ical_line_endings(ical): + """Parse icalendar line endings to ensure they are RFC 5545 compliant""" + return re.sub(r'\r(?!\n)|(? Date: Fri, 25 Jul 2025 10:45:32 +0200 Subject: [PATCH 03/12] code cleanup --- ietf/meeting/tests_views.py | 52 +++++++++++++---------- ietf/meeting/views.py | 84 +++++++++++++++++++++---------------- 2 files changed, 76 insertions(+), 60 deletions(-) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 1aac2a65235..2a67baa9818 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -384,9 +384,6 @@ def test_meeting_agenda(self): r = self.client.get(ical_url) assert_ical_response_is_valid(self, r) - self.assertContains(r, "BEGIN:VTIMEZONE") - self.assertContains(r, "END:VTIMEZONE") - self.assertContains(r, meeting.time_zone, msg_prefix="time_zone should appear in its original case") self.assertNotEqual( meeting.time_zone, meeting.time_zone.lower(), @@ -405,21 +402,26 @@ def test_meeting_agenda(self): assert_ical_response_is_valid(self, r) self.assertContains(r, session.group.acronym) self.assertContains(r, session.group.name) - self.assertContains(r, session.remote_instructions) - self.assertContains(r, slot.location.name) - self.assertContains(r, 'https://onsite.example.com') - self.assertContains(r, 'https://meetecho.example.com') - self.assertContains(r, "BEGIN:VTIMEZONE") - self.assertContains(r, "END:VTIMEZONE") - self.assertContains(r, session.agenda().get_href()) - self.assertContains( - r, - urlreverse( - 'ietf.meeting.views.session_details', - kwargs=dict(num=meeting.number, acronym=session.group.acronym)), - msg_prefix='ical should contain link to meeting materials page for session') + from icalendar import Calendar + cal = Calendar.from_ical(r.content) + for component in cal.walk(): + if component.name == "VEVENT": + self.assertIn(session.remote_instructions, component.get('description')) + self.assertIn("Onsite tool: https://onsite.example.com", component.get('description')) + self.assertIn("Meetecho: https://meetecho.example.com", component.get('description')) + self.assertIn(f"Agenda {session.agenda().get_href()}", component.get('description')) + self.assertIn( + urlreverse( + 'ietf.meeting.views.session_details', + kwargs=dict(num=meeting.number, acronym=session.group.acronym)), + component.get('description')) + break + + self.assertContains(r, f"LOCATION:{slot.location.name}") + self.assertContains(r, f"URL:{session.agenda().get_href()}") + # Floor Plan r = self.client.get(urlreverse('floor-plan', kwargs=dict(num=meeting.number))) self.assertEqual(r.status_code, 200) @@ -1049,32 +1051,36 @@ def test_group_ical(self): s1 = Session.objects.filter(meeting=meeting, group__acronym="mars").first() a1 = s1.official_timeslotassignment() t1 = a1.timeslot + # Create an extra session t2 = TimeSlotFactory.create( meeting=meeting, - time=meeting.tz().localize( + time=pytz.utc.localize( datetime.datetime.combine(meeting.date, datetime.time(11, 30)) ) ) + s2 = SessionFactory.create(meeting=meeting, group=s1.group, add_to_schedule=False) SchedTimeSessAssignment.objects.create(timeslot=t2, session=s2, schedule=meeting.schedule) - # + url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'acronym':s1.group.acronym, }) r = self.client.get(url) assert_ical_response_is_valid(self, r, expected_event_summaries=['mars - Martian Special Interest Group'], expected_event_count=2) - self.assertContains(r, t1.local_start_time().strftime('%Y%m%dT%H%M%S')) - self.assertContains(r, t2.local_start_time().strftime('%Y%m%dT%H%M%S')) - # + self.assertContains(r, f"DTSTART:{t1.time.strftime('%Y%m%dT%H%M%SZ')}") + self.assertContains(r, f"DTEND:{(t1.time + t1.duration).strftime('%Y%m%dT%H%M%SZ')}") + self.assertContains(r, f"DTSTART:{t2.time.strftime('%Y%m%dT%H%M%SZ')}") + self.assertContains(r, f"DTEND:{(t2.time + t2.duration).strftime('%Y%m%dT%H%M%SZ')}") + url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'session_id':s1.id, }) r = self.client.get(url) assert_ical_response_is_valid(self, r, expected_event_summaries=['mars - Martian Special Interest Group'], expected_event_count=1) - self.assertContains(r, t1.local_start_time().strftime('%Y%m%dT%H%M%S')) - self.assertNotContains(r, t2.local_start_time().strftime('%Y%m%dT%H%M%S')) + self.assertContains(r, f"DTSTART:{t1.time.strftime('%Y%m%dT%H%M%SZ')}") + self.assertNotContains(r, f"DTSTART:{t2.time.strftime('%Y%m%dT%H%M%SZ')}") def test_parse_agenda_filter_params(self): def _r(show=(), hide=(), showtypes=(), hidetypes=()): diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 0af3ebd844b..94aaa7e00e0 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -118,9 +118,9 @@ UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm, UploadNarrativeMinutesForm) -from icalendar import Calendar, Event, vText +from icalendar import Calendar, Event import pytz -from ietf.doc.templatetags.ietf_filters import absurl, ics_esc +from ietf.doc.templatetags.ietf_filters import absurl request_summary_exclude_group_types = ['team'] @@ -1990,8 +1990,15 @@ def agenda_by_type_ics(request,num=None,type=None): ).order_by('session__type__slug','timeslot__time') if type: assignments = assignments.filter(session__type__slug=type) - updated = meeting.updated() - return render(request,"meeting/agenda.ics",{"schedule":schedule,"updated":updated,"assignments":assignments},content_type="text/calendar") + + # now = timezone.now() + # updated = meeting.updated() + # rendr = render(request,"meeting/agenda.ics",{"schedule":schedule,"updated":updated,"assignments":assignments},content_type="text/calendar") + return render_icalendar(schedule, assignments) + # print(f"Timestamp: {timezone.now() - now} - Generating ICS for type '{type}' in meeting {num}") + # return rendr + # return render_icalendar(schedule, assignments) + def session_draft_list(num, acronym): try: @@ -2111,6 +2118,10 @@ def ical_session_status(assignment): else: return "CONFIRMED" +def render_icalendar(schedule, assignments): + ical_content = generate_agenda_ical(schedule, assignments) + return HttpResponse(ical_content, content_type="text/calendar") + def generate_agenda_ical(schedule, assignments): """Generate iCalendar using the icalendar library""" @@ -2128,48 +2139,47 @@ def generate_agenda_ical(schedule, assignments): uid = f"ietf-{schedule.meeting.number}-{item.timeslot.pk}-{item.session.group.acronym}" event.add('uid', uid) - # SUMMARY: session name or group acronym and name if item.session.name: summary = item.session.name else: group = item.session.group_at_the_time() - summary = f"{group.acronym.upper()} - {group.name}" + summary = f"{group.acronym} - {group.name}" - # Add agenda note if present if item.session.agenda_note: summary += f" ({item.session.agenda_note})" - event.add('summary', vText(summary)) + event.add('summary', summary) - # LOCATION: only if location should be shown if item.timeslot.show_location and item.timeslot.get_location(): - event.add('location', vText(item.timeslot.get_location())) + event.add('location', item.timeslot.get_location()) - status = ical_session_status(item) + if item.session and hasattr(item.session, "current_status"): + status = ical_session_status(item) + else: + status = '' event.add('status', status) event.add('class', 'PUBLIC') - start_time = item.timeslot.local_start_time() - end_time = item.timeslot.local_end_time() - - # Convert to UTC for maximum compatibility - event.add('dtstart', start_time.astimezone(pytz.UTC)) - event.add('dtend', end_time.astimezone(pytz.UTC)) + # Use UTC for best compatibility + event.add('dtstart', item.timeslot.utc_start_time()) + event.add('dtend', item.timeslot.utc_end_time()) # DTSTAMP: when the event was last modified (in UTC) dtstamp = item.timeslot.modified.astimezone(pytz.UTC) event.add('dtstamp', dtstamp) + agenda = item.session.agenda() + # URL: link to session agenda if available - if item.session.agenda and hasattr(item.session.agenda, 'get_versionless_href'): - event.add('url', item.session.agenda.get_versionless_href()) + if agenda and hasattr(agenda, 'get_versionless_href'): + event.add('url', agenda.get_versionless_href()) # DESCRIPTION: build comprehensive description - description_parts = [ics_esc(item.timeslot.name)] + description_parts = [item.timeslot.name] if item.session.agenda_note: - description_parts.append(f"Note: {ics_esc(item.session.agenda_note)}") + description_parts.append(f"Note: {item.session.agenda_note}") if hasattr(item.session, 'onsite_tool_url') and callable(item.session.onsite_tool_url): onsite_url = item.session.onsite_tool_url() @@ -2180,19 +2190,16 @@ def generate_agenda_ical(schedule, assignments): video_url = item.session.video_stream_url() if video_url: description_parts.append(f"Meetecho: {video_url}") - - if item.timeslot.location and hasattr(item.timeslot.location, 'webex_url') and callable(item.timeslot.location.webex_url): + + if (item.timeslot.location + and hasattr(item.timeslot.location, 'webex_url') + and callable(item.timeslot.location.webex_url) + and item.timeslot.location.webex_url() is not None): description_parts.append(f"Webex: {item.timeslot.location.webex_url()}") - + if item.session.remote_instructions: description_parts.append(f"Remote instructions: {item.session.remote_instructions}") - if item.session.agenda and hasattr(item.session.agenda, 'get_versionless_href'): - agenda = item.session.agenda - agenda_url = agenda.get_versionless_href() - if agenda_url: - description_parts.append(f"{agenda.type} {agenda_url}") - # Add session materials link try: materials_url = absurl('ietf.meeting.views.session_details', @@ -2209,14 +2216,19 @@ def generate_agenda_ical(schedule, assignments): description_parts.append(f"See in schedule: {agenda_url}#row-{item.slug()}") except: pass - - # Join all description parts with 2 newlines (not escaped) + + + if agenda and hasattr(agenda, 'get_versionless_href'): + agenda_url = agenda.get_versionless_href() + description_parts.append(f"{agenda.type} {agenda_url}") + + # Join all description parts with 2 newlines description = "\n\n".join(description_parts) - event.add('description', vText(description)) + event.add('description', description) # Add event to calendar cal.add_component(event) - + return cal.to_ical().decode('utf-8') def parse_agenda_filter_params(querydict): @@ -2296,9 +2308,7 @@ def agenda_ical(request, num=None, acronym=None, session_id=None): elif session_id: assignments = [ a for a in assignments if a.session_id == int(session_id) ] - ical_content = generate_agenda_ical(schedule, assignments) - ical_content = parse_ical_line_endings(ical_content) - return HttpResponse(ical_content, content_type="text/calendar") + return render_icalendar(schedule, assignments) @cache_page(15 * 60) def agenda_json(request, num=None): From 9c9844c65b05db2f2f7369588a7bbdc399fc4445 Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Fri, 25 Jul 2025 10:49:51 +0200 Subject: [PATCH 04/12] ruff ruff --- ietf/meeting/views.py | 116 +++++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 94aaa7e00e0..7565eaeecff 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2124,112 +2124,126 @@ def render_icalendar(schedule, assignments): def generate_agenda_ical(schedule, assignments): """Generate iCalendar using the icalendar library""" - + # Create calendar cal = Calendar() - cal.add('prodid', '-//IETF//datatracker.ietf.org ical agenda//EN') - cal.add('version', '2.0') - cal.add('method', 'PUBLISH') - + cal.add("prodid", "-//IETF//datatracker.ietf.org ical agenda//EN") + cal.add("version", "2.0") + cal.add("method", "PUBLISH") + # Process each assignment/session for item in assignments: event = Event() - + # UID: ietf-{meeting_number}-{timeslot_pk}-{group_acronym} uid = f"ietf-{schedule.meeting.number}-{item.timeslot.pk}-{item.session.group.acronym}" - event.add('uid', uid) - + event.add("uid", uid) + if item.session.name: summary = item.session.name else: group = item.session.group_at_the_time() summary = f"{group.acronym} - {group.name}" - + if item.session.agenda_note: summary += f" ({item.session.agenda_note})" - - event.add('summary', summary) - + + event.add("summary", summary) + if item.timeslot.show_location and item.timeslot.get_location(): - event.add('location', item.timeslot.get_location()) - + event.add("location", item.timeslot.get_location()) + if item.session and hasattr(item.session, "current_status"): status = ical_session_status(item) else: - status = '' - event.add('status', status) - - event.add('class', 'PUBLIC') - + status = "" + event.add("status", status) + + event.add("class", "PUBLIC") + # Use UTC for best compatibility - event.add('dtstart', item.timeslot.utc_start_time()) - event.add('dtend', item.timeslot.utc_end_time()) - + event.add("dtstart", item.timeslot.utc_start_time()) + event.add("dtend", item.timeslot.utc_end_time()) + # DTSTAMP: when the event was last modified (in UTC) dtstamp = item.timeslot.modified.astimezone(pytz.UTC) - event.add('dtstamp', dtstamp) - + event.add("dtstamp", dtstamp) + agenda = item.session.agenda() # URL: link to session agenda if available - if agenda and hasattr(agenda, 'get_versionless_href'): - event.add('url', agenda.get_versionless_href()) - + if agenda and hasattr(agenda, "get_versionless_href"): + event.add("url", agenda.get_versionless_href()) + # DESCRIPTION: build comprehensive description description_parts = [item.timeslot.name] - + if item.session.agenda_note: description_parts.append(f"Note: {item.session.agenda_note}") - - if hasattr(item.session, 'onsite_tool_url') and callable(item.session.onsite_tool_url): + + if hasattr(item.session, "onsite_tool_url") and callable( + item.session.onsite_tool_url + ): onsite_url = item.session.onsite_tool_url() if onsite_url: description_parts.append(f"Onsite tool: {onsite_url}") - - if hasattr(item.session, 'video_stream_url') and callable(item.session.video_stream_url): + + if hasattr(item.session, "video_stream_url") and callable( + item.session.video_stream_url + ): video_url = item.session.video_stream_url() if video_url: description_parts.append(f"Meetecho: {video_url}") - if (item.timeslot.location - and hasattr(item.timeslot.location, 'webex_url') - and callable(item.timeslot.location.webex_url) - and item.timeslot.location.webex_url() is not None): + if ( + item.timeslot.location + and hasattr(item.timeslot.location, "webex_url") + and callable(item.timeslot.location.webex_url) + and item.timeslot.location.webex_url() is not None + ): description_parts.append(f"Webex: {item.timeslot.location.webex_url()}") if item.session.remote_instructions: - description_parts.append(f"Remote instructions: {item.session.remote_instructions}") - + description_parts.append( + f"Remote instructions: {item.session.remote_instructions}" + ) + # Add session materials link try: - materials_url = absurl('ietf.meeting.views.session_details', - num=schedule.meeting.number, - acronym=item.session.group.acronym) + materials_url = absurl( + "ietf.meeting.views.session_details", + num=schedule.meeting.number, + acronym=item.session.group.acronym, + ) description_parts.append(f"Session materials: {materials_url}") except: pass - + # Add schedule link for IETF meetings - if hasattr(schedule.meeting, 'get_number') and schedule.meeting.get_number() is not None: + if ( + hasattr(schedule.meeting, "get_number") + and schedule.meeting.get_number() is not None + ): try: - agenda_url = absurl('agenda', num=schedule.meeting.number) - description_parts.append(f"See in schedule: {agenda_url}#row-{item.slug()}") + agenda_url = absurl("agenda", num=schedule.meeting.number) + description_parts.append( + f"See in schedule: {agenda_url}#row-{item.slug()}" + ) except: pass - - if agenda and hasattr(agenda, 'get_versionless_href'): + if agenda and hasattr(agenda, "get_versionless_href"): agenda_url = agenda.get_versionless_href() description_parts.append(f"{agenda.type} {agenda_url}") # Join all description parts with 2 newlines description = "\n\n".join(description_parts) - event.add('description', description) - + event.add("description", description) + # Add event to calendar cal.add_component(event) - - return cal.to_ical().decode('utf-8') + + return cal.to_ical().decode("utf-8") def parse_agenda_filter_params(querydict): """Parse agenda filter parameters from a request""" From bbc5afd938838e2cdd9467fa8caba850d9d8e82d Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Fri, 25 Jul 2025 11:03:41 +0200 Subject: [PATCH 05/12] remove comments --- ietf/meeting/views.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 7565eaeecff..87fe07eda68 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1991,13 +1991,8 @@ def agenda_by_type_ics(request,num=None,type=None): if type: assignments = assignments.filter(session__type__slug=type) - # now = timezone.now() - # updated = meeting.updated() - # rendr = render(request,"meeting/agenda.ics",{"schedule":schedule,"updated":updated,"assignments":assignments},content_type="text/calendar") return render_icalendar(schedule, assignments) - # print(f"Timestamp: {timezone.now() - now} - Generating ICS for type '{type}' in meeting {num}") - # return rendr - # return render_icalendar(schedule, assignments) + def session_draft_list(num, acronym): From 52260e622ee74896dda5e53e05fbfe375b1c9ef2 Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Fri, 25 Jul 2025 11:26:06 +0200 Subject: [PATCH 06/12] add custom field with meeting's local Time zone --- ietf/meeting/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 87fe07eda68..69da32cd35f 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2134,6 +2134,9 @@ def generate_agenda_ical(schedule, assignments): uid = f"ietf-{schedule.meeting.number}-{item.timeslot.pk}-{item.session.group.acronym}" event.add("uid", uid) + # add custom field with meetings's local TZ + event.add("x-meeting-tz", schedule.meeting.time_zone) + if item.session.name: summary = item.session.name else: From 01511727556bc7f38d6b01a31a27e1a1510e5818 Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Fri, 25 Jul 2025 11:43:57 +0200 Subject: [PATCH 07/12] more code cleanup --- ietf/meeting/views.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 69da32cd35f..324e8d43ff9 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2120,21 +2120,18 @@ def render_icalendar(schedule, assignments): def generate_agenda_ical(schedule, assignments): """Generate iCalendar using the icalendar library""" - # Create calendar cal = Calendar() cal.add("prodid", "-//IETF//datatracker.ietf.org ical agenda//EN") cal.add("version", "2.0") cal.add("method", "PUBLISH") - # Process each assignment/session for item in assignments: event = Event() - # UID: ietf-{meeting_number}-{timeslot_pk}-{group_acronym} uid = f"ietf-{schedule.meeting.number}-{item.timeslot.pk}-{item.session.group.acronym}" event.add("uid", uid) - # add custom field with meetings's local TZ + # add custom field with meeting's local TZ event.add("x-meeting-tz", schedule.meeting.time_zone) if item.session.name: @@ -2159,21 +2156,18 @@ def generate_agenda_ical(schedule, assignments): event.add("class", "PUBLIC") - # Use UTC for best compatibility event.add("dtstart", item.timeslot.utc_start_time()) event.add("dtend", item.timeslot.utc_end_time()) - # DTSTAMP: when the event was last modified (in UTC) + # DTSTAMP: when the event was created or last modified (in UTC) dtstamp = item.timeslot.modified.astimezone(pytz.UTC) event.add("dtstamp", dtstamp) agenda = item.session.agenda() - # URL: link to session agenda if available if agenda and hasattr(agenda, "get_versionless_href"): event.add("url", agenda.get_versionless_href()) - # DESCRIPTION: build comprehensive description description_parts = [item.timeslot.name] if item.session.agenda_note: @@ -2206,7 +2200,6 @@ def generate_agenda_ical(schedule, assignments): f"Remote instructions: {item.session.remote_instructions}" ) - # Add session materials link try: materials_url = absurl( "ietf.meeting.views.session_details", @@ -2217,7 +2210,6 @@ def generate_agenda_ical(schedule, assignments): except: pass - # Add schedule link for IETF meetings if ( hasattr(schedule.meeting, "get_number") and schedule.meeting.get_number() is not None From f030cca0870d4e76f110aecc1aa79eebadb313a7 Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Fri, 25 Jul 2025 13:01:16 +0200 Subject: [PATCH 08/12] remove unused template for ical --- ietf/templates/meeting/agenda.ics | 32 ------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 ietf/templates/meeting/agenda.ics diff --git a/ietf/templates/meeting/agenda.ics b/ietf/templates/meeting/agenda.ics deleted file mode 100644 index eb83dd479ad..00000000000 --- a/ietf/templates/meeting/agenda.ics +++ /dev/null @@ -1,32 +0,0 @@ -{% load humanize tz %}{% autoescape off %}{% timezone schedule.meeting.tz %}{% with tzname=schedule.meeting.time_zone %}{% load ietf_filters textfilters %}{% load cache %}{% cache 1800 ietf_meeting_agenda_ics schedule.meeting.number request.path request.GET %}BEGIN:VCALENDAR -VERSION:2.0 -METHOD:PUBLISH -PRODID:-//IETF//datatracker.ietf.org ical agenda//EN -{% if tzname != "utc" and tzname != "gmt" %}{% firstof schedule.meeting.vtimezone "" %}{% endif %}{% for item in assignments %}BEGIN:VEVENT -UID:ietf-{{schedule.meeting.number}}-{{item.timeslot.pk}}-{{item.session.group.acronym}} -SUMMARY:{% if item.session.name %}{{item.session.name|ics_esc}}{% else %}{{item.session.group_at_the_time.acronym|lower}} - {{item.session.group_at_the_time.name}}{%endif%}{% if item.session.agenda_note %} ({{item.session.agenda_note}}){% endif %} -{% if item.timeslot.show_location %}LOCATION:{{item.timeslot.get_location}} -{% endif %}STATUS:{{item.session.ical_status}} -CLASS:PUBLIC -DTSTART{% ics_date_time item.timeslot.local_start_time tzname %} -DTEND{% ics_date_time item.timeslot.local_end_time tzname %} -DTSTAMP{% ics_date_time item.timeslot.modified|utc 'utc' %}{% if item.session.agenda %} -URL:{{item.session.agenda.get_versionless_href}}{% endif %} -DESCRIPTION:{{item.timeslot.name|ics_esc}}\n{% if item.session.agenda_note %} - Note: {{item.session.agenda_note|ics_esc}}\n{% endif %}{% if item.session.onsite_tool_url %} - \n - Onsite tool: {{ item.session.onsite_tool_url }}\n{% endif %}{% if item.session.video_stream_url %} - \n - Meetecho: {{ item.session.video_stream_url }}\n{% endif %}{% if item.timeslot.location.webex_url %} - \n - Webex: {{ item.timeslot.location.webex_url }}\n{% endif %}{% if item.session.remote_instructions %} - \n - Remote instructions: {{ item.session.remote_instructions }}\n{% endif %}{% if item.session.agenda %}{% with agenda=item.session.agenda %} - \n - {{agenda.type}} {{agenda.get_versionless_href}}\n{% endwith %}{% endif %} - \n - Session materials: {% absurl 'ietf.meeting.views.session_details' num=schedule.meeting.number acronym=item.session.group.acronym %}\n{% if schedule.meeting.get_number is not None %} - \n{# link agenda for ietf meetings #} - See in schedule: {% absurl 'agenda' num=schedule.meeting.number %}#row-{{ item.slug }}\n{% endif %} -END:VEVENT -{% endfor %}END:VCALENDAR{% endcache %}{% endwith %}{% endtimezone %}{% endautoescape %} From 94bc546c1e6e1349d5ef163ec8e340034725cdd1 Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Fri, 25 Jul 2025 13:56:11 +0200 Subject: [PATCH 09/12] pyflakes: remove unused imports and vars --- ietf/meeting/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 324e8d43ff9..7ab89dc8386 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -119,7 +119,6 @@ UploadNarrativeMinutesForm) from icalendar import Calendar, Event -import pytz from ietf.doc.templatetags.ietf_filters import absurl request_summary_exclude_group_types = ['team'] @@ -2286,7 +2285,6 @@ def agenda_ical(request, num=None, acronym=None, session_id=None): else: meeting = get_meeting(num, type_in=None) # get requested meeting, whatever its type schedule = get_schedule(meeting) - updated = meeting.updated() if schedule is None and acronym is None and session_id is None: raise Http404 From 619fc7fc359cdea91d0692f71bf07a75d4eea157 Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Tue, 5 Aug 2025 16:31:51 -0400 Subject: [PATCH 10/12] improve tests and code coverage --- ietf/meeting/tests_views.py | 41 +++++++++++++++++++++++-------------- ietf/meeting/views.py | 8 ++++++-- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 2a67baa9818..27a7e778171 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -15,6 +15,7 @@ from mock import call, patch, PropertyMock from pyquery import PyQuery from lxml.etree import tostring +from icalendar import Calendar from io import StringIO, BytesIO from bs4 import BeautifulSoup from urllib.parse import urlparse, urlsplit @@ -403,24 +404,34 @@ def test_meeting_agenda(self): self.assertContains(r, session.group.acronym) self.assertContains(r, session.group.name) - from icalendar import Calendar cal = Calendar.from_ical(r.content) - - for component in cal.walk(): - if component.name == "VEVENT": - self.assertIn(session.remote_instructions, component.get('description')) - self.assertIn("Onsite tool: https://onsite.example.com", component.get('description')) - self.assertIn("Meetecho: https://meetecho.example.com", component.get('description')) - self.assertIn(f"Agenda {session.agenda().get_href()}", component.get('description')) - self.assertIn( - urlreverse( - 'ietf.meeting.views.session_details', - kwargs=dict(num=meeting.number, acronym=session.group.acronym)), - component.get('description')) - break + events = [component for component in cal.walk() if component.name == "VEVENT"] + + self.assertEqual(len(events), 2) + self.assertIn(session.remote_instructions, events[0].get('description')) + self.assertIn("Onsite tool: https://onsite.example.com", events[0].get('description')) + self.assertIn("Meetecho: https://meetecho.example.com", events[0].get('description')) + self.assertIn(f"Agenda {session.agenda().get_href()}", events[0].get('description')) + session_materials_url = settings.IDTRACKER_BASE_URL + urlreverse( + 'ietf.meeting.views.session_details', + kwargs=dict(num=meeting.number, acronym=session.group.acronym) + ) + self.assertIn(f"Session materials: {session_materials_url}", events[0].get('description')) + self.assertIn( + urlreverse( + 'ietf.meeting.views.session_details', + kwargs=dict(num=meeting.number, acronym=session.group.acronym)), + events[0].get('description')) + + session_cal_url = settings.IDTRACKER_BASE_URL + urlreverse( + 'ietf.meeting.views.agenda_ical', + kwargs=dict(num=meeting.number, session_id=session.id)) + self.assertEqual( + session_cal_url, + events[0].get('url') + ) self.assertContains(r, f"LOCATION:{slot.location.name}") - self.assertContains(r, f"URL:{session.agenda().get_href()}") # Floor Plan r = self.client.get(urlreverse('floor-plan', kwargs=dict(num=meeting.number))) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 7ab89dc8386..bcc2a8c716f 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2164,8 +2164,12 @@ def generate_agenda_ical(schedule, assignments): agenda = item.session.agenda() - if agenda and hasattr(agenda, "get_versionless_href"): - event.add("url", agenda.get_versionless_href()) + # if agenda and hasattr(agenda, "get_versionless_href"): + ical_url = absurl("ietf.meeting.views.agenda_ical", + num=schedule.meeting.number, + session_id=item.session.id + ) + event.add("url", ical_url) description_parts = [item.timeslot.name] From 1f1d1eeeff3ec16905577a9d3b55b47607c9d78d Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Tue, 5 Aug 2025 16:34:29 -0400 Subject: [PATCH 11/12] remove commented line --- ietf/meeting/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index bcc2a8c716f..5dadfffeec2 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2164,7 +2164,6 @@ def generate_agenda_ical(schedule, assignments): agenda = item.session.agenda() - # if agenda and hasattr(agenda, "get_versionless_href"): ical_url = absurl("ietf.meeting.views.agenda_ical", num=schedule.meeting.number, session_id=item.session.id From dcc89100e800830c2795c549224d28c02c36ba0c Mon Sep 17 00:00:00 2001 From: Rudi Matz Date: Wed, 6 Aug 2025 15:03:32 -0400 Subject: [PATCH 12/12] change URL in ical to use session material page --- ietf/meeting/tests_views.py | 6 +----- ietf/meeting/views.py | 10 ++-------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 27a7e778171..7bed3925742 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -422,12 +422,8 @@ def test_meeting_agenda(self): 'ietf.meeting.views.session_details', kwargs=dict(num=meeting.number, acronym=session.group.acronym)), events[0].get('description')) - - session_cal_url = settings.IDTRACKER_BASE_URL + urlreverse( - 'ietf.meeting.views.agenda_ical', - kwargs=dict(num=meeting.number, session_id=session.id)) self.assertEqual( - session_cal_url, + session_materials_url, events[0].get('url') ) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 5dadfffeec2..b68dcf4f76f 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2162,14 +2162,6 @@ def generate_agenda_ical(schedule, assignments): dtstamp = item.timeslot.modified.astimezone(pytz.UTC) event.add("dtstamp", dtstamp) - agenda = item.session.agenda() - - ical_url = absurl("ietf.meeting.views.agenda_ical", - num=schedule.meeting.number, - session_id=item.session.id - ) - event.add("url", ical_url) - description_parts = [item.timeslot.name] if item.session.agenda_note: @@ -2209,6 +2201,7 @@ def generate_agenda_ical(schedule, assignments): acronym=item.session.group.acronym, ) description_parts.append(f"Session materials: {materials_url}") + event.add("url", materials_url) except: pass @@ -2224,6 +2217,7 @@ def generate_agenda_ical(schedule, assignments): except: pass + agenda = item.session.agenda() if agenda and hasattr(agenda, "get_versionless_href"): agenda_url = agenda.get_versionless_href() description_parts.append(f"{agenda.type} {agenda_url}")