Repository: ietf-tools/datatracker

Issue:
agenda editor "swap days" needs tzaware fix
### Describe the issue

The "swap days" feature on the agenda editor involves passing the dates to be swapped to an AJAX endpoint. This needs adjustment to ensure time zones are handled consistently or it may fail for meetings held in the Eastern hemisphere

### Code of Conduct

- [X] I agree to follow the [IETF's Code of Conduct](https://github.com/ietf-tools/.github/blob/main/CODE_OF_CONDUCT.md)


Discussion:

Resolving patch:
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -446,9 +446,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
 
     lock_time = settings.MEETING_SESSION_LOCK_TIME
     def timeslot_locked(ts):
-        meeting_now = timezone.now().astimezone(pytz.timezone(meeting.time_zone))
-        if not settings.USE_TZ:
-            meeting_now = meeting_now.replace(tzinfo=None)
+        meeting_now = timezone.now().astimezone(meeting.tz())
         return schedule.is_official and (ts.time - meeting_now < lock_time)
 
     if not can_see:
@@ -645,7 +643,7 @@ def prepare_timeslots_for_display(timeslots, rooms):
             rd = room_data[t.location_id]
             rd['timeslot_count'] += 1
             rd['start_and_duration'].append((t.time, t.duration))
-            ttd = t.time.date()
+            ttd = t.local_start_time().date()  # date in meeting timezone
             all_days.add(ttd)
             if ttd not in rd['timeslots_by_day']:
                 rd['timeslots_by_day'][ttd] = []
@@ -830,8 +828,8 @@ def _json_response(success, status=None, **extra_data):
             source_day = swap_days_form.cleaned_data['source_day']
             target_day = swap_days_form.cleaned_data['target_day']
 
-            source_timeslots = [ts for ts in timeslots_qs if ts.time.date() == source_day]
-            target_timeslots = [ts for ts in timeslots_qs if ts.time.date() == target_day]
+            source_timeslots = [ts for ts in timeslots_qs if ts.local_start_time().date() == source_day]
+            target_timeslots = [ts for ts in timeslots_qs if ts.local_start_time().date() == target_day]
             if any(timeslot_locked(ts) for ts in source_timeslots + target_timeslots):
                 return HttpResponseBadRequest("Can't swap these days.")
 
@@ -888,8 +886,8 @@ def _json_response(success, status=None, **extra_data):
     # possible timeslot start/ends
     timeslot_groups = defaultdict(set)
     for ts in timeslots_qs:
-        ts.start_end_group = "ts-group-{}-{}".format(ts.time.strftime("%Y%m%d-%H%M"), int(ts.duration.total_seconds() / 60))
-        timeslot_groups[ts.time.date()].add((ts.time, ts.end_time(), ts.start_end_group))
+        ts.start_end_group = "ts-group-{}-{}".format(ts.local_start_time().strftime("%Y%m%d-%H%M"), int(ts.duration.total_seconds() / 60))
+        timeslot_groups[ts.local_start_time().date()].add((ts.local_start_time(), ts.local_end_time(), ts.start_end_group))
 
     # prepare sessions
     prepare_sessions_for_display(sessions)
@@ -970,22 +968,23 @@ def cubehelix(i, total, hue=1.2, start_angle=0.5):
             if ts_type.slug in session_data.get('enabled_timeslot_types', [])
         ]
 
-    return render(request, "meeting/edit_meeting_schedule.html", {
-        'meeting': meeting,
-        'schedule': schedule,
-        'can_edit': can_edit,
-        'can_edit_properties': can_edit or secretariat,
-        'secretariat': secretariat,
-        'days': days,
-        'timeslot_groups': sorted((d, list(sorted(t_groups))) for d, t_groups in timeslot_groups.items()),
-        'unassigned_sessions': unassigned_sessions,
-        'session_parents': session_parents,
-        'session_purposes': session_purposes,
-        'timeslot_types': timeslot_types,
-        'hide_menu': True,
-        'lock_time': lock_time,
-        'enabled_timeslot_types': enabled_timeslot_types,
-    })
+    with timezone.override(meeting.tz()):
+        return render(request, "meeting/edit_meeting_schedule.html", {
+            'meeting': meeting,
+            'schedule': schedule,
+            'can_edit': can_edit,
+            'can_edit_properties': can_edit or secretariat,
+            'secretariat': secretariat,
+            'days': days,
+            'timeslot_groups': sorted((d, list(sorted(t_groups))) for d, t_groups in timeslot_groups.items()),
+            'unassigned_sessions': unassigned_sessions,
+            'session_parents': session_parents,
+            'session_purposes': session_purposes,
+            'timeslot_types': timeslot_types,
+            'hide_menu': True,
+            'lock_time': lock_time,
+            'enabled_timeslot_types': enabled_timeslot_types,
+        })
 
 
 class RoomNameModelChoiceField(forms.ModelChoiceField):


Test patch:
diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py
--- a/ietf/meeting/tests_views.py
+++ b/ietf/meeting/tests_views.py
@@ -21,6 +21,7 @@
 from PIL import Image
 from pathlib import Path
 from tempfile import NamedTemporaryFile
+from zoneinfo import ZoneInfo
 
 from django.urls import reverse as urlreverse
 from django.conf import settings
@@ -1558,8 +1559,6 @@ def _decode_json_response(self, r):
     @staticmethod
     def _right_now_in(tzinfo):
         right_now = timezone.now().astimezone(tzinfo)
-        if not settings.USE_TZ:
-            right_now = right_now.replace(tzinfo=None)
         return right_now
 
     def test_assign_session(self):
@@ -1860,6 +1859,58 @@ def test_editor_with_no_timeslots(self):
         self.assertContains(r, 'No timeslots exist')
         self.assertContains(r, urlreverse('ietf.meeting.views.edit_timeslots', kwargs={'num': meeting.number}))
 
+    def test_editor_time_zone(self):
+        """Agenda editor should show meeting time zone"""
+        time_zone = 'Etc/GMT+8'
+        meeting_tz = ZoneInfo(time_zone)
+        meeting = MeetingFactory(
+            type_id='ietf',
+            date=date_today(meeting_tz) + datetime.timedelta(days=7),
+            populate_schedule=False,
+            time_zone=time_zone,
+        )
+        meeting.schedule = ScheduleFactory(meeting=meeting)
+        meeting.save()
+        timeslot = TimeSlotFactory(meeting=meeting)
+        ts_start = timeslot.time.astimezone(meeting_tz)
+        ts_end = timeslot.end_time().astimezone(meeting_tz)
+        url = urlreverse('ietf.meeting.views.edit_meeting_schedule', kwargs={'num': meeting.number})
+        self.assertTrue(self.client.login(username='secretary', password='secretary+password'))
+        r = self.client.get(url)
+        self.assertEqual(r.status_code, 200)
+        pq = PyQuery(r.content)
+
+        day_header = pq('.day-flow .day-label')
+        self.assertIn(ts_start.strftime('%A'), day_header.text())
+
+        day_swap = day_header.find('.swap-days')
+        self.assertEqual(day_swap.attr('data-dayid'), ts_start.date().isoformat())
+        self.assertEqual(day_swap.attr('data-start'), ts_start.date().isoformat())
+
+        time_label = pq('.day-flow .time-header .time-label')
+        self.assertEqual(len(time_label), 1)
+        # strftime() does not seem to support hours without leading 0, so do this manually
+        time_label_string = f'{ts_start.hour:d}:{ts_start.minute:02d} - {ts_end.hour:d}:{ts_end.minute:02d}'
+        self.assertIn(time_label_string, time_label.text())
+        self.assertEqual(time_label.attr('data-start'), ts_start.astimezone(datetime.timezone.utc).isoformat())
+        self.assertEqual(time_label.attr('data-end'), ts_end.astimezone(datetime.timezone.utc).isoformat())
+
+        ts_swap = time_label.find('.swap-timeslot-col')
+        origin_label = ts_swap.attr('data-origin-label')
+        # testing the exact date in origin_label is hard because Django's date filter uses
+        # different month formats than Python's strftime, so just check a couple parts.
+        self.assertIn(ts_start.strftime('%A'), origin_label)
+        self.assertIn(f'{ts_start.hour:d}:{ts_start.minute:02d}-{ts_end.hour:d}:{ts_end.minute:02d}', origin_label)
+
+        timeslot_elt = pq(f'#timeslot{timeslot.pk}')
+        self.assertEqual(len(timeslot_elt), 1)
+        self.assertEqual(timeslot_elt.attr('data-start'), ts_start.astimezone(datetime.timezone.utc).isoformat())
+        self.assertEqual(timeslot_elt.attr('data-end'), ts_end.astimezone(datetime.timezone.utc).isoformat())
+
+        timeslot_label = pq(f'#timeslot{timeslot.pk} .time-label')
+        self.assertEqual(len(timeslot_label), 1)
+        self.assertIn(time_label_string, timeslot_label.text())
+
 
 class EditTimeslotsTests(TestCase):
     def login(self, username='secretary'):