Merge ~cjwatson/launchpad:date-widget-time-zone-name into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 64219c788bd9554f6b45ea3d4ecdfbdc9929ce72
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:date-widget-time-zone-name
Merge into: launchpad:master
Diff against target: 420 lines (+86/-72)
7 files modified
lib/lp/app/widgets/date.py (+44/-46)
lib/lp/app/widgets/templates/datetime.pt (+1/-1)
lib/lp/blueprints/browser/sprint.py (+7/-7)
lib/lp/blueprints/browser/sprintattendance.py (+5/-5)
lib/lp/services/webapp/doc/launchbag.rst (+14/-7)
lib/lp/services/webapp/interfaces.py (+1/-0)
lib/lp/services/webapp/launchbag.py (+14/-6)
Reviewer Review Type Date Requested Status
Guruprasad Approve
Review via email: mp+443006@code.launchpad.net

Commit message

Refactor time zone name handling in DateTimeWidget

Description of the change

`DateTimeWidget` currently works out the time zone name to display by calling `.tzname(None)` on a time zone object. This works with `pytz`, but it won't work once we switch to `dateutil.tz`, and there doesn't seem to be a reasonable way to get it to work with that time zone provider without poking about in private attributes; the best we could do would return a time zone abbreviation string rather than the original time zone name the user asked for (e.g. "CET" rather than "Europe/Paris"), which isn't very satisfactory.

Since the whole approach of expecting to be able to recover the original time zone name from a time zone object seems fundamentally dodgy, a better approach is to explicitly keep track of the original time zone name to start with. This needs some changes to `LaunchBag`, but the result should be much more robust and can easily be made to work with any time zone provider.

To post a comment you must log in.
Revision history for this message
Guruprasad (lgp171188) wrote :

LGTM ๐Ÿ‘๐Ÿผ

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/app/widgets/date.py b/lib/lp/app/widgets/date.py
2index acaa0f2..e024dfa 100644
3--- a/lib/lp/app/widgets/date.py
4+++ b/lib/lp/app/widgets/date.py
5@@ -17,8 +17,9 @@ __all__ = [
6 "DatetimeDisplayWidget",
7 ]
8
9-from datetime import datetime, timezone
10+from datetime import datetime, timezone, tzinfo
11
12+import pytz
13 from zope.browserpage import ViewPageTemplateFile
14 from zope.component import getUtility
15 from zope.datetime import DateTimeError, parse
16@@ -40,7 +41,6 @@ from lp.services.webapp.interfaces import ILaunchBag
17 class DateTimeWidget(TextWidget):
18 """A date and time selection widget with popup selector.
19
20- >>> import pytz
21 >>> from zope.schema import Field
22 >>> from lp.services.webapp.servers import LaunchpadTestRequest
23 >>> field = Field(__name__="foo", title="Foo")
24@@ -63,7 +63,7 @@ class DateTimeWidget(TextWidget):
25 If there is a required time zone, then that overrides the user or system
26 default, and the user is not invited to change the time zone:
27
28- >>> widget.required_time_zone = pytz.timezone("America/Los_Angeles")
29+ >>> widget.required_time_zone_name = "America/Los_Angeles"
30 >>> print(widget()) # doctest: +ELLIPSIS
31 <BLANKLINE>
32 <...in time zone: America/Los_Angeles...
33@@ -114,7 +114,7 @@ class DateTimeWidget(TextWidget):
34 """
35
36 timeformat = "%Y-%m-%d %H:%M:%S"
37- required_time_zone = None
38+ required_time_zone_name = None # type: str
39 display_zone = True
40 from_date = None
41 to_date = None
42@@ -126,7 +126,7 @@ class DateTimeWidget(TextWidget):
43 def __init__(self, context, request):
44 super().__init__(context, request)
45 launchbag = getUtility(ILaunchBag)
46- self.system_time_zone = launchbag.time_zone
47+ self.system_time_zone_name = launchbag.time_zone_name
48
49 @property
50 def supported_input_formats(self):
51@@ -158,7 +158,17 @@ class DateTimeWidget(TextWidget):
52 return [o.strip() for o in outputs]
53
54 @property
55- def time_zone(self):
56+ def time_zone_name(self) -> str:
57+ """The name of the widget time zone for display in the widget."""
58+ if self.required_time_zone_name is not None:
59+ return self.required_time_zone_name
60+ assert (
61+ self.system_time_zone_name is not None
62+ ), "DateTime widget needs a time zone."
63+ return self.system_time_zone_name
64+
65+ @property
66+ def time_zone(self) -> tzinfo:
67 """The widget time zone.
68
69 This will either give you the user's time zone, or the system
70@@ -167,7 +177,7 @@ class DateTimeWidget(TextWidget):
71 externally-defined time zone. For example, when a person will join a
72 conference in the time zone in which the conference is being held.
73
74- >>> import pytz
75+ >>> from datetime import tzinfo
76 >>> from zope.publisher.browser import TestRequest
77 >>> from zope.schema import Field
78 >>> field = Field(__name__="foo", title="Foo")
79@@ -176,12 +186,12 @@ class DateTimeWidget(TextWidget):
80 The time zone is a time zone object, not the string representation
81 of that.
82
83- >>> print(type(widget.time_zone))
84- <class 'datetime.timezone'>
85+ >>> isinstance(widget.time_zone, tzinfo)
86+ True
87
88- The widget required_time_zone is None by default.
89+ The widget required_time_zone_name is None by default.
90
91- >>> print(widget.required_time_zone)
92+ >>> print(widget.required_time_zone_name)
93 None
94
95 The widget "system time zone" is generally UTC. It is the logged in
96@@ -189,41 +199,32 @@ class DateTimeWidget(TextWidget):
97 user. Although this isn't used directly, it influences the outcome
98 of widget.time_zone.
99
100- >>> print(repr(widget.system_time_zone))
101- datetime.timezone.utc
102+ >>> print(widget.system_time_zone_name)
103+ UTC
104
105- When there is no required_time_zone, then we get the system time
106- zone.
107+ When there is no required_time_zone_name, then we get the system
108+ time zone.
109
110- >>> print(widget.required_time_zone)
111+ >>> print(widget.required_time_zone_name)
112 None
113+ >>> print(widget.time_zone_name)
114+ UTC
115 >>> print(repr(widget.time_zone))
116 datetime.timezone.utc
117
118- When there is a required_time_zone, we get it:
119+ When there is a required_time_zone_name, we get it:
120
121- >>> widget.required_time_zone = pytz.timezone("Africa/Maseru")
122+ >>> widget.required_time_zone_name = "Africa/Maseru"
123+ >>> print(widget.time_zone_name)
124+ Africa/Maseru
125 >>> print(widget.time_zone)
126 Africa/Maseru
127
128 """
129- if self.required_time_zone is not None:
130- return self.required_time_zone
131- assert (
132- self.system_time_zone is not None
133- ), "DateTime widget needs a time zone."
134- return self.system_time_zone
135-
136- @property
137- def time_zone_name(self):
138- """The name of the widget time zone for display in the widget."""
139- # XXX cjwatson 2023-03-09: In Python < 3.6, `timezone.utc.tzname`
140- # returns "UTC+00:00" rather than "UTC". Drop this once we require
141- # Python >= 3.6.
142- if self.time_zone is timezone.utc:
143- return "UTC"
144+ if self.time_zone_name == "UTC":
145+ return timezone.utc
146 else:
147- return self.time_zone.tzname(None)
148+ return pytz.timezone(self.time_zone_name)
149
150 def _align_date_constraints_with_time_zone(self):
151 """Ensure that from_date and to_date use the widget time zone."""
152@@ -383,14 +384,13 @@ class DateTimeWidget(TextWidget):
153 def _parseInput(self, input):
154 """Convert a string to a datetime value.
155
156- >>> import pytz
157 >>> from zope.publisher.browser import TestRequest
158 >>> from zope.schema import Field
159 >>> field = Field(__name__="foo", title="Foo")
160 >>> widget = DateTimeWidget(field, TestRequest())
161- >>> widget.required_time_zone = timezone.utc
162- >>> widget.time_zone
163- datetime.timezone.utc
164+ >>> widget.required_time_zone_name = "UTC"
165+ >>> widget.time_zone_name
166+ 'UTC'
167
168 The widget converts an empty string to the missing value:
169
170@@ -404,7 +404,7 @@ class DateTimeWidget(TextWidget):
171
172 But it will handle other time zones:
173
174- >>> widget.required_time_zone = pytz.timezone("Australia/Perth")
175+ >>> widget.required_time_zone_name = "Australia/Perth"
176 >>> print(widget._parseInput("2006-01-01 12:00:00"))
177 2006-01-01 12:00:00+08:00
178
179@@ -434,7 +434,6 @@ class DateTimeWidget(TextWidget):
180 def _toFormValue(self, value):
181 """Convert a date to its string representation.
182
183- >>> import pytz
184 >>> from zope.publisher.browser import TestRequest
185 >>> from zope.schema import Field
186 >>> field = Field(__name__="foo", title="Foo")
187@@ -455,7 +454,7 @@ class DateTimeWidget(TextWidget):
188 The date value will be converted to the widget's time zone
189 before being displayed:
190
191- >>> widget.required_time_zone = pytz.timezone("America/New_York")
192+ >>> widget.required_time_zone_name = "America/New_York"
193 >>> widget._toFormValue(dt)
194 '2006-01-01 07:00:00'
195 """
196@@ -497,7 +496,6 @@ class DateWidget(DateTimeWidget):
197 The DateWidget subclass can limit requests to date ranges:
198
199 >>> from datetime import date
200- >>> import pytz
201 >>> from zope.publisher.browser import TestRequest
202 >>> from zope.schema import Field
203 >>> field = Field(__name__="foo", title="Foo")
204@@ -509,7 +507,7 @@ class DateWidget(DateTimeWidget):
205 >>> "[[2004,04,05],[2004,04,10]]" in widget()
206 True
207
208- This widget ignores required_time_zone and system_time_zone and
209+ This widget ignores required_time_zone_name and system_time_zone_name and
210 interprets everything as UTC. This does not matter, because it is only
211 picking the date, and it will always be rendered as a date sans time
212 zone even if it is stored as a datetime.
213@@ -517,11 +515,11 @@ class DateWidget(DateTimeWidget):
214 >>> widget.time_zone
215 datetime.timezone.utc
216
217- >>> widget.system_time_zone = pytz.timezone("America/New_York")
218+ >>> widget.system_time_zone_name = "America/New_York"
219 >>> widget.time_zone
220 datetime.timezone.utc
221
222- >>> widget.required_time_zone = pytz.timezone("America/Los_Angeles")
223+ >>> widget.required_time_zone_name = "America/Los_Angeles"
224 >>> widget.time_zone
225 datetime.timezone.utc
226
227@@ -536,7 +534,7 @@ class DateWidget(DateTimeWidget):
228 """
229
230 timeformat = "%Y-%m-%d"
231- time_zone = timezone.utc
232+ time_zone_name = "UTC"
233
234 # ZPT that renders our widget
235 __call__ = ViewPageTemplateFile("templates/date.pt")
236diff --git a/lib/lp/app/widgets/templates/datetime.pt b/lib/lp/app/widgets/templates/datetime.pt
237index d53d9ba..1042d65 100644
238--- a/lib/lp/app/widgets/templates/datetime.pt
239+++ b/lib/lp/app/widgets/templates/datetime.pt
240@@ -9,7 +9,7 @@
241 disabled view/disabled_flag" />
242 <tal:display_zone condition="view/display_zone">
243 <span> in time zone: <tal:tz replace="view/time_zone_name" />
244- <a tal:condition="not: view/required_time_zone"
245+ <a tal:condition="not: view/required_time_zone_name"
246 href="/people/+me/+editlocation">
247 <img src="/@@/edit"/>
248 </a></span>
249diff --git a/lib/lp/blueprints/browser/sprint.py b/lib/lp/blueprints/browser/sprint.py
250index a89a362..41148c0 100644
251--- a/lib/lp/blueprints/browser/sprint.py
252+++ b/lib/lp/blueprints/browser/sprint.py
253@@ -291,9 +291,9 @@ class SprintAddView(LaunchpadFormView):
254 self.widgets["time_ends"].timeformat = timeformat
255 time_zone_widget = self.widgets["time_zone"]
256 if time_zone_widget.hasValidInput():
257- tz = pytz.timezone(time_zone_widget.getInputValue())
258- self.widgets["time_starts"].required_time_zone = tz
259- self.widgets["time_ends"].required_time_zone = tz
260+ tz_name = time_zone_widget.getInputValue()
261+ self.widgets["time_starts"].required_time_zone_name = tz_name
262+ self.widgets["time_ends"].required_time_zone_name = tz_name
263
264 def validate(self, data):
265 time_starts = data.get("time_starts")
266@@ -373,11 +373,11 @@ class SprintEditView(LaunchpadEditFormView):
267 time_zone_widget = self.widgets["time_zone"]
268 # What time zone are the start and end values relative to?
269 if time_zone_widget.hasValidInput():
270- tz = pytz.timezone(time_zone_widget.getInputValue())
271+ tz_name = time_zone_widget.getInputValue()
272 else:
273- tz = pytz.timezone(self.context.time_zone)
274- self.widgets["time_starts"].required_time_zone = tz
275- self.widgets["time_ends"].required_time_zone = tz
276+ tz_name = self.context.time_zone
277+ self.widgets["time_starts"].required_time_zone_name = tz_name
278+ self.widgets["time_ends"].required_time_zone_name = tz_name
279
280 def validate(self, data):
281 time_starts = data.get("time_starts")
282diff --git a/lib/lp/blueprints/browser/sprintattendance.py b/lib/lp/blueprints/browser/sprintattendance.py
283index 2964ffd..194f340 100644
284--- a/lib/lp/blueprints/browser/sprintattendance.py
285+++ b/lib/lp/blueprints/browser/sprintattendance.py
286@@ -44,11 +44,10 @@ class BaseSprintAttendanceAddView(LaunchpadFormView):
287
288 def setUpWidgets(self):
289 LaunchpadFormView.setUpWidgets(self)
290- tz = pytz.timezone(self.context.time_zone)
291 self.starts_widget = self.widgets["time_starts"]
292 self.ends_widget = self.widgets["time_ends"]
293- self.starts_widget.required_time_zone = tz
294- self.ends_widget.required_time_zone = tz
295+ self.starts_widget.required_time_zone_name = self.context.time_zone
296+ self.ends_widget.required_time_zone_name = self.context.time_zone
297 # We don't need to display seconds
298 timeformat = "%Y-%m-%d %H:%M"
299 self.starts_widget.timeformat = timeformat
300@@ -57,8 +56,9 @@ class BaseSprintAttendanceAddView(LaunchpadFormView):
301 # after the sprint. We will accept a time just before or just after
302 # and map those to the beginning and end times, respectively, in
303 # self.getDates().
304- from_date = self.context.time_starts.astimezone(tz)
305- to_date = self.context.time_ends.astimezone(tz)
306+ time_zone = pytz.timezone(self.context.time_zone)
307+ from_date = self.context.time_starts.astimezone(time_zone)
308+ to_date = self.context.time_ends.astimezone(time_zone)
309 self.starts_widget.from_date = from_date - timedelta(days=1)
310 self.starts_widget.to_date = to_date
311 self.ends_widget.from_date = from_date
312diff --git a/lib/lp/services/webapp/doc/launchbag.rst b/lib/lp/services/webapp/doc/launchbag.rst
313index 304707d..41e7b6e 100644
314--- a/lib/lp/services/webapp/doc/launchbag.rst
315+++ b/lib/lp/services/webapp/doc/launchbag.rst
316@@ -79,20 +79,23 @@ will be cookie@example.com.
317 cookie@example.com
318
319
320-time_zone
321----------
322+time_zone_name and time_zone
323+----------------------------
324
325-The time_zone attribute gives the user's time zone, as an pytz object.
326+The time_zone_name attribute gives the name of the user's time zone; the
327+time_zone attribute gives it as a tzinfo object.
328
329 >>> from lp.testing.factory import LaunchpadObjectFactory
330 >>> factory = LaunchpadObjectFactory()
331 >>> person = factory.makePerson()
332 >>> ignored = login_person(person)
333+ >>> launchbag.time_zone_name
334+ 'UTC'
335 >>> launchbag.time_zone
336- <UTC>
337+ datetime.timezone.utc
338
339-It's cached, so even if the user's time zone is changed, it will stay
340-the same. This is to optimize the look-up time, since some pages look it
341+They're cached, so even if the user's time zone is changed, they will stay
342+the same. This is to optimize the look-up time, since some pages look them
343 up a lot of times.
344
345 >>> person.setLocation(
346@@ -101,11 +104,15 @@ up a lot of times.
347 ... "Europe/Paris",
348 ... launchbag.user,
349 ... )
350+ >>> launchbag.time_zone_name
351+ 'UTC'
352 >>> launchbag.time_zone
353- <UTC>
354+ datetime.timezone.utc
355
356 After the LaunchBag has been cleared, the correct time zone is returned.
357
358 >>> launchbag.clear()
359+ >>> launchbag.time_zone_name
360+ 'Europe/Paris'
361 >>> launchbag.time_zone
362 <... 'Europe/Paris' ...>
363diff --git a/lib/lp/services/webapp/interfaces.py b/lib/lp/services/webapp/interfaces.py
364index c6ef41a..ad0a103 100644
365--- a/lib/lp/services/webapp/interfaces.py
366+++ b/lib/lp/services/webapp/interfaces.py
367@@ -335,6 +335,7 @@ class ILaunchBag(Interface):
368 user = Attribute("Currently authenticated IPerson, or None")
369 login = Attribute("The login used by the authenticated person, or None")
370
371+ time_zone_name = Attribute("The name of the user's time zone")
372 time_zone = Attribute("The user's time zone")
373
374 developer = Bool(
375diff --git a/lib/lp/services/webapp/launchbag.py b/lib/lp/services/webapp/launchbag.py
376index a9d12d7..f20dfe0 100644
377--- a/lib/lp/services/webapp/launchbag.py
378+++ b/lib/lp/services/webapp/launchbag.py
379@@ -30,8 +30,6 @@ from lp.services.webapp.interaction import get_current_principal
380 from lp.services.webapp.interfaces import ILoggedInEvent, IOpenLaunchBag
381 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
382
383-_utc_tz = timezone.utc
384-
385
386 @implementer(IOpenLaunchBag)
387 class LaunchBag:
388@@ -92,6 +90,7 @@ class LaunchBag:
389 setattr(store, attribute, None)
390 store.login = None
391 store.time_zone = None
392+ store.time_zone_name = None
393
394 @property
395 def person(self):
396@@ -147,13 +146,22 @@ class LaunchBag:
397 return getattr(self._store, "bugtask", None)
398
399 @property
400- def time_zone(self):
401- if getattr(self._store, "time_zone", None) is None:
402+ def time_zone_name(self):
403+ if getattr(self._store, "time_zone_name", None) is None:
404 if self.user:
405- self._store.time_zone = pytz.timezone(self.user.time_zone)
406+ self._store.time_zone_name = self.user.time_zone
407 else:
408 # fall back to UTC
409- self._store.time_zone = _utc_tz
410+ self._store.time_zone_name = "UTC"
411+ return self._store.time_zone_name
412+
413+ @property
414+ def time_zone(self):
415+ if getattr(self._store, "time_zone", None) is None:
416+ if self.time_zone_name == "UTC":
417+ self._store.time_zone = timezone.utc
418+ else:
419+ self._store.time_zone = pytz.timezone(self.time_zone_name)
420 return self._store.time_zone
421
422

Subscribers

People subscribed via source and target branches

to status/vote changes: