django-fuzzy-dates 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_fuzzy_dates-1.0.1/CHANGELOG.md +4 -0
- django_fuzzy_dates-1.0.1/LICENSE +21 -0
- django_fuzzy_dates-1.0.1/PKG-INFO +203 -0
- django_fuzzy_dates-1.0.1/README.md +171 -0
- django_fuzzy_dates-1.0.1/fuzzy_dates/__init__.py +1 -0
- django_fuzzy_dates-1.0.1/fuzzy_dates/fuzzy_dates.py +485 -0
- django_fuzzy_dates-1.0.1/fuzzy_dates/fuzzy_dates_old.py +203 -0
- django_fuzzy_dates-1.0.1/fuzzy_dates/templates/fuzzy_dates/time_widget.html +1 -0
- django_fuzzy_dates-1.0.1/pyproject.toml +39 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Imaginary Landscape, LLC.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-fuzzy-dates
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Package to support nonspecific dates in form yyyy or yyyy.mm
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Noel Taylor
|
|
8
|
+
Author-email: ntaylor@imagescape.com
|
|
9
|
+
Requires-Python: >=3.8,<4.0
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 3.2
|
|
14
|
+
Classifier: Framework :: Django :: 4.2
|
|
15
|
+
Classifier: Framework :: Django :: 5.0
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
28
|
+
Requires-Dist: django (>=4.2)
|
|
29
|
+
Project-URL: Repository, https://github.com/ImaginaryLandscape/django-fuzzy-dates
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
django-fuzzy-dates
|
|
33
|
+
=================
|
|
34
|
+
|
|
35
|
+
Introduction
|
|
36
|
+
-----
|
|
37
|
+
|
|
38
|
+
This package provides a custom model field for storing not only typical dates
|
|
39
|
+
("yyyy.mm.dd"), but also a year with only a month ("yyyy.mm"), or just a year by
|
|
40
|
+
itself ("yyyy"). Dates with no day or no month are "fuzzy" because they are less
|
|
41
|
+
precise than typical dates. This can be useful for concepts such as timelines
|
|
42
|
+
that include points where precise data is not available or required (e.g.,
|
|
43
|
+
"1492: Columbus reaches the new world" or "Oct. 2002: I begin my year abroad").
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
The FuzzyDate Object
|
|
47
|
+
-----
|
|
48
|
+
|
|
49
|
+
Fuzzy dates can be instantiated directly in a few different ways:
|
|
50
|
+
|
|
51
|
+
1) with a python 'datetime' object
|
|
52
|
+
2) with a python 'date' object
|
|
53
|
+
3) with a string in the format "yyyy", "yyyy.mm", or "yyyy.mm.dd"
|
|
54
|
+
4) with keyword arguments "y", "m", and "d"
|
|
55
|
+
|
|
56
|
+
Here we create one FuzzyDate with a datetime and another with a date:
|
|
57
|
+
|
|
58
|
+
$ ./manage.py shell
|
|
59
|
+
...
|
|
60
|
+
>>> from fuzzy_dates import FuzzyDate
|
|
61
|
+
>>> from datetime import datetime
|
|
62
|
+
>>> today = datetime.today()
|
|
63
|
+
>>> FuzzyDate(today)
|
|
64
|
+
FuzzyDate('2024.03.15')
|
|
65
|
+
>>> FuzzyDate(today.date())
|
|
66
|
+
FuzzyDate('2024.03.15')
|
|
67
|
+
|
|
68
|
+
Here we create one with a string and another with keyword arguments:
|
|
69
|
+
|
|
70
|
+
>>> fd1 = FuzzyDate("2019.01")
|
|
71
|
+
>>> fd2 = FuzzyDate(y="2024", m="2", d="28")
|
|
72
|
+
|
|
73
|
+
Note that when printing the object, the output is formatted in a more
|
|
74
|
+
user-friendly way:
|
|
75
|
+
|
|
76
|
+
>>> print(fd1)
|
|
77
|
+
01/2019
|
|
78
|
+
>>> print(fd2)
|
|
79
|
+
02/28/2024
|
|
80
|
+
|
|
81
|
+
Note also that the individual components of the date are available in the
|
|
82
|
+
object's "year", "month", and "day" attributes:
|
|
83
|
+
|
|
84
|
+
>>> fd1.year
|
|
85
|
+
'2019'
|
|
86
|
+
>>> fd1.month
|
|
87
|
+
'01'
|
|
88
|
+
>>> fd1.day
|
|
89
|
+
''
|
|
90
|
+
|
|
91
|
+
If you want to see the non-fuzzy start and end dates of a FuzzyDate instance,
|
|
92
|
+
you can call the instance's "get_range()" method. It will return a pair of
|
|
93
|
+
non-fuzzy FuzzyDate instances:
|
|
94
|
+
|
|
95
|
+
>>> fd1.get_range()
|
|
96
|
+
(FuzzyDate('2019.01.01'), FuzzyDate('2019.01.31'))
|
|
97
|
+
|
|
98
|
+
A FuzzyDate can tell you whether it's fuzzy or not -- that is, if either the
|
|
99
|
+
month or day is unspecified. You might use this, for example, in a form where
|
|
100
|
+
the user can supply a time of day as well as a date. A time of day is only
|
|
101
|
+
meaningful if the date is fully specified, so your form might have a `clean()`
|
|
102
|
+
method like:
|
|
103
|
+
|
|
104
|
+
def clean(self):
|
|
105
|
+
cleaned_data = super().clean()
|
|
106
|
+
if cleaned_data.get("start_time"):
|
|
107
|
+
start_date = cleaned_data.get("start_date")
|
|
108
|
+
if not start_date or start_date.is_fuzzy:
|
|
109
|
+
self.add_error("start_time", "If start time is specified, a start date with month, day, and year must also be specified.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
Customization
|
|
113
|
+
-----
|
|
114
|
+
|
|
115
|
+
The format of a stringified FuzzyDate object can be tweaked with the settings
|
|
116
|
+
FUZZY_DATE_FIELD_ORDER and FUZZY_DATE_FIELD_SEPARATOR. These default to "mdy"
|
|
117
|
+
and "/". If we change these (e.g., to "ymd" and "-"), we'll see a different
|
|
118
|
+
result
|
|
119
|
+
|
|
120
|
+
# With FUZZY_DATE_FIELD_ORDER = "ymd" and FUZZY_DATE_FIELD_SEPARATOR = "-"
|
|
121
|
+
$ ./manage.py shell
|
|
122
|
+
...
|
|
123
|
+
>>> fd = FuzzyDate("2019.01")
|
|
124
|
+
>>> print(fd)
|
|
125
|
+
2019-01
|
|
126
|
+
|
|
127
|
+
Note that the dot ("."), dash ("-"), and forward slash ("/") are the only valid
|
|
128
|
+
values for FUZZY_DATE_FIELD_SEPARATOR.
|
|
129
|
+
|
|
130
|
+
By default, the printed date includes leading zeros in values like "01". This
|
|
131
|
+
can be changed with the setting "FUZZY_DATE_TRIM_LEADING_ZEROS". By setting
|
|
132
|
+
this to True, January 2009 (for example) could be printed as "1/2019" instead
|
|
133
|
+
of as "01/2019"
|
|
134
|
+
|
|
135
|
+
Note that changing the FUZZY_DATE_FIELD_ORDER setting will change the order of
|
|
136
|
+
the components in the printed date, but the value must still be exactly three
|
|
137
|
+
characters long and contain "y", "m", and "d" in some combination.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
Using FuzzyDates in Models
|
|
141
|
+
-----
|
|
142
|
+
|
|
143
|
+
Fuzzy dates are probably most useful as fields on a Django model. Therefore,
|
|
144
|
+
this package provides a model field, "FuzzyDateField". Here is a quick example
|
|
145
|
+
of how the field might be used in your own "models.py" module.
|
|
146
|
+
|
|
147
|
+
from django.db import models
|
|
148
|
+
from fuzzy_dates import FuzzyDateField
|
|
149
|
+
|
|
150
|
+
class Event(models.Model):
|
|
151
|
+
name = models.CharField(max_length=50)
|
|
152
|
+
date = FuzzyDateField(blank=True)
|
|
153
|
+
|
|
154
|
+
def __str__(self):
|
|
155
|
+
return f"{self.name}: {self.date}"
|
|
156
|
+
|
|
157
|
+
Note that since FuzzyDate inherits its properties from the `string` class, we
|
|
158
|
+
can define the model with `blank=True` just as we would with a string. However,
|
|
159
|
+
unlike with a string, you do not need to pass a `max_length` parameter.
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
Sorting and Filtering
|
|
163
|
+
-----
|
|
164
|
+
|
|
165
|
+
Fuzzy dates can be sorted alongside non-fuzzy dates. For example, if we create
|
|
166
|
+
a few model instances like this:
|
|
167
|
+
|
|
168
|
+
$ ./manage.py shell
|
|
169
|
+
...
|
|
170
|
+
>>> from <your_app>.models import Event
|
|
171
|
+
>>> Event.objects.create(name="A New Year", date="1992")
|
|
172
|
+
>>> Event.objects.create(name="New Year's Party", date="1991.12.31")
|
|
173
|
+
>>> Event.objects.create(name="New Year's Headache", date="1992.01.01")
|
|
174
|
+
|
|
175
|
+
We can sort them in the usual way like this:
|
|
176
|
+
|
|
177
|
+
>>> for ev in Event.objects.order_by("date"): print(ev)
|
|
178
|
+
...
|
|
179
|
+
New Year's Party: 12/31/1991
|
|
180
|
+
A New Year: 1992
|
|
181
|
+
New Year's Headache: 01/01/1992
|
|
182
|
+
|
|
183
|
+
Or we can filter them like this:
|
|
184
|
+
|
|
185
|
+
>>> for ev in Event.objects.filter(date__gte="1992").order_by("date"): print(ev)
|
|
186
|
+
...
|
|
187
|
+
A New Year: 1992
|
|
188
|
+
New Year's Headache: 01/01/1992
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
Using FuzzyDates In Forms
|
|
192
|
+
-----
|
|
193
|
+
|
|
194
|
+
The Fuzzy date model field uses a custom form widget with separate entries for
|
|
195
|
+
the year, month, and day. This form field, which can be imported directly with
|
|
196
|
+
|
|
197
|
+
>>> from fuzzy_dates import FuzzyDateFormField
|
|
198
|
+
|
|
199
|
+
will appear in a Django ModelForm that maps to an object with a FuzzyDateField.
|
|
200
|
+
It is also used in the Django admin change view for such an object. Again, the
|
|
201
|
+
ordering of the year, month, and day fields will follow the order prescribed by
|
|
202
|
+
the FUZZY_DATE_FIELD_ORDER setting.
|
|
203
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
django-fuzzy-dates
|
|
2
|
+
=================
|
|
3
|
+
|
|
4
|
+
Introduction
|
|
5
|
+
-----
|
|
6
|
+
|
|
7
|
+
This package provides a custom model field for storing not only typical dates
|
|
8
|
+
("yyyy.mm.dd"), but also a year with only a month ("yyyy.mm"), or just a year by
|
|
9
|
+
itself ("yyyy"). Dates with no day or no month are "fuzzy" because they are less
|
|
10
|
+
precise than typical dates. This can be useful for concepts such as timelines
|
|
11
|
+
that include points where precise data is not available or required (e.g.,
|
|
12
|
+
"1492: Columbus reaches the new world" or "Oct. 2002: I begin my year abroad").
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
The FuzzyDate Object
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
Fuzzy dates can be instantiated directly in a few different ways:
|
|
19
|
+
|
|
20
|
+
1) with a python 'datetime' object
|
|
21
|
+
2) with a python 'date' object
|
|
22
|
+
3) with a string in the format "yyyy", "yyyy.mm", or "yyyy.mm.dd"
|
|
23
|
+
4) with keyword arguments "y", "m", and "d"
|
|
24
|
+
|
|
25
|
+
Here we create one FuzzyDate with a datetime and another with a date:
|
|
26
|
+
|
|
27
|
+
$ ./manage.py shell
|
|
28
|
+
...
|
|
29
|
+
>>> from fuzzy_dates import FuzzyDate
|
|
30
|
+
>>> from datetime import datetime
|
|
31
|
+
>>> today = datetime.today()
|
|
32
|
+
>>> FuzzyDate(today)
|
|
33
|
+
FuzzyDate('2024.03.15')
|
|
34
|
+
>>> FuzzyDate(today.date())
|
|
35
|
+
FuzzyDate('2024.03.15')
|
|
36
|
+
|
|
37
|
+
Here we create one with a string and another with keyword arguments:
|
|
38
|
+
|
|
39
|
+
>>> fd1 = FuzzyDate("2019.01")
|
|
40
|
+
>>> fd2 = FuzzyDate(y="2024", m="2", d="28")
|
|
41
|
+
|
|
42
|
+
Note that when printing the object, the output is formatted in a more
|
|
43
|
+
user-friendly way:
|
|
44
|
+
|
|
45
|
+
>>> print(fd1)
|
|
46
|
+
01/2019
|
|
47
|
+
>>> print(fd2)
|
|
48
|
+
02/28/2024
|
|
49
|
+
|
|
50
|
+
Note also that the individual components of the date are available in the
|
|
51
|
+
object's "year", "month", and "day" attributes:
|
|
52
|
+
|
|
53
|
+
>>> fd1.year
|
|
54
|
+
'2019'
|
|
55
|
+
>>> fd1.month
|
|
56
|
+
'01'
|
|
57
|
+
>>> fd1.day
|
|
58
|
+
''
|
|
59
|
+
|
|
60
|
+
If you want to see the non-fuzzy start and end dates of a FuzzyDate instance,
|
|
61
|
+
you can call the instance's "get_range()" method. It will return a pair of
|
|
62
|
+
non-fuzzy FuzzyDate instances:
|
|
63
|
+
|
|
64
|
+
>>> fd1.get_range()
|
|
65
|
+
(FuzzyDate('2019.01.01'), FuzzyDate('2019.01.31'))
|
|
66
|
+
|
|
67
|
+
A FuzzyDate can tell you whether it's fuzzy or not -- that is, if either the
|
|
68
|
+
month or day is unspecified. You might use this, for example, in a form where
|
|
69
|
+
the user can supply a time of day as well as a date. A time of day is only
|
|
70
|
+
meaningful if the date is fully specified, so your form might have a `clean()`
|
|
71
|
+
method like:
|
|
72
|
+
|
|
73
|
+
def clean(self):
|
|
74
|
+
cleaned_data = super().clean()
|
|
75
|
+
if cleaned_data.get("start_time"):
|
|
76
|
+
start_date = cleaned_data.get("start_date")
|
|
77
|
+
if not start_date or start_date.is_fuzzy:
|
|
78
|
+
self.add_error("start_time", "If start time is specified, a start date with month, day, and year must also be specified.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
Customization
|
|
82
|
+
-----
|
|
83
|
+
|
|
84
|
+
The format of a stringified FuzzyDate object can be tweaked with the settings
|
|
85
|
+
FUZZY_DATE_FIELD_ORDER and FUZZY_DATE_FIELD_SEPARATOR. These default to "mdy"
|
|
86
|
+
and "/". If we change these (e.g., to "ymd" and "-"), we'll see a different
|
|
87
|
+
result
|
|
88
|
+
|
|
89
|
+
# With FUZZY_DATE_FIELD_ORDER = "ymd" and FUZZY_DATE_FIELD_SEPARATOR = "-"
|
|
90
|
+
$ ./manage.py shell
|
|
91
|
+
...
|
|
92
|
+
>>> fd = FuzzyDate("2019.01")
|
|
93
|
+
>>> print(fd)
|
|
94
|
+
2019-01
|
|
95
|
+
|
|
96
|
+
Note that the dot ("."), dash ("-"), and forward slash ("/") are the only valid
|
|
97
|
+
values for FUZZY_DATE_FIELD_SEPARATOR.
|
|
98
|
+
|
|
99
|
+
By default, the printed date includes leading zeros in values like "01". This
|
|
100
|
+
can be changed with the setting "FUZZY_DATE_TRIM_LEADING_ZEROS". By setting
|
|
101
|
+
this to True, January 2009 (for example) could be printed as "1/2019" instead
|
|
102
|
+
of as "01/2019"
|
|
103
|
+
|
|
104
|
+
Note that changing the FUZZY_DATE_FIELD_ORDER setting will change the order of
|
|
105
|
+
the components in the printed date, but the value must still be exactly three
|
|
106
|
+
characters long and contain "y", "m", and "d" in some combination.
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
Using FuzzyDates in Models
|
|
110
|
+
-----
|
|
111
|
+
|
|
112
|
+
Fuzzy dates are probably most useful as fields on a Django model. Therefore,
|
|
113
|
+
this package provides a model field, "FuzzyDateField". Here is a quick example
|
|
114
|
+
of how the field might be used in your own "models.py" module.
|
|
115
|
+
|
|
116
|
+
from django.db import models
|
|
117
|
+
from fuzzy_dates import FuzzyDateField
|
|
118
|
+
|
|
119
|
+
class Event(models.Model):
|
|
120
|
+
name = models.CharField(max_length=50)
|
|
121
|
+
date = FuzzyDateField(blank=True)
|
|
122
|
+
|
|
123
|
+
def __str__(self):
|
|
124
|
+
return f"{self.name}: {self.date}"
|
|
125
|
+
|
|
126
|
+
Note that since FuzzyDate inherits its properties from the `string` class, we
|
|
127
|
+
can define the model with `blank=True` just as we would with a string. However,
|
|
128
|
+
unlike with a string, you do not need to pass a `max_length` parameter.
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
Sorting and Filtering
|
|
132
|
+
-----
|
|
133
|
+
|
|
134
|
+
Fuzzy dates can be sorted alongside non-fuzzy dates. For example, if we create
|
|
135
|
+
a few model instances like this:
|
|
136
|
+
|
|
137
|
+
$ ./manage.py shell
|
|
138
|
+
...
|
|
139
|
+
>>> from <your_app>.models import Event
|
|
140
|
+
>>> Event.objects.create(name="A New Year", date="1992")
|
|
141
|
+
>>> Event.objects.create(name="New Year's Party", date="1991.12.31")
|
|
142
|
+
>>> Event.objects.create(name="New Year's Headache", date="1992.01.01")
|
|
143
|
+
|
|
144
|
+
We can sort them in the usual way like this:
|
|
145
|
+
|
|
146
|
+
>>> for ev in Event.objects.order_by("date"): print(ev)
|
|
147
|
+
...
|
|
148
|
+
New Year's Party: 12/31/1991
|
|
149
|
+
A New Year: 1992
|
|
150
|
+
New Year's Headache: 01/01/1992
|
|
151
|
+
|
|
152
|
+
Or we can filter them like this:
|
|
153
|
+
|
|
154
|
+
>>> for ev in Event.objects.filter(date__gte="1992").order_by("date"): print(ev)
|
|
155
|
+
...
|
|
156
|
+
A New Year: 1992
|
|
157
|
+
New Year's Headache: 01/01/1992
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
Using FuzzyDates In Forms
|
|
161
|
+
-----
|
|
162
|
+
|
|
163
|
+
The Fuzzy date model field uses a custom form widget with separate entries for
|
|
164
|
+
the year, month, and day. This form field, which can be imported directly with
|
|
165
|
+
|
|
166
|
+
>>> from fuzzy_dates import FuzzyDateFormField
|
|
167
|
+
|
|
168
|
+
will appear in a Django ModelForm that maps to an object with a FuzzyDateField.
|
|
169
|
+
It is also used in the Django admin change view for such an object. Again, the
|
|
170
|
+
ordering of the year, month, and day fields will follow the order prescribed by
|
|
171
|
+
the FUZZY_DATE_FIELD_ORDER setting.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .fuzzy_dates import * # noqa
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import re
|
|
3
|
+
from datetime import date, datetime, timezone
|
|
4
|
+
from django import forms
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core.exceptions import ValidationError
|
|
7
|
+
from django.core.validators import MinValueValidator
|
|
8
|
+
from django.db import models
|
|
9
|
+
from zoneinfo import available_timezones, ZoneInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# This regex matches dates in the format yyyy, yyyy.mm, or yyyy.mm.dd (other
|
|
13
|
+
# separators are allowed, too, e.g., yyyy-mm-dd or yyyy/mm/dd). A time value
|
|
14
|
+
# in the format hh:mm can also be appended. If a time is present, a timezone
|
|
15
|
+
# value with the format area/location must also be present. Thanks to
|
|
16
|
+
# https://stackoverflow.com/questions/15474741/python-regex-optional-capture-group
|
|
17
|
+
DATE_PATTERN = re.compile(
|
|
18
|
+
r"(\d{4})" # year
|
|
19
|
+
r"(?:[.\-/](\d{2})" # optional month
|
|
20
|
+
r"(?:[.\-/](\d{2})" # optional day
|
|
21
|
+
r"(?:\s+(\d{2}):(\d{2})\s+([A-Za-z_]+/[A-Za-z_]+))?)?)$" # optional time block
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DATE_FIELD_ORDER = getattr(settings, "FUZZY_DATE_FIELD_ORDER", "mdy").lower()
|
|
25
|
+
DATE_FIELD_SEPARATOR = getattr(settings, "FUZZY_DATE_FIELD_SEPARATOR", "/")
|
|
26
|
+
DATE_FIELD_PLACEHOLDERS = {
|
|
27
|
+
"y": "yyyy",
|
|
28
|
+
"m": "mm",
|
|
29
|
+
"d": "dd",
|
|
30
|
+
}
|
|
31
|
+
DATE_FIELD_REQUIRED = {
|
|
32
|
+
"y": True,
|
|
33
|
+
"m": False,
|
|
34
|
+
"d": False,
|
|
35
|
+
}
|
|
36
|
+
DATE_FIELD_WIDGET_NAMES = {
|
|
37
|
+
"y": "year_widget",
|
|
38
|
+
"m": "month_widget",
|
|
39
|
+
"d": "day_widget",
|
|
40
|
+
}
|
|
41
|
+
EMPTY_CHOICE = (("", "---------"),)
|
|
42
|
+
TRIM_CHAR = "0" if getattr(settings, "FUZZY_DATE_TRIM_LEADING_ZEROS", False) else ""
|
|
43
|
+
TZ_PATTERN = re.compile(r"^[A-Za-z]+/[A-Za-z_]+")
|
|
44
|
+
|
|
45
|
+
if len(DATE_FIELD_ORDER) != 3 or set(DATE_FIELD_ORDER) != set("ymd"):
|
|
46
|
+
raise ValueError("The FUZZY_DATE_FIELD_ORDER setting must be a 3-character string containing 'y', 'm', and 'd'.")
|
|
47
|
+
|
|
48
|
+
if DATE_FIELD_SEPARATOR not in ("-", ".", "/"):
|
|
49
|
+
raise ValueError("The FUZZY_DATE_FIELD_SEPARATOR setting must be one of '-', '.', or '/'.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# All dates are stored in the DB as strings formatted as "yyyy.mm.dd" or as
|
|
53
|
+
# "yyyy.mm.dd HH:MM tz". Using this format means that comparing and sorting
|
|
54
|
+
# dates is as easy as comparing and sorting strings. For fuzzy dates (e.g.,
|
|
55
|
+
# just a year or just a year and a month), we use a value of "00" in place
|
|
56
|
+
# of the missing month and/or day. Fuzzy dates can then be sorted with non-
|
|
57
|
+
# fuzzy dates.
|
|
58
|
+
class FuzzyDate(str):
|
|
59
|
+
def __new__(cls, *args, **kwargs):
|
|
60
|
+
base = ""
|
|
61
|
+
year = ""
|
|
62
|
+
month = ""
|
|
63
|
+
day = ""
|
|
64
|
+
hour = ""
|
|
65
|
+
minute = ""
|
|
66
|
+
tz = ""
|
|
67
|
+
|
|
68
|
+
if args and kwargs:
|
|
69
|
+
raise ValueError("Cannot mix positional and keyword arguments when creating a FuzzyDate.")
|
|
70
|
+
|
|
71
|
+
if args and args[0] not in ("", None):
|
|
72
|
+
seed = args[0]
|
|
73
|
+
|
|
74
|
+
if isinstance(seed, FuzzyDate):
|
|
75
|
+
year = seed.year
|
|
76
|
+
month = seed.month
|
|
77
|
+
day = seed.day
|
|
78
|
+
hour = seed.hour
|
|
79
|
+
minute = seed.minute
|
|
80
|
+
tz = seed.tz
|
|
81
|
+
|
|
82
|
+
elif isinstance(seed, datetime):
|
|
83
|
+
if seed.tzinfo is None or seed.tzinfo == timezone.utc:
|
|
84
|
+
tz_key = "Etc/UTC"
|
|
85
|
+
elif not hasattr(seed.tzinfo, "key"):
|
|
86
|
+
raise ValueError("Datetime must use a named IANA timezone or 'Etc/UTC'.")
|
|
87
|
+
else:
|
|
88
|
+
tz_key = seed.tzinfo.key # e.g., 'America/Chicago'
|
|
89
|
+
# else
|
|
90
|
+
year = seed.year
|
|
91
|
+
month = seed.month
|
|
92
|
+
day = seed.day
|
|
93
|
+
hour = seed.hour
|
|
94
|
+
minute = seed.minute
|
|
95
|
+
tz = tz_key
|
|
96
|
+
|
|
97
|
+
elif isinstance(seed, date):
|
|
98
|
+
year = seed.year
|
|
99
|
+
month = seed.month
|
|
100
|
+
day = seed.day
|
|
101
|
+
|
|
102
|
+
elif isinstance(seed, str):
|
|
103
|
+
if not (m := DATE_PATTERN.fullmatch(seed.strip())):
|
|
104
|
+
raise ValueError(f"Invalid FuzzyDate string: {seed}")
|
|
105
|
+
# else
|
|
106
|
+
year, month, day, hour, minute, tz = m.groups()
|
|
107
|
+
|
|
108
|
+
else:
|
|
109
|
+
raise TypeError(f"Unable to create FuzzyDate from type: {type(seed)}")
|
|
110
|
+
elif kwargs:
|
|
111
|
+
def norm(val):
|
|
112
|
+
return "" if val is None else val
|
|
113
|
+
|
|
114
|
+
year = norm(kwargs.pop("y", ""))
|
|
115
|
+
month = norm(kwargs.pop("m", ""))
|
|
116
|
+
day = norm(kwargs.pop("d", ""))
|
|
117
|
+
hour = norm(kwargs.pop("hour", ""))
|
|
118
|
+
minute = norm(kwargs.pop("minute", ""))
|
|
119
|
+
tz = norm(kwargs.pop("tz", ""))
|
|
120
|
+
|
|
121
|
+
if kwargs:
|
|
122
|
+
raise ValueError(f"Unexpected keyword arguments when creating FuzzyDate: {', '.join(kwargs.keys())}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Now we have all our values stored in variables "year", "month", etc. Depending on the type
|
|
126
|
+
# of seed, some values may be integers. We must coerce them all to strings before we're done.
|
|
127
|
+
if not {year, month, day, hour, minute, tz} <= {None, ""}:
|
|
128
|
+
# Some date or time element has been specified
|
|
129
|
+
if not year:
|
|
130
|
+
raise ValueError("Year must be specified if any other date or time component is specified")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
year = f"{int(year):04}"
|
|
134
|
+
except (ValueError, TypeError):
|
|
135
|
+
raise ValueError("Year must be an integer")
|
|
136
|
+
|
|
137
|
+
if day and not month:
|
|
138
|
+
raise ValueError("If day is specified, month must also be specified")
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
month = f"{int(month):02}" if month not in ("", None, "00") else "00"
|
|
142
|
+
day = f"{int(day):02}" if day not in ("", None, "00") else "00"
|
|
143
|
+
except (ValueError, TypeError):
|
|
144
|
+
raise ValueError("Month and date, if provided, must be integers")
|
|
145
|
+
|
|
146
|
+
# At this point, year, month and day should all be valid numerical strings.
|
|
147
|
+
# Leverage the "datetime" library's "date()" function to check that values
|
|
148
|
+
# are valid. We temporarily replace any fuzzy values with 1. This lets us
|
|
149
|
+
# eliminate invalid dates like 2000.13.01 or 2000.01.32.
|
|
150
|
+
int_year = int(year)
|
|
151
|
+
int_month = int(month) if month != "00" else 1
|
|
152
|
+
int_day = int(day) if day != "00" else 1
|
|
153
|
+
if int_year < 1000 or int_year > 9999:
|
|
154
|
+
# Keep the year within this range as years outside it would break
|
|
155
|
+
# sorting (e.g., "900" > "1000" alphanumerically speaking). Later
|
|
156
|
+
# on I might try to relax this restriction by padding short years
|
|
157
|
+
# with zeros, but it would take some doing.
|
|
158
|
+
raise ValueError("The year must be no less than 1000 and no greater than 9999.")
|
|
159
|
+
# else
|
|
160
|
+
try:
|
|
161
|
+
date(year=int_year, month=int_month, day=int_day)
|
|
162
|
+
except ValueError as e:
|
|
163
|
+
raise e
|
|
164
|
+
|
|
165
|
+
# Now we know the date is not invalid, though it may still be fuzzy.
|
|
166
|
+
base = f"{year}.{month}.{day}"
|
|
167
|
+
|
|
168
|
+
# Now deal with the time values.
|
|
169
|
+
if not {hour, minute, tz} <= {None, ""}:
|
|
170
|
+
# Some time element has been specified
|
|
171
|
+
if day == "00":
|
|
172
|
+
raise ValueError("If any time fields are specified, day and month must also be specified")
|
|
173
|
+
|
|
174
|
+
# Now we know that day, month, and year are all specified and not fuzzy
|
|
175
|
+
if {hour, minute, tz} & {None, ""}:
|
|
176
|
+
# Although we know that some time element has been specified, not all of them are
|
|
177
|
+
raise ValueError("If any of hour, minute, or timezone is specified, all must be specified")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
hour = f"{int(hour):02}"
|
|
181
|
+
except (ValueError, TypeError):
|
|
182
|
+
raise ValueError("Hour must be an integer.")
|
|
183
|
+
if not (0 <= int(hour) < 24):
|
|
184
|
+
raise ValueError("Hour must be between 0 and 23.")
|
|
185
|
+
try:
|
|
186
|
+
minute = f"{int(minute):02}"
|
|
187
|
+
except (ValueError, TypeError):
|
|
188
|
+
raise ValueError("Minute must be an integer.")
|
|
189
|
+
if not (0 <= int(minute) < 60):
|
|
190
|
+
raise ValueError("Minute must be between 0 and 59.")
|
|
191
|
+
|
|
192
|
+
if not TZ_PATTERN.match(tz):
|
|
193
|
+
raise ValueError("Timezone must be in the format Area/Location (e.g., America/Chicago).")
|
|
194
|
+
|
|
195
|
+
base += f" {hour}:{minute} {tz}"
|
|
196
|
+
|
|
197
|
+
instance = super().__new__(cls, base)
|
|
198
|
+
instance.year = year
|
|
199
|
+
instance.month = "" if month == "00" else month
|
|
200
|
+
instance.day = "" if day == "00" else day
|
|
201
|
+
instance.hour = hour
|
|
202
|
+
instance.minute = minute
|
|
203
|
+
instance.tz = tz
|
|
204
|
+
return instance
|
|
205
|
+
|
|
206
|
+
def __iter__(self):
|
|
207
|
+
# Map component names to instance attributes
|
|
208
|
+
component_map = {
|
|
209
|
+
'y': self.year,
|
|
210
|
+
'm': self.month,
|
|
211
|
+
'd': self.day,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Build ordered date components
|
|
215
|
+
components = [component_map[c] for c in DATE_FIELD_ORDER]
|
|
216
|
+
|
|
217
|
+
# Optionally add time parts if they are defined
|
|
218
|
+
if self.has_time() and self.has_timezone():
|
|
219
|
+
components.extend([self.hour, self.minute, self.tz])
|
|
220
|
+
|
|
221
|
+
return iter(components)
|
|
222
|
+
|
|
223
|
+
def __repr__(self):
|
|
224
|
+
return f"FuzzyDate({super().__repr__()})"
|
|
225
|
+
|
|
226
|
+
def __str__(self):
|
|
227
|
+
data_dict = dict(zip("ymd", self.as_list()))
|
|
228
|
+
date_part = DATE_FIELD_SEPARATOR.join(
|
|
229
|
+
[data_dict[el].lstrip(TRIM_CHAR) for el in DATE_FIELD_ORDER if data_dict[el]]
|
|
230
|
+
)
|
|
231
|
+
if self.has_time() and self.has_timezone():
|
|
232
|
+
return f"{date_part} {self.hour}:{self.minute} {self.tz}"
|
|
233
|
+
# else
|
|
234
|
+
return date_part
|
|
235
|
+
|
|
236
|
+
def as_list(self):
|
|
237
|
+
return [self.year, self.month, self.day]
|
|
238
|
+
|
|
239
|
+
def get_range(self):
|
|
240
|
+
start_year = self.year
|
|
241
|
+
start_month = self.month or "01"
|
|
242
|
+
start_day = self.day or "01"
|
|
243
|
+
end_year = self.year
|
|
244
|
+
end_month = self.month or "12"
|
|
245
|
+
end_day = self.day or str(calendar.monthrange(int(end_year), int(end_month))[1])
|
|
246
|
+
return (
|
|
247
|
+
FuzzyDate(y=start_year, m=start_month, d=start_day),
|
|
248
|
+
FuzzyDate(y=end_year, m=end_month, d=end_day)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def has_time(self):
|
|
252
|
+
return self.hour not in ("", None) and self.minute not in ("", None)
|
|
253
|
+
|
|
254
|
+
def has_timezone(self):
|
|
255
|
+
return bool(self.tz)
|
|
256
|
+
|
|
257
|
+
def has_datetime(self):
|
|
258
|
+
return not self.is_fuzzy and self.has_time() and self.has_timezone()
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def is_fuzzy(self):
|
|
262
|
+
return self.day == ""
|
|
263
|
+
|
|
264
|
+
def to_date(self):
|
|
265
|
+
"""
|
|
266
|
+
Convert this FuzzyDate instance to a date object, if possible
|
|
267
|
+
"""
|
|
268
|
+
if self.is_fuzzy:
|
|
269
|
+
return None
|
|
270
|
+
# else
|
|
271
|
+
try:
|
|
272
|
+
return date(
|
|
273
|
+
year=int(self.year),
|
|
274
|
+
month=int(self.month),
|
|
275
|
+
day=int(self.day)
|
|
276
|
+
)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
raise ValueError(f"Unable to convert FuzzyDate to date: {e}")
|
|
279
|
+
|
|
280
|
+
def to_datetime(self):
|
|
281
|
+
"""
|
|
282
|
+
Convert this FuzzyDate instance to a timezone-aware datetime.datetime object, if possible
|
|
283
|
+
"""
|
|
284
|
+
if not self.has_datetime():
|
|
285
|
+
return None
|
|
286
|
+
# else
|
|
287
|
+
try:
|
|
288
|
+
return datetime(
|
|
289
|
+
year=int(self.year),
|
|
290
|
+
month=int(self.month),
|
|
291
|
+
day=int(self.day),
|
|
292
|
+
hour=int(self.hour),
|
|
293
|
+
minute=int(self.minute),
|
|
294
|
+
tzinfo=ZoneInfo(self.tz)
|
|
295
|
+
)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
raise ValueError(f"Unable to convert FuzzyDate to datetime: {e}")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class FuzzyDateWidget(forms.MultiWidget):
|
|
301
|
+
|
|
302
|
+
# Django is surprisingly resistant to allowing "type='time'" on an input element in
|
|
303
|
+
# a multi-widget form field. And we need that type to get the browser to display a
|
|
304
|
+
# time picker. Overriding the template was the only way I could find to do it. And
|
|
305
|
+
# it still won't work if using Grappelli instead of the default admin interface.
|
|
306
|
+
class CustomTimeInput(forms.TimeInput):
|
|
307
|
+
template_name = "fuzzy_dates/time_widget.html"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def __init__(self, attrs=None):
|
|
311
|
+
# Define the date-related input widgets in the user's preferred order.
|
|
312
|
+
widgets = [
|
|
313
|
+
forms.NumberInput(attrs={"min": 1, "placeholder": DATE_FIELD_PLACEHOLDERS[el]})
|
|
314
|
+
for el in DATE_FIELD_ORDER
|
|
315
|
+
]
|
|
316
|
+
# Now add the time widgets
|
|
317
|
+
widgets += [
|
|
318
|
+
self.CustomTimeInput(attrs={"placeholder": "hh:mm"}),
|
|
319
|
+
forms.Select(choices=EMPTY_CHOICE + tuple([(name, name) for name in sorted(available_timezones())]))
|
|
320
|
+
]
|
|
321
|
+
super().__init__(widgets, attrs)
|
|
322
|
+
for widget_name, widget in zip(self.widgets_names, self.widgets):
|
|
323
|
+
widget.attrs["data-name"] = widget_name
|
|
324
|
+
|
|
325
|
+
def decompress(self, value):
|
|
326
|
+
if value: # will be a FuzzyDate object
|
|
327
|
+
data_dict = dict(zip("ymd", value.as_list()))
|
|
328
|
+
time_str = f"{value.hour}:{value.minute}" if value.has_time() else ""
|
|
329
|
+
retlist = [data_dict[el] for el in DATE_FIELD_ORDER] # rearrange to the user's preferred order
|
|
330
|
+
retlist += [time_str, value.tz or ""]
|
|
331
|
+
return retlist
|
|
332
|
+
return ["", "", "", "", ""]
|
|
333
|
+
|
|
334
|
+
# The following properties allow the form to access the subwidgets using user-friendly names.
|
|
335
|
+
# For example, a form could replace the timezone widget with a readonly text field like this:
|
|
336
|
+
#
|
|
337
|
+
# def __init__(self, *args, **kwargs):
|
|
338
|
+
# super().__init__(*args, **kwargs)
|
|
339
|
+
# ...
|
|
340
|
+
# self.fields["start_date"].widget.timezone_widget = forms.TextInput(attrs={"readonly": True})
|
|
341
|
+
@property
|
|
342
|
+
def year_widget(self):
|
|
343
|
+
return self.widgets[DATE_FIELD_ORDER.index("y")]
|
|
344
|
+
|
|
345
|
+
@year_widget.setter
|
|
346
|
+
def year_widget(self, value):
|
|
347
|
+
self.widgets[DATE_FIELD_ORDER.index("y")] = value
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def month_widget(self):
|
|
351
|
+
return self.widgets[DATE_FIELD_ORDER.index("m")]
|
|
352
|
+
|
|
353
|
+
@month_widget.setter
|
|
354
|
+
def month_widget(self, value):
|
|
355
|
+
self.widgets[DATE_FIELD_ORDER.index("m")] = value
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def date_widget(self):
|
|
359
|
+
return self.widgets[DATE_FIELD_ORDER.index("d")]
|
|
360
|
+
|
|
361
|
+
@date_widget.setter
|
|
362
|
+
def date_widget(self, value):
|
|
363
|
+
self.widgets[DATE_FIELD_ORDER.index("d")] = value
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def time_widget(self):
|
|
367
|
+
return self.widgets[3]
|
|
368
|
+
|
|
369
|
+
@time_widget.setter
|
|
370
|
+
def time_widget(self, value):
|
|
371
|
+
self.widgets[3] = value
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def timezone_widget(self):
|
|
375
|
+
return self.widgets[4]
|
|
376
|
+
|
|
377
|
+
@timezone_widget.setter
|
|
378
|
+
def timezone_widget(self, value):
|
|
379
|
+
self.widgets[4] = value
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class FuzzyDateFormField(forms.MultiValueField):
|
|
384
|
+
def __init__(self, *args, **kwargs):
|
|
385
|
+
kwargs.pop("max_length", None) # max_length is here because FuzzyDateField (below) subclasses
|
|
386
|
+
# models.CharField, but it's not valid for forms.MultiValueField
|
|
387
|
+
fields = [
|
|
388
|
+
forms.IntegerField(min_value=1, required=DATE_FIELD_REQUIRED[el])
|
|
389
|
+
for el in DATE_FIELD_ORDER
|
|
390
|
+
] + [
|
|
391
|
+
forms.TimeField(required=False),
|
|
392
|
+
forms.CharField(required=False)
|
|
393
|
+
]
|
|
394
|
+
kwargs["require_all_fields"] = False
|
|
395
|
+
super().__init__(fields, *args, **kwargs)
|
|
396
|
+
self.widget = FuzzyDateWidget()
|
|
397
|
+
for field in fields[:3]:
|
|
398
|
+
for validator in field.validators:
|
|
399
|
+
if isinstance(validator, MinValueValidator):
|
|
400
|
+
validator.message = "Ensure all values are greater than 1."
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def compress(self, data_list):
|
|
404
|
+
if data_list:
|
|
405
|
+
date_part = dict(zip(DATE_FIELD_ORDER, data_list[:3]))
|
|
406
|
+
time_obj = data_list[3]
|
|
407
|
+
tz_val = data_list[4]
|
|
408
|
+
|
|
409
|
+
if time_obj and tz_val:
|
|
410
|
+
hour = f"{time_obj.hour:02}"
|
|
411
|
+
minute = f"{time_obj.minute:02}"
|
|
412
|
+
return FuzzyDate(**date_part, hour=hour, minute=minute, tz=tz_val)
|
|
413
|
+
|
|
414
|
+
return FuzzyDate(**date_part)
|
|
415
|
+
return ""
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class FuzzyDateField(models.CharField):
|
|
419
|
+
def __init__(self, *args, **kwargs):
|
|
420
|
+
kwargs["max_length"] = 50
|
|
421
|
+
super().__init__(*args, **kwargs)
|
|
422
|
+
|
|
423
|
+
def formfield(self, **kwargs):
|
|
424
|
+
kwargs.update({"form_class": FuzzyDateFormField})
|
|
425
|
+
return super().formfield(**kwargs)
|
|
426
|
+
|
|
427
|
+
def from_db_value(self, value, expression, connection):
|
|
428
|
+
return self.to_python(value)
|
|
429
|
+
|
|
430
|
+
def to_python(self, value):
|
|
431
|
+
if isinstance(value, FuzzyDate):
|
|
432
|
+
return value
|
|
433
|
+
if value in self.empty_values:
|
|
434
|
+
return FuzzyDate()
|
|
435
|
+
try:
|
|
436
|
+
return FuzzyDate(value)
|
|
437
|
+
except ValueError as e:
|
|
438
|
+
raise ValidationError(e)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# Custom lookup to handle IS NULL and IS NOT NULL for FuzzyDateField,
|
|
442
|
+
@FuzzyDateField.register_lookup
|
|
443
|
+
class FuzzyIsNullLookup(models.lookups.IsNull):
|
|
444
|
+
def as_sql(self, compiler, connection):
|
|
445
|
+
lhs, lhs_params = self.process_lhs(compiler, connection)
|
|
446
|
+
if self.rhs:
|
|
447
|
+
return f"({lhs} IS NULL OR {lhs} = '')", lhs_params
|
|
448
|
+
else:
|
|
449
|
+
return f"({lhs} IS NOT NULL AND {lhs} <> '')", lhs_params
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# Mixin to exclude empty strings and NULLs from fuzzy date comparisons
|
|
453
|
+
class _FuzzyExcludeEmptyBase:
|
|
454
|
+
"""
|
|
455
|
+
Mixin that wraps a comparison operator with exclusion of NULL and empty strings.
|
|
456
|
+
"""
|
|
457
|
+
operator = None # must be overridden by subclasses
|
|
458
|
+
|
|
459
|
+
def as_sql(self, compiler, connection):
|
|
460
|
+
lhs, lhs_params = self.process_lhs(compiler, connection)
|
|
461
|
+
rhs, rhs_params = self.process_rhs(compiler, connection)
|
|
462
|
+
# combine parameters and build guarded SQL
|
|
463
|
+
sql = f"({lhs} <> '' AND {lhs} IS NOT NULL AND {lhs} {self.operator} {rhs})"
|
|
464
|
+
params = lhs_params + rhs_params
|
|
465
|
+
return sql, params
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@FuzzyDateField.register_lookup
|
|
469
|
+
class FuzzyLessThan(_FuzzyExcludeEmptyBase, models.lookups.LessThan):
|
|
470
|
+
operator = "<"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@FuzzyDateField.register_lookup
|
|
474
|
+
class FuzzyLessThanOrEqual(_FuzzyExcludeEmptyBase, models.lookups.LessThanOrEqual):
|
|
475
|
+
operator = "<="
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@FuzzyDateField.register_lookup
|
|
479
|
+
class FuzzyGreaterThan(_FuzzyExcludeEmptyBase, models.lookups.GreaterThan):
|
|
480
|
+
operator = ">"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@FuzzyDateField.register_lookup
|
|
484
|
+
class FuzzyGreaterThanOrEqual(_FuzzyExcludeEmptyBase, models.lookups.GreaterThanOrEqual):
|
|
485
|
+
operator = ">="
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import re
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from django import forms
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core.exceptions import ValidationError
|
|
7
|
+
from django.core.validators import MinValueValidator
|
|
8
|
+
from django.db import models
|
|
9
|
+
|
|
10
|
+
# This regex matches dates in the format yyyy, yyyy.mm, or yyyy.mm.dd (other
|
|
11
|
+
# separators are allowed, too, e.g., yyyy-mm-dd or yyyy/mm/dd). Thanks to
|
|
12
|
+
# https://stackoverflow.com/questions/15474741/python-regex-optional-capture-group
|
|
13
|
+
DATE_PATTERN = re.compile(r"(\d{4})(?:[.\-/](\d{2})(?:[.\-/](\d{2}))?)?$")
|
|
14
|
+
DATE_FIELD_ORDER = getattr(settings, "FUZZY_DATE_FIELD_ORDER", "mdy").lower()
|
|
15
|
+
DATE_FIELD_SEPARATOR = getattr(settings, "FUZZY_DATE_FIELD_SEPARATOR", "/")
|
|
16
|
+
DATE_FIELD_PLACEHOLDERS = {
|
|
17
|
+
"y": "yyyy",
|
|
18
|
+
"m": "mm",
|
|
19
|
+
"d": "dd",
|
|
20
|
+
}
|
|
21
|
+
DATE_FIELD_REQUIRED = {
|
|
22
|
+
"y": True,
|
|
23
|
+
"m": False,
|
|
24
|
+
"d": False,
|
|
25
|
+
}
|
|
26
|
+
TRIM_CHAR = "0" if getattr(settings, "FUZZY_DATE_TRIM_LEADING_ZEROS", False) else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if len(DATE_FIELD_ORDER) != 3 or set(DATE_FIELD_ORDER) != set("ymd"):
|
|
30
|
+
raise ValueError("The FUZZY_DATE_FIELD_ORDER setting must be a 3-character string containing 'y', 'm', and 'd'.")
|
|
31
|
+
|
|
32
|
+
if DATE_FIELD_SEPARATOR not in ("-", ".", "/"):
|
|
33
|
+
raise ValueError("The FUZZY_DATE_FIELD_SEPARATOR setting must be one of '-', '.', or '/'.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# We use a custom metaclass to normalize parameters before they are passed to
|
|
37
|
+
# the class's "__new__()" and "__init__()" methods. It also allows FuzzyDate
|
|
38
|
+
# instances to be initialized either with a string or via keyword arguments.
|
|
39
|
+
class CustomMeta(type):
|
|
40
|
+
def __call__(cls, seed=None, *args, **kwargs):
|
|
41
|
+
if seed:
|
|
42
|
+
if isinstance(seed, str):
|
|
43
|
+
if m := DATE_PATTERN.match(seed):
|
|
44
|
+
year, month, day = m.groups()
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError("Dates given as a string must be formatted as yyyy, yyyy.mm, or yyyy.mm.dd")
|
|
47
|
+
elif isinstance(seed, date) or isinstance(seed, datetime):
|
|
48
|
+
year, month, day = seed.year, seed.month, seed.day
|
|
49
|
+
else:
|
|
50
|
+
raise TypeError("Only a string, a date, or a datetime can be passed as an initialization argument")
|
|
51
|
+
else:
|
|
52
|
+
# These could be strings, ints, or None at this point
|
|
53
|
+
year = kwargs.get("y")
|
|
54
|
+
month = kwargs.get("m")
|
|
55
|
+
day = kwargs.get("d")
|
|
56
|
+
|
|
57
|
+
if not year:
|
|
58
|
+
raise ValueError("Year must be specified")
|
|
59
|
+
|
|
60
|
+
if day and not month:
|
|
61
|
+
raise ValueError("If day is specified, month must also be specified")
|
|
62
|
+
|
|
63
|
+
fuzzy_value = "00"
|
|
64
|
+
month = month or fuzzy_value
|
|
65
|
+
day = day or fuzzy_value
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Check that values are valid, replacing any fuzzy values with 1. This
|
|
69
|
+
# lets us eliminate invalid dates like 2000.13.01 or 2000.01.32.
|
|
70
|
+
int_year = int(year)
|
|
71
|
+
int_month = int(month) if month != fuzzy_value else 1
|
|
72
|
+
int_day = int(day) if day != fuzzy_value else 1
|
|
73
|
+
if int_year < 1000 or int_year > 9999:
|
|
74
|
+
# Keep the year within this range as years outside it would break
|
|
75
|
+
# sorting (e.g., "900" > "1000" alphanumerically speaking). Later
|
|
76
|
+
# on I might try to relax this restriction by padding short years
|
|
77
|
+
# with zeros, but it would take some doing.
|
|
78
|
+
raise ValueError("The year must be no less than 1000 and no greater than 9999.")
|
|
79
|
+
# else
|
|
80
|
+
date(year=int_year, month=int_month, day=int_day)
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
raise e
|
|
83
|
+
|
|
84
|
+
kwargs = {"y": f"{year}", "m": f"{month:>02}", "d": f"{day:>02}"}
|
|
85
|
+
return super().__call__(*args, **kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# All dates are stored in the DB as strings formatted as "yyyy.mm.dd". Using
|
|
89
|
+
# this format means that comparing and sorting dates is as easy as comparing
|
|
90
|
+
# and sorting strings. For fuzzy dates (e.g., just a year or just a year and
|
|
91
|
+
# a month), we use a value of "00" in place of the missing month and/or day.
|
|
92
|
+
# Fuzzy dates can then be sorted with non-fuzzy dates.
|
|
93
|
+
class FuzzyDate(str, metaclass=CustomMeta):
|
|
94
|
+
def __new__(cls, **kwargs):
|
|
95
|
+
return super().__new__(cls, "{y}.{m}.{d}".format(**kwargs))
|
|
96
|
+
|
|
97
|
+
def __init__(self, **kwargs):
|
|
98
|
+
self.year = kwargs["y"]
|
|
99
|
+
self.month = kwargs["m"] if kwargs["m"] != "00" else ""
|
|
100
|
+
self.day = kwargs["d"] if kwargs["d"] != "00" else ""
|
|
101
|
+
return super().__init__()
|
|
102
|
+
|
|
103
|
+
def __repr__(self):
|
|
104
|
+
return "FuzzyDate({})".format(super().__repr__())
|
|
105
|
+
|
|
106
|
+
def __str__(self):
|
|
107
|
+
data_dict = dict(zip("ymd", self.as_list()))
|
|
108
|
+
return DATE_FIELD_SEPARATOR.join(
|
|
109
|
+
[data_dict[el].lstrip(TRIM_CHAR) for el in DATE_FIELD_ORDER if data_dict[el]]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def as_list(self):
|
|
113
|
+
return [self.year, self.month, self.day]
|
|
114
|
+
|
|
115
|
+
def get_range(self):
|
|
116
|
+
start_year = self.year
|
|
117
|
+
start_month = self.month or "01"
|
|
118
|
+
start_day = self.day or "01"
|
|
119
|
+
|
|
120
|
+
end_year = self.year
|
|
121
|
+
end_month = self.month or "12"
|
|
122
|
+
end_day = self.day or str(calendar.monthrange(int(end_year), 12)[1])
|
|
123
|
+
|
|
124
|
+
return FuzzyDate(y=start_year, m=start_month, d=start_day), FuzzyDate(y=end_year, m=end_month, d=end_day)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_fuzzy(self):
|
|
128
|
+
return self.day == ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FuzzyDateWidget(forms.MultiWidget):
|
|
132
|
+
def __init__(self, attrs=None):
|
|
133
|
+
# Define the input widgets in the user's preferred order.
|
|
134
|
+
widgets = [
|
|
135
|
+
forms.NumberInput(attrs={
|
|
136
|
+
"min": 1, "placeholder": DATE_FIELD_PLACEHOLDERS[el]
|
|
137
|
+
}) for el in DATE_FIELD_ORDER
|
|
138
|
+
]
|
|
139
|
+
super().__init__(widgets, attrs)
|
|
140
|
+
|
|
141
|
+
def decompress(self, value):
|
|
142
|
+
if value: # will be a FuzzyDate object
|
|
143
|
+
data_dict = dict(zip("ymd", value.as_list()))
|
|
144
|
+
return [data_dict[el] for el in DATE_FIELD_ORDER] # rearrange to the user's preferred order
|
|
145
|
+
return ["", "", ""]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class FuzzyDateFormField(forms.MultiValueField):
|
|
149
|
+
|
|
150
|
+
def __init__(self, *args, **kwargs):
|
|
151
|
+
kwargs.pop("max_length", None) # max_length is here because FuzzyDateField (below) subclasses
|
|
152
|
+
# models.CharField, but it's not valid for forms.MultiValueField
|
|
153
|
+
fields = [
|
|
154
|
+
forms.IntegerField(
|
|
155
|
+
min_value=1, required=DATE_FIELD_REQUIRED[el]
|
|
156
|
+
) for el in DATE_FIELD_ORDER
|
|
157
|
+
]
|
|
158
|
+
kwargs["require_all_fields"] = False
|
|
159
|
+
super().__init__(fields, *args, **kwargs)
|
|
160
|
+
self.widget = FuzzyDateWidget()
|
|
161
|
+
for field in fields:
|
|
162
|
+
for validator in field.validators:
|
|
163
|
+
if isinstance(validator, MinValueValidator):
|
|
164
|
+
validator.message = "Ensure all values are greater than 1."
|
|
165
|
+
|
|
166
|
+
def compress(self, data_list):
|
|
167
|
+
if data_list:
|
|
168
|
+
data_dict = dict(zip(DATE_FIELD_ORDER, data_list))
|
|
169
|
+
try:
|
|
170
|
+
return FuzzyDate(**data_dict)
|
|
171
|
+
except ValueError as e:
|
|
172
|
+
raise ValidationError(e)
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class FuzzyDateField(models.CharField):
|
|
177
|
+
|
|
178
|
+
def __init__(self, *args, **kwargs):
|
|
179
|
+
kwargs["max_length"] = 10
|
|
180
|
+
super().__init__(*args, **kwargs)
|
|
181
|
+
|
|
182
|
+
def formfield(self, **kwargs):
|
|
183
|
+
kwargs.update({"form_class": FuzzyDateFormField})
|
|
184
|
+
return super().formfield(**kwargs)
|
|
185
|
+
|
|
186
|
+
def from_db_value(self, value, expression, connection):
|
|
187
|
+
if value:
|
|
188
|
+
# Values coming from the DB should be in the format yyyy.mm.dd
|
|
189
|
+
return FuzzyDate(value)
|
|
190
|
+
# else
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
def to_python(self, value):
|
|
194
|
+
if value and not isinstance(value, FuzzyDate):
|
|
195
|
+
try:
|
|
196
|
+
if m := DATE_PATTERN.match(value):
|
|
197
|
+
y, m, d = m.groups()
|
|
198
|
+
value = FuzzyDate(y=y, m=m, d=d)
|
|
199
|
+
else:
|
|
200
|
+
raise ValidationError("Date strings must be formatted as 'yyyy', 'yyyy.mm', or 'yyyy.mm.dd'")
|
|
201
|
+
except TypeError as e:
|
|
202
|
+
raise ValidationError(e)
|
|
203
|
+
return value
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<input type="time" data_foo="bar" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "django-fuzzy-dates"
|
|
3
|
+
version = "1.0.1"
|
|
4
|
+
description = "Package to support nonspecific dates in form yyyy or yyyy.mm"
|
|
5
|
+
authors = ["Noel Taylor <ntaylor@imagescape.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
repository = "https://github.com/ImaginaryLandscape/django-fuzzy-dates"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Environment :: Web Environment",
|
|
12
|
+
"Framework :: Django",
|
|
13
|
+
"Framework :: Django :: 3.2",
|
|
14
|
+
"Framework :: Django :: 4.2",
|
|
15
|
+
"Framework :: Django :: 5.0",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
]
|
|
26
|
+
packages = [{include = "fuzzy_dates"}]
|
|
27
|
+
include = [
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"CHANGELOG.md",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.poetry.dependencies]
|
|
33
|
+
python = "^3.8"
|
|
34
|
+
django = ">=4.2"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["poetry-core"]
|
|
39
|
+
build-backend = "poetry.core.masonry.api"
|