Skip to content

Commit 86bce86

Browse files
authored
feat: use icalendar instead manual template (#9187)
* feat: use icalendar instead manual template * avoid code duplication * code cleanup * ruff ruff * remove comments * add custom field with meeting's local Time zone * more code cleanup * remove unused template for ical * pyflakes: remove unused imports and vars * improve tests and code coverage * remove commented line * change URL in ical to use session material page
1 parent ebe6fbf commit 86bce86

File tree

4 files changed

+168
-66
lines changed

4 files changed

+168
-66
lines changed

ietf/meeting/tests_views.py

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from unittest.mock import call, patch, PropertyMock
1616
from pyquery import PyQuery
1717
from lxml.etree import tostring
18+
from icalendar import Calendar
1819
from io import StringIO, BytesIO
1920
from bs4 import BeautifulSoup
2021
from urllib.parse import urlparse, urlsplit
@@ -384,9 +385,6 @@ def test_meeting_agenda(self):
384385
r = self.client.get(ical_url)
385386

386387
assert_ical_response_is_valid(self, r)
387-
self.assertContains(r, "BEGIN:VTIMEZONE")
388-
self.assertContains(r, "END:VTIMEZONE")
389-
self.assertContains(r, meeting.time_zone, msg_prefix="time_zone should appear in its original case")
390388
self.assertNotEqual(
391389
meeting.time_zone,
392390
meeting.time_zone.lower(),
@@ -405,21 +403,32 @@ def test_meeting_agenda(self):
405403
assert_ical_response_is_valid(self, r)
406404
self.assertContains(r, session.group.acronym)
407405
self.assertContains(r, session.group.name)
408-
self.assertContains(r, session.remote_instructions)
409-
self.assertContains(r, slot.location.name)
410-
self.assertContains(r, 'https://onsite.example.com')
411-
self.assertContains(r, 'https://meetecho.example.com')
412-
self.assertContains(r, "BEGIN:VTIMEZONE")
413-
self.assertContains(r, "END:VTIMEZONE")
414406

415-
self.assertContains(r, session.agenda().get_href())
416-
self.assertContains(
417-
r,
407+
cal = Calendar.from_ical(r.content)
408+
events = [component for component in cal.walk() if component.name == "VEVENT"]
409+
410+
self.assertEqual(len(events), 2)
411+
self.assertIn(session.remote_instructions, events[0].get('description'))
412+
self.assertIn("Onsite tool: https://onsite.example.com", events[0].get('description'))
413+
self.assertIn("Meetecho: https://meetecho.example.com", events[0].get('description'))
414+
self.assertIn(f"Agenda {session.agenda().get_href()}", events[0].get('description'))
415+
session_materials_url = settings.IDTRACKER_BASE_URL + urlreverse(
416+
'ietf.meeting.views.session_details',
417+
kwargs=dict(num=meeting.number, acronym=session.group.acronym)
418+
)
419+
self.assertIn(f"Session materials: {session_materials_url}", events[0].get('description'))
420+
self.assertIn(
418421
urlreverse(
419422
'ietf.meeting.views.session_details',
420423
kwargs=dict(num=meeting.number, acronym=session.group.acronym)),
421-
msg_prefix='ical should contain link to meeting materials page for session')
424+
events[0].get('description'))
425+
self.assertEqual(
426+
session_materials_url,
427+
events[0].get('url')
428+
)
422429

430+
self.assertContains(r, f"LOCATION:{slot.location.name}")
431+
423432
# Floor Plan
424433
r = self.client.get(urlreverse('floor-plan', kwargs=dict(num=meeting.number)))
425434
self.assertEqual(r.status_code, 200)
@@ -1049,32 +1058,36 @@ def test_group_ical(self):
10491058
s1 = Session.objects.filter(meeting=meeting, group__acronym="mars").first()
10501059
a1 = s1.official_timeslotassignment()
10511060
t1 = a1.timeslot
1061+
10521062
# Create an extra session
10531063
t2 = TimeSlotFactory.create(
10541064
meeting=meeting,
1055-
time=meeting.tz().localize(
1065+
time=pytz.utc.localize(
10561066
datetime.datetime.combine(meeting.date, datetime.time(11, 30))
10571067
)
10581068
)
1069+
10591070
s2 = SessionFactory.create(meeting=meeting, group=s1.group, add_to_schedule=False)
10601071
SchedTimeSessAssignment.objects.create(timeslot=t2, session=s2, schedule=meeting.schedule)
1061-
#
1072+
10621073
url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'acronym':s1.group.acronym, })
10631074
r = self.client.get(url)
10641075
assert_ical_response_is_valid(self,
10651076
r,
10661077
expected_event_summaries=['mars - Martian Special Interest Group'],
10671078
expected_event_count=2)
1068-
self.assertContains(r, t1.local_start_time().strftime('%Y%m%dT%H%M%S'))
1069-
self.assertContains(r, t2.local_start_time().strftime('%Y%m%dT%H%M%S'))
1070-
#
1079+
self.assertContains(r, f"DTSTART:{t1.time.strftime('%Y%m%dT%H%M%SZ')}")
1080+
self.assertContains(r, f"DTEND:{(t1.time + t1.duration).strftime('%Y%m%dT%H%M%SZ')}")
1081+
self.assertContains(r, f"DTSTART:{t2.time.strftime('%Y%m%dT%H%M%SZ')}")
1082+
self.assertContains(r, f"DTEND:{(t2.time + t2.duration).strftime('%Y%m%dT%H%M%SZ')}")
1083+
10711084
url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'session_id':s1.id, })
10721085
r = self.client.get(url)
10731086
assert_ical_response_is_valid(self, r,
10741087
expected_event_summaries=['mars - Martian Special Interest Group'],
10751088
expected_event_count=1)
1076-
self.assertContains(r, t1.local_start_time().strftime('%Y%m%dT%H%M%S'))
1077-
self.assertNotContains(r, t2.local_start_time().strftime('%Y%m%dT%H%M%S'))
1089+
self.assertContains(r, f"DTSTART:{t1.time.strftime('%Y%m%dT%H%M%SZ')}")
1090+
self.assertNotContains(r, f"DTSTART:{t2.time.strftime('%Y%m%dT%H%M%SZ')}")
10781091

10791092
def test_parse_agenda_filter_params(self):
10801093
def _r(show=(), hide=(), showtypes=(), hidetypes=()):

ietf/meeting/views.py

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@
118118
UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm,
119119
UploadNarrativeMinutesForm)
120120

121+
from icalendar import Calendar, Event
122+
from ietf.doc.templatetags.ietf_filters import absurl
123+
121124
request_summary_exclude_group_types = ['team']
122125

123126

@@ -137,6 +140,10 @@ def send_interim_change_notice(request, meeting):
137140
message.related_groups.add(group)
138141
send_mail_message(request, message)
139142

143+
def parse_ical_line_endings(ical):
144+
"""Parse icalendar line endings to ensure they are RFC 5545 compliant"""
145+
return re.sub(r'\r(?!\n)|(?<!\r)\n', '\r\n', ical)
146+
140147
# -------------------------------------------------
141148
# View Functions
142149
# -------------------------------------------------
@@ -1982,8 +1989,10 @@ def agenda_by_type_ics(request,num=None,type=None):
19821989
).order_by('session__type__slug','timeslot__time')
19831990
if type:
19841991
assignments = assignments.filter(session__type__slug=type)
1985-
updated = meeting.updated()
1986-
return render(request,"meeting/agenda.ics",{"schedule":schedule,"updated":updated,"assignments":assignments},content_type="text/calendar")
1992+
1993+
return render_icalendar(schedule, assignments)
1994+
1995+
19871996

19881997
def session_draft_list(num, acronym):
19891998
try:
@@ -2103,6 +2112,125 @@ def ical_session_status(assignment):
21032112
else:
21042113
return "CONFIRMED"
21052114

2115+
def render_icalendar(schedule, assignments):
2116+
ical_content = generate_agenda_ical(schedule, assignments)
2117+
return HttpResponse(ical_content, content_type="text/calendar")
2118+
2119+
def generate_agenda_ical(schedule, assignments):
2120+
"""Generate iCalendar using the icalendar library"""
2121+
2122+
cal = Calendar()
2123+
cal.add("prodid", "-//IETF//datatracker.ietf.org ical agenda//EN")
2124+
cal.add("version", "2.0")
2125+
cal.add("method", "PUBLISH")
2126+
2127+
for item in assignments:
2128+
event = Event()
2129+
2130+
uid = f"ietf-{schedule.meeting.number}-{item.timeslot.pk}-{item.session.group.acronym}"
2131+
event.add("uid", uid)
2132+
2133+
# add custom field with meeting's local TZ
2134+
event.add("x-meeting-tz", schedule.meeting.time_zone)
2135+
2136+
if item.session.name:
2137+
summary = item.session.name
2138+
else:
2139+
group = item.session.group_at_the_time()
2140+
summary = f"{group.acronym} - {group.name}"
2141+
2142+
if item.session.agenda_note:
2143+
summary += f" ({item.session.agenda_note})"
2144+
2145+
event.add("summary", summary)
2146+
2147+
if item.timeslot.show_location and item.timeslot.get_location():
2148+
event.add("location", item.timeslot.get_location())
2149+
2150+
if item.session and hasattr(item.session, "current_status"):
2151+
status = ical_session_status(item)
2152+
else:
2153+
status = ""
2154+
event.add("status", status)
2155+
2156+
event.add("class", "PUBLIC")
2157+
2158+
event.add("dtstart", item.timeslot.utc_start_time())
2159+
event.add("dtend", item.timeslot.utc_end_time())
2160+
2161+
# DTSTAMP: when the event was created or last modified (in UTC)
2162+
dtstamp = item.timeslot.modified.astimezone(pytz.UTC)
2163+
event.add("dtstamp", dtstamp)
2164+
2165+
description_parts = [item.timeslot.name]
2166+
2167+
if item.session.agenda_note:
2168+
description_parts.append(f"Note: {item.session.agenda_note}")
2169+
2170+
if hasattr(item.session, "onsite_tool_url") and callable(
2171+
item.session.onsite_tool_url
2172+
):
2173+
onsite_url = item.session.onsite_tool_url()
2174+
if onsite_url:
2175+
description_parts.append(f"Onsite tool: {onsite_url}")
2176+
2177+
if hasattr(item.session, "video_stream_url") and callable(
2178+
item.session.video_stream_url
2179+
):
2180+
video_url = item.session.video_stream_url()
2181+
if video_url:
2182+
description_parts.append(f"Meetecho: {video_url}")
2183+
2184+
if (
2185+
item.timeslot.location
2186+
and hasattr(item.timeslot.location, "webex_url")
2187+
and callable(item.timeslot.location.webex_url)
2188+
and item.timeslot.location.webex_url() is not None
2189+
):
2190+
description_parts.append(f"Webex: {item.timeslot.location.webex_url()}")
2191+
2192+
if item.session.remote_instructions:
2193+
description_parts.append(
2194+
f"Remote instructions: {item.session.remote_instructions}"
2195+
)
2196+
2197+
try:
2198+
materials_url = absurl(
2199+
"ietf.meeting.views.session_details",
2200+
num=schedule.meeting.number,
2201+
acronym=item.session.group.acronym,
2202+
)
2203+
description_parts.append(f"Session materials: {materials_url}")
2204+
event.add("url", materials_url)
2205+
except:
2206+
pass
2207+
2208+
if (
2209+
hasattr(schedule.meeting, "get_number")
2210+
and schedule.meeting.get_number() is not None
2211+
):
2212+
try:
2213+
agenda_url = absurl("agenda", num=schedule.meeting.number)
2214+
description_parts.append(
2215+
f"See in schedule: {agenda_url}#row-{item.slug()}"
2216+
)
2217+
except:
2218+
pass
2219+
2220+
agenda = item.session.agenda()
2221+
if agenda and hasattr(agenda, "get_versionless_href"):
2222+
agenda_url = agenda.get_versionless_href()
2223+
description_parts.append(f"{agenda.type} {agenda_url}")
2224+
2225+
# Join all description parts with 2 newlines
2226+
description = "\n\n".join(description_parts)
2227+
event.add("description", description)
2228+
2229+
# Add event to calendar
2230+
cal.add_component(event)
2231+
2232+
return cal.to_ical().decode("utf-8")
2233+
21062234
def parse_agenda_filter_params(querydict):
21072235
"""Parse agenda filter parameters from a request"""
21082236
if len(querydict) == 0:
@@ -2154,7 +2282,6 @@ def agenda_ical(request, num=None, acronym=None, session_id=None):
21542282
else:
21552283
meeting = get_meeting(num, type_in=None) # get requested meeting, whatever its type
21562284
schedule = get_schedule(meeting)
2157-
updated = meeting.updated()
21582285

21592286
if schedule is None and acronym is None and session_id is None:
21602287
raise Http404
@@ -2180,15 +2307,7 @@ def agenda_ical(request, num=None, acronym=None, session_id=None):
21802307
elif session_id:
21812308
assignments = [ a for a in assignments if a.session_id == int(session_id) ]
21822309

2183-
for a in assignments:
2184-
if a.session:
2185-
a.session.ical_status = ical_session_status(a)
2186-
2187-
return render(request, "meeting/agenda.ics", {
2188-
"schedule": schedule,
2189-
"assignments": assignments,
2190-
"updated": updated
2191-
}, content_type="text/calendar")
2310+
return render_icalendar(schedule, assignments)
21922311

21932312
@cache_page(15 * 60)
21942313
def agenda_json(request, num=None):
@@ -4138,7 +4257,7 @@ def upcoming_ical(request):
41384257
'assignments': assignments,
41394258
'ietfs': ietfs,
41404259
}, request=request)
4141-
response = re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", response)
4260+
response = parse_ical_line_endings(response)
41424261

41434262
response = HttpResponse(response, content_type='text/calendar')
41444263
response['Content-Disposition'] = 'attachment; filename="upcoming.ics"'
@@ -4764,7 +4883,7 @@ def important_dates(request, num=None, output_format=None):
47644883
'meetings': meetings,
47654884
}, request=request)
47664885
# icalendar response file should have '\r\n' line endings per RFC5545
4767-
response = HttpResponse(re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", ics), content_type='text/calendar')
4886+
response = HttpResponse(parse_ical_line_endings(ics), content_type='text/calendar')
47684887
response['Content-Disposition'] = 'attachment; filename="important-dates.ics"'
47694888
return response
47704889

@@ -5198,3 +5317,4 @@ def import_session_minutes(request, session_id, num):
51985317
'contents_unchanged': not contents_changed,
51995318
},
52005319
)
5320+

ietf/templates/meeting/agenda.ics

Lines changed: 0 additions & 32 deletions
This file was deleted.

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ gunicorn>=20.1.0
4242
hashids>=1.3.1
4343
html2text>=2020.1.16 # Used only to clean comment field of secr/sreq
4444
html5lib>=1.1 # Only used in tests
45+
icalendar>=5.0.0
4546
inflect>= 6.0.2
4647
jsonfield>=3.1.0,<3.2.0 # 3.2.0 needs py3.10; deprecated-replace with Django JSONField
4748
jsonschema[format]>=4.2.1

0 commit comments

Comments
 (0)