diff --git a/chroniker/__init__.py b/chroniker/__init__.py
index 0ea551f..c5e71ab 100644
--- a/chroniker/__init__.py
+++ b/chroniker/__init__.py
@@ -1,2 +1,2 @@
-VERSION = (1, 0, 23)
+VERSION = (1, 0, 24)
__version__ = '.'.join(map(str, VERSION))
diff --git a/chroniker/admin.py b/chroniker/admin.py
index a8d8c29..eef084b 100644
--- a/chroniker/admin.py
+++ b/chroniker/admin.py
@@ -1,6 +1,6 @@
from django import forms
from django.conf import settings
-from django.conf.urls import re_path as url
+from django.urls import re_path as url
from django.contrib import admin
from django.core.management import get_commands
from django.urls import reverse, NoReverseMatch
@@ -14,7 +14,10 @@
from django.utils.formats import get_format
from django.utils.html import format_html
from django.utils.text import capfirst
-from django.utils.translation import ugettext_lazy as _
+try:
+ from django.utils.translation import gettext_lazy as _
+except ImportError:
+ from django.utils.translation import ugettext_lazy as _
from chroniker.models import Job, Log, JobDependency, Monitor
from chroniker import utils
diff --git a/chroniker/apps.py b/chroniker/apps.py
new file mode 100644
index 0000000..3488ddf
--- /dev/null
+++ b/chroniker/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ChronikerConfig(AppConfig):
+ name = 'chroniker'
+ default_auto_field = 'django.db.models.AutoField'
diff --git a/chroniker/models.py b/chroniker/models.py
index c30eaa4..c1ccbb2 100644
--- a/chroniker/models.py
+++ b/chroniker/models.py
@@ -1,5 +1,6 @@
from __future__ import print_function
+import itertools
import logging
import os
import shlex
@@ -692,11 +693,13 @@ def clean(self):
errors['raw_command'] = errors['command']
raise ValidationError(errors)
- def full_clean(self, exclude=None, validate_unique=True):
+ def full_clean(self, **kwargs):
self.clean()
def save(self, **kwargs):
self.full_clean()
+ # The `clean` method can update `self.frequency`
+ save_fields = ['frequency']
tz = timezone.get_default_timezone()
@@ -708,6 +711,7 @@ def save(self, **kwargs):
if not self.next_run or j.params != self.params:
logger.debug("Updating 'next_run")
next_run = self.next_run or timezone.now()
+ save_fields += ['next_run']
try:
self.next_run = self.rrule.after(utils.make_aware(next_run, tz))
except ValueError:
@@ -718,9 +722,23 @@ def save(self, **kwargs):
if not self.is_running:
self.current_hostname = None
self.current_pid = None
+ save_fields += ['current_hostname', 'current_pid']
if self.next_run:
self.next_run = utils.make_aware(self.next_run, tz)
+ if 'next_run' not in save_fields:
+ save_fields += ['next_run']
+
+ update_fields = kwargs.get('update_fields')
+ if update_fields is not None:
+ extra_update_fields = [
+ field for field in save_fields if field not in update_fields
+ ]
+ # update_fields can be a tuple, or list, or set
+ extended_update_fields = type(update_fields)(
+ itertools.chain(update_fields, extra_update_fields)
+ )
+ kwargs['update_fields'] = extended_update_fields
super().save(**kwargs)
@@ -756,7 +774,7 @@ def get_timeuntil(self):
"""
Returns a string representing the time until the next
time this Job will be run (actually, the "string" returned
- is really an instance of ``ugettext_lazy``).
+ is really an instance of ``gettext_lazy``).
>>> job = Job(next_run=timezone.now())
>>> job.get_timeuntil().translate('en')
@@ -1234,6 +1252,15 @@ def save(self, **kwargs):
assert self.run_start_datetime <= self.run_end_datetime, 'Job must start before it ends.'
time_diff = (self.run_end_datetime - self.run_start_datetime)
self.duration_seconds = time_diff.total_seconds()
+
+ update_fields = kwargs.get('update_fields')
+ if update_fields is not None and 'duration_seconds' not in update_fields:
+ # update_fields can be a tuple, or list, or set
+ extended_update_fields = type(update_fields)(
+ itertools.chain(update_fields, ['duration_seconds'])
+ )
+ kwargs['update_fields'] = extended_update_fields
+
super().save(**kwargs)
def duration_str(self):
diff --git a/chroniker/templates/admin/chroniker/job/duration_graph.html b/chroniker/templates/admin/chroniker/job/duration_graph.html
index c9fee81..70935e8 100644
--- a/chroniker/templates/admin/chroniker/job/duration_graph.html
+++ b/chroniker/templates/admin/chroniker/job/duration_graph.html
@@ -1,6 +1,6 @@
{% extends 'admin/change_form.html' %}
{#{% load url from future %}#}
-{% load i18n admin_urls admin_static admin_modify %}
+{% load i18n admin_urls static admin_modify %}
{% block breadcrumbs %}
diff --git a/chroniker/tests/settings.py b/chroniker/tests/settings.py
index 950388c..93bc0de 100644
--- a/chroniker/tests/settings.py
+++ b/chroniker/tests/settings.py
@@ -46,6 +46,9 @@ def __getitem__(self, item):
TIME_ZONE = 'America/New_York'
+# See https://docs.djangoproject.com/en/4.2/ref/settings/#use-deprecated-pytz
+USE_DEPRECATED_PYTZ = True
+
AUTH_USER_MODEL = 'auth.User'
SECRET_KEY = 'abc123'
diff --git a/chroniker/tests/urls.py b/chroniker/tests/urls.py
index 5705891..992a3f1 100644
--- a/chroniker/tests/urls.py
+++ b/chroniker/tests/urls.py
@@ -1,9 +1,3 @@
-try:
- # Removed in Django 1.6
- from django.conf.urls.defaults import url, include
-except ImportError:
- from django.conf.urls import url, include
-
try:
# Relocated in Django 1.6
from django.conf.urls.defaults import patterns
@@ -16,17 +10,22 @@
from django.core.exceptions import ImproperlyConfigured
from django.contrib import admin
+try:
+ from django.urls import include, re_path
+except ImportError:
+ from django.conf.urls import include, url as re_path
+
admin.autodiscover()
try:
_patterns = [
- url(r'^admin/', include(admin.site.urls)),
+ re_path(r'^admin/', include(admin.site.urls)),
]
except ImproperlyConfigured:
# Django >= 2.1.7.
_patterns = [
- url(r'^admin/', admin.site.urls),
+ re_path(r'^admin/', admin.site.urls),
]
if patterns is None:
diff --git a/chroniker/widgets.py b/chroniker/widgets.py
index 9bb9c5c..5a6292e 100644
--- a/chroniker/widgets.py
+++ b/chroniker/widgets.py
@@ -10,11 +10,11 @@
from django.urls import reverse
try:
# force_unicode was deprecated in Django 1.5.
- from django.utils.encoding import force_unicode as force_text
- from django.utils.encoding import smart_unicode as smart_text
+ from django.utils.encoding import force_unicode as force_str
+ from django.utils.encoding import smart_unicode as smart_str
except ImportError:
- from django.utils.encoding import force_text
- from django.utils.encoding import smart_text
+ from django.utils.encoding import force_str
+ from django.utils.encoding import smart_str
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -58,7 +58,7 @@ def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(attrs, extra_attrs={'type': self.input_type, 'name': name})
if value != '':
# Only add the 'value' attribute if a value is non-empty.
- final_attrs['value'] = force_text(self._format_value(value))
+ final_attrs['value'] = force_str(self._format_value(value))
final_attrs['size'] = 10
t = Template(
str(
@@ -112,7 +112,7 @@ def label_for_value(self, value):
for v in values:
try:
obj = self.remote_field.model._default_manager.using(self.db).get(**{key: v})
- x = smart_text(obj)
+ x = smart_str(obj)
change_url = reverse("admin:%s_%s_change" % (obj._meta.app_label, obj._meta.object_name.lower()), args=(obj.pk,))
str_values += ['
%s' \
% (change_url, escape(x))]
diff --git a/requirements-min-django.txt b/requirements-min-django.txt
index 5762ddd..d273c18 100644
--- a/requirements-min-django.txt
+++ b/requirements-min-django.txt
@@ -1 +1 @@
-Django>=3.0
+Django>=3.2
diff --git a/requirements-test.txt b/requirements-test.txt
index 86a3009..813734d 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -6,3 +6,7 @@ mkdocs==1.4.2
yapf==0.29.0
twine==4.0.2
pre-commit==2.11.0
+# Support for using pytz will be removed in Django 5.0.
+# Don't specify a version for it now. It will already be included in
+# Django versions prior to 4.0.
+pytz
diff --git a/setup.py b/setup.py
index 88ac95c..3e1b5f7 100644
--- a/setup.py
+++ b/setup.py
@@ -53,10 +53,7 @@ def get_reqs(*fns):
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python',
- 'Programming Language :: Python :: 3.4',
- 'Programming Language :: Python :: 3.5',
- 'Programming Language :: Python :: 3.6',
- 'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3.8',
'Framework :: Django',
],
zip_safe=False,
diff --git a/tox.ini b/tox.ini
index 3a1e099..72e1793 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
[tox]
-envlist = py{38}-django{30}
+envlist = py{38}-django{42}
recreate = True
[testenv]
@@ -8,5 +8,7 @@ basepython =
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/requirements-test.txt
- django30: Django>=3.0,<3.1
-commands = django-admin.py test --traceback --settings=chroniker.tests.settings chroniker.tests.tests.JobTestCase{env:TESTNAME:}
+ django32: Django>=3.2,<3.3
+ django42: Django>=4.2,<5.0
+ -e . # Install the current package in editable mode
+commands = django-admin test --traceback --settings=chroniker.tests.settings chroniker.tests.tests.JobTestCase{env:TESTNAME:}