├── tests ├── testproject │ ├── __init__.py │ ├── testproject │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── wsgi.py │ │ ├── urls.py │ │ └── settings.py │ ├── testapp │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ └── models.py │ ├── requirements.txt │ └── manage.py ├── conftest.py ├── .coveragerc ├── test_utils.py └── test_relativedeltafield.py ├── .coverage ├── .envrc.example ├── MANIFEST.in ├── .gitignore ├── .editorconfig ├── Makefile ├── src └── relativedeltafield │ ├── __init__.py │ ├── forms.py │ ├── utils.py │ └── fields.py ├── .bumpversion.cfg ├── .env.example ├── CONTRIBUTING.md ├── LICENSE ├── CHANGELOG.md ├── .github └── workflows │ └── develop.yml ├── setup.py ├── tox.ini └── README.rst /tests/testproject/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testproject/testproject/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testproject/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeYellowBV/django-relativedelta/HEAD/.coverage -------------------------------------------------------------------------------- /.envrc.example: -------------------------------------------------------------------------------- 1 | # we load the environment variables from .env see https://direnv.net/ 2 | dotenv 3 | -------------------------------------------------------------------------------- /tests/testproject/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2 2 | python-dateutil>=2.6.0 3 | psycopg2-binary>=2.8.6 4 | pytest>=6.0.2 5 | pytest-coverage 6 | pytest-django>=3.7.0 7 | tox>=3.14.3 8 | tox-pyenv>=1.1.0 9 | mysqlclient>=2.0.1 10 | bump2version>=1.0.0 -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include MANIFEST.in 3 | include setup.cfg 4 | include setup.py 5 | include tox.ini 6 | recursive-exclude src *.pyc 7 | recursive-include src * 8 | recursive-include tests *.coveragerc 9 | recursive-include tests *.py 10 | -------------------------------------------------------------------------------- /tests/testproject/testproject/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from testapp.models import Interval 4 | 5 | 6 | @admin.register(Interval) 7 | class IntervalAdmin(admin.ModelAdmin): 8 | list_display = ['value'] 9 | list_filter = ['value'] 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #.* 2 | !.gitignore 3 | !.*.example 4 | !.travis.yml 5 | !.editorconfig 6 | !tests/.coveragerc 7 | !.bumpversion.cfg 8 | 9 | build/ 10 | 11 | *.egg-info 12 | 13 | *.sqlite 14 | 15 | /dist/ 16 | __pycache__/ 17 | venv 18 | *.egg 19 | /.eggs/ 20 | *.pyc 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def dummy_intervals(db): 6 | from testapp.models import Interval 7 | yield [ 8 | Interval.objects.create(date='2020-03-06'), 9 | Interval.objects.create(date='2020-10-06'), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/testproject/testapp/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.db import models 4 | from relativedeltafield import RelativeDeltaField 5 | 6 | 7 | class Interval(models.Model): 8 | value = RelativeDeltaField(null=True, blank=True) 9 | date = models.DateField(default=datetime.date(2020, 10, 21)) 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help buil 2 | .DEFAULT_GOAL := help 3 | 4 | 5 | help: 6 | @echo "Bumps version" 7 | 8 | 9 | bump: 10 | @while :; do \ 11 | read -r -p "bumpversion [major/minor/patch]: " PART; \ 12 | case "$$PART" in \ 13 | major|minor|patch) break ;; \ 14 | esac \ 15 | done ; \ 16 | bumpversion --no-commit --allow-dirty $$PART 17 | -------------------------------------------------------------------------------- /src/relativedeltafield/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1.2' 2 | 3 | from django.contrib.admin.options import FORMFIELD_FOR_DBFIELD_DEFAULTS 4 | 5 | from .fields import RelativeDeltaField # noqa 6 | from .forms import RelativeDeltaFormField # noqa 7 | 8 | FORMFIELD_FOR_DBFIELD_DEFAULTS[RelativeDeltaField] = { 9 | 'form_class': RelativeDeltaFormField 10 | } 11 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.1.2 3 | commit = True 4 | tag = False 5 | tag_name = "{new_version}" 6 | parse = (?P\d+)\.(?P\d+)\.(?P\d+) 7 | serialize = 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:src/relativedeltafield/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # the following will defualt to '' 2 | PGPASSWORD='mypass' 3 | 4 | # the following will defualt to '' 5 | MYSQL_PASSWORD='mypass' 6 | 7 | 8 | ### the following are the defaults and can be omitted 9 | 10 | # postgres variables 11 | PGHOST=127.0.0.1 12 | PGPORT=5432 13 | PGUSER=postgres 14 | 15 | # mysql variables 16 | MYSQL_HOST=127.0.0.1 17 | MYSQL_PORT=3306 18 | MYSQL_USER=reldelta 19 | -------------------------------------------------------------------------------- /tests/testproject/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Dev env setup 4 | 5 | 1. Clone the project 6 | 2. Create a virtualenv 7 | 3. Make sure you have latest version of pip: `pip install -U pip` 8 | 4. pip install -e ".[test]" 9 | 10 | ## Launch tests 11 | 12 | With pytest: 13 | ```bash 14 | pytest tests 15 | ``` 16 | 17 | With tox: 18 | ```bash 19 | tox 20 | ``` 21 | 22 | ## Update version (maintainers only) 23 | 24 | Use [bump2version](https://github.com/c4urself/bump2version) (tip, use: `make bump`) 25 | in order to change version number and update it in all needed files. 26 | 27 | Remember to update [CHANGELOG.md](./CHANGELOG.md) 28 | -------------------------------------------------------------------------------- /tests/testproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /src/relativedeltafield/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | 4 | from relativedeltafield.utils import parse_relativedelta, format_relativedelta 5 | 6 | 7 | class RelativeDeltaFormField(forms.CharField): 8 | 9 | def prepare_value(self, value): 10 | try: 11 | return format_relativedelta(value) 12 | except Exception: 13 | return value 14 | 15 | def to_python(self, value): 16 | return parse_relativedelta(value) 17 | 18 | def clean(self, value): 19 | try: 20 | return parse_relativedelta(value) 21 | except Exception: 22 | raise ValidationError('Not a valid (extended) ISO8601 interval specification', code='format') 23 | -------------------------------------------------------------------------------- /tests/testproject/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-02 14:44 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | import relativedeltafield.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Interval', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('value', relativedeltafield.fields.RelativeDeltaField(blank=True, null=True)), 21 | ('date', models.DateField(default=datetime.date(2020, 10, 21))), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = relativedeltafield 4 | include = 5 | 6 | # omit = django 7 | 8 | [report] 9 | # Regexes for lines to exclude from consideration 10 | exclude_lines = 11 | # Have to re-enable the standard pragma 12 | pragma: no cover 13 | pragma: no-cover 14 | # Don't complain about missing debug-only code: 15 | def __repr__ 16 | if self\.debug 17 | # Don't complain if tests don't hit defensive assertion code: 18 | raise AssertionError 19 | raise NotImplementedError 20 | # Don't complain if non-runnable code isn't run: 21 | #if 0: 22 | if __name__ == .__main__.: 23 | raise template.TemplateSyntaxError 24 | raise ImproperlyConfigure 25 | 26 | ignore_errors = True 27 | 28 | [html] 29 | directory = build/coverage 30 | -------------------------------------------------------------------------------- /tests/testproject/testproject/urls.py: -------------------------------------------------------------------------------- 1 | """testproject URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Code Yellow B.V. 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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## v2.0.0 4 | 5 | * Include support for Django versions 3 fully and 4 until (not including) Django 4.2 6 | * Add Github workflow testing support to the codebase 7 | 8 | ## v1.1.2 9 | 10 | * Make sure deprecation warning doesn't trigger in Django 3 (#12). 11 | * Introduced compatibility with non postgres databases (#16). 12 | * Introduced pytest and tox for testing (#13). 13 | 14 | ## v1.1.1 15 | 16 | * Make check for Postgres more lenient by checking the vendor attribute (#11). 17 | 18 | ## v1.1.0 19 | 20 | * Add support for Django 3.0 (#9, #10) 21 | * Test on multiple Django/Python combinations 22 | 23 | ## v1.0.6 24 | 25 | * Loosen up check for Postgres backend by using a substring search, so 26 | that the code won't break on custom Postgres adapters (#8). 27 | 28 | ## v1.0.5 29 | 30 | * Do not coerce "weeks" numbers to float. 31 | * Remove hard dependency on psycopg (#6). 32 | 33 | ## v1.0.4 34 | 35 | * Also recognise Postgis contrib engine as a supported Postgres engine. 36 | 37 | ## v1.0.3 38 | 39 | * Accidental upload; is identical to v1.0.2. 40 | 41 | ## v1.0.2 42 | 43 | * Also accept `django.db.backends.postgresql` alias as Postgresql driver (#1). 44 | * Do not serialize `weeks` value when storing in the database, which caused those days to be counted twice (#2). 45 | 46 | ## v1.0.1 47 | 48 | * Ensure integer values are always parsed as integers. This prevents 49 | issues when performing date arithmetic on relativedelta objects, as 50 | Python's date class demands integer arguments. 51 | -------------------------------------------------------------------------------- /.github/workflows/develop.yml: -------------------------------------------------------------------------------- 1 | name: Relativedelta tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | services: 10 | mysql: 11 | image: mysql:5.7 12 | env: 13 | DB_USER: root 14 | DB_PASSWORD: root 15 | DB_HOST: localhost 16 | DB_PORT: 3306 17 | DB_DATABASE: relativedeltafield 18 | MYSQL_ROOT_PASSWORD: root 19 | ports: 20 | - 3306 21 | postgres: 22 | image: postgres:11.6 23 | env: 24 | PGUSER: postgres 25 | PGPASSWORD: postgres 26 | PGDATABASE: relativedeltafield 27 | PGHOST: localhost 28 | ports: 29 | - 5432:5432 30 | strategy: 31 | matrix: 32 | python-version: ["3.8", "3.9", "3.10"] 33 | steps: 34 | - name: Start Postgres 35 | run: | 36 | sudo /etc/init.d/postgresql start || journalctl -xe 37 | - name: Start MySQL 38 | run: | 39 | sudo /etc/init.d/mysql start || journalctl -xe 40 | - uses: actions/checkout@v3 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install dependencies 46 | run: | 47 | python -m pip install --upgrade pip 48 | pip install tox tox-gh-actions 49 | - name: Lint with 'flake8' 50 | run: | 51 | pip install flake8 52 | # stop the build if there are Python syntax errors or undefined names 53 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 54 | flake8 src --count --show-source --statistics --exit-zero --max-complexity=20 55 | - name: Test with pytest 56 | run: | 57 | tox 58 | env: 59 | PGUSER: postgres 60 | PGPASSWORD: postgres 61 | PGDATABASE: relativedeltafield 62 | PGHOST: localhost 63 | MYSQL_USER: root 64 | MYSQL_PASSWORD: root 65 | MYSQL_HOST: localhost 66 | MYSQL_PORT: 3306 67 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import os 4 | 5 | from setuptools import find_packages, setup 6 | 7 | test_deps = [ 8 | 'psycopg2-binary <= 2.8.6', 9 | 'pytest >= 6.0.2', 10 | 'pytest-pythonpath>=0.7.3', 11 | 'pytest-echo>=1.7.1', 12 | 'pytest-coverage', 13 | 'pytest-django >= 3.7.0', 14 | 'tox >= 3.14.3', 15 | 'tox-pyenv >= 1.1.0', 16 | 'bump2version >= 1.0.0', 17 | 'flake8 >= 3.8.3', 18 | 'isort >= 5.5.3', 19 | 'mysqlclient >= 2.0.1' 20 | ] 21 | 22 | setup( 23 | name='django-relativedelta', 24 | version='2.0.0', 25 | package_dir={'': 'src'}, 26 | packages=find_packages('src'), 27 | include_package_data=True, 28 | license='MIT License', 29 | description='Django alternative to DurationField using dateutil.relativedelta', 30 | long_description=open('README.rst').read(), 31 | url='https://github.com/CodeYellowBV/django-relativedelta', 32 | keywords='django, ', 33 | setup_requires=[], 34 | author='Code Yellow B.V.', 35 | author_email='django-relativedelta@codeyellow.nl', 36 | test_suite='tests', 37 | classifiers=[ 38 | 'Development Status :: 5 - Production/Stable', 39 | 'Environment :: Web Environment', 40 | 'Framework :: Django', 41 | 'Framework :: Django :: 3.2', 42 | 'Framework :: Django :: 4.0', 43 | 'Framework :: Django :: 4.1', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Operating System :: OS Independent', 47 | 'Programming Language :: Python', 48 | 'Programming Language :: Python :: 3', 49 | 'Programming Language :: Python :: 3.9', 50 | 'Programming Language :: Python :: 3.10', 51 | 'Topic :: Database', 52 | 'Topic :: Internet :: WWW/HTTP', 53 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 54 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 55 | 'Topic :: Software Development :: Libraries :: Python Modules', 56 | ], 57 | install_requires=[ 58 | 'Django >= 1.10', 59 | 'python-dateutil >= 2.6.0', 60 | ], 61 | tests_require=test_deps, 62 | extras_require={ 63 | 'test': test_deps, 64 | } 65 | ) 66 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from unittest import TestCase 3 | 4 | from dateutil.relativedelta import relativedelta 5 | 6 | from relativedeltafield.utils import parse_relativedelta, relativedelta_as_csv 7 | 8 | 9 | class ParseRelativedeltaTest(TestCase): 10 | def assertStrictEqual(self, a, b): 11 | self.assertEqual(a, b) 12 | self.assertEqual(type(a), type(b)) 13 | 14 | def test_simple_parse_timedelta(self): 15 | self.assertStrictEqual(relativedelta(microseconds=300000.0), parse_relativedelta(timedelta(seconds=0.3))) 16 | self.assertStrictEqual(relativedelta( 17 | seconds=2, microseconds=300000.0), parse_relativedelta(timedelta(seconds=2.3)) 18 | ) 19 | self.assertStrictEqual( 20 | relativedelta(days=11, hours=18, minutes=11, seconds=50, microseconds=100010), 21 | parse_relativedelta(timedelta(weeks=1, days=4.5, hours=5, minutes=70.5, seconds=80.100005, microseconds=5)) 22 | ) 23 | 24 | def test_normalize_timedelta(self): 25 | self.assertStrictEqual( 26 | relativedelta(seconds=21, microseconds=600000.0), 27 | parse_relativedelta(timedelta(seconds=2.3, microseconds=19.3e6)) 28 | ) 29 | 30 | def test_parse_iso8601csv(self): 31 | """Test parsing the internal comma-separated-value representation""" 32 | self.assertStrictEqual( 33 | relativedelta(years=1925, months=9, days=-14, hours=-12, minutes=27, seconds=54, microseconds=-123456), 34 | parse_relativedelta('01925/009/-14 -12:027:054.-123456') 35 | ) 36 | 37 | 38 | class CSVFormatTest(TestCase): 39 | def test_csv_conversions(self): 40 | self.assertEqual( 41 | relativedelta_as_csv(relativedelta(years=1925, months=9, days=-4, 42 | hours=-12, minutes=27, seconds=54, 43 | microseconds=-123456)), 44 | '01925/009/-04 -12:027:054.-123456') 45 | self.assertEqual( 46 | parse_relativedelta('01925/009/-04 -12:027:054.-123456'), 47 | relativedelta(years=1925, months=9, days=-4, 48 | hours=-12, minutes=27, seconds=54, 49 | microseconds=-123456) 50 | ) 51 | self.assertEqual( 52 | relativedelta_as_csv(relativedelta(years=-1925, months=9, days=-4, 53 | hours=-12, minutes=27, seconds=-54, 54 | microseconds=123456)), 55 | '-1925/009/-04 -12:027:-54.0123456') 56 | self.assertEqual( 57 | parse_relativedelta('-1925/009/-04 -12:027:-54.0123456'), 58 | relativedelta(years=-1925, months=9, days=-4, 59 | hours=-12, minutes=27, seconds=-54, 60 | microseconds=123456) 61 | ) 62 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | # Create the env list by setting the required versions for python/django using sqlite 3 | # Once those tests run successfully, copy the block twice and change 4 | # py38 to {py} where py is one of the supported versions, 5 | # 'sqlite' to {db} where db is one of 'mysql' or 'pg' 6 | # See also: 7 | # https://docs.djangoproject.com/en/4.0/faq/install/#what-python-version-can-i-use-with-django 8 | # for supported versions: 9 | envlist = 10 | py38-d{20,21,22}-sqlite 11 | py38-d{30,31,32,40,41}-sqlite 12 | py39-d{30,31,32,40,41}-sqlite 13 | py310-d{32,40,41}-sqlite 14 | py38-d{20,21,22}-pg 15 | py38-d{30,31,32,40,41}-pg 16 | py39-d{30,31,32,40,41}-pg 17 | py310-d{32,40,41}-pg 18 | py38-d{20,21,22}-mysql 19 | py38-d{30,31,32,40,41}-mysql 20 | py39-d{30,31,32,40,41}-mysql 21 | py310-d{32,40,41}-mysql 22 | 23 | [gh-actions] 24 | python = 25 | 3.8: py38 26 | 3.9: py39 27 | 3.10: py310 28 | django = 29 | 2.0: d20 30 | 2.1: d21 31 | 2.2: d22 32 | 3.0: d30 33 | 3.1: d31 34 | 3.2: d32 35 | 4.0: d40 36 | 4.1: d41 37 | 38 | [pytest] 39 | python_paths=./tests/testproject/ src 40 | django_find_project = false 41 | DJANGO_SETTINGS_MODULE=testproject.settings 42 | norecursedirs = data .venv .tox ~* docs ./testproject/ 43 | python_files=tests/test_*.py 44 | addopts = 45 | -rs 46 | -p no:xdist 47 | -p no:warnings 48 | --tb=short 49 | --capture=no 50 | --echo-version django 51 | --echo-attr django.conf.settings.DATABASES.default.ENGINE 52 | --cov=src/relativedeltafield 53 | --cov-report=html 54 | --cov-config=tests/.coveragerc 55 | 56 | pep8ignore = * ALL 57 | 58 | [testenv] 59 | passenv = 60 | PYTHON_VERSION 61 | PYTHONDONTWRITEBYTECODE 62 | MYSQL_PASSWORD 63 | MYSQL_HOST 64 | MYSQL_PORT 65 | MYSQL_USER 66 | PGDATABASE 67 | PGHOST 68 | PGHOSTADDR 69 | PGPORT 70 | PGUSER 71 | PGPASSWORD 72 | PGPASS 73 | 74 | 75 | whitelist_externals = 76 | /usr/local/bin/psql 77 | /bin/sh 78 | /usr/local/bin/mysql 79 | /usr/local/mysql/bin/mysql 80 | /usr/bin/psql 81 | changedir={toxinidir} 82 | setenv = 83 | DBNAME = relativedeltafield 84 | pg: DBENGINE = pg 85 | mysql: DBENGINE = mysql 86 | sqlite: DBENGINE = sqlite 87 | 88 | 89 | deps = 90 | pytest 91 | pytest-cov 92 | pytest-django 93 | pytest-echo 94 | pytest-pythonpath 95 | psycopg2-binary==2.8.6 96 | mysql: mysqlclient 97 | docs: -rdocs/requirements.pip 98 | d20: django>=2.0,<2.1 99 | d21: django>=2.1,<2.2 100 | d22: django>=2.2,<2.3 101 | d30: django>=3.0,<3.1 102 | d31: django>=3.1,<3.2 103 | d32: django>=3.2,<3.3 104 | d40: django>=4.0,<4.1 105 | d41: django==4.1.* 106 | 107 | commands = 108 | {posargs:pytest tests -rw --create-db} 109 | 110 | [testenv:mysql] 111 | commands = 112 | - mysql -u root -h 127.0.0.1 -e 'DROP DATABASE IF EXISTS relativedeltafield;' 113 | - mysql -u root -h 127.0.0.1 -e 'CREATE DATABASE IF NOT EXISTS relativedeltafield;' 114 | {[testenv]commands} 115 | 116 | [testenv:pg] 117 | commands = 118 | - psql -h 127.0.0.1 -c 'DROP DATABASE "relativedeltafield";' -U postgres 119 | - psql -h 127.0.0.1 -c 'CREATE DATABASE "relativedeltafield";' -U postgres 120 | {[testenv]commands} 121 | 122 | [testenv:clean] 123 | commands = 124 | mysql: - mysql -u root -e 'DROP DATABASE IF EXISTS relativedeltafield;' 125 | pg: - psql -c 'DROP DATABASE "relativedeltafield";' -U postgres 126 | 127 | 128 | [testenv:docs] 129 | commands = 130 | mkdir -p {toxinidir}/build/docs 131 | pipenv run sphinx-build -aE docs/ {toxinidir}/build/docs 132 | 133 | 134 | [flake8] 135 | max-complexity = 12 136 | max-line-length = 120 137 | exclude = .tox,.venv,__pyache__,dist,migrations,.git,docs,settings,~* 138 | -------------------------------------------------------------------------------- /src/relativedeltafield/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import timedelta 3 | 4 | from dateutil.relativedelta import relativedelta 5 | 6 | # This is not quite ISO8601, as it allows the SQL/Postgres extension 7 | # of allowing a minus sign in the values, and you can mix weeks with 8 | # other units (which ISO doesn't allow). 9 | iso8601_duration_re = re.compile( 10 | r'P' 11 | r'(?:(?P-?\d+(?:\.\d+)?)Y)?' 12 | r'(?:(?P-?\d+(?:\.\d+)?)M)?' 13 | r'(?:(?P-?\d+(?:\.\d+)?)W)?' 14 | r'(?:(?P-?\d+(?:\.\d+)?)D)?' 15 | r'(?:T' 16 | r'(?:(?P-?\d+(?:\.\d+)?)H)?' 17 | r'(?:(?P-?\d+(?:\.\d+)?)M)?' 18 | r'(?:(?P-?\d+(?:\.\d+)?)S)?' 19 | r')?' 20 | r'$' 21 | ) 22 | 23 | # This is the comma-separated internal value to be used for databases non supporting the interval type natively 24 | iso8601_csv_re = re.compile(r"^(?P^[-\d]\d{4})/(?P[-\d]\d{2})/(?P[-\d]\d{2}) " 25 | r"(?P[-\d]\d{2}):(?P[-\d]\d{2}):(?P[-\d]\d{2})\." 26 | r"(?P[-\d]\d{6})$") 27 | 28 | 29 | # Parse ISO8601 timespec 30 | def parse_relativedelta(value): 31 | if value is None or value == '': 32 | return None 33 | elif isinstance(value, timedelta): 34 | microseconds = value.seconds % 1 * 1e6 + value.microseconds 35 | seconds = int(value.seconds) 36 | return relativedelta(days=value.days, seconds=seconds, microseconds=microseconds) 37 | elif isinstance(value, relativedelta): 38 | return value.normalized() 39 | elif isinstance(value, str): 40 | try: 41 | m = iso8601_duration_re.match(value) or iso8601_csv_re.match(value) 42 | if m: 43 | args = {} 44 | for k, v in m.groupdict().items(): 45 | if v is None: 46 | args[k] = 0 47 | elif '.' in v: 48 | args[k] = float(v) 49 | else: 50 | args[k] = int(v) 51 | return relativedelta(**args).normalized() if m else None 52 | except Exception: 53 | pass 54 | raise ValueError('Not a valid (extended) ISO8601 interval specification') 55 | 56 | 57 | def relativedelta_as_csv(self) -> str: 58 | return '%05d/%03d/%03d %03d:%03d:%03d.%07d' % ( 59 | self.years, 60 | self.months, 61 | self.days, 62 | self.hours, 63 | self.minutes, 64 | self.seconds, 65 | self.microseconds 66 | ) 67 | 68 | 69 | # Format ISO8601 timespec 70 | def format_relativedelta(relativedelta): 71 | result_big = '' 72 | # TODO: We could always include all components, but that's kind of 73 | # ugly, since one second would be formatted as 'P0Y0M0W0DT0M1S' 74 | if relativedelta.years: 75 | result_big += '{}Y'.format(relativedelta.years) 76 | if relativedelta.months: 77 | result_big += '{}M'.format(relativedelta.months) 78 | if relativedelta.days: 79 | result_big += '{}D'.format(relativedelta.days) 80 | 81 | result_small = '' 82 | if relativedelta.hours: 83 | result_small += '{}H'.format(relativedelta.hours) 84 | if relativedelta.minutes: 85 | result_small += '{}M'.format(relativedelta.minutes) 86 | # Microseconds is allowed here as a convenience, the user may have 87 | # used normalized(), which can result in microseconds 88 | if relativedelta.seconds: 89 | seconds = relativedelta.seconds 90 | if relativedelta.microseconds: 91 | seconds += relativedelta.microseconds / 1000000.0 92 | result_small += '{}S'.format(seconds) 93 | 94 | if len(result_small) > 0: 95 | return 'P{}T{}'.format(result_big, result_small) 96 | elif len(result_big) == 0: 97 | return 'P0D' # Doesn't matter much what field is zero, but just 'P' is invalid syntax, and so is '' 98 | else: 99 | return 'P{}'.format(result_big) 100 | -------------------------------------------------------------------------------- /src/relativedeltafield/fields.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.db import models 3 | from relativedeltafield.utils import (format_relativedelta, 4 | parse_relativedelta, 5 | relativedelta_as_csv) 6 | 7 | try: 8 | from django.utils.translation import gettext_lazy as _ 9 | except ImportError: 10 | from django.utils.translation import ugettext as _ 11 | 12 | 13 | class RelativeDeltaDescriptor: 14 | def __init__(self, field) -> None: 15 | self.field = field 16 | 17 | def __get__(self, obj, objtype=None): 18 | if obj is None: 19 | return None 20 | value = obj.__dict__.get(self.field.name) 21 | if value is None: 22 | return None 23 | try: 24 | return parse_relativedelta(value) 25 | except ValueError as e: 26 | raise ValidationError({self.field.name: e}) 27 | 28 | def __set__(self, obj, value): 29 | obj.__dict__[self.field.name] = value 30 | 31 | 32 | class RelativeDeltaField(models.Field): 33 | """Stores dateutil.relativedelta.relativedelta objects. 34 | 35 | Uses INTERVAL on PostgreSQL. 36 | """ 37 | empty_strings_allowed = False 38 | default_error_messages = { 39 | 'invalid': _("'%(value)s' value has an invalid format. It must be in " 40 | "ISO8601 interval format.") 41 | } 42 | description = _("RelativeDelta") 43 | descriptor_class = RelativeDeltaDescriptor 44 | 45 | def get_lookup(self, lookup_name): 46 | ret = super().get_lookup(lookup_name) 47 | return ret 48 | 49 | def db_type(self, connection): 50 | if connection.vendor == 'postgresql': 51 | return 'interval' 52 | else: 53 | return 'varchar(33)' 54 | 55 | def get_db_prep_save(self, value, connection): 56 | if value is None: 57 | return None 58 | if connection.vendor == 'postgresql': 59 | return super().get_db_prep_save(value, connection) 60 | else: 61 | if isinstance(value, str): # we need to convert it to the non-postgres format 62 | return relativedelta_as_csv(parse_relativedelta(value)) 63 | return relativedelta_as_csv(value) 64 | 65 | def to_python(self, value): 66 | if value is None: 67 | return value 68 | try: 69 | return parse_relativedelta(value) 70 | except (ValueError, TypeError): 71 | raise ValidationError( 72 | self.error_messages['invalid'], 73 | code='invalid', 74 | params={'value': value}, 75 | ) 76 | 77 | def get_db_prep_value(self, value, connection, prepared=False): 78 | if value is None: 79 | return value 80 | else: 81 | if connection.vendor == 'postgresql': 82 | return format_relativedelta(self.to_python(value)) 83 | else: 84 | return relativedelta_as_csv(self.to_python(value)) 85 | 86 | # This is a bit of a mindfuck. We have to cast the output field 87 | # as text to bypass the standard deserialisation of PsycoPg2 to 88 | # datetime.timedelta, which loses information. We then parse it 89 | # ourselves in convert_relativedeltafield_value(). 90 | # 91 | # We make it easier for ourselves by doing some formatting here, 92 | # so that we don't need to rely on weird detection logic for the 93 | # current value of IntervalStyle (PsycoPg2 actually gets this 94 | # wrong; it only checks / sets DateStyle, but not IntervalStyle) 95 | # 96 | # We can't simply replace or remove PsycoPg2's parser, because 97 | # that would mess with any existing Django DurationFields, since 98 | # Django assumes PsycoPg2 returns pre-parsed datetime.timedeltas. 99 | def select_format(self, compiler, sql, params): 100 | if compiler.connection.vendor == 'postgresql': 101 | fmt = 'to_char(%s, \'PYYYY"Y"MM"M"DD"DT"HH24"H"MI"M"SS.US"S"\')' % sql 102 | else: 103 | fmt = sql 104 | return fmt, params 105 | 106 | def from_db_value(self, value, expression, connection, context=None): 107 | if value is not None: 108 | return parse_relativedelta(value) 109 | 110 | def value_to_string(self, obj): 111 | val = self.value_from_object(obj) 112 | return '' if val is None else format_relativedelta(val) 113 | -------------------------------------------------------------------------------- /tests/testproject/testproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '1a152)__1_z!ep1en%g8)_$m-mo%h3)4&6!x+sp!!45ucca!j)' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.messages', 38 | 'django.contrib.staticfiles', 39 | 'django.contrib.admin', 40 | 'testapp', 41 | 'testproject', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'testproject.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'testproject.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 77 | db = os.environ.get('DBENGINE', 'pg') 78 | if db == 'sqlite': 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.sqlite3', 82 | 'NAME': 'relativedelta-test', 83 | 84 | }} 85 | elif db == 'mysql': 86 | DATABASES = { 87 | 'default': { 88 | 'ENGINE': 'django.db.backends.mysql', 89 | 'NAME': 'relativedelta-test', 90 | 'HOST': os.environ.get('MYSQL_HOST', '127.0.0.1'), 91 | 'PORT': os.environ.get('MYSQL_PORT', 3306), 92 | 'USER': os.environ.get('MYSQL_USER', 'root'), 93 | 'PASSWORD': os.environ.get('MYSQL_PASSWORD', ''), 94 | 'OPTIONS': { 95 | 'isolation_level': 'read uncommitted' 96 | }, 97 | 'TEST': { 98 | # 'NAME': 'relativedelta-test', 99 | 'OPTIONS': { 100 | 'isolation_level': 'read uncommitted' 101 | }, 102 | }, 103 | 'CHARSET': 'utf8', 104 | 'COLLATION': 'utf8_general_ci'}} 105 | else: 106 | DATABASES = { 107 | 'default': { 108 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 109 | 'NAME': os.environ.get('PGDATABASE', 'relativedelta-test'), 110 | 'HOST': os.environ.get('PGHOSTADDR', os.environ.get('PGHOST')), 111 | 'PORT': os.environ.get('PGPORT'), 112 | 'USER': os.environ.get('PGUSER'), 113 | 'PASSWORD': os.environ.get('PGPASSWORD', os.environ.get('PGPASS')) 114 | }, 115 | } 116 | 117 | 118 | # Password validation 119 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 120 | 121 | AUTH_PASSWORD_VALIDATORS = [ 122 | ] 123 | 124 | 125 | # Internationalization 126 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 127 | 128 | LANGUAGE_CODE = 'en-us' 129 | 130 | TIME_ZONE = 'UTC' 131 | 132 | USE_I18N = True 133 | 134 | USE_L10N = True 135 | 136 | USE_TZ = True 137 | 138 | 139 | # Static files (CSS, JavaScript, Images) 140 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 141 | 142 | STATIC_URL = '/static/' 143 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-relativedelta 2 | ==================== 3 | 4 | .. image:: https://travis-ci.org/CodeYellowBV/django-relativedelta.svg?branch=master 5 | :target: https://travis-ci.org/CodeYellowBV/django-relativedelta 6 | 7 | A Django field for the `dateutil.relativedelta.relativedelta `_ class, 8 | which conveniently maps to the `PostgreSQL INTERVAL type `_. 9 | 10 | The standard `Django DurationField `_ 11 | maps to `Python's datetime.timedelta `_, which 12 | has support for days and weeks, but not for years and months. And if you try to read an ``INTERVAL`` that contains 13 | months anyway, information is lost because each month gets converted to 30 days. 14 | 15 | For compatibility, a `VARCHAR` field is used on other databases. This 16 | uses a custom relativedelta representation. However, this means that 17 | true in-database interval operations are not supported in these 18 | databases. Sorting and comparing between two relativedelta fields or 19 | a relativedelta field and a fixed relativedelta value is supported, 20 | however. 21 | 22 | You should use this package when you need to store payment intervals 23 | (which tend to be monthly or quarterly), publication intervals (which 24 | can be weekly but also monthly) and so on, or when you simply don't 25 | know what the intervals are going to be and want to offer some 26 | flexibility. 27 | 28 | If you want to use more advanced recurring dates, you should consider 29 | using `django-recurrence `_ 30 | instead. This maps to the `dateutil.rrule.rrule `_ 31 | class, but it doesn't use native database field types, so you can't 32 | perform arithmetic on them within the database. 33 | 34 | Usage 35 | ----- 36 | 37 | Using the field is straightforward. You can add the field to your 38 | model like so: 39 | 40 | .. code:: python 41 | 42 | from django.db import models 43 | from relativedeltafield import RelativeDeltaField 44 | 45 | class MyModel(models.Model): 46 | rdfield=RelativeDeltaField() 47 | 48 | Then later, you can use it: 49 | 50 | .. code:: python 51 | 52 | from dateutil.relativedelta import relativedelta 53 | 54 | rd = relativedelta(months=2,days=1,hours=6) 55 | my_model = MyModel(rdfield=rd) 56 | my_model.save() 57 | 58 | 59 | Or, alternatively, you can use a string with the 60 | `ISO8601 "format with designators" time interval syntax `_: 61 | 62 | .. code:: python 63 | 64 | from dateutil.relativedelta import relativedelta 65 | 66 | my_model = MyModel(rdfield='P2M1DT6H') 67 | my_model.save() 68 | 69 | 70 | For convenience, a standard Python ``datetime.timedelta`` object is 71 | also accepted: 72 | 73 | .. code:: python 74 | 75 | from datetime import timedelta 76 | 77 | td = timedelta(days=62,hours=6) 78 | my_model = MyModel(rdfield=td) 79 | my_model.save() 80 | 81 | After a ``full_clean()``, the object will always be converted to a 82 | _normalized_ ``relativedelta`` instance. It is highly recommended 83 | you use the `django-fullclean `_ 84 | app to always force ``full_clean()`` on ``save()``, so you can be 85 | sure that after a ``save()``, your fields are both normalized 86 | and validated. 87 | 88 | 89 | Limitations and pitfalls 90 | ------------------------ 91 | 92 | Because this field is backed by an ``INTERVAL`` column, it neither 93 | supports the relative ``weekday``, ``leapdays``, ``yearday`` and 94 | ``nlyearday`` arguments, nor the absolute arguments ``year``, 95 | ``month``, ``day``, ``hour``, ``second`` and ``microsecond``. 96 | 97 | The ``microseconds`` field is converted to a fractional ``seconds`` 98 | value, which might lead to some precision loss due to floating-point 99 | representation. 100 | 101 | The ``weeks`` field is "virtual", being derived from the multiple of 7 102 | days. Thus, any week value in the input interval specification is 103 | converted to days and added to the ``days`` field of the interval. 104 | When serializing back to a string, weeks will never be written. 105 | Similarly, if the interval contains a multiple of 7 days, you can read 106 | this back out from the ``weeks`` property. 107 | 108 | Support for databases other than PostgreSQL is limited. 109 | 110 | For consistency reasons, when a relativedelta object is assigned to a 111 | RelativeDeltaField, it automatically calls ``normalized()`` on 112 | ``full_clean``. This ensures that the database representation is as 113 | similar to the relativedelta as possible (for instance, fractional 114 | days are always converted to hours). 115 | -------------------------------------------------------------------------------- /tests/test_relativedeltafield.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timedelta, date 3 | 4 | import django 5 | import pytest 6 | from dateutil.relativedelta import relativedelta 7 | from django.core.exceptions import ValidationError 8 | from django.db.models import Value, DurationField, F, ExpressionWrapper, DateField 9 | from django.db.models.functions import Cast 10 | from django.test import TestCase 11 | from testapp.models import Interval 12 | 13 | from relativedeltafield import RelativeDeltaField 14 | 15 | 16 | class RelativeDeltaFieldTest(TestCase): 17 | def setUp(self): 18 | Interval.objects.all().delete() 19 | 20 | def assertStrictEqual(self, a, b): 21 | self.assertEqual(a, b) 22 | self.assertEqual(type(a), type(b)) 23 | 24 | def test_basic_value_survives_db_roundtrip(self): 25 | input_value = relativedelta(years=2, months=3, days=4, hours=5, minutes=52, seconds=30, microseconds=5) 26 | obj = Interval(value=input_value) 27 | obj.save() 28 | 29 | obj.refresh_from_db() 30 | self.assertStrictEqual(input_value, obj.value) 31 | 32 | def test_empty_value_survives_db_roundtrip(self): 33 | obj = Interval(value=relativedelta()) 34 | obj.save() 35 | 36 | obj.refresh_from_db() 37 | self.assertEqual(relativedelta(), obj.value) 38 | self.assertStrictEqual(relativedelta(), obj.value) 39 | 40 | def test_each_separate_value_survives_db_roundtrip(self): 41 | values = { 42 | 'years': 501, 43 | 'months': 10, 44 | 'days': 2, 45 | 'hours': 1, 46 | 'minutes': 52, 47 | 'seconds': 12, 48 | } 49 | 50 | for k in values: 51 | input_value = relativedelta(**{k: values[k]}) 52 | obj = Interval(value=input_value) 53 | obj.save() 54 | 55 | obj.refresh_from_db() 56 | # Put the object in a dict to get descriptive output on failure 57 | self.assertEqual({k: input_value}, {k: obj.value}) 58 | self.assertEqual({k: int}, {k: type(getattr(obj.value, k))}) 59 | 60 | # See issue #2, we should not serialize weeks separately, because 61 | # it is a derived value. 62 | def test_weeks_value_survives_db_roundtrip_as_days(self): 63 | input_value = relativedelta(weeks=2, days=1) 64 | obj = Interval(value=input_value) 65 | obj.save() 66 | 67 | obj.refresh_from_db() 68 | self.assertStrictEqual(15, obj.value.days) 69 | self.assertStrictEqual(2, obj.value.weeks) 70 | 71 | def test_none_value_also_survives_db_roundtrip(self): 72 | obj = Interval(value=None) 73 | obj.save() 74 | 75 | obj.refresh_from_db() 76 | self.assertIsNone(obj.value) 77 | 78 | def test_none_value_survives_full_clean(self): 79 | obj = Interval(value=None) 80 | obj.full_clean() 81 | self.assertIsNone(obj.value) 82 | 83 | # Specific check, because to_python doesn't get called by save() 84 | # or full_clean() when the value is None (but other things might) 85 | # This is a regression test for a bug where we'd call normalized() 86 | # even on None values. 87 | def test_none_value_survives_to_python(self): 88 | self.assertIsNone(RelativeDeltaField().to_python(None)) 89 | 90 | def test_value_is_normalized_on_full_clean(self): 91 | input_value = relativedelta(years=1, months=3, weeks=1, days=4.5, hours=5, minutes=70.5, seconds=80.100005, 92 | microseconds=5) 93 | obj = Interval(value=input_value) 94 | obj.full_clean() 95 | 96 | self.assertNotEqual(input_value, obj.value) 97 | self.assertStrictEqual(input_value.normalized(), obj.value) 98 | 99 | # Quick sanity check to ensure the input isn't mutated. 100 | # Take into account that weeks are added to days though! 101 | self.assertStrictEqual(11.5, input_value.days) 102 | self.assertStrictEqual(1, input_value.weeks) 103 | 104 | # Check that the values are normalized 105 | self.assertStrictEqual(1, obj.value.years) 106 | self.assertStrictEqual(3, obj.value.months) 107 | self.assertStrictEqual(11, obj.value.days) 108 | self.assertStrictEqual(18, obj.value.hours) 109 | self.assertStrictEqual(11, obj.value.minutes) 110 | self.assertStrictEqual(50, obj.value.seconds) 111 | self.assertStrictEqual(100010, obj.value.microseconds) 112 | 113 | # Derived value, from the number of days (see #2); but it's no 114 | # longer converted to a floating-point number in newer 115 | # versions of relativedelta.... 116 | self.assertStrictEqual(1, obj.value.weeks) 117 | 118 | def test_weeks_value_is_derived_as_int_when_normalizing_on_full_clean(self): 119 | input_value = relativedelta(years=1, months=3, weeks=1, days=11.5, hours=5, minutes=70.5, seconds=80.100005, 120 | microseconds=5) 121 | obj = Interval(value=input_value) 122 | obj.full_clean() 123 | 124 | self.assertNotEqual(input_value, obj.value) 125 | self.assertStrictEqual(input_value.normalized(), obj.value) 126 | 127 | # Quick sanity check to ensure the input isn't mutated. 128 | # Take into account that weeks are added to days though! 129 | self.assertStrictEqual(18.5, input_value.days) 130 | self.assertStrictEqual(2, input_value.weeks) 131 | 132 | # Derived value, from the number of days (see #2) 133 | self.assertStrictEqual(2, obj.value.weeks) 134 | 135 | def test_string_input(self): 136 | obj = Interval(value='P1Y3M1W4.5DT5H70.5M80.10001S') 137 | obj.full_clean() 138 | 139 | self.assertIsInstance(obj.value, relativedelta) 140 | 141 | # Check that the values are normalized 142 | self.assertStrictEqual(1, obj.value.years) 143 | self.assertStrictEqual(3, obj.value.months) 144 | self.assertStrictEqual(11, obj.value.days) 145 | self.assertStrictEqual(18, obj.value.hours) 146 | self.assertStrictEqual(11, obj.value.minutes) 147 | self.assertStrictEqual(50, obj.value.seconds) 148 | self.assertStrictEqual(100010, obj.value.microseconds) 149 | 150 | # Derived value, from the number of days (see #2) 151 | self.assertStrictEqual(1, obj.value.weeks) 152 | 153 | def test_invalid_string_inputs_raise_validation_error(self): 154 | obj = Interval() 155 | 156 | obj.value = 'blabla' 157 | with self.assertRaises(ValidationError) as cm: 158 | obj.full_clean() 159 | self.assertEqual(set(['value']), set(cm.exception.message_dict.keys())) 160 | 161 | obj.value = 'P1.5M' # not allowed by relativedelta because it is supposedly ambiguous 162 | with self.assertRaises(ValidationError) as cm: 163 | obj.full_clean() 164 | self.assertEqual(set(['value']), set(cm.exception.message_dict.keys())) 165 | 166 | obj.value = 'P1M' # Check that the error is cleared when made valid again 167 | obj.full_clean() 168 | 169 | def test_invalid_objects_raise_validation_errors(self): 170 | obj = Interval() 171 | 172 | obj.value = True 173 | with self.assertRaises(ValidationError) as cm: 174 | obj.full_clean() 175 | self.assertEqual(set(['value']), set(cm.exception.message_dict.keys())) 176 | 177 | obj.value = 1 178 | with self.assertRaises(ValidationError) as cm: 179 | obj.full_clean() 180 | self.assertEqual(set(['value']), set(cm.exception.message_dict.keys())) 181 | 182 | obj.value = 'P1M' # Check that the error is cleared when made valid again 183 | obj.full_clean() 184 | 185 | def test_timedelta_input(self): 186 | td = timedelta(weeks=1, days=4.5, hours=5, minutes=70.5, seconds=80.100005, microseconds=5) 187 | obj = Interval(value=td) 188 | obj.full_clean() 189 | 190 | self.assertIsInstance(obj.value, relativedelta) 191 | 192 | # Check that the values are normalized 193 | self.assertStrictEqual(0, obj.value.years) 194 | self.assertStrictEqual(0, obj.value.months) 195 | self.assertStrictEqual(11, obj.value.days) 196 | self.assertStrictEqual(18, obj.value.hours) 197 | self.assertStrictEqual(11, obj.value.minutes) 198 | self.assertStrictEqual(50, obj.value.seconds) 199 | self.assertStrictEqual(100010, int(obj.value.microseconds)) 200 | 201 | # Derived value, from the number of days (see #2) 202 | self.assertStrictEqual(1, obj.value.weeks) 203 | 204 | def test_filtering_works(self): 205 | obj1 = Interval(value='P1Y3M1W4.5DT5H70.5M80.10001S') 206 | obj1.save() 207 | 208 | obj2 = Interval(value='P12D') 209 | obj2.save() 210 | 211 | q = Interval.objects.filter(value__gt='P1Y') 212 | self.assertEqual(1, q.count()) 213 | 214 | q = Interval.objects.filter(value__lt='P2Y') 215 | self.assertEqual(2, q.count()) 216 | 217 | obj2 = Interval(value='P100D') 218 | obj2.save() 219 | 220 | def test_filterning_non_postgres(self): 221 | obj1 = Interval(value='P1Y3M1W4.5DT5H70.5M80.10001S') 222 | obj1.save() 223 | 224 | obj2 = Interval(value='P12D') 225 | obj2.save() 226 | 227 | q = Interval.objects.filter(value__gt='P99D') 228 | self.assertEqual(1, q.count()) 229 | 230 | q = Interval.objects.filter(value__gt='P9D') 231 | self.assertEqual(2, q.count()) 232 | 233 | @pytest.mark.xfail(django.VERSION[0] < 3, reason="Incompatible with Django < 3") 234 | def test_value_usable_as_timedelta(self): 235 | obj1 = Interval(value='P1Y2M3W4DT5H6M7S') 236 | sum = datetime(2019, 1, 4, 10, 11, 12, 500) + obj1.value 237 | self.assertEqual(sum, datetime(2020, 3, 29, 15, 17, 19, 500)) 238 | 239 | 240 | @pytest.mark.xfail(os.environ.get('DBENGINE', 'pg') != 'pg', reason="Incompatible with non postgres DB") 241 | def test_simple_annotation(dummy_intervals): 242 | one_month = Cast( 243 | Value(relativedelta(months=1), output_field=RelativeDeltaField()), 244 | DurationField() 245 | ) 246 | q = Interval.objects.annotate(month_earlier=ExpressionWrapper(F('date') - one_month, output_field=DateField())) 247 | result = list(q.values_list('date', 'month_earlier')) 248 | assert result == [(date(2020, 3, 6), datetime(2020, 2, 6, 0, 0)), 249 | (date(2020, 10, 6), datetime(2020, 9, 6, 0, 0))] 250 | assert 1 == q.filter(month_earlier__lt='2020-09-06').count() 251 | assert 2 == q.filter(month_earlier__lte='2020-09-06').count() 252 | --------------------------------------------------------------------------------