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.
@@ -0,0 +1,4 @@
1
+ 1.0.0 / 2024-04-02
2
+ ==================
3
+
4
+ * Initial commit
@@ -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"