├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── partial_date ├── __init__.py ├── fields.py └── tests.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ktowen 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. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django_partial_date 2 | ================ 3 | 4 | Django custom model field for partial dates with the form YYYY, YYYY-MM, YYYY-MM-DD 5 | 6 | * Works with DRF 7 | * Supports comparison operations 8 | * Supports Django 2.0 and Django 3.0 9 | 10 | Usage 11 | ================ 12 | 13 | install the package 14 | 15 | ```bash 16 | pip install django_partial_date 17 | ``` 18 | 19 | 20 | ## partial_date.PartialDateField 21 | 22 | A django model field for storing partial dates. Accepts None, a partial_date.PartialDate object, or a formatted string such as YYYY, YYYY-MM, YYYY-MM-DD. In the database it saves the date in a column of type DateTimeField and uses the seconds to save the level of precision. 23 | 24 | ## class partial_date.PartialDate 25 | 26 | Object to represent the partial dates. 27 | 28 | ## Example 29 | 30 | models.py 31 | ```python 32 | from django.db import models 33 | from partial_date import PartialDateField 34 | 35 | class TestModel(models.Model): 36 | some_partial_date = PartialDateField() 37 | ``` 38 | 39 | ```python 40 | >>> from partial_date import PartialDate 41 | >>> from core.models import TestModel 42 | >>> obj = TestModel(some_partial_date="1995") 43 | >>> obj.save() 44 | >>> obj.some_partial_date 45 | '1995' 46 | >>> obj.some_partial_date = PartialDate("1995-09") 47 | >>> obj.save() 48 | >>> obj.some_partial_date 49 | 1995-09 50 | >>> 51 | ``` 52 | 53 | ```python 54 | >>> from partial_date import PartialDate 55 | >>> import datetime 56 | >>> partial_date_instance = PartialDate(datetime.date(2012, 9, 21), precision=PartialDate.DAY) 57 | >>> partial_date_instance 58 | 2012-09-21 59 | >>> partial_date_instance.precisionYear() 60 | False 61 | >>> partial_date_instance.precisionMonth() 62 | False 63 | >>> partial_date_instance.precisionDay() 64 | True 65 | >>> partial_date_instance.precision == PartialDate.YEAR 66 | False 67 | >>> partial_date_instance.precision == PartialDate.MONTH 68 | False 69 | >>> partial_date_instance.precision == PartialDate.DAY 70 | True 71 | >>> partial_date_instance.precision = PartialDate.MONTH 72 | >>> partial_date_instance 73 | 2012-09 74 | >>> partial_date_instance = PartialDate("2015-11-01") 75 | >>> partial_date_instance.date 76 | datetime.date(2015, 11, 1) 77 | ``` 78 | 79 | 80 | ```python 81 | >>> from partial_date import PartialDate 82 | >>> partial_date_instance = PartialDate("2015-11-01") 83 | >>> partial_date_instance 84 | 2015-11-01 85 | >>> partial_date_instance.format('%Y', '%m/%Y', '%m/%d/%Y') # .format(precision_year, precision_month, precision_day) 86 | '11/01/2015' 87 | >>> partial_date_instance = PartialDate("2015-11") 88 | >>> partial_date_instance 89 | 2015-11 90 | >>> partial_date_instance.format('%Y', '%m/%Y', '%m/%d/%Y') 91 | '11/2015' 92 | >>> partial_date_instance = PartialDate("2015") 93 | >>> partial_date_instance 94 | 2015 95 | >>> partial_date_instance.format('%Y', '%m/%Y', '%m/%d/%Y') 96 | '2015' 97 | ``` 98 | 99 | Thanks for their collaborations to 100 | - lorinkoz 101 | - howieweiner 102 | - jghyllebert 103 | -------------------------------------------------------------------------------- /partial_date/__init__.py: -------------------------------------------------------------------------------- 1 | from partial_date.fields import PartialDate, PartialDateField 2 | -------------------------------------------------------------------------------- /partial_date/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | import datetime 4 | import re 5 | import six 6 | 7 | from django.core import exceptions 8 | from django.db import models 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | 12 | partial_date_re = re.compile( 13 | r"^(?P\d{4})(?:-(?P\d{1,2}))?(?:-(?P\d{1,2}))?$" 14 | ) 15 | 16 | 17 | class PartialDate(object): 18 | YEAR = 0 19 | MONTH = 1 20 | DAY = 2 21 | 22 | _date = None 23 | _precision = None 24 | 25 | DATE_FORMATS = {YEAR: "%Y", MONTH: "%Y-%m", DAY: "%Y-%m-%d"} 26 | 27 | def __init__(self, date, precision=DAY): 28 | if isinstance(date, six.text_type): 29 | date, precision = PartialDate.parseDate(date) 30 | 31 | self.date = date 32 | self.precision = precision 33 | 34 | def __repr__(self): 35 | return ( 36 | "" 37 | if not self._date 38 | else self._date.strftime(self.DATE_FORMATS[self._precision]) 39 | ) 40 | 41 | def format(self, precision_year=None, precision_month=None, precision_day=None): 42 | if self.precisionYear(): 43 | format = precision_year 44 | elif self.precisionMonth(): 45 | format = precision_month 46 | else: 47 | format = precision_day 48 | return "" if not self._date else self._date.strftime(format) 49 | 50 | @property 51 | def date(self): 52 | return self._date 53 | 54 | @date.setter 55 | def date(self, value): 56 | if not isinstance(value, datetime.date): 57 | raise exceptions.ValidationError( 58 | _("%(value)s is not datetime.date instance"), params={"value": value} 59 | ) 60 | self._date = value 61 | 62 | @property 63 | def precision(self): 64 | return self._precision 65 | 66 | @precision.setter 67 | def precision(self, value): 68 | self._precision = ( 69 | value if value in (self.YEAR, self.MONTH, self.DAY) else self.DAY 70 | ) 71 | if self._precision == self.MONTH: 72 | self._date.replace(day=1) 73 | if self._precision == self.YEAR: 74 | self._date.replace(month=1, day=1) 75 | 76 | def precisionYear(self): 77 | return self.precision == self.YEAR 78 | 79 | def precisionMonth(self): 80 | return self.precision == self.MONTH 81 | 82 | def precisionDay(self): 83 | return self.precision == self.DAY 84 | 85 | @staticmethod 86 | def parseDate(value): 87 | """ 88 | Returns a tuple (datetime.date, precision) from a string formatted as YYYY, YYYY-MM, YYYY-MM-DD. 89 | """ 90 | match = partial_date_re.match(value) 91 | 92 | try: 93 | match_dict = match.groupdict() 94 | kw = {k: int(v) if v else 1 for k, v in six.iteritems(match_dict)} 95 | 96 | precision = ( 97 | PartialDate.DAY 98 | if match_dict["day"] 99 | else PartialDate.MONTH 100 | if match_dict["month"] 101 | else PartialDate.YEAR 102 | ) 103 | return (datetime.date(**kw), precision) 104 | except (AttributeError, ValueError): 105 | raise exceptions.ValidationError( 106 | _("'%(value)s' is not a valid date string (YYYY, YYYY-MM, YYYY-MM-DD)"), 107 | params={"value": value}, 108 | ) 109 | 110 | def __eq__(self, other): 111 | if isinstance(other, PartialDate): 112 | return self.date == other.date and self.precision == other.precision 113 | else: 114 | return NotImplemented 115 | 116 | def __gt__(self, other): 117 | if isinstance(other, PartialDate): 118 | return self.__ge__(other) and not self.__eq__(other) 119 | else: 120 | return NotImplemented 121 | 122 | def __ge__(self, other): 123 | if isinstance(other, PartialDate): 124 | return self.date >= other.date and self.precision >= other.precision 125 | else: 126 | return NotImplemented 127 | 128 | 129 | class PartialDateField(models.Field): 130 | """ 131 | A django model field for storing partial dates. 132 | Accepts None, a partial_date.PartialDate object, 133 | or a formatted string such as YYYY, YYYY-MM, YYYY-MM-DD. 134 | In the database it saves the date in a column of type DateTimeField 135 | and uses the seconds to save the level of precision. 136 | """ 137 | 138 | def get_internal_type(self): 139 | return "DateTimeField" 140 | 141 | def from_db_value(self, value, expression, connection, context=None): 142 | if value is None: 143 | return value 144 | return PartialDate(value.date(), value.second) 145 | 146 | def to_python(self, value): 147 | if value is None: 148 | return value 149 | 150 | if isinstance(value, PartialDate): 151 | return value 152 | 153 | if isinstance(value, six.text_type): 154 | return PartialDate(value) 155 | 156 | raise exceptions.ValidationError( 157 | _( 158 | "'%(name)s' value must be a PartialDate instance, " 159 | "a valid partial date string (YYYY, YYYY-MM, YYYY-MM-DD) " 160 | "or None, not '%(value)s'" 161 | ), 162 | params={"name": self.name, "value": value}, 163 | ) 164 | 165 | def get_prep_value(self, value): 166 | if value in (None, ""): 167 | return None 168 | partial_date = self.to_python(value) 169 | date = partial_date.date 170 | return datetime.datetime( 171 | date.year, date.month, date.day, second=partial_date.precision 172 | ) 173 | -------------------------------------------------------------------------------- /partial_date/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from datetime import date 5 | from django.test import TestCase 6 | from partial_date import PartialDate, PartialDateField 7 | 8 | 9 | class PartialDateTestCase(TestCase): 10 | def test_init_with_string(self): 11 | self.assertEqual(PartialDate("2000").date, date(year=2000, month=1, day=1)) 12 | self.assertEqual(PartialDate("2000-02").date, date(year=2000, month=2, day=1)) 13 | self.assertEqual( 14 | PartialDate("2000-03-04").date, date(year=2000, month=3, day=4) 15 | ) 16 | 17 | self.assertEqual(PartialDate("2000").precision, PartialDate.YEAR) 18 | self.assertEqual(PartialDate("2000-02").precision, PartialDate.MONTH) 19 | self.assertEqual(PartialDate("2000-03-04").precision, PartialDate.DAY) 20 | 21 | def test_init_with_date(self): 22 | self.assertEqual( 23 | PartialDate( 24 | date(year=2000, month=3, day=4), precision=PartialDate.DAY 25 | ).__repr__(), 26 | "2000-03-04", 27 | ) 28 | self.assertEqual( 29 | PartialDate( 30 | date(year=2000, month=3, day=4), precision=PartialDate.MONTH 31 | ).__repr__(), 32 | "2000-03", 33 | ) 34 | self.assertEqual( 35 | PartialDate( 36 | date(year=2000, month=3, day=4), precision=PartialDate.YEAR 37 | ).__repr__(), 38 | "2000", 39 | ) 40 | 41 | def test_eq(self): 42 | self.assertTrue(PartialDate("2000") == PartialDate("2000")) 43 | self.assertFalse(PartialDate("2001") == PartialDate("2000")) 44 | self.assertFalse(PartialDate("2000-01") == PartialDate("2000")) 45 | self.assertFalse(PartialDate("2000-01") == PartialDate("2000-01-01")) 46 | 47 | def test_ne(self): 48 | self.assertFalse(PartialDate("2000") != PartialDate("2000")) 49 | self.assertTrue(PartialDate("2001") != PartialDate("2000")) 50 | self.assertTrue(PartialDate("2000-01") != PartialDate("2000")) 51 | self.assertTrue(PartialDate("2000-01") != PartialDate("2000-01-01")) 52 | 53 | def test_gt(self): 54 | self.assertFalse(PartialDate("2000") > PartialDate("2000")) 55 | self.assertTrue(PartialDate("2001") > PartialDate("2000")) 56 | self.assertTrue(PartialDate("2000-01") > PartialDate("2000")) 57 | self.assertFalse(PartialDate("2000-01") > PartialDate("2000-01-01")) 58 | 59 | def test_lt(self): 60 | self.assertFalse(PartialDate("2000") < PartialDate("2000")) 61 | self.assertFalse(PartialDate("2001") < PartialDate("2000")) 62 | self.assertFalse(PartialDate("2000-01") < PartialDate("2000")) 63 | self.assertTrue(PartialDate("2000-01") < PartialDate("2000-01-01")) 64 | 65 | def test_ge(self): 66 | self.assertTrue(PartialDate("2000") >= PartialDate("2000")) 67 | self.assertTrue(PartialDate("2001") >= PartialDate("2000")) 68 | self.assertTrue(PartialDate("2000-01") >= PartialDate("2000")) 69 | self.assertFalse(PartialDate("2000-01") >= PartialDate("2000-01-01")) 70 | 71 | def test_le(self): 72 | self.assertTrue(PartialDate("2000") <= PartialDate("2000")) 73 | self.assertFalse(PartialDate("2001") <= PartialDate("2000")) 74 | self.assertFalse(PartialDate("2000-01") <= PartialDate("2000")) 75 | self.assertTrue(PartialDate("2000-01") <= PartialDate("2000-01-01")) 76 | 77 | def test_format(self): 78 | format = ("%Y", "%m/%Y", "%m/%d/%Y") 79 | self.assertEqual(PartialDate("2000-03-04").format(*format), "03/04/2000") 80 | self.assertEqual(PartialDate("2000-03").format(*format), "03/2000") 81 | self.assertEqual(PartialDate("2000").format(*format), "2000") 82 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | from codecs import open 4 | from os import path 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | with open(path.join(here, "README.md"), encoding="utf-8") as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name="django_partial_date", 13 | version="1.3.1", 14 | description="Django custom model field for partial dates with the form YYYY, YYYY-MM, YYYY-MM-DD", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | url="https://github.com/ktowen/django_partial_date", 18 | author="ktowen", 19 | author_email="towenpa@gmail.com", 20 | license="MIT", 21 | classifiers=[ 22 | "Development Status :: 5 - Production/Stable", 23 | "Framework :: Django", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: MIT License", 26 | ], 27 | keywords=["fields", "django", "dates", "partial"], 28 | packages=["partial_date"], 29 | install_requires=["six", "django"], 30 | ) 31 | --------------------------------------------------------------------------------