Skip to content

Commit 8b52d27

Browse files
refactor: refactor timestamp handling so tests in meeting app pass (ietf-tools#4371)
* refactor: replace datetime.now with timezone.now * refactor: migrate model fields to use timezone.now as default * refactor: replace datetime.today with timezone.now datetime.datetime.today() is equivalent to datetime.datetime.now(); both return a naive datetime with the current local time. * refactor: rephrase datetime.now(tz) as timezone.now().astimezone(tz) This is effectively the same, but is less likely to encourage accidental use of naive datetimes. * refactor: revert datetime.today() change to old migrations * refactor: change a missed datetime.now to timezone.now * chore: renumber timezone_now migration * chore: add migration to change timestamps to UTC * refactor: move tz instantiation/caching from TimeSlot to Meeting * fix: assume utc if meeting.time_zone is blank * chore: make datetime.combine() calls tz aware in the meeting app * ci: correctly use meeting.tz in TimeSlotFactory * chore: compute TimeSlot utc / local times assuming tz-aware times * chore: use tzaware math for agenda editor timeslot layout * chore: fill in Meeting.time_zone where it is blank Nearly all interim meetings on or before 2016-07-01 have blank time_zone values. This migration fills these in with PST8PDT. * chore: disallow blank Meeting.time_zone value * refactor: no need to handle blank time_zone case in TZ migration * refactor: remove now-unnecessary checks that meeting has time_zone * chore: fix timezone handling in agenda.ics and Meeting.updated() * chore: fix tz handling in interim_request_details, exercise in tests * chore: fix timezone handling for test_interim_send_announcement * chore: fix timezone handling in agenda_json() * chore: fix timezone handling in old agenda * chore: fix timezone handling for EditTimeslotsTests * refactor: refactor a few fixes for more consistent timezone handling * chore: add timezone info to timestamps in fixtures * chore: remove naive datetime warnings found in meetings.tests_views * chore: fix a few more test failures in meetings.tests_views All tests in meetings.tests_views now passing * chore: remove unused import * chore: fix timezone handling in test_schedule_generator.py * chore: fix timezone handling affecting meeting.tests_js * chore: fix timeslot test bug when local date != UTC date * test: fix a few failing tests, all meetings tests now pass (for me, anyway) * chore: renumber migrations * chore: update timestamp conversion migration The django-celery-beat package introduces tables with timestamp columns. These columns are stored in CELERY_TIMEZONE. Because we run with this set to UTC, the migration ignores these columns. * chore: fix pytz-related change in migration * chore: remove duplicate migrations * chore: remove CELERY_BEAT_TZ_AWARE setting now that USE_TZ is True * test: avoid failure in test with bogus timezone
1 parent 42203d7 commit 8b52d27

23 files changed

Lines changed: 484 additions & 312 deletions

ietf/group/models.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# -*- coding: utf-8 -*-
33

44

5-
import datetime
65
import email.utils
76
import jsonfield
87
import os
@@ -181,11 +180,15 @@ def liaison_approvers(self):
181180
return self.role_set.none()
182181

183182
def status_for_meeting(self,meeting):
184-
end_date = meeting.end_date()+datetime.timedelta(days=1)
185183
previous_meeting = meeting.previous_meeting()
186-
status_events = self.groupevent_set.filter(type='status_update',time__lte=end_date).order_by('-time')
184+
status_events = self.groupevent_set.filter(
185+
type='status_update',
186+
time__lt=meeting.end_datetime(),
187+
).order_by('-time')
187188
if previous_meeting:
188-
status_events = status_events.filter(time__gte=previous_meeting.end_date()+datetime.timedelta(days=1))
189+
status_events = status_events.filter(
190+
time__gte=previous_meeting.end_datetime()
191+
)
189192
return status_events.first()
190193

191194
def get_description(self):

ietf/meeting/factories.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ def location(obj, create, extracted, **kwargs): # pylint: disable=no-self-argume
187187

188188
@factory.lazy_attribute
189189
def time(self):
190-
return datetime.datetime.combine(self.meeting.date,datetime.time(11,0))
190+
return self.meeting.tz().localize(
191+
datetime.datetime.combine(self.meeting.date, datetime.time(11, 0))
192+
)
191193

192194
@factory.lazy_attribute
193195
def duration(self):

ietf/meeting/fixtures/proceedings_templates.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"comments": "",
3333
"list_subscribe": "",
3434
"state": "active",
35-
"time": "2012-02-26T00:21:36",
35+
"time": "2012-02-26T00:21:36Z",
3636
"unused_tags": [],
3737
"list_archive": "",
3838
"type": "ietf",

ietf/meeting/helpers.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe
118118
# assignments = list(assignments_queryset) # make sure we're set in stone
119119
assignments = assignments_queryset
120120

121-
meeting_time = datetime.datetime.combine(meeting.date, datetime.time())
121+
# meeting_time is meeting-local midnight at the start of the meeting date
122+
meeting_time = meeting.tz().localize(
123+
datetime.datetime.combine(meeting.date, datetime.time())
124+
)
122125

123126
# replace groups with historic counterparts
124127
groups = [ ]
@@ -1149,26 +1152,30 @@ def sessions_post_cancel(request, sessions):
11491152

11501153

11511154
def update_interim_session_assignment(form):
1152-
"""Helper function to create / update timeslot assigned to interim session"""
1153-
time = datetime.datetime.combine(
1154-
form.cleaned_data['date'],
1155-
form.cleaned_data['time'])
1155+
"""Helper function to create / update timeslot assigned to interim session
1156+
1157+
form is an InterimSessionModelForm
1158+
"""
11561159
session = form.instance
1160+
meeting = session.meeting
1161+
time = meeting.tz().localize(
1162+
datetime.datetime.combine(form.cleaned_data['date'], form.cleaned_data['time'])
1163+
)
11571164
if session.official_timeslotassignment():
11581165
slot = session.official_timeslotassignment().timeslot
11591166
slot.time = time
11601167
slot.duration = session.requested_duration
11611168
slot.save()
11621169
else:
11631170
slot = TimeSlot.objects.create(
1164-
meeting=session.meeting,
1171+
meeting=meeting,
11651172
type_id='regular',
11661173
duration=session.requested_duration,
11671174
time=time)
11681175
SchedTimeSessAssignment.objects.create(
11691176
timeslot=slot,
11701177
session=session,
1171-
schedule=session.meeting.schedule)
1178+
schedule=meeting.schedule)
11721179

11731180
def populate_important_dates(meeting):
11741181
assert ImportantDate.objects.filter(meeting=meeting).exists() is False

ietf/meeting/management/commands/create_dummy_meeting.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
import datetime
4949
import pytz
5050

51-
from django.core.management.base import BaseCommand
51+
from django.core.management.base import BaseCommand, CommandError
5252
from django.db import transaction
5353
from django.db.models import Q
5454

@@ -75,10 +75,12 @@ def add_arguments(self, parser):
7575

7676
def _meeting_datetime(self, day, *time_args):
7777
"""Generate a datetime on a meeting day"""
78-
return datetime.datetime.combine(
79-
self.start_date,
80-
datetime.time(*time_args)
81-
) + datetime.timedelta(days=day)
78+
return self.meeting_tz.localize(
79+
datetime.datetime.combine(
80+
self.start_date,
81+
datetime.time(*time_args)
82+
) + datetime.timedelta(days=day)
83+
)
8284

8385
def handle(self, *args, **options):
8486
if socket.gethostname().split('.')[0] in ['core3', 'ietfa', 'ietfb', 'ietfc', ]:
@@ -87,17 +89,19 @@ def handle(self, *args, **options):
8789
opt_delete = options.get('delete', False)
8890
opt_use_old_conflicts = options.get('old_conflicts', False)
8991
self.start_date = options['start_date']
90-
meeting_tz = options['tz']
91-
if not opt_delete and (meeting_tz not in pytz.common_timezones):
92-
self.stderr.write("Warning: {} is not a recognized time zone.".format(meeting_tz))
93-
92+
meeting_tzname = options['tz']
9493
if opt_delete:
9594
if Meeting.objects.filter(number='999').exists():
9695
Meeting.objects.filter(number='999').delete()
9796
self.stdout.write("Deleted dummy meeting IETF 999 and its related objects.")
9897
else:
9998
self.stderr.write("Dummy meeting IETF 999 does not exist; nothing to do.\n")
10099
else:
100+
try:
101+
self.meeting_tz = pytz.timezone(meeting_tzname)
102+
except pytz.UnknownTimeZoneError:
103+
raise CommandError("{} is not a recognized time zone.".format(meeting_tzname))
104+
101105
if Meeting.objects.filter(number='999').exists():
102106
self.stderr.write("Dummy meeting IETF 999 already exists; nothing to do.\n")
103107
else:
@@ -111,7 +115,7 @@ def handle(self, *args, **options):
111115
type_id='IETF',
112116
date=self._meeting_datetime(0).date(),
113117
days=7,
114-
time_zone=meeting_tz,
118+
time_zone=meeting_tzname,
115119
)
116120

117121
# Set enabled constraints

ietf/meeting/models.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ def get_meeting_date (self,offset):
138138
def end_date(self):
139139
return self.get_meeting_date(self.days-1)
140140

141+
def end_datetime(self):
142+
"""Datetime of the first instant _after_ the meeting's last day"""
143+
return self.tz().localize(
144+
datetime.datetime.combine(self.get_meeting_date(self.days), datetime.time())
145+
)
141146
def get_00_cutoff(self):
142147
start_date = datetime.datetime(year=self.date.year, month=self.date.month, day=self.date.day, tzinfo=pytz.utc)
143148
importantdate = self.importantdate_set.filter(name_id='idcutoff').first()
@@ -322,23 +327,23 @@ def build_timeslices(self):
322327
for ts in self.timeslot_set.all():
323328
if ts.location_id is None:
324329
continue
325-
ymd = ts.time.date()
330+
ymd = ts.local_start_time().date()
326331
if ymd not in time_slices:
327332
time_slices[ymd] = []
328333
slots[ymd] = []
329334
days.append(ymd)
330335

331336
if ymd in time_slices:
332337
# only keep unique entries
333-
if [ts.time, ts.time + ts.duration, ts.duration.seconds] not in time_slices[ymd]:
334-
time_slices[ymd].append([ts.time, ts.time + ts.duration, ts.duration.seconds])
338+
if [ts.local_start_time(), ts.local_end_time(), ts.duration.seconds] not in time_slices[ymd]:
339+
time_slices[ymd].append([ts.local_start_time(), ts.local_end_time(), ts.duration.seconds])
335340
slots[ymd].append(ts)
336341

337342
days.sort()
338343
for ymd in time_slices:
339344
# Make sure these sort the same way
340345
time_slices[ymd].sort()
341-
slots[ymd].sort(key=lambda x: (x.time, x.duration))
346+
slots[ymd].sort(key=lambda x: (x.local_start_time(), x.duration))
342347
return days,time_slices,slots
343348

344349
# this functions makes a list of timeslices and rooms, and
@@ -354,6 +359,11 @@ def build_timeslices(self):
354359
# SchedTimeSessAssignment.objects.create(schedule = sched,
355360
# timeslot = ts)
356361

362+
def tz(self):
363+
if not hasattr(self, '_cached_tz'):
364+
self._cached_tz = pytz.timezone(self.time_zone)
365+
return self._cached_tz
366+
357367
def vtimezone(self):
358368
try:
359369
tzfn = os.path.join(settings.TZDATA_ICS_PATH, self.time_zone + ".ics")
@@ -374,16 +384,14 @@ def set_official_schedule(self, schedule):
374384
self.save()
375385

376386
def updated(self):
377-
min_time = datetime.datetime(1970, 1, 1, 0, 0, 0) # should be Meeting.modified, but we don't have that
387+
# should be Meeting.modified, but we don't have that
388+
min_time = pytz.utc.localize(datetime.datetime(1970, 1, 1, 0, 0, 0))
378389
timeslots_updated = self.timeslot_set.aggregate(Max('modified'))["modified__max"] or min_time
379390
sessions_updated = self.session_set.aggregate(Max('modified'))["modified__max"] or min_time
380391
assignments_updated = min_time
381392
if self.schedule:
382393
assignments_updated = SchedTimeSessAssignment.objects.filter(schedule__in=[self.schedule, self.schedule.base if self.schedule else None]).aggregate(Max('modified'))["modified__max"] or min_time
383-
ts = max(timeslots_updated, sessions_updated, assignments_updated)
384-
tz = pytz.timezone(settings.PRODUCTION_TIMEZONE)
385-
ts = tz.localize(ts)
386-
return ts
394+
return max(timeslots_updated, sessions_updated, assignments_updated)
387395

388396
@memoize
389397
def previous_meeting(self):
@@ -604,29 +612,22 @@ def get_html_location(self):
604612
return self._cached_html_location
605613

606614
def tz(self):
607-
if not hasattr(self, '_cached_tz'):
608-
self._cached_tz = pytz.timezone(self.meeting.time_zone)
609-
return self._cached_tz
615+
return self.meeting.tz()
610616

611617
def tzname(self):
612618
return self.tz().tzname(self.time)
613619

614620
def utc_start_time(self):
615-
local_start_time = self.tz().localize(self.time)
616-
return local_start_time.astimezone(pytz.utc)
621+
return self.time.astimezone(pytz.utc) # USE_TZ is True, so time is aware
617622

618623
def utc_end_time(self):
619-
utc_start = self.utc_start_time()
620-
# Add duration after converting start time, otherwise errors creep in around DST change
621-
return None if utc_start is None else utc_start + self.duration
624+
return self.time.astimezone(pytz.utc) + self.duration # USE_TZ is True, so time is aware
622625

623626
def local_start_time(self):
624-
return self.tz().localize(self.time)
627+
return self.time.astimezone(self.tz())
625628

626629
def local_end_time(self):
627-
local_start = self.local_start_time()
628-
# Add duration after converting start time, otherwise errors creep in around DST change
629-
return None if local_start is None else local_start + self.duration
630+
return (self.time.astimezone(pytz.utc) + self.duration).astimezone(self.tz())
630631

631632
@property
632633
def js_identifier(self):

ietf/meeting/test_data.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
from ietf.person.models import Person
2222
from ietf.utils.test_data import make_test_data
2323

24-
def make_interim_meeting(group,date,status='sched'):
24+
def make_interim_meeting(group,date,status='sched',tz='UTC'):
2525
system_person = Person.objects.get(name="(System)")
26-
time = datetime.datetime.combine(date, datetime.time(9))
27-
meeting = create_interim_meeting(group=group,date=date)
26+
meeting = create_interim_meeting(group=group,date=date,timezone=tz)
27+
time = meeting.tz().localize(
28+
datetime.datetime.combine(date, datetime.time(9))
29+
)
2830
session = SessionFactory(meeting=meeting, group=group,
2931
attendees=10,
3032
requested_duration=datetime.timedelta(minutes=20),
@@ -102,24 +104,37 @@ def make_meeting_test_data(meeting=None, create_interims=False):
102104

103105
# slots
104106
session_date = meeting.date + datetime.timedelta(days=1)
107+
tz = meeting.tz()
105108
slot1 = TimeSlot.objects.create(meeting=meeting, type_id='regular', location=room,
106109
duration=datetime.timedelta(minutes=60),
107-
time=datetime.datetime.combine(session_date, datetime.time(9, 30)))
110+
time=tz.localize(
111+
datetime.datetime.combine(session_date, datetime.time(9, 30))
112+
))
108113
slot2 = TimeSlot.objects.create(meeting=meeting, type_id='regular', location=room,
109114
duration=datetime.timedelta(minutes=60),
110-
time=datetime.datetime.combine(session_date, datetime.time(10, 50)))
115+
time=tz.localize(
116+
datetime.datetime.combine(session_date, datetime.time(10, 50))
117+
))
111118
breakfast_slot = TimeSlot.objects.create(meeting=meeting, type_id="lead", location=breakfast_room,
112119
duration=datetime.timedelta(minutes=90),
113-
time=datetime.datetime.combine(session_date, datetime.time(7,0)))
120+
time=tz.localize(
121+
datetime.datetime.combine(session_date, datetime.time(7,0))
122+
))
114123
reg_slot = TimeSlot.objects.create(meeting=meeting, type_id="reg", location=reg_room,
115124
duration=datetime.timedelta(minutes=480),
116-
time=datetime.datetime.combine(session_date, datetime.time(9,0)))
125+
time=tz.localize(
126+
datetime.datetime.combine(session_date, datetime.time(9,0))
127+
))
117128
break_slot = TimeSlot.objects.create(meeting=meeting, type_id="break", location=break_room,
118129
duration=datetime.timedelta(minutes=90),
119-
time=datetime.datetime.combine(session_date, datetime.time(7,0)))
130+
time=tz.localize(
131+
datetime.datetime.combine(session_date, datetime.time(7,0))
132+
))
120133
plenary_slot = TimeSlot.objects.create(meeting=meeting, type_id="plenary", location=room,
121134
duration=datetime.timedelta(minutes=60),
122-
time=datetime.datetime.combine(session_date, datetime.time(11,0)))
135+
time=tz.localize(
136+
datetime.datetime.combine(session_date, datetime.time(11,0))
137+
))
123138
# mars WG
124139
mars = Group.objects.get(acronym='mars')
125140
mars_session = SessionFactory(meeting=meeting, group=mars,
@@ -213,7 +228,7 @@ def make_meeting_test_data(meeting=None, create_interims=False):
213228

214229
return meeting
215230

216-
def make_interim_test_data():
231+
def make_interim_test_data(meeting_tz='UTC'):
217232
date = datetime.date.today() + datetime.timedelta(days=365)
218233
date2 = datetime.date.today() + datetime.timedelta(days=1000)
219234
PersonFactory(user__username='plain')
@@ -225,10 +240,10 @@ def make_interim_test_data():
225240
RoleFactory(group=mars,person__user__username='marschairman',name_id='chair')
226241
RoleFactory(group=ames,person__user__username='ameschairman',name_id='chair')
227242

228-
make_interim_meeting(group=mars,date=date,status='sched')
229-
make_interim_meeting(group=mars,date=date2,status='apprw')
230-
make_interim_meeting(group=ames,date=date,status='canceled')
231-
make_interim_meeting(group=ames,date=date2,status='apprw')
243+
make_interim_meeting(group=mars,date=date,status='sched',tz=meeting_tz)
244+
make_interim_meeting(group=mars,date=date2,status='apprw',tz=meeting_tz)
245+
make_interim_meeting(group=ames,date=date,status='canceled',tz=meeting_tz)
246+
make_interim_meeting(group=ames,date=date2,status='apprw',tz=meeting_tz)
232247

233248
return
234249

0 commit comments

Comments
 (0)