From f397b5811fd41d3347bebe2da68c04d6cebcbc58 Mon Sep 17 00:00:00 2001 From: David Krauth Date: Wed, 15 Jul 2020 15:15:08 -1000 Subject: [PATCH] Updates for Django 2.x --- .gitignore | 11 +++-- LICENSE | 2 +- README.md | 5 ++- autosave/mixins.py | 94 +++++++++++++++++++++--------------------- setup.cfg | 16 ++++--- setup.py | 19 ++++----- tests/__init__.py | 0 tests/models.py | 21 ++++++++++ tests/settings.py | 58 ++++++++++++++++++++++++++ tests/test_autosave.py | 17 ++++++++ tox.ini | 23 +++++++++++ 11 files changed, 200 insertions(+), 66 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/models.py create mode 100644 tests/settings.py create mode 100644 tests/test_autosave.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index 1cb7197..5c0140e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ *.pyc +__pycache__ .DS_Store -build - -*.egg-info \ No newline at end of file +build/ +dist/ +.python-version +.tox/ +*.egg-info/ +venv/ +db.sqlite3 diff --git a/LICENSE b/LICENSE index b3ae787..0484eb8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ This software is published under the BSD 2-Clause License as listed below. http://www.opensource.org/licenses/bsd-license.php -Copyright (c) 2014-2019, Atlantic Media +Copyright (c) 2014-2020, Atlantic Media All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 5e1d888..b420a9f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ Gives users the option to recover their unsaved changes in the event of a browser crash or lost connection. -**Note:** Version 1.0 supports Django >= 1.11, Python 2.7, Python >= 3.5. Version 2.0 will drop support for Django < 2.0. +> **Note:** +> +> * Version 1.0 supports Django >= 1.11, Python 2.7, >= 3.5. +> * Version 2.0 will drop support for Django < 2.0. ## Setup diff --git a/autosave/mixins.py b/autosave/mixins.py index fe151b6..9d4fe1b 100644 --- a/autosave/mixins.py +++ b/autosave/mixins.py @@ -1,32 +1,43 @@ -from __future__ import absolute_import +from __future__ import absolute_import, unicode_literals import time import json import functools import textwrap from datetime import datetime -from django.utils.six.moves.urllib.parse import urlparse +from urllib.parse import urlparse from django import forms from django.contrib import messages from django.contrib.admin.models import LogEntry, ADDITION -import six from django.contrib.admin.utils import unquote from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from django.conf.urls import url from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.db.models.fields import FieldDoesNotExist from django.forms.utils import ErrorDict from django.http import HttpResponse, Http404 from django.utils.encoding import force_text from django.utils.html import escape +from django.utils import timezone from django.utils.safestring import mark_safe -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ +from django.utils.functional import cached_property + +from . import __version__ class AdminAutoSaveMixin(object): autosave_last_modified_field = None + @cached_property + def app_model_label(self): + opts = self.model._meta + app = opts.app_label + mod = getattr(opts, 'model_name', None) or getattr(opts, 'module_name', None) + return f'{app}_{mod}' + def get_form(self, request, obj=None, **kwargs): """ This is a filthy hack that allows us to return the posted @@ -42,66 +53,63 @@ def full_clean(self): kwargs['form'] = IllegalForm + refresh_action = 'view the original' if obj else 'clear the form' messages.info(request, mark_safe(( 'Successfully loaded from your latest autosave. ' - 'Click here to %(refresh_action)s. ' + f'Click here to {refresh_action}. ' '[discard autosave]' - ) % { - 'refresh_action': 'view the original' if obj else 'clear the form', - })) + ))) return super(AdminAutoSaveMixin, self).get_form(request, obj, **kwargs) def autosave_js(self, request, object_id, extra_context=None): - opts = self.model._meta - info = (opts.app_label, getattr(opts, 'model_name', None) or getattr(opts, 'module_name', None)) - try: object_id = int(unquote(object_id)) except ValueError: - return HttpResponse(u"", status=404, content_type='application/x-javascript') + return HttpResponse(status=404, content_type='application/x-javascript') obj = None updated = None # Raise exception if the admin doesn't have a 'autosave_last_modified_field' property if not self.autosave_last_modified_field: - raise ImproperlyConfigured(( - u"Autosave is not configured correctly. %(cls_name)s " - u"is missing property 'autosave_last_modified_field', which " - u"should be set to the model's last updated datetime field.") % { - 'cls_name': ".".join([self.__module__, self.__class__.__name__]), - }) + cls_name = f"{self.__module__}.{self.__class__.__name__}" + raise ImproperlyConfigured( + f"Autosave is not configured correctly. {cls_name} " + "is missing property 'autosave_last_modified_field', which " + "should be set to the model's last updated datetime field.") # Raise exception if self.autosave_last_modified_field is not set try: - opts.get_field(self.autosave_last_modified_field) + self.model._meta.get_field(self.autosave_last_modified_field) except FieldDoesNotExist: raise + prefix = self.app_model_label if not object_id: - autosave_url = reverse("admin:%s_%s_add" % info) + autosave_url = reverse(f"admin:{prefix}_add") add_log_entries = LogEntry.objects.filter( - user=request.user, - content_type=ContentType.objects.get_for_model(self.model), - action_flag=ADDITION) + user=request.user, + content_type=ContentType.objects.get_for_model(self.model), + action_flag=ADDITION) try: updated = add_log_entries[0].action_time except IndexError: pass else: - autosave_url = reverse("admin:%s_%s_change" % info, args=[str(object_id)]) + autosave_url = reverse(f"admin:{prefix}_change", args=[str(object_id)]) try: obj = self.get_object(request, object_id) except (ValueError, self.model.DoesNotExist): - raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % { - 'name': force_text(opts.verbose_name), - 'key': escape(object_id), - }) + name = force_text(opts.verbose_name) + key = escape(object_id) + raise Http404(_(f'{name} object with primary key {key} does not exist.')) else: updated = getattr(obj, self.autosave_last_modified_field, None) # Make sure date modified time doesn't predate Unix-time. if updated: + if timezone.is_aware(updated): + updated = timezone.make_naive(updated) # I'm pretty confident they didn't do any Django autosaving in 1969. updated = max(updated, datetime(year=1970, month=1, day=1)) @@ -133,13 +141,6 @@ def autosave_js(self, request, object_id, extra_context=None): def get_urls(self): """Adds a last-modified checker to the admin urls.""" - try: - from django.conf.urls.defaults import url - except ImportError: - from django.conf.urls import url - - opts = self.model._meta - info = (opts.app_label, getattr(opts, 'model_name', None) or getattr(opts, 'module_name', None)) # Use admin_site.admin_view to add permission checking def wrap(view): @@ -149,10 +150,14 @@ def wrapper(*args, **kwargs): # This has to be \w because if it's not, parameters following the obj_id will be # caught up in the regular change_view url pattern, and 500. + prefix = self.app_model_label + opts = self.model._meta + info = (opts.app_label, getattr(opts, 'model_name', None) or getattr(opts, 'module_name', None)) + name = "%s_%s_autosave_js" % info return [ url(r'^(.+)/autosave_variables\.js', wrap(self.autosave_js), - name="%s_%s_autosave_js" % info), + name=name), ] + super(AdminAutoSaveMixin, self).get_urls() def autosave_media(self, obj=None, get_params=''): @@ -162,14 +167,11 @@ def autosave_media(self, obj=None, get_params=''): This can be appended to the media in add_view and change_view, and enables us to pull autosave information specific to a given object. """ - opts = self.model._meta - info = (opts.app_label, getattr(opts, 'model_name', None) or getattr(opts, 'module_name', None)) - + prefix = self.app_model_label pk = getattr(obj, 'pk', None) or 0 - return forms.Media(js=( - reverse('admin:%s_%s_autosave_js' % info, args=[pk]) + get_params, - "autosave/js/autosave.js?v=3", + reverse(f'admin:{prefix}_autosave_js', args=[pk]) + get_params, + f'autosave/js/autosave.js?v={__version__}', )) def set_autosave_flag(self, request, response): @@ -194,12 +196,12 @@ def response_change(self, request, obj): def render_change_form(self, request, context, add=False, obj=None, **kwargs): if 'media' in context: - get_params = u'' + get_params = '' if 'is_retrieved_from_autosave' in request.POST: - get_params = u'?is_recovered=1' + get_params = '?is_recovered=1' autosave_media = self.autosave_media(obj, get_params=get_params) - if isinstance(context['media'], six.string_types): - autosave_media = six.text_type(autosave_media) + if isinstance(context['media'], str): + autosave_media = str(autosave_media) context['media'] += autosave_media return super(AdminAutoSaveMixin, self).render_change_form( request, context, add=add, obj=obj, **kwargs) diff --git a/setup.cfg b/setup.cfg index ee6453f..864ad84 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,13 @@ -[bumpversion] -current_version = 1.0.0 -commit = True -tag = True +[bdist_wheel] +universal = 1 -[bumpversion:file:setup.py] +[flake8] +max-line-length = 100 +ignore = E722, E128, E126 +[tool:pytest] +python_files = tests.py test_*.py *_test.py +DJANGO_SETTINGS_MODULE = tests.settings +addopts = --tb=short --create-db +django_find_project = false +testpaths = tests diff --git a/setup.py b/setup.py index 8f8eeac..3a5e06f 100644 --- a/setup.py +++ b/setup.py @@ -3,18 +3,20 @@ from __future__ import absolute_import from setuptools import setup, find_packages +import autosave + setup( name="Django Autosave", - version="1.0.0", - author='Jason Goldstein', - author_email='jason@betheshoe.com', + version='2.0.0', + author='The Atlantic', + author_email='programmers@theatlantic.com', url='https://github.com/theatlantic/django-autosave', packages=['autosave'], description='Generic autosave for the Django Admin.', long_description=open('README.md').read(), long_description_content_type='text/markdown', - install_requires=['Django>=1.11'], - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4', + install_requires=['Django>=2.0'], + python_requires='>=3.7,<4', classifiers=[ 'Development Status :: 5 - Production', 'License :: OSI Approved :: BSD License', @@ -23,14 +25,11 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Framework :: Django', - 'Framework :: Django :: 1.11', 'Framework :: Django :: 2.0', 'Framework :: Django :: 2.1', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', + 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.0', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', ], include_package_data=True, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..ea14b3f --- /dev/null +++ b/tests/models.py @@ -0,0 +1,21 @@ +from django.db import models +from django.contrib import admin +from django.conf.urls import url + +from autosave.mixins import AdminAutoSaveMixin + + +class MyModel(models.Model): + name = models.CharField(max_length=50) + date_modified = models.DateTimeField(auto_now=True) + + +@admin.register(MyModel) +class MyAdmin(AdminAutoSaveMixin, admin.ModelAdmin): + autosave_last_modified_field = 'date_modified' + + +admin.autodiscover() +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..14012d0 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,58 @@ +from pathlib import Path +import sys + +BASE_DIR = Path(__file__).parents[1] + +ALLOWED_HOSTS = [] +AUTH_PASSWORD_VALIDATORS = [] +DEBUG = True +LANGUAGE_CODE = 'en-us' +MEDIA_URL = '/media/' +ROOT_URLCONF = 'tests.models' +SECRET_KEY = 'supersecretkey' +STATIC_URL = '/static/' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_L10N = True +USE_TZ = False + +DATABASES = {'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' if 'pytest' in sys.argv else 'db.sqlite3', +}} + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'autosave', + 'tests', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +TEMPLATES = [{ + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, +}] diff --git a/tests/test_autosave.py b/tests/test_autosave.py new file mode 100644 index 0000000..d546cf4 --- /dev/null +++ b/tests/test_autosave.py @@ -0,0 +1,17 @@ +import pytest +from .models import MyModel + + +@pytest.mark.django_db +def test_smoke(admin_client): + my = MyModel.objects.create(name='name') + rsp = admin_client.get(f'/admin/tests/mymodel/{my.id}/change/') + assert rsp.status_code == 200 + + html = rsp.content.decode() + assert f'src="/admin/tests/mymodel/{my.id}/autosave_variables.js' in html + assert f'src="/static/autosave/js/autosave.js' in html + + rsp = admin_client.get(f'/admin/tests/mymodel/{my.id}/autosave_variables.js') + assert rsp.status_code == 200 + assert b'var DjangoAutosave' in rsp.content diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..b6fa85a --- /dev/null +++ b/tox.ini @@ -0,0 +1,23 @@ +[tox] +isolated_build = true +skip_missing_interpreters = true +envlist = + py37-django{20,21,22,30} + +[testenv] +skip_install = true +setenv = + PYTHONPATH={toxinidir} +commands = + pytest {posargs} + +passenv = + CHROME_HEADLESS + +deps = + pytest + pytest-django + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<3.0 + django30: Django>=3.0,<3.1