Skip to content

Commit 908a67a

Browse files
author
Alex Hill
committed
Support for SQLite, MySQL, Postgres and Django up to 2.1
1 parent 1fb2aac commit 908a67a

File tree

4 files changed

+387
-120
lines changed

4 files changed

+387
-120
lines changed

.travis.yml

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,64 @@ language: python
66
# List the versions of Python you'd like to test against
77
services:
88
- postgresql
9+
- mysql
910
env:
1011
global:
1112
- DJANGO_SETTINGS_MODULE=tests.settings
1213
matrix:
13-
- DJANGO_VERSION=1.8.19
14-
- DJANGO_VERSION=1.9.13
15-
- DJANGO_VERSION=1.10.8
16-
- DJANGO_VERSION=1.11.18
14+
- DJANGO_VERSION=1.8.19 DB=mysql
15+
- DJANGO_VERSION=1.8.19 DB=postgres
16+
- DJANGO_VERSION=1.8.19 DB=sqlite
17+
- DJANGO_VERSION=1.9.13 DB=mysql
18+
- DJANGO_VERSION=1.9.13 DB=postgres
19+
- DJANGO_VERSION=1.9.13 DB=sqlite
20+
- DJANGO_VERSION=1.10.8 DB=mysql
21+
- DJANGO_VERSION=1.10.8 DB=postgres
22+
- DJANGO_VERSION=1.10.8 DB=sqlite
23+
- DJANGO_VERSION=1.11.18 DB=mysql
24+
- DJANGO_VERSION=1.11.18 DB=postgres
25+
- DJANGO_VERSION=1.11.18 DB=sqlite
26+
- DJANGO_VERSION=2.0.10 DB=mysql
27+
- DJANGO_VERSION=2.0.10 DB=postgres
28+
- DJANGO_VERSION=2.0.10 DB=sqlite
29+
- DJANGO_VERSION=2.1.5 DB=mysql
30+
- DJANGO_VERSION=2.1.5 DB=postgres
31+
- DJANGO_VERSION=2.1.5 DB=sqlite
32+
matrix:
33+
exclude:
34+
- python: "2.7"
35+
env: DJANGO_VERSION=2.0.10 DB=mysql
36+
- python: "2.7"
37+
env: DJANGO_VERSION=2.0.10 DB=sqlite
38+
- python: "2.7"
39+
env: DJANGO_VERSION=2.0.10 DB=postgres
40+
- python: "2.7"
41+
env: DJANGO_VERSION=2.1.5 DB=mysql
42+
- python: "2.7"
43+
env: DJANGO_VERSION=2.1.5 DB=sqlite
44+
- python: "2.7"
45+
env: DJANGO_VERSION=2.1.5 DB=postgres
46+
- python: "3.4"
47+
env: DJANGO_VERSION=2.1.5 DB=mysql
48+
- python: "3.4"
49+
env: DJANGO_VERSION=2.1.5 DB=sqlite
50+
- python: "3.4"
51+
env: DJANGO_VERSION=2.1.5 DB=postgres
1752
python:
1853
- "2.7"
1954
- "3.4"
2055
- "3.5"
2156
- "3.6"
2257
# Tell it the things it will need to install when it boots
2358
install:
24-
- pip install -q pytz coveralls flake8 psycopg2
25-
- pip install -q "Django==$DJANGO_VERSION"
26-
- pip install -e .
59+
- pip install -q pytz coveralls flake8 psycopg2 mysqlclient
60+
- pip install -q "Django==$DJANGO_VERSION"
61+
- pip install -e .
2762
# Tell Travis how to run the test script itself
2863
before_script:
29-
- psql -c 'create database naivedatetimefield;' -U postgres
64+
- mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql
3065
script:
3166
- flake8 naivedatetimefield/
3267
- coverage run --parallel-mode --source=naivedatetimefield runtests.py
3368
- coverage combine
34-
after_success: coveralls
69+
# after_success: coveralls

naivedatetimefield/__init__.py

Lines changed: 87 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
11
import datetime
2+
import sys
3+
import warnings
24

3-
from django import forms
5+
import django
6+
import pytz
47
from django.core import exceptions, checks
5-
6-
from django.db import models
7-
8+
from django.db.models import DateTimeField
89
from django.utils import timezone
910
from django.utils.dateparse import parse_date, parse_datetime
1011
from django.utils.translation import gettext_lazy as _
1112

13+
if django.VERSION >= (1, 11):
14+
from django.db.models.functions.datetime import TruncBase, Extract, ExtractYear
15+
from django.db.models.lookups import Exact, GreaterThan, GreaterThanOrEqual, \
16+
LessThan, LessThanOrEqual
17+
1218

13-
class NaiveDateTimeField(models.DateField):
19+
class NaiveDateTimeField(DateTimeField):
1420
description = _("Naive Date (with time)")
1521

22+
default_error_messages = {
23+
'tzaware': _("TZ-aware datetimes cannot be coerced to naive datetimes"),
24+
}
25+
1626
def get_internal_type(self):
17-
return "NaiveDateTime"
27+
return "DateTimeField"
1828

1929
def db_type(self, connection):
20-
if connection.settings_dict["ENGINE"] in [
21-
"django_prometheus.db.backends.postgresql",
22-
"django_prometheus.db.backends.postgresql_psycopg2",
23-
"django.db.backends.postgresql",
24-
"django.db.backends.postgresql_psycopg2",
25-
]:
30+
if connection.vendor == "postgresql":
2631
return "timestamp without time zone"
27-
28-
raise NotImplementedError("Only postgresql is supported at this time.")
32+
return super(NaiveDateTimeField, self).db_type(connection)
2933

3034
def _check_fix_default_value(self):
3135
"""
@@ -81,13 +85,17 @@ def to_python(self, value):
8185
if value is None:
8286
return value
8387
if isinstance(value, datetime.datetime):
84-
return value.replace(tzinfo=None)
88+
if timezone.is_aware(value):
89+
raise exceptions.ValidationError(self.error_messages['tzaware'])
90+
return value
8591
if isinstance(value, datetime.date):
8692
return datetime.datetime(value.year, value.month, value.day)
8793

8894
try:
8995
parsed = parse_datetime(value)
9096
if parsed is not None:
97+
if timezone.is_aware(parsed):
98+
raise exceptions.ValidationError(self.error_messages['tzaware'])
9199
return parsed
92100
except ValueError:
93101
raise exceptions.ValidationError(
@@ -112,104 +120,91 @@ def to_python(self, value):
112120
)
113121

114122
def get_prep_value(self, value):
115-
"""
116-
Ensure we have a naive datetime ready for insertion
117-
"""
118-
value = super(NaiveDateTimeField, self).get_prep_value(value)
119-
value = self.to_python(value)
120-
121-
if value is not None and timezone.is_aware(value):
122-
# We were given an aware datetime, strip off tzinfo
123-
value = value.replace(tzinfo=None)
124-
125-
return value
126-
127-
def get_db_prep_value(self, value, connection, prepared=False):
128-
if not prepared:
129-
value = self.get_prep_value(value)
130-
131-
if value is None:
132-
return None
133-
134-
if hasattr(value, "resolve_expression"):
123+
return super(DateTimeField, self).get_prep_value(value)
124+
125+
def from_db_value(self, value, expression, connection, context):
126+
is_truncbase = django.VERSION >= (1, 11) and isinstance(expression, TruncBase)
127+
if is_truncbase and not isinstance(expression, NaiveAsSQLMixin):
128+
raise TypeError(
129+
"Django's %s cannot be used with a NaiveDateTimeField"
130+
% expression.__class__.__name__
131+
)
132+
if connection.vendor == "postgresql":
133+
if is_truncbase:
134+
return timezone.make_naive(value, pytz.utc)
135135
return value
136-
137-
if connection.settings_dict["ENGINE"] == "django.db.backends.mysql":
138-
return str(value)
139-
140-
elif connection.settings_dict["ENGINE"] == "django.db.backends.sqlite3":
141-
return str(value)
142-
143-
elif connection.settings_dict["ENGINE"] == "django.db.backends.oracle":
144-
from django.db.backends.oracle.utils import Oracle_datetime
145-
146-
return Oracle_datetime.from_datetime(value)
147-
136+
if timezone.is_aware(value):
137+
if django.VERSION < (1, 9):
138+
return timezone.make_naive(value, pytz.utc)
139+
return timezone.make_naive(value, connection.timezone)
148140
return value
149141

150142
def pre_save(self, model_instance, add):
151143
if self.auto_now or (self.auto_now_add and add):
152-
value = timezone.now().replace(tzinfo=None)
144+
value = timezone.make_naive(timezone.now())
153145
setattr(model_instance, self.attname, value)
154146
return value
155147
else:
156148
return super(NaiveDateTimeField, self).pre_save(model_instance, add)
157149

158-
def value_to_string(self, obj):
159-
val = self.value_from_object(obj)
160-
return "" if val is None else val.isoformat()
161150

162-
def formfield(self, **kwargs):
163-
defaults = {"form_class": forms.DateTimeField}
164-
defaults.update(kwargs)
165-
return super(NaiveDateTimeField, self).formfield(**defaults)
151+
class NaiveTimezoneMixin(object):
152+
def get_tzname(self):
153+
if isinstance(self.output_field, NaiveDateTimeField):
154+
if self.tzinfo is not None:
155+
warnings.warn(
156+
"tzinfo argument provided when truncating a NaiveDateTimeField. "
157+
"This argument will have no effect."
158+
)
159+
return 'UTC'
160+
return super(NaiveTimezoneMixin, self).get_tzname()
166161

167162

168-
# try to register our field for the __time and __date lookups
169-
try:
170-
from django.db.models import TimeField
171-
from django.db.models.functions.datetime import TruncBase
163+
class NaiveConvertValueMixin(object):
164+
def convert_value(self, value, *args, **kwargs):
165+
if isinstance(self.output_field, NaiveDateTimeField):
166+
return value
167+
return super(NaiveConvertValueMixin, self).convert_value(value, *args, **kwargs)
172168

173-
class TruncTimeNaive(TruncBase):
174-
kind = "time"
175-
lookup_name = "time"
176-
output_field = TimeField()
177169

178-
def as_sql(self, compiler, connection):
179-
# Cast to date rather than truncate to date.
180-
lhs, lhs_params = compiler.compile(self.lhs)
170+
class NaiveAsSQLMixin(object):
171+
def as_sql(self, compiler, connection):
172+
if isinstance(self.lhs.output_field, NaiveDateTimeField):
173+
with timezone.override(pytz.utc):
174+
return super(NaiveAsSQLMixin, self).as_sql(compiler, connection)
175+
return super(NaiveAsSQLMixin, self).as_sql(compiler, connection)
181176

182-
# this is a postgresql only compatible cast, replacing
183-
# a call to connection.ops.datetime_cast_time_sql that
184-
# wouldn't work with None tzinfo
185-
sql = "(%s)::time" % lhs
186177

187-
return sql, lhs_params
178+
_monkeypatching = False
188179

189-
NaiveDateTimeField.register_lookup(TruncTimeNaive)
190-
except ImportError:
191-
pass
192180

193-
try:
194-
from django.db.models import DateField
195-
from django.db.models.functions.datetime import TruncBase
181+
if django.VERSION >= (1, 11):
182+
_this_module = sys.modules[__name__]
183+
_db_functions = sys.modules['django.db.models.functions']
184+
_lookups = set(DateTimeField.get_lookups().values())
185+
_patch_classes = [
186+
(Extract, [NaiveAsSQLMixin, NaiveTimezoneMixin]),
187+
(TruncBase, [NaiveAsSQLMixin, NaiveTimezoneMixin, NaiveConvertValueMixin]),
188+
]
189+
for original, mixins in _patch_classes:
190+
for cls in original.__subclasses__():
196191

197-
class TruncDateNaive(TruncBase):
198-
kind = "date"
199-
lookup_name = "date"
200-
output_field = DateField()
192+
bases = tuple(mixins) + (cls,)
193+
naive_cls = type(cls.__name__, bases, {})
201194

202-
def as_sql(self, compiler, connection):
203-
# Cast to date rather than truncate to date.
204-
lhs, lhs_params = compiler.compile(self.lhs)
195+
if _monkeypatching:
196+
setattr(_db_functions, cls.__name__, naive_cls)
205197

206-
# this is a postgresql only compatible cast, replacing
207-
# a call to connection.ops.datetime_cast_date_sql that
208-
# wouldn't work with None tzinfo
209-
sql = "(%s)::date" % lhs
198+
if cls in _lookups:
199+
NaiveDateTimeField.register_lookup(naive_cls)
210200

211-
return sql, lhs_params
201+
# Year lookups don't need special handling with naive fields
202+
if cls is ExtractYear:
203+
naive_cls.register_lookup(Exact)
204+
naive_cls.register_lookup(GreaterThan)
205+
naive_cls.register_lookup(GreaterThanOrEqual)
206+
naive_cls.register_lookup(LessThan)
207+
naive_cls.register_lookup(LessThanOrEqual)
212208

213-
NaiveDateTimeField.register_lookup(TruncDateNaive)
214-
except ImportError:
215-
pass
209+
# Add an attribute to this module so these functions can be imported
210+
setattr(_this_module, cls.__name__, naive_cls)

tests/settings.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,29 @@
2626

2727
MIDDLEWARE = MIDDLEWARE_CLASSES
2828

29-
DATABASES = {
30-
'default': {
31-
'ENGINE': 'django.db.backends.postgresql_psycopg2',
29+
AVAILABLE_DATABASES = {
30+
"sqlite": {
31+
"ENGINE": "django.db.backends.sqlite3",
32+
"NAME": ":memory:",
33+
},
34+
"postgres": {
35+
"ENGINE": "django.db.backends.postgresql_psycopg2",
3236
"NAME": os.environ.get("DJANGO_DATABASE_NAME_POSTGRES", "naivedatetimefield"),
3337
"USER": os.environ.get("DJANGO_DATABASE_USER_POSTGRES", 'postgres'),
3438
"PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD_POSTGRES", ""),
3539
"HOST": os.environ.get("DJANGO_DATABASE_HOST_POSTGRES", ""),
36-
}
40+
},
41+
"mysql": {
42+
"ENGINE": "django.db.backends.mysql",
43+
"NAME": os.environ.get("DJANGO_DATABASE_NAME_MYSQL", "naivedatetimefield"),
44+
"USER": os.environ.get("DJANGO_DATABASE_USER_MYSQL", 'root'),
45+
"PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD_MYSQL", ""),
46+
"HOST": os.environ.get("DJANGO_DATABASE_HOST_MYSQL", ""),
47+
},
3748
}
3849

50+
DATABASES = {"default": AVAILABLE_DATABASES[os.environ.get("DB", "postgres")]}
51+
3952
CACHES = {
4053
'default': {
4154
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
@@ -68,3 +81,29 @@
6881
},
6982
},
7083
]
84+
85+
LOGGING = {
86+
'disable_existing_loggers': False,
87+
'version': 1,
88+
'handlers': {
89+
'console': {
90+
# logging handler that outputs log messages to terminal
91+
'class': 'logging.StreamHandler',
92+
'level': 'DEBUG', # message level to be written to console
93+
},
94+
},
95+
'loggers': {
96+
'': {
97+
# this sets root level logger to log debug and higher level
98+
# logs to console. All other loggers inherit settings from
99+
# root level logger.
100+
'handlers': ['console'],
101+
'level': 'DEBUG',
102+
'propagate': False, # this tells logger to send logging message
103+
# to its parent (will send if set to True)
104+
},
105+
'django.db': {
106+
# django also has database level logging
107+
},
108+
},
109+
}

0 commit comments

Comments
 (0)