Skip to content

Commit

Permalink
Merge pull request #174 from octoenergy/tdcdev-add-parameter-to-datet…
Browse files Browse the repository at this point in the history
…ime-fields

Add parameter to DateTime-based range fields
  • Loading branch information
tdcdev authored Oct 29, 2024
2 parents 4daab41 + b0bcd47 commit 455e8ec
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 17 deletions.
6 changes: 4 additions & 2 deletions docs/xocto/model_fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ Type: [xocto.ranges.FiniteDatetimeRange](xocto.ranges.FiniteDatetimeRange)
A field that represents an inclusive-exclusive `[)` ranges of timezone-aware
datetimes. Both the start and end of the range must not be `None`.

The values returned from the database will always be converted to the local timezone
The values returned from the database will be converted to `timezone` if
provided. If not provided, the values will be converted to the local timezone
as per the `TIME_ZONE` setting in `settings.py`.

```python
Expand Down Expand Up @@ -132,7 +133,8 @@ Type: [xocto.ranges.HalfFiniteDatetimeRange](xocto.ranges.HalfFiniteRange)
A field that represents an inclusive-exclusive `[)` ranges of timezone-aware
datetimes. The end of the range may be open-ended, represented by `None`.

The values returned from the database will always be converted to the local timezone
The values returned from the database will be converted to `timezone` if
provided. If not provided, the values will be converted to the local timezone
as per the `TIME_ZONE` setting in `settings.py`.

```python
Expand Down
74 changes: 69 additions & 5 deletions tests/fields/postgres/test_ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from dateutil import relativedelta
from django.conf import settings
from django.core import serializers
from django.utils import timezone

from tests.models import models
from xocto import localtime, ranges
Expand Down Expand Up @@ -247,6 +246,7 @@ def test_timezone_conversions(self):
"""
Timezones are converted correctly when round tripping.
"""
TZ_UTC = zoneinfo.ZoneInfo("UTC")
TZ_MELB = zoneinfo.ZoneInfo("Australia/Melbourne")
TZ_DEFAULT = zoneinfo.ZoneInfo(settings.TIME_ZONE)

Expand All @@ -256,19 +256,50 @@ def test_timezone_conversions(self):
)
obj = models.FiniteDateTimeRangeModel.objects.create(
finite_datetime_range=finite_datetime_range_melb,
finite_datetime_range_utc=finite_datetime_range_melb,
)
finite_datetime_range_london = ranges.FiniteDatetimeRange(
start=timezone.localtime(finite_datetime_range_melb.start),
end=timezone.localtime(finite_datetime_range_melb.end),
start=localtime.as_localtime(finite_datetime_range_melb.start),
end=localtime.as_localtime(finite_datetime_range_melb.end),
)
finite_datetime_range_utc = ranges.FiniteDatetimeRange(
start=localtime.as_utc(finite_datetime_range_melb.start),
end=localtime.as_utc(finite_datetime_range_melb.end),
)
obj.refresh_from_db()
assert (
obj.finite_datetime_range
== obj.finite_datetime_range_utc
== finite_datetime_range_london
== finite_datetime_range_melb
== finite_datetime_range_utc
)
assert obj.finite_datetime_range.start.tzinfo == TZ_DEFAULT
assert obj.finite_datetime_range.start.tzinfo != TZ_MELB
assert obj.finite_datetime_range_utc.start.tzinfo == TZ_UTC
assert obj.finite_datetime_range.start.tzinfo != TZ_MELB

def test_timezone_conversions_and_dst_issue(self):
TZ_UTC = zoneinfo.ZoneInfo("UTC")

dst_missing_hour = ranges.FiniteDatetimeRange(
start=datetime.datetime(2021, 10, 31, 0, tzinfo=TZ_UTC),
end=datetime.datetime(2021, 10, 31, 1, tzinfo=TZ_UTC),
)
utc_obj = models.FiniteDateTimeRangeUTCModel.objects.create(
finite_datetime_range=dst_missing_hour,
)
local_obj = models.FiniteDateTimeRangeModel.objects.create(
finite_datetime_range=dst_missing_hour,
)

# No issue getting this object as the range is configurated as UTC
utc_obj.refresh_from_db()

# Unable to get this object because the datetime (stored as UTC) is converted to a DST timezone
# and then both start and end == datetime.datetime(2021, 10, 31, 1,) raising a ValueError
with pytest.raises(ValueError):
local_obj.refresh_from_db()


class TestHalfFiniteDateTimeRangeField:
Expand Down Expand Up @@ -428,24 +459,57 @@ def test_timezone_conversions(self):
"""
Timezones are converted correctly when round tripping.
"""
TZ_UTC = zoneinfo.ZoneInfo("UTC")
TZ_MELB = zoneinfo.ZoneInfo("Australia/Melbourne")
TZ_DEFAULT = zoneinfo.ZoneInfo(settings.TIME_ZONE)

half_finite_datetime_range_melb = ranges.HalfFiniteDatetimeRange(
start=datetime.datetime(2024, 1, 10, tzinfo=TZ_MELB),
end=None,
)
obj = models.HalfFiniteDateTimeRangeModel.objects.create(
half_finite_datetime_range=half_finite_datetime_range_melb,
half_finite_datetime_range_utc=half_finite_datetime_range_melb,
)
half_finite_datetime_range_london = ranges.HalfFiniteDatetimeRange(
start=timezone.localtime(half_finite_datetime_range_melb.start),
half_finite_datetime_range_london = ranges.FiniteDatetimeRange(
start=localtime.as_localtime(half_finite_datetime_range_melb.start),
end=None,
)
half_finite_datetime_range_utc = ranges.FiniteDatetimeRange(
start=localtime.as_utc(half_finite_datetime_range_melb.start),
end=None,
)
obj.refresh_from_db()
assert (
obj.half_finite_datetime_range
== obj.half_finite_datetime_range_utc
== half_finite_datetime_range_london
== half_finite_datetime_range_melb
== half_finite_datetime_range_utc
)
assert obj.half_finite_datetime_range.start.tzinfo == TZ_DEFAULT
assert obj.half_finite_datetime_range.start.tzinfo != TZ_MELB
assert obj.half_finite_datetime_range_utc.start.tzinfo == TZ_UTC
assert obj.half_finite_datetime_range_utc.start.tzinfo != TZ_MELB

def test_timezone_conversions_and_dst_issue(self):
TZ_UTC = zoneinfo.ZoneInfo("UTC")

dst_missing_hour = ranges.HalfFiniteDatetimeRange(
start=datetime.datetime(2021, 10, 31, 0, tzinfo=TZ_UTC),
end=datetime.datetime(2021, 10, 31, 1, tzinfo=TZ_UTC),
)
utc_obj = models.HalfFiniteDateTimeRangeUTCModel.objects.create(
half_finite_datetime_range=dst_missing_hour,
)
local_obj = models.HalfFiniteDateTimeRangeModel.objects.create(
half_finite_datetime_range=dst_missing_hour,
)

# No issue getting this object as the range is configurated as UTC
utc_obj.refresh_from_db()

# Unable to get this object because the datetime (stored as UTC) is converted to a DST timezone
# and then both start and end == datetime.datetime(2021, 10, 31, 1,) raising a ValueError
with pytest.raises(ValueError):
local_obj.refresh_from_db()
20 changes: 20 additions & 0 deletions tests/models/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import zoneinfo

from django.db import models

from xocto.fields.postgres import ranges as range_fields
Expand All @@ -11,10 +13,28 @@ class FiniteDateRangeModel(models.Model):
class FiniteDateTimeRangeModel(models.Model):
finite_datetime_range = range_fields.FiniteDateTimeRangeField()
finite_datetime_range_nullable = range_fields.FiniteDateTimeRangeField(null=True)
finite_datetime_range_utc = range_fields.FiniteDateTimeRangeField(
timezone=zoneinfo.ZoneInfo("UTC"), null=True
)


class FiniteDateTimeRangeUTCModel(models.Model):
finite_datetime_range = range_fields.FiniteDateTimeRangeField(
timezone=zoneinfo.ZoneInfo("UTC"), null=True
)


class HalfFiniteDateTimeRangeModel(models.Model):
half_finite_datetime_range = range_fields.HalfFiniteDateTimeRangeField()
half_finite_datetime_range_nullable = range_fields.HalfFiniteDateTimeRangeField(
null=True
)
half_finite_datetime_range_utc = range_fields.HalfFiniteDateTimeRangeField(
timezone=zoneinfo.ZoneInfo("UTC"), null=True
)


class HalfFiniteDateTimeRangeUTCModel(models.Model):
half_finite_datetime_range = range_fields.HalfFiniteDateTimeRangeField(
timezone=zoneinfo.ZoneInfo("UTC"), null=True
)
31 changes: 21 additions & 10 deletions xocto/fields/postgres/ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,18 @@ def _upper_to_inclusive(self, value: pg_ranges.DateRange) -> datetime.date:
return value.upper - datetime.timedelta(days=1)


class FiniteDateTimeRangeField(pg_fields.DateTimeRangeField):
class _LocaliserMixin:
def __init__(
self, *args: Any, timezone: Optional[datetime.tzinfo] = None, **kwargs: Any
):
super().__init__(*args, **kwargs)
self._timezone = timezone

def localise(self, value: datetime.datetime) -> datetime.datetime:
return localtime.as_localtime(value, self._timezone)


class FiniteDateTimeRangeField(_LocaliserMixin, pg_fields.DateTimeRangeField):
"""
A DateTimeRangeField with Inclusive-Exclusive [) bounds that aren't infinite.
Expand Down Expand Up @@ -125,17 +136,17 @@ def from_db_value(
if value is None:
return None
return ranges.FiniteDatetimeRange(
start=localtime.as_localtime(value.lower),
end=localtime.as_localtime(value.upper),
start=self.localise(value.lower),
end=self.localise(value.upper),
)

def to_python(self, value: Optional[str]) -> Optional[ranges.FiniteDatetimeRange]:
if value is None:
return None
obj = json.loads(value)
return ranges.FiniteDatetimeRange(
start=localtime.as_localtime(self.base_field.to_python(obj["start"])),
end=localtime.as_localtime(self.base_field.to_python(obj["end"])),
start=self.localise(self.base_field.to_python(obj["start"])),
end=self.localise(self.base_field.to_python(obj["end"])),
)

def value_to_string(self, obj: models.Model) -> Optional[str]:
Expand All @@ -157,7 +168,7 @@ def value_to_string(self, obj: models.Model) -> Optional[str]:
)


class HalfFiniteDateTimeRangeField(pg_fields.DateTimeRangeField):
class HalfFiniteDateTimeRangeField(_LocaliserMixin, pg_fields.DateTimeRangeField):
"""
A DateTimeRangeField with Inclusive-Exclusive [) bounds that allows an infinite/open upper bound.
Expand Down Expand Up @@ -189,8 +200,8 @@ def from_db_value(
if value is None:
return None
return ranges.HalfFiniteDatetimeRange(
start=localtime.as_localtime(value.lower),
end=localtime.as_localtime(value.upper) if value.upper else None,
start=self.localise(value.lower),
end=self.localise(value.upper) if value.upper else None,
)

def to_python(
Expand All @@ -201,8 +212,8 @@ def to_python(
obj = json.loads(value)
end = self.base_field.to_python(obj["end"])
return ranges.HalfFiniteDatetimeRange(
start=localtime.as_localtime(self.base_field.to_python(obj["start"])),
end=localtime.as_localtime(end) if end else None,
start=self.localise(self.base_field.to_python(obj["start"])),
end=self.localise(end) if end else None,
)

def value_to_string(self, obj: models.Model) -> Optional[str]:
Expand Down

0 comments on commit 455e8ec

Please sign in to comment.