Skip to content

Commit 84f1089

Browse files
authored
Added date and time field/widget for Dexterity contents (#1903)
* Implemented datetime field * Custom setter for datetime field * Implemented datetime widget * Handle timezones * Added datetime API * Always localize the date on field level * Datetime widget refactored to use the date API * Extended API * Check if timezone is callable This can be the case when the `default_timezone` is set through a directive on the widget, e.g.: from plone.app.event.base import default_timezone * Changelog updated * Updated scrutinizer config * Added API function to generate a POSIX timestamp * Added from_timestamp API function * Added ISO format API * Handle Python date objects * Dropped get_min/get_max methods * Test fixed for TZ * Fixed datetimewidget display value * Test fixture * Fixed test
1 parent 2d6cc35 commit 84f1089

19 files changed

+1213
-9
lines changed

.scrutinizer.yml

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,18 @@ filter:
2121

2222
build:
2323
environment:
24-
python: 2.7.9
24+
python: 2.7.18
2525

2626
dependencies:
2727
before:
28-
- pip install -v pip==19.3.1
29-
- pip install -v setuptools==44.1.1
30-
- pip install -v zc.buildout==2.13.3
3128
- pip install virtualenv
3229
- pip install -r requirements.txt
33-
- /home/scrutinizer/.pyenv/versions/2.7.9/bin/buildout -v
34-
- mv -v /home/scrutinizer/build/develop-eggs/* /home/scrutinizer/.pyenv/versions/2.7.9/lib/python2.7/site-packages/
35-
- mv -v /home/scrutinizer/build/eggs/*.egg /home/scrutinizer/.pyenv/versions/2.7.9/lib/python2.7/site-packages/
30+
- /home/scrutinizer/.pyenv/versions/2.7.18/bin/buildout -v
31+
- mv -v /home/scrutinizer/build/develop-eggs/* /home/scrutinizer/.pyenv/versions/2.7.18/lib/python2.7/site-packages/
32+
- mv -v /home/scrutinizer/build/eggs/*.egg /home/scrutinizer/.pyenv/versions/2.7.18/lib/python2.7/site-packages/
3633

3734
nodes:
3835
analysis:
3936
tests:
4037
override:
41-
- py-scrutinizer-run
38+
- py-scrutinizer-run

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Changelog
44
2.0.1 (unreleased)
55
------------------
66

7+
- #1903 Added date and time field/widget for Dexterity contents
78
- #1901 Ensure `get_tool` returns a tool when a name is set as the default param
89
- #1900 Fix snapshot listing fails on orphan catalog entries
910
- #1897 Support date and number fields copy in sample add form

src/senaite/core/api/dtime.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import os
4+
import time
5+
from datetime import date
6+
from datetime import datetime
7+
8+
import pytz
9+
from bika.lims import logger
10+
from DateTime import DateTime
11+
12+
13+
def is_d(dt):
14+
"""Check if the date is a Python `date` object
15+
16+
:param dt: date to check
17+
:returns: True when the date is a Python `date`
18+
"""
19+
return type(dt) is date
20+
21+
22+
def is_dt(dt):
23+
"""Check if the date is a Python `datetime` object
24+
25+
:param dt: date to check
26+
:returns: True when the date is a Python `datetime`
27+
"""
28+
return type(dt) is datetime
29+
30+
31+
def is_DT(dt):
32+
"""Check if the date is a Zope `DateTime` object
33+
34+
:param dt: object to check
35+
:returns: True when the object is a Zope `DateTime`
36+
"""
37+
return type(dt) is DateTime
38+
39+
40+
def is_date(dt):
41+
"""Check if the date is a datetime or DateTime object
42+
43+
:param dt: date to check
44+
:returns: True when the object is either a datetime or DateTime
45+
"""
46+
if is_d(dt):
47+
return True
48+
if is_dt(dt):
49+
return True
50+
if is_DT(dt):
51+
return True
52+
return False
53+
54+
55+
def is_timezone_naive(dt):
56+
"""Check if the date is timezone naive
57+
58+
:param dt: date to check
59+
:returns: True when the date has no timezone
60+
"""
61+
if is_d(dt):
62+
return True
63+
elif is_DT(dt):
64+
return dt.timezoneNaive()
65+
elif is_dt(dt):
66+
return dt.tzinfo is None
67+
raise TypeError("Expected a date, got '%r'" % type(dt))
68+
69+
70+
def is_timezone_aware(dt):
71+
"""Check if the date is timezone aware
72+
73+
:param dt: date to check
74+
:returns: True when the date has a timezone
75+
"""
76+
return not is_timezone_naive(dt)
77+
78+
79+
def to_DT(dt):
80+
"""Convert to DateTime
81+
82+
:param dt: DateTime/datetime/date
83+
:returns: DateTime object
84+
"""
85+
if is_DT(dt):
86+
return dt
87+
elif is_dt(dt):
88+
return DateTime(dt.isoformat())
89+
elif is_d(dt):
90+
dt = datetime(dt.year, dt.month, dt.day)
91+
return DateTime(dt.isoformat())
92+
raise TypeError("Expected datetime, got '%r'" % type(dt))
93+
94+
95+
def to_dt(dt):
96+
"""Convert to datetime
97+
98+
:param dt: DateTime/datetime/date
99+
:returns: datetime object
100+
"""
101+
if is_DT(dt):
102+
return dt.asdatetime()
103+
elif is_dt(dt):
104+
return dt
105+
elif is_d(dt):
106+
return datetime(dt.year, dt.month, dt.day)
107+
raise TypeError("Expected DateTime, got '%r'" % type(dt))
108+
109+
110+
def is_valid_timezone(timezone):
111+
"""Checks if the timezone is a valid pytz/Olson name
112+
113+
:param timezone: pytz/Olson timezone name
114+
:returns: True when the timezone is a valid zone
115+
"""
116+
try:
117+
pytz.timezone(timezone)
118+
return True
119+
except pytz.UnknownTimeZoneError:
120+
return False
121+
122+
123+
def get_os_timezone():
124+
"""Return the default timezone of the system
125+
126+
:returns: OS timezone or UTC
127+
"""
128+
fallback = "UTC"
129+
timezone = None
130+
if "TZ" in os.environ.keys():
131+
# Timezone from OS env var
132+
timezone = os.environ["TZ"]
133+
if not timezone:
134+
# Timezone from python time
135+
zones = time.tzname
136+
if zones and len(zones) > 0:
137+
timezone = zones[0]
138+
else:
139+
# Default fallback = UTC
140+
logger.warn(
141+
"Operating system\'s timezone cannot be found. "
142+
"Falling back to UTC.")
143+
timezone = fallback
144+
if not is_valid_timezone(timezone):
145+
return fallback
146+
return timezone
147+
148+
149+
def to_zone(dt, timezone):
150+
"""Convert date to timezone
151+
152+
Adds the timezone for timezone naive datetimes
153+
154+
:param dt: date object
155+
:param timezone: timezone
156+
:returns: date converted to timezone
157+
"""
158+
if is_dt(dt):
159+
zone = pytz.timezone(timezone)
160+
if is_timezone_aware(dt):
161+
return dt.astimezone(zone)
162+
return zone.localize(dt)
163+
if is_DT(dt):
164+
# NOTE: This shifts the time according to the TZ offset
165+
return dt.toZone(timezone)
166+
167+
168+
def to_timestamp(dt):
169+
"""Generate a Portable Operating System Interface (POSIX) timestamp
170+
171+
:param dt: date object
172+
:returns: timestamp in seconds
173+
"""
174+
timestamp = 0
175+
if is_DT(dt):
176+
timestamp = dt.timeTime()
177+
elif is_dt(dt):
178+
timestamp = time.mktime(dt.timetuple())
179+
return timestamp
180+
181+
182+
def from_timestamp(timestamp):
183+
"""Generate a datetime object from a POSIX timestamp
184+
185+
:param timestamp: POSIX timestamp
186+
:returns: datetime object
187+
"""
188+
189+
return datetime.utcfromtimestamp(timestamp)
190+
191+
192+
def to_iso_format(dt):
193+
"""Convert to ISO format
194+
"""
195+
if is_dt(dt):
196+
return dt.isoformat()
197+
elif is_DT(dt):
198+
return dt.ISO()
199+
return None

src/senaite/core/schema/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
from zope.interface import classImplementsFirst
44

5+
from .datetimefield import DatetimeField
56
from .fields import IntField
7+
from .interfaces import IDatetimeField
68
from .interfaces import IIntField
79
from .uidreferencefield import IUIDReferenceField
810
from .uidreferencefield import UIDReferenceField
911

1012
classImplementsFirst(IntField, IIntField)
1113
classImplementsFirst(UIDReferenceField, IUIDReferenceField)
14+
classImplementsFirst(DatetimeField, IDatetimeField)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from senaite.core.api import dtime
4+
from senaite.core.schema.fields import BaseField
5+
from senaite.core.schema.interfaces import IDatetimeField
6+
from zope.interface import implementer
7+
from zope.schema import Datetime
8+
9+
10+
def localize(dt, fallback="UTC"):
11+
if dtime.is_timezone_naive(dt):
12+
zone = dtime.get_os_timezone()
13+
if not zone:
14+
zone = fallback
15+
dt = dtime.to_zone(dt, zone)
16+
return dt
17+
18+
19+
@implementer(IDatetimeField)
20+
class DatetimeField(Datetime, BaseField):
21+
"""A field that handles date and time
22+
"""
23+
24+
def set(self, object, value):
25+
"""Set datetime value
26+
27+
28+
NOTE: we need to ensure timzone aware datetime values,
29+
so that also API calls work
30+
31+
:param object: the instance of the field
32+
:param value: datetime value
33+
:type value: datetime
34+
"""
35+
if dtime.is_dt(value):
36+
value = localize(value)
37+
super(DatetimeField, self).set(object, value)
38+
39+
def get(self, object):
40+
"""Get the current date
41+
42+
:param object: the instance of the field
43+
:returns: datetime or None
44+
"""
45+
value = super(DatetimeField, self).get(object)
46+
if not dtime.is_dt(value):
47+
return None
48+
return localize(value)
49+
50+
def _validate(self, value):
51+
"""Validator when called from form submission
52+
"""
53+
super(DatetimeField, self)._validate(value)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
Datetime field
2+
==============
3+
4+
The datetime field stores Python datetime values.
5+
6+
7+
Running this test from the buildout directory:
8+
9+
bin/test test_schema_fields -t datetimefield
10+
11+
12+
Hints
13+
14+
- z3c.form.converter.txt (Date Data Converter)
15+
16+
17+
Test preparation
18+
----------------
19+
20+
>>> import sys
21+
>>> from bika.lims import api
22+
>>> from plone.app.testing import setRoles
23+
>>> from plone.app.testing import TEST_USER_ID
24+
>>> from plone.app.testing import TEST_USER_NAME
25+
>>> from plone.app.testing import TEST_USER_PASSWORD
26+
27+
Helper functions:
28+
29+
>>> def commit():
30+
... import transaction; transaction.commit()
31+
32+
33+
Grant required privileges:
34+
35+
>>> setRoles(portal, TEST_USER_ID, ["Manager",])
36+
>>> commit()
37+
38+
Test fixture:
39+
40+
>>> import os
41+
>>> os.environ["TZ"] = "CET"
42+
43+
44+
Using the field
45+
---------------
46+
47+
The field can be used much like any other field:
48+
49+
>>> from zope.interface import Interface, implementer
50+
>>> from senaite.core.schema import DatetimeField
51+
52+
>>> class IContent(Interface):
53+
... date = DatetimeField(title=u"Date")
54+
55+
>>> field = IContent['date']
56+
>>> field
57+
<senaite.core.schema.datetimefield.DatetimeField object at ...>
58+
59+
>>> from persistent import Persistent
60+
>>> @implementer(IContent)
61+
... class Content(Persistent):
62+
... def __init__(self, date=None):
63+
... self.date = date
64+
65+
66+
>>> from datetime import datetime
67+
>>> datestring = "2030-12-24"
68+
>>> date = datetime.strptime(datestring, "%Y-%m-%d")
69+
>>> content = Content()
70+
71+
72+
Set a value through the field:
73+
74+
>>> field.set(content, date)
75+
>>> field.get(content)
76+
datetime.datetime(2030, 12, 24, 0, 0, tzinfo=<DstTzInfo 'CET' CET+1:00:00 STD>)

0 commit comments

Comments
 (0)