diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..acc93a0 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + env: { + browser: true, + es2021: true + }, + extends: [ + 'standard' + ], + parserOptions: { + ecmaVersion: 'latest' + }, + rules: { + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b62a147..8466fcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,24 +9,16 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [ 3.5, 3.6, 3.7 ] + python: [ "3.6", "3.7", "3.8", "3.9", "3.10" ] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python }} - - name: Install dependencies - run: pip install tox tox-gh-actions - - name: Run tests - run: tox - - name: Coverage - if: ${{ matrix.python == 3.7 }} - run: | - pip install coverage[toml] django==2.0 - coverage run manage.py test --settings=test_settings - - name: Upload coverage - if: ${{ matrix.python == 3.7 }} - uses: codecov/codecov-action@v1 - with: - name: Python ${{ matrix.python }} + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh-actions + - name: Test with tox + run: tox diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..dfc86c3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,17 @@ +name: lint + +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - uses: pre-commit/action@v3.0.0 diff --git a/.gitignore b/.gitignore index 60ae115..546d446 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,11 @@ dist/ env/ demo/__pycache__/ +demo/build/ *.sqlite3 +.direnv/ +node_modules/ +.envrc +package.json +package-lock.json +.tox/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d2b46ee --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,91 @@ +default_language_version: + python: python3.10 +repos: + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: check-ast + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-private-key + - id: check-added-large-files + - id: check-json + - id: check-symlinks + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: mixed-line-ending + args: [--fix=lf] + - repo: https://github.com/asottile/reorder_python_imports + rev: v3.0.1 + hooks: + - id: reorder-python-imports + args: + - --py3-plus + - --application-directories=.:src + exclude: migrations/ + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.9.0 + hooks: + - id: python-check-blanket-noqa + - id: python-check-mock-methods + - id: python-no-eval + - id: python-no-log-warn + - id: rst-backticks + - repo: https://github.com/asottile/pyupgrade + rev: v2.31.1 + hooks: + - id: pyupgrade + args: + - --py310-plus + exclude: migrations/ + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.4.0 + hooks: + - id: django-upgrade + args: + - --target-version=4.0 + - repo: https://github.com/asottile/yesqa + rev: v1.3.0 + hooks: + - id: yesqa + - repo: https://github.com/asottile/add-trailing-comma + rev: v2.2.2 + hooks: + - id: add-trailing-comma + args: + - --py36-plus + - repo: https://github.com/hadialqattan/pycln + rev: v1.2.5 + hooks: + - id: pycln + - repo: https://github.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + exclude: | + (?x)^( + .*/migrations/.* + )$ + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-tidy-imports + - flake8-print + args: [--max-line-length=120] + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.25.0 + hooks: + - id: eslint + args: [--fix] + additional_dependencies: + - "eslint@8.25.0" + - "eslint-plugin-prettier@4.2.1" + - "eslint-config-standard@17.0.0" + - "eslint-plugin-import@2.26.0" + - "eslint-plugin-n@15.3.0" + - "eslint-plugin-promise@6.1.0" diff --git a/LICENCE b/LICENCE index 8d53d77..6c3c8df 100644 --- a/LICENCE +++ b/LICENCE @@ -28,4 +28,4 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.rst b/README.rst index 44b9cea..a5bce60 100644 --- a/README.rst +++ b/README.rst @@ -6,7 +6,7 @@ To run the demo project for testing:: $ git clone git://github.com/jojax/django-js-error-hook.git $ cd django-js-error-hook - $ virtualenv env --python=python3 + $ python -m venv env $ source env/bin/activate (env) $ pip install -e . (env) $ pip install -e demo @@ -92,7 +92,10 @@ Then install the urls:: In your template, simply add the js_error_hook script:: - + + Now every JavaScript error will be logged in your logging error stream. (Mail, Sentry, ...) @@ -106,4 +109,3 @@ against various versions of Python and Django: pip install tox tox - diff --git a/demo/demoproject/manage.py b/demo/demoproject/manage.py index ae1ab0e..fb4635b 100755 --- a/demo/demoproject/manage.py +++ b/demo/demoproject/manage.py @@ -11,7 +11,7 @@ def main(): raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" + "forget to activate a virtual environment?", ) from exc execute_from_command_line(sys.argv) diff --git a/demo/demoproject/settings.py b/demo/demoproject/settings.py index 911862e..94711f7 100644 --- a/demo/demoproject/settings.py +++ b/demo/demoproject/settings.py @@ -1,6 +1,8 @@ # Django settings for demo project. -from os.path import abspath, dirname, join -demoproject_dir = dirname(abspath(__file__)) +from os.path import dirname +from os.path import join + +demoproject_dir = dirname(__file__) DEBUG = True @@ -11,21 +13,21 @@ MANAGERS = ADMINS DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'test.sqlite3', - } + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "test.sqlite3", + }, } # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. # In a Windows environment this must be set to your system time zone. -TIME_ZONE = 'America/Chicago' +TIME_ZONE = "America/Chicago" # Language code for this installation. All choices can be found here: # http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" SITE_ID = 1 @@ -42,22 +44,22 @@ # Absolute filesystem path to the directory that will hold user-uploaded files. # Example: "/home/media/media.lawrence.com/media/" -MEDIA_ROOT = '' +MEDIA_ROOT = "" # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash. # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" -MEDIA_URL = '' +MEDIA_URL = "" # Absolute path to the directory static files should be collected to. # Don't put anything in this directory yourself; store your static files # in apps' "static/" subdirectories and in STATICFILES_DIRS. # Example: "/home/media/media.lawrence.com/static/" -STATIC_ROOT = '' +STATIC_ROOT = "" # URL prefix for static files. # Example: "http://media.lawrence.com/static/" -STATIC_URL = '/static/' +STATIC_URL = "/static/" # Additional locations of static files STATICFILES_DIRS = ( @@ -69,46 +71,44 @@ # List of finder classes that know how to find static files in # various locations. STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ) # Make this unique, and don't share it with anybody. -SECRET_KEY = 'k9i055*z@6@9$7xvyw(8y4sk_w0@1ltf2$y-^zu^&wnlt1oez5' +SECRET_KEY = "k9i055*z@6@9$7xvyw(8y4sk_w0@1ltf2$y-^zu^&wnlt1oez5" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'APP_DIRS': True, - 'DIRS': ( - join(demoproject_dir, 'templates'), - ) + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "DIRS": (join(demoproject_dir, "templates"),), }, ] MIDDLEWARE = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", # Uncomment the next line for simple clickjacking protection: # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) -ROOT_URLCONF = 'demoproject.urls' +ROOT_URLCONF = "demoproject.urls" # Python dotted path to the WSGI application used by Django's runserver. -WSGI_APPLICATION = 'demoproject.wsgi.application' +WSGI_APPLICATION = "demoproject.wsgi.application" INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django_js_error_hook', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_js_error_hook", # Uncomment the next line to enable the admin: # 'django.contrib.admin', # Uncomment the next line to enable admin documentation: @@ -123,43 +123,37 @@ # See http://docs.djangoproject.com/en/dev/topics/logging for # more details on how to customize your logging configuration. LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' - } - }, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' - }, - 'simple': { - 'format': '\033[22;32m%(levelname)s\033[0;0m %(message)s' + "version": 1, + "disable_existing_loggers": False, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s", }, + "simple": {"format": "\033[22;32m%(levelname)s\033[0;0m %(message)s"}, }, - 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' + "handlers": { + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "simple", }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'simple' - }, }, - 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, + "loggers": { + "django.request": { + "handlers": ["mail_admins"], + "level": "ERROR", + "propagate": True, }, - 'javascript_error': { - 'handlers': ['mail_admins', 'console'], - 'level': 'ERROR', - 'propagate': True, + "javascript_error": { + "handlers": ["mail_admins", "console"], + "level": "ERROR", + "propagate": True, }, - } + }, } diff --git a/demo/demoproject/tests/__init__.py b/demo/demoproject/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/demoproject/tests/helpers.py b/demo/demoproject/tests/helpers.py new file mode 100644 index 0000000..5384976 --- /dev/null +++ b/demo/demoproject/tests/helpers.py @@ -0,0 +1,75 @@ +import logging +import os + +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.core.servers.basehttp import WSGIRequestHandler +from django.core.servers.basehttp import WSGIServer +from django.test.testcases import LiveServerThread +from selenium import webdriver +from selenium.webdriver.chrome.service import Service as ChromiumService +from webdriver_manager.chrome import ChromeDriverManager +from webdriver_manager.core.utils import ChromeType + + +logger = logging.getLogger(__name__) + + +class VerboseLiveServerTestCase(StaticLiveServerTestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @classmethod + def setUpClass(cls): + options = webdriver.ChromeOptions() + if os.getenv("CI"): + # Github Actions + options.binary_location = "/usr/bin/google-chrome-stable" + options.add_argument("--headless") + options.add_argument("--window-size=1920,1080") + options.add_argument("--disable-gpu") + cls.wait_time = 20 + else: + # Ubuntu Desktop, etc. + cls.wait_time = 6 + cls.driver = webdriver.Chrome( + service=ChromiumService( + ChromeDriverManager(chrome_type=ChromeType.GOOGLE).install(), + ), + options=options, + ) + cls.driver.implicitly_wait(cls.wait_time) + super().setUpClass() + + def tearDown(self): + # Source: https://stackoverflow.com/a/39606065 + if hasattr(self._outcome, "errors"): + # Python 3.4 - 3.10 (These two methods have no side effects) + result = self.defaultTestResult() + self._feedErrorsToResult(result, self._outcome.errors) + else: + # Python 3.11+ + result = self._outcome.result + ok = all(test != self for test, text in result.errors + result.failures) + if not ok: + if not os.path.exists("screenshots"): + os.makedirs("screenshots") + screenshotfile = f"screenshots/{self._testMethodName}.png" + logger.info(f"Saving {screenshotfile}") + self.driver.save_screenshot(screenshotfile) + return super().tearDown() + + @classmethod + def tearDownClass(cls): + cls.driver.quit() + super().tearDownClass() + + class VersboseLiveServerThread(LiveServerThread): + def _create_server(self): + WSGIRequestHandler.handle = WSGIRequestHandler.handle_one_request + return WSGIServer( + (self.host, self.port), + WSGIRequestHandler, + allow_reuse_address=False, + ) + + server_thread_class = VersboseLiveServerThread diff --git a/demo/demoproject/tests/test_backend.py b/demo/demoproject/tests/test_backend.py new file mode 100644 index 0000000..f5ae9bc --- /dev/null +++ b/demo/demoproject/tests/test_backend.py @@ -0,0 +1,20 @@ +from django.test import TestCase +from django.urls import reverse + + +class BackendTestCase(TestCase): + """Test project views.""" + + def test_error_handler_view(self): + """A POST should log the error""" + response = self.client.post( + reverse("js-error-handler"), + { + "context": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36" + ), + "details": "Description of the error by the browser javascript engine.", + }, + ) + self.assertEqual(response.status_code, 200) diff --git a/demo/demoproject/tests/test_frontend.py b/demo/demoproject/tests/test_frontend.py new file mode 100644 index 0000000..f844bbc --- /dev/null +++ b/demo/demoproject/tests/test_frontend.py @@ -0,0 +1,10 @@ +import time + +from .helpers import VerboseLiveServerTestCase + + +class InteractionTest(VerboseLiveServerTestCase): + def test_error_in_browser(self): + self.driver.maximize_window() + self.driver.get(self.live_server_url + "/") + time.sleep(1000) diff --git a/demo/demoproject/urls.py b/demo/demoproject/urls.py index d4e445e..84282a3 100644 --- a/demo/demoproject/urls.py +++ b/demo/demoproject/urls.py @@ -1,7 +1,8 @@ -from django.conf.urls import include, url +from django.urls import include +from django.urls import path from django.views.generic import TemplateView urlpatterns = [ - url('^$', TemplateView.as_view(template_name="error_test.html")), - url('^error_hook/', include('django_js_error_hook.urls')), + path("", TemplateView.as_view(template_name="error_test.html")), + path("error_hook/", include("django_js_error_hook.urls")), ] diff --git a/demo/demoproject/wsgi.py b/demo/demoproject/wsgi.py index 9c101ed..3cc6f83 100644 --- a/demo/demoproject/wsgi.py +++ b/demo/demoproject/wsgi.py @@ -14,6 +14,7 @@ """ import os + from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") diff --git a/demo/setup.py b/demo/setup.py index 41ad75f..ec2db97 100644 --- a/demo/setup.py +++ b/demo/setup.py @@ -1,4 +1,5 @@ import os + from setuptools import setup @@ -9,33 +10,36 @@ def read_relative_file(filename): return f.read() -NAME = 'django-js-error-hook-demo' -README = read_relative_file('README') +NAME = "django-js-error-hook-demo" +README = read_relative_file("README") VERSION = 0.1 -PACKAGES = ['demoproject'] -REQUIRES = ['django-js-error-hook'] +PACKAGES = ["demoproject", "demoproject.tests"] +REQUIRES = ["django-js-error-hook"] -setup(name=NAME, - version=VERSION, - description='Demo project for django-js-error-hook.', - long_description=README, - classifiers=['Development Status :: 1 - Planning', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 2.6', - 'Framework :: Django'], - keywords='class-based view, generic view, js error hooking', - author='Jonathan Dorival', - author_email='jonathan.dorival@novapost.fr', - url='https://github.com/jojax/%s' % NAME, - license='BSD', - packages=PACKAGES, - include_package_data=True, - zip_safe=False, - install_requires=REQUIRES, - entry_points={ - 'console_scripts': [ - 'demo = demoproject.manage:main', - ] - }) +setup( + name=NAME, + version=VERSION, + description="Demo project for django-js-error-hook.", + long_description=README, + classifiers=[ + "Development Status :: 1 - Planning", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Framework :: Django", + ], + keywords="class-based view, generic view, js error hooking", + author="Jonathan Dorival", + author_email="jonathan.dorival@novapost.fr", + url="https://github.com/jojax/%s" % NAME, + license="BSD", + packages=PACKAGES, + include_package_data=True, + zip_safe=False, + install_requires=REQUIRES, + entry_points={ + "console_scripts": [ + "demo = demoproject.manage:main", + ], + }, +) diff --git a/demo/test_requirements.txt b/demo/test_requirements.txt new file mode 100644 index 0000000..d3280eb --- /dev/null +++ b/demo/test_requirements.txt @@ -0,0 +1,2 @@ +selenium +webdriver-manager diff --git a/django_js_error_hook/static/django_js_error_hook/utils.js b/django_js_error_hook/static/django_js_error_hook/utils.js new file mode 100644 index 0000000..1c55f9d --- /dev/null +++ b/django_js_error_hook/static/django_js_error_hook/utils.js @@ -0,0 +1,60 @@ +(function () { + function getCookie (name) { + const nameEQ = name + '=' + const ca = document.cookie.split(';') + for (let i = 0; i < ca.length; i++) { + let c = ca[i] + while (c.charAt(0) === ' ') c = c.substring(1, c.length) + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) + } + return null + } + + function logError (details) { + const xhr = new XMLHttpRequest() + + xhr.open('POST', window.djangoJSErrorHandlerUrl, true) + xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded') + const cookie = getCookie('csrftoken') + if (cookie) { + xhr.setRequestHeader('X-CSRFToken', cookie) + } + const query = [] + const data = { + context: navigator.userAgent, + details + } + for (const key in data) { + query.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key])) + } + xhr.send(query.join('&')) + } + + window.onerror = function (msg, url, lineNumber, columnNumber, errorObj) { + let logMessage = url + ': ' + lineNumber + ': ' + msg + if (columnNumber) { + logMessage += ', ' + columnNumber + } + if (errorObj && errorObj.stack) { + logMessage += ', ' + errorObj.stack + } + logError(logMessage) + } + + if (window.addEventListener) { + window.addEventListener('unhandledrejection', function (rejection) { + let logMessage = rejection.type + if (rejection.reason) { + if (rejection.reason.message) { + logMessage += ', ' + rejection.reason.message + } else { + logMessage += ', ' + JSON.stringify(rejection.reason) + } + if (rejection.reason.stack) { + logMessage += ', ' + rejection.reason.stack + } + } + logError(logMessage) + }) + } +})() diff --git a/django_js_error_hook/templates/django_js_error_hook/utils.js b/django_js_error_hook/templates/django_js_error_hook/utils.js deleted file mode 100644 index eca72ed..0000000 --- a/django_js_error_hook/templates/django_js_error_hook/utils.js +++ /dev/null @@ -1,58 +0,0 @@ -(function() { - function getCookie(name) { - var nameEQ = name + "="; - var ca = document.cookie.split(';'); - for(var i=0;i < ca.length;i++) { - var c = ca[i]; - while (c.charAt(0)==' ') c = c.substring(1,c.length); - if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length); - } - return null; - } - function logError(details) { - var xhr = new XMLHttpRequest(); - - xhr.open("POST", "{% url 'js-error-handler' %}", true); - xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - var cookie = getCookie('csrftoken'); - if (cookie) { - xhr.setRequestHeader("X-CSRFToken", cookie); - } - var query = [], data = { - context: navigator.userAgent, - details: details - }; - for (var key in data) { - query.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key])); - } - xhr.send(query.join('&')); - } - - window.onerror = function(msg, url, line_number, column_number, error_obj) { - var log_message = url + ': ' + line_number + ': ' + msg; - if (column_number) { - log_message += ", " + column_number; - } - if (error_obj && error_obj.stack) { - log_message += ", " + error_obj.stack; - } - logError(log_message); - }; - - if (window.addEventListener) { - window.addEventListener('unhandledrejection', function(rejection) { - var log_message = rejection.type; - if (rejection.reason) { - if (rejection.reason.message) { - log_message += ", " + rejection.reason.message; - } else { - log_message += ", " + JSON.stringify(rejection.reason); - } - if (rejection.reason.stack) { - log_message += ", " + rejection.reason.stack; - } - } - logError(log_message); - }) - } -})(); diff --git a/django_js_error_hook/tests.py b/django_js_error_hook/tests.py deleted file mode 100644 index cda5e89..0000000 --- a/django_js_error_hook/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -import unittest -from django.test import TestCase -from django.urls import reverse - -class JSErrorHookTestCase(TestCase): - """Test project views.""" - - @unittest.skip('Noticed test fails as part of the github-actions PR - this needs fixing in a separate ticket.') - def test_error_handler_view(self): - """A POST should log the error""" - response = self.client.post(reverse('js-error-handler'), {"details": "Description of the error by the browser javascript engine."}) - self.assertEqual(response.status_code, 200) - - def test_error_js_utils_view(self): - response = self.client.get(reverse('js-error-handler-js')) - self.assertEqual(response.status_code, 200) diff --git a/django_js_error_hook/urls.py b/django_js_error_hook/urls.py index a336fe9..ca8ca40 100644 --- a/django_js_error_hook/urls.py +++ b/django_js_error_hook/urls.py @@ -1,8 +1,8 @@ from django.urls import re_path -from .views import js_error_view, utils_js + +from .views import js_error_view urlpatterns = [ re_path("^$", js_error_view, name="js-error-handler"), - re_path("^utils.js$", utils_js, name="js-error-handler-js"), ] diff --git a/django_js_error_hook/views.py b/django_js_error_hook/views.py index c41619c..2cb7d8e 100644 --- a/django_js_error_hook/views.py +++ b/django_js_error_hook/views.py @@ -1,17 +1,18 @@ -from distutils.version import StrictVersion +import logging -from django import get_version from django.conf import settings from django.http import HttpResponse -from django.views.decorators.cache import cache_page from django.views.decorators.csrf import csrf_exempt -from django.views.generic import TemplateView, View -import logging +from django.views.generic import View -ERROR_ID = getattr(settings, 'JAVASCRIPT_ERROR_ID', 'javascript_error') -CSRF_EXEMPT = getattr(settings, 'JAVASCRIPT_ERROR_CSRF_EXEMPT', False) -BLACKLIST_USERAGENT = getattr(settings, 'JAVASCRIPT_ERROR_USERAGENT_BLACKLIST', ['googlebot', 'bingbot']) -BLACKLIST_ERRORS = getattr(settings, 'JAVASCRIPT_ERROR_BLACKLIST', []) +ERROR_ID = getattr(settings, "JAVASCRIPT_ERROR_ID", "javascript_error") +CSRF_EXEMPT = getattr(settings, "JAVASCRIPT_ERROR_CSRF_EXEMPT", False) +BLACKLIST_USERAGENT = getattr( + settings, + "JAVASCRIPT_ERROR_USERAGENT_BLACKLIST", + ["googlebot", "bingbot"], +) +BLACKLIST_ERRORS = getattr(settings, "JAVASCRIPT_ERROR_BLACKLIST", []) logger = logging.getLogger(ERROR_ID) @@ -22,48 +23,28 @@ class JSErrorHandlerView(View): def post(self, request): """Read POST data and log it as an JS error""" error_dict = request.POST.dict() - if hasattr(request, 'user'): - error_dict['user'] = request.user if request.user.is_authenticated else "" + if hasattr(request, "user"): + error_dict["user"] = ( + request.user if request.user.is_authenticated else "" + ) else: - error_dict['user'] = "" - + error_dict["user"] = "" + level = logging.ERROR - if any(useragent in error_dict['context'].lower() for useragent in BLACKLIST_USERAGENT) or \ - any(error in error_dict['details'].lower() for error in BLACKLIST_ERRORS): + if any( + useragent in error_dict["context"].lower() + for useragent in BLACKLIST_USERAGENT + ) or any(error in error_dict["details"].lower() for error in BLACKLIST_ERRORS): level = logging.WARNING logger.log( level, - "Got error: \n%s", '\n'.join("\t%s: %s" % (key, value) for key, value in error_dict.items()), - extra={ - 'status_code': 500, - 'request': request - } + "Got error: \n%s", + "\n".join(f"\t{key}: {value}" for key, value in error_dict.items()), + extra={"status_code": 500, "request": request}, ) - return HttpResponse('Error logged') - - -class MimetypeTemplateView(TemplateView): - """TemplateView with mimetype override""" - template_name = "django_js_error_hook/utils.js" - mimetype = "text/javascript" - - def render_to_response(self, context, **response_kwargs): - """ - Before django 1.5 : 'mimetype' - From django 1.5 : 'content_type' - - Add the parameter to return the right mimetype - """ - if StrictVersion(get_version()) < StrictVersion('1.5'): - mimetype_parameter = 'mimetype' - else: - mimetype_parameter = 'content_type' - - response_kwargs[mimetype_parameter] = self.mimetype - return super(MimetypeTemplateView, self).render_to_response(context, **response_kwargs) + return HttpResponse("Error logged") -utils_js = cache_page(2 * 31 * 24 * 60 * 60)(MimetypeTemplateView.as_view()) #: Cache 2 months if CSRF_EXEMPT: js_error_view = csrf_exempt(JSErrorHandlerView.as_view()) diff --git a/manage.py b/manage.py index 9a7d44a..171bd6a 100644 --- a/manage.py +++ b/manage.py @@ -10,6 +10,6 @@ raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" + "forget to activate a virtual environment?", ) from exc execute_from_command_line(sys.argv) diff --git a/setup.py b/setup.py index 4c38152..c2729f3 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ -# coding=utf-8 """Python packaging.""" import os + from setuptools import setup @@ -11,30 +11,31 @@ def read_relative_file(filename): return f.read() -NAME = 'django-js-error-hook' -README = read_relative_file('README.rst') -VERSION = read_relative_file('VERSION').strip() -PACKAGES = ['django_js_error_hook'] -REQUIRES = ['django>=1.10'] +NAME = "django-js-error-hook" +README = read_relative_file("README.rst") +VERSION = read_relative_file("VERSION").strip() +PACKAGES = ["django_js_error_hook"] +REQUIRES = ["django>=3.2.0"] -setup(name=NAME, - version=VERSION, - description='Generic handler for hooking client side javascript error.', - long_description=README, - classifiers=['Development Status :: 1 - Planning', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 2.7', - 'Framework :: Django', - ], - keywords='class-based view, generic view, js error hooking', - author='Jonathan Dorival', - author_email='jonathan.dorival@novapost.fr', - url='https://github.com/jojax/%s' % NAME, - license='BSD', - packages=PACKAGES, - include_package_data=True, - zip_safe=False, - install_requires=REQUIRES, - ) +setup( + name=NAME, + version=VERSION, + description="Generic handler for hooking client side javascript error.", + long_description=README, + classifiers=[ + "Development Status :: 1 - Planning", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Framework :: Django", + ], + keywords="class-based view, generic view, js error hooking", + author="Jonathan Dorival", + author_email="jonathan.dorival@novapost.fr", + url="https://github.com/jojax/%s" % NAME, + license="BSD", + packages=PACKAGES, + include_package_data=True, + zip_safe=False, + install_requires=REQUIRES, +) diff --git a/test_settings.py b/test_settings.py index 84fe59e..25a1372 100644 --- a/test_settings.py +++ b/test_settings.py @@ -1,6 +1,7 @@ # Django settings for demoproject project. -from os.path import abspath, dirname, join -demoproject_dir = dirname(abspath(__file__)) +from os.path import dirname + +demoproject_dir = dirname(__file__) DEBUG = True @@ -11,25 +12,25 @@ MANAGERS = ADMINS DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. - 'NAME': 'test.sqlite3', # Or path to database file if using sqlite3. - 'USER': '', # Not used with sqlite3. - 'PASSWORD': '', # Not used with sqlite3. - 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. - 'PORT': '', # Set to empty string for default. Not used with sqlite3. - } + "default": { + "ENGINE": "django.db.backends.sqlite3", # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. + "NAME": "test.sqlite3", # Or path to database file if using sqlite3. + "USER": "", # Not used with sqlite3. + "PASSWORD": "", # Not used with sqlite3. + "HOST": "", # Set to empty string for localhost. Not used with sqlite3. + "PORT": "", # Set to empty string for default. Not used with sqlite3. + }, } # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. # In a Windows environment this must be set to your system time zone. -TIME_ZONE = 'America/Chicago' +TIME_ZONE = "America/Chicago" # Language code for this installation. All choices can be found here: # http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" SITE_ID = 1 @@ -46,22 +47,22 @@ # Absolute filesystem path to the directory that will hold user-uploaded files. # Example: "/home/media/media.lawrence.com/media/" -MEDIA_ROOT = '' +MEDIA_ROOT = "" # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash. # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" -MEDIA_URL = '' +MEDIA_URL = "" # Absolute path to the directory static files should be collected to. # Don't put anything in this directory yourself; store your static files # in apps' "static/" subdirectories and in STATICFILES_DIRS. # Example: "/home/media/media.lawrence.com/static/" -STATIC_ROOT = '' +STATIC_ROOT = "" # URL prefix for static files. # Example: "http://media.lawrence.com/static/" -STATIC_URL = '/static/' +STATIC_URL = "/static/" # Additional locations of static files STATICFILES_DIRS = ( @@ -73,44 +74,44 @@ # List of finder classes that know how to find static files in # various locations. STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -# 'django.contrib.staticfiles.finders.DefaultStorageFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + # 'django.contrib.staticfiles.finders.DefaultStorageFinder', ) # Make this unique, and don't share it with anybody. -SECRET_KEY = 'k9i055*z@6@9$7xvyw(8y4sk_w0@1ltf2$y-^zu^&wnlt1oez5' +SECRET_KEY = "k9i055*z@6@9$7xvyw(8y4sk_w0@1ltf2$y-^zu^&wnlt1oez5" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'APP_DIRS': True, + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, }, ] MIDDLEWARE = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", # Uncomment the next line for simple clickjacking protection: # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) -ROOT_URLCONF = 'django_js_error_hook.urls' +ROOT_URLCONF = "django_js_error_hook.urls" # Python dotted path to the WSGI application used by Django's runserver. -WSGI_APPLICATION = 'demoproject.wsgi.application' +WSGI_APPLICATION = "demoproject.wsgi.application" INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django_js_error_hook', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_js_error_hook", # Uncomment the next line to enable the admin: # 'django.contrib.admin', # Uncomment the next line to enable admin documentation: @@ -123,43 +124,37 @@ # See http://docs.djangoproject.com/en/dev/topics/logging for # more details on how to customize your logging configuration. LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' - } - }, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' - }, - 'simple': { - 'format': '\033[22;32m%(levelname)s\033[0;0m %(message)s' + "version": 1, + "disable_existing_loggers": False, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s", }, + "simple": {"format": "\033[22;32m%(levelname)s\033[0;0m %(message)s"}, }, - 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' + "handlers": { + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "simple", }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'simple' - }, }, - 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, + "loggers": { + "django.request": { + "handlers": ["mail_admins"], + "level": "ERROR", + "propagate": True, }, - 'javascript_error': { - 'handlers': ['mail_admins', 'console'], - 'level': 'ERROR', - 'propagate': True, + "javascript_error": { + "handlers": ["mail_admins", "console"], + "level": "ERROR", + "propagate": True, }, - } + }, } diff --git a/tox.ini b/tox.ini index 2fcb234..76cf7de 100644 --- a/tox.ini +++ b/tox.ini @@ -1,19 +1,21 @@ [tox] envlist = - py35-django110, - {py35,py36,py37}-django111, - {py35,py36,py37}-django20, + {py36,py37,py38,py39,py310}-django32, + {py38,py39,py310}-django40, + {py38,py39,py310}-django41, [testenv] deps= - django110: Django>=1.10,<1.11 - django111: Django>=1.11,<2.0 - django20: Django>=2.0,<2.1 + django32: Django>=3.2.0,<4.0.0 + django40: Django>=4.0.0,<4.1.0 + django41: Django>=4.1.0,<4.2.0 commands= python manage.py test --settings=test_settings [gh-actions] python = - 3.5: py35 3.6: py36 3.7: py37 + 3.8: py38 + 3.9: py39 + 3.10: py310