├── docs ├── _static │ └── .keep ├── _templates │ └── .keep ├── changelog.rst ├── images │ ├── logo.png │ └── favicon.ico ├── index.rst ├── reference │ ├── django_celery_monitor.utils.rst │ ├── django_celery_monitor.camera.rst │ ├── django_celery_monitor.models.rst │ ├── django_celery_monitor.humanize.rst │ ├── django_celery_monitor.managers.rst │ └── index.rst ├── conf.py ├── copyright.rst └── Makefile ├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── conftest.py │ └── test_camera.py └── proj │ ├── __init__.py │ ├── urls.py │ ├── celery.py │ ├── wsgi.py │ └── settings.py ├── requirements ├── test-ci.txt ├── default.txt ├── docs.txt ├── test.txt └── pkgutils.txt ├── django_celery_monitor ├── migrations │ ├── __init__.py │ ├── 0002_workerstate_last_update.py │ └── 0001_initial.py ├── static │ └── django_celery_monitor │ │ └── style.css ├── apps.py ├── templates │ └── django_celery_monitor │ │ └── confirm_rate_limit.html ├── __init__.py ├── humanize.py ├── utils.py ├── managers.py ├── camera.py ├── models.py └── admin.py ├── .coveragerc ├── .editorconfig ├── CONTRIBUTING.rst ├── manage.py ├── .bumpversion.cfg ├── .gitignore ├── setup.cfg ├── MANIFEST.in ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── tox.ini ├── CHANGELOG.rst ├── CODE_OF_CONDUCT.md ├── LICENSE ├── AUTHORS ├── Makefile ├── setup.py └── README.rst /docs/_static/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_templates/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/test-ci.txt: -------------------------------------------------------------------------------- 1 | pytest-cov 2 | -------------------------------------------------------------------------------- /django_celery_monitor/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/default.txt: -------------------------------------------------------------------------------- 1 | celery>=4.0,<5.0 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | sphinx_celery>=1.1 2 | Django>=1.10,<2.0 3 | -------------------------------------------------------------------------------- /tests/proj/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app # noqa 2 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | case>=1.3.1 2 | pytest>=3.0 3 | pytest-django 4 | pytz 5 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-celery-monitor/master/docs/images/logo.png -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-celery-monitor/master/docs/images/favicon.ico -------------------------------------------------------------------------------- /django_celery_monitor/static/django_celery_monitor/style.css: -------------------------------------------------------------------------------- 1 | .form-row.field-traceback p { 2 | font-family: monospace; 3 | white-space: pre; 4 | } 5 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = 1 3 | cover_pylib = 0 4 | include = *django_celery_monitor/* 5 | omit = tests/* 6 | 7 | [report] 8 | omit = 9 | */python?.?/* 10 | */site-packages/* 11 | */pypy/* 12 | -------------------------------------------------------------------------------- /tests/proj/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.conf.urls import url 4 | from django.contrib import admin 5 | 6 | urlpatterns = [ 7 | url(r'^admin/', admin.site.urls), 8 | ] 9 | -------------------------------------------------------------------------------- /requirements/pkgutils.txt: -------------------------------------------------------------------------------- 1 | setuptools>=20.6.7 2 | wheel>=0.29.0 3 | flake8>=2.5.4 4 | flakeplus>=1.1 5 | tox>=2.3.1 6 | sphinx2rst>=1.0 7 | bumpversion==0.5.3 8 | pydocstyle==2.0.0 9 | docutils==0.14 10 | readme-renderer==17.2 11 | check-manifest==0.36 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [Makefile] 14 | indent_style = tab 15 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents 4 | ======== 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | 9 | copyright 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | reference/index 15 | 16 | .. toctree:: 17 | :maxdepth: 1 18 | 19 | changelog 20 | -------------------------------------------------------------------------------- /docs/reference/django_celery_monitor.utils.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | ``django_celery_monitor.utils`` 3 | ================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_celery_monitor.utils 8 | 9 | .. automodule:: django_celery_monitor.utils 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/django_celery_monitor.camera.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | ``django_celery_monitor.camera`` 3 | ================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_celery_monitor.camera 8 | 9 | .. automodule:: django_celery_monitor.camera 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/django_celery_monitor.models.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | ``django_celery_monitor.models`` 3 | =================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_celery_monitor.models 8 | 9 | .. automodule:: django_celery_monitor.models 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/django_celery_monitor.humanize.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | ``django_celery_monitor.humanize`` 3 | ===================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_celery_monitor.humanize 8 | 9 | .. automodule:: django_celery_monitor.humanize 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/reference/django_celery_monitor.managers.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | ``django_celery_monitor.managers`` 3 | ===================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_celery_monitor.managers 8 | 9 | .. automodule:: django_celery_monitor.managers 10 | :members: 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://jazzband.co/static/img/jazzband.svg 2 | :target: https://jazzband.co/ 3 | :alt: Jazzband 4 | 5 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 6 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import absolute_import, unicode_literals 3 | import os 4 | import sys 5 | 6 | if __name__ == '__main__': 7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.proj.settings') 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.1.2 3 | commit = True 4 | tag = True 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P[a-z]+)? 6 | serialize = 7 | {major}.{minor}.{patch}{releaselevel} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:django_celery_monitor/__init__.py] 11 | 12 | [bumpversion:file:README.rst] 13 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | .. _apiref: 2 | 3 | =============== 4 | API Reference 5 | =============== 6 | 7 | :Release: |version| 8 | :Date: |today| 9 | 10 | .. toctree:: 11 | :maxdepth: 1 12 | 13 | django_celery_monitor.camera 14 | django_celery_monitor.humanize 15 | django_celery_monitor.managers 16 | django_celery_monitor.models 17 | django_celery_monitor.utils 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *$py.class 4 | *~ 5 | .*.sw[pon] 6 | dist/ 7 | *.egg-info 8 | *.egg 9 | *.egg/ 10 | build/ 11 | .build/ 12 | _build/ 13 | pip-log.txt 14 | .directory 15 | erl_crash.dump 16 | *.db 17 | Documentation/ 18 | .tox/ 19 | .ropeproject/ 20 | .project 21 | .pydevproject 22 | .idea/ 23 | .coverage 24 | .ve* 25 | cover/ 26 | .vagrant/ 27 | *.sqlite3 28 | .cache/ 29 | htmlcov/ 30 | coverage.xml 31 | .tox/ 32 | -------------------------------------------------------------------------------- /tests/proj/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import os 4 | 5 | from celery import Celery 6 | 7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proj.settings') 8 | 9 | app = Celery('proj') 10 | 11 | # Using a string here means the worker doesn't have to serialize 12 | # the configuration object. 13 | app.config_from_object('django.conf:settings', namespace='CELERY') 14 | 15 | app.autodiscover_tasks() 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests/unit 3 | python_classes = test_* 4 | DJANGO_SETTINGS_MODULE=tests.proj.settings 5 | 6 | [flake8] 7 | # classes can be lowercase, arguments and variables can be uppercase 8 | # whenever it makes the code more readable. 9 | ignore = N806, N802, N801, N803 10 | 11 | [pep257] 12 | ignore = D102,D104,D203,D105,D213 13 | match-dir = [^migrations] 14 | 15 | [wheel] 16 | universal = 1 17 | 18 | [check-manifest] 19 | ignore = 20 | docs/_build* 21 | -------------------------------------------------------------------------------- /django_celery_monitor/apps.py: -------------------------------------------------------------------------------- 1 | """Application configuration.""" 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from django.apps import AppConfig 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | __all__ = ['CeleryMonitorConfig'] 8 | 9 | 10 | class CeleryMonitorConfig(AppConfig): 11 | """Default configuration for the django_celery_monitor app.""" 12 | 13 | name = 'django_celery_monitor' 14 | label = 'celery_monitor' 15 | verbose_name = _('Celery Monitor') 16 | -------------------------------------------------------------------------------- /tests/proj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for Test 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/1.9/howto/deployment/wsgi/ 8 | """ 9 | from __future__ import absolute_import, unicode_literals 10 | 11 | import os 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "proj.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include .bumpversion.cfg 2 | include .coveragerc 3 | include .editorconfig 4 | include AUTHORS 5 | include CHANGELOG.rst 6 | include LICENSE 7 | include README.rst 8 | include MANIFEST.in 9 | include Makefile 10 | include setup.cfg 11 | include setup.py 12 | include manage.py 13 | include tox.ini 14 | recursive-include docs * 15 | recursive-include requirements *.txt 16 | recursive-include tests *.py 17 | recursive-include django_celery_monitor *.css 18 | recursive-include django_celery_monitor *.html 19 | recursive-exclude * __pycache__ 20 | recursive-exclude * *.py[co] 21 | recursive-exclude * .*.sw* 22 | -------------------------------------------------------------------------------- /django_celery_monitor/migrations/0002_workerstate_last_update.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from django.db import migrations, models 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('celery_monitor', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='workerstate', 17 | name='last_update', 18 | field=models.DateTimeField( 19 | default=django.utils.timezone.now, 20 | auto_now=True, 21 | verbose_name='last update', 22 | ), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_celery_monitor/templates/django_celery_monitor/confirm_rate_limit.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block breadcrumbs %} 5 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 |
{% csrf_token %} 15 |
16 | {% for obj in queryset %} 17 | 18 | {% endfor %} 19 | 20 | 21 | 22 | 23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import pytest 4 | 5 | from celery.contrib.pytest import depends_on_current_app 6 | from celery.contrib.testing.app import TestApp, Trap 7 | 8 | __all__ = ['app', 'depends_on_current_app'] 9 | 10 | 11 | @pytest.fixture(scope='session', autouse=True) 12 | def setup_default_app_trap(): 13 | from celery._state import set_default_app 14 | set_default_app(Trap()) 15 | 16 | 17 | @pytest.fixture() 18 | def app(celery_app): 19 | return celery_app 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | def test_cases_shortcuts(request, app, patching): 24 | if request.instance: 25 | @app.task 26 | def add(x, y): 27 | return x + y 28 | 29 | # IMPORTANT: We set an .app attribute for every test case class. 30 | request.instance.app = app 31 | request.instance.Celery = TestApp 32 | request.instance.add = add 33 | request.instance.patching = patching 34 | yield 35 | if request.instance: 36 | request.instance.app = None 37 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import os 5 | 6 | from sphinx_celery import conf 7 | 8 | globals().update(conf.build_config( 9 | 'django_celery_monitor', __file__, 10 | project='django_celery_monitor', 11 | version_dev='1.2.0', 12 | version_stable='1.1.2', 13 | canonical_url='http://django-celery-monitor.readthedocs.io', 14 | webdomain='', 15 | github_project='jazzband/django-celery-monitor', 16 | copyright='2009-2017', 17 | django_settings='proj.settings', 18 | include_intersphinx={'python', 'sphinx', 'django', 'celery'}, 19 | path_additions=[os.path.join(os.pardir, 'tests')], 20 | extra_extensions=['sphinx.ext.napoleon'], 21 | html_logo='images/logo.png', 22 | html_favicon='images/favicon.ico', 23 | html_prepend_sidebars=[], 24 | apicheck_ignore_modules=[ 25 | 'django_celery_monitor', 26 | 'django_celery_monitor.apps', 27 | 'django_celery_monitor.admin', 28 | r'django_celery_monitor.migrations.*', 29 | ], 30 | suppress_warnings=['image.nonlocal_uri'], 31 | )) 32 | -------------------------------------------------------------------------------- /docs/copyright.rst: -------------------------------------------------------------------------------- 1 | Copyright 2 | ========= 3 | 4 | *django_celery_monitor User Manual* 5 | 6 | by Ask Solem and Jannis Leidel 7 | 8 | .. |copy| unicode:: U+000A9 .. COPYRIGHT SIGN 9 | 10 | Copyright |copy| 2017, Jannis Leidel 11 | Copyright |copy| 2016, Ask Solem 12 | 13 | All rights reserved. This material may be copied or distributed only 14 | subject to the terms and conditions set forth in the `Creative Commons 15 | Attribution-ShareAlike 4.0 International 16 | `_ license. 17 | 18 | You may share and adapt the material, even for commercial purposes, but 19 | you must give the original author credit. 20 | If you alter, transform, or build upon this 21 | work, you may distribute the resulting work only under the same license or 22 | a license compatible to this one. 23 | 24 | .. note:: 25 | 26 | While the django_celery_monitor *documentation* is offered under the 27 | Creative Commons *Attribution-ShareAlike 4.0 International* license 28 | the django_celery_monitor *software* is offered under the 29 | `BSD License (3 Clause) `_ 30 | -------------------------------------------------------------------------------- /django_celery_monitor/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Celery monitor for Django.""" 3 | # :copyright: (c) 2016, Ask Solem. 4 | # All rights reserved. 5 | # :license: BSD (3 Clause), see LICENSE for more details. 6 | 7 | from __future__ import absolute_import, unicode_literals 8 | 9 | import re 10 | 11 | from collections import namedtuple 12 | 13 | __version__ = '1.1.2' 14 | __author__ = 'Jannis Leidel' 15 | __contact__ = 'jannis@leidel.info' 16 | __homepage__ = 'https://github.com/jazzband/django-celery-monitor' 17 | __docformat__ = 'restructuredtext' 18 | 19 | # -eof meta- 20 | 21 | version_info_t = namedtuple('version_info_t', ( 22 | 'major', 'minor', 'micro', 'releaselevel', 'serial', 23 | )) 24 | 25 | # bumpversion can only search for {current_version} 26 | # so we have to parse the version here. 27 | _temp = re.match( 28 | r'(\d+)\.(\d+).(\d+)(.+)?', __version__).groups() 29 | VERSION = version_info = version_info_t( 30 | int(_temp[0]), int(_temp[1]), int(_temp[2]), _temp[3] or '', '') 31 | del(_temp) 32 | del(re) 33 | 34 | __all__ = [] 35 | 36 | default_app_config = 'django_celery_monitor.apps.CeleryMonitorConfig' 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | max-parallel: 5 11 | matrix: 12 | python-version: ['2.7', '3.5', '3.6'] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Get pip cache dir 23 | id: pip-cache 24 | run: | 25 | echo "::set-output name=dir::$(pip cache dir)" 26 | 27 | - name: Cache 28 | uses: actions/cache@v2 29 | with: 30 | path: ${{ steps.pip-cache.outputs.dir }} 31 | key: 32 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }} 33 | restore-keys: | 34 | ${{ matrix.python-version }}-v1- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install --upgrade tox tox-gh-actions 40 | 41 | - name: Tox tests 42 | run: | 43 | tox -v 44 | 45 | - name: Upload coverage 46 | uses: codecov/codecov-action@v1 47 | with: 48 | name: Python ${{ matrix.python-version }} 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | tests-py{py,27,35}-dj111 4 | tests-py36-dj111 5 | apicheck 6 | builddocs 7 | flake8 8 | flakeplus 9 | manifest 10 | pydocstyle 11 | readme 12 | 13 | [gh-actions] 14 | python = 15 | 2.7: py27, flake8, flakeplus, pydocstyle 16 | 3.5: py35 17 | 3.6: py36, apicheck, builddocs, linkcheck 18 | 19 | [testenv] 20 | sitepackages = False 21 | deps= 22 | -r{toxinidir}/requirements/default.txt 23 | -r{toxinidir}/requirements/test.txt 24 | -r{toxinidir}/requirements/test-ci.txt 25 | 26 | dj111: django>=1.11,<2 27 | 28 | apicheck,builddocs,linkcheck: -r{toxinidir}/requirements/docs.txt 29 | flake8,flakeplus,manifest,pydocstyle,readme: -r{toxinidir}/requirements/pkgutils.txt 30 | 31 | commands = 32 | tests: pytest -xv --cov=django_celery_monitor --cov-report=term --cov-report=xml --no-cov-on-fail [] 33 | apicheck: sphinx-build -W -b apicheck -d {envtmpdir}/doctrees docs docs/_build/apicheck 34 | builddocs: sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html 35 | flake8: flake8 {toxinidir}/django_celery_monitor {toxinidir}/tests 36 | flakeplus: flakeplus --2.7 {toxinidir}/django_celery_monitor {toxinidir}/tests 37 | linkcheck: sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/linkcheck 38 | manifest: check-manifest -v 39 | pydocstyle: pydocstyle {toxinidir}/django_celery_monitor 40 | readme: python setup.py check -r -s 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-celery-monitor' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Get pip cache dir 24 | id: pip-cache 25 | run: | 26 | echo "::set-output name=dir::$(pip cache dir)" 27 | 28 | - name: Cache 29 | uses: actions/cache@v2 30 | with: 31 | path: ${{ steps.pip-cache.outputs.dir }} 32 | key: release-${{ hashFiles('**/setup.py') }} 33 | restore-keys: | 34 | release- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install -U pip 39 | python -m pip install -U setuptools twine wheel 40 | 41 | - name: Build package 42 | run: | 43 | python setup.py --version 44 | python setup.py sdist --format=gztar bdist_wheel 45 | twine check dist/* 46 | 47 | - name: Upload packages to Jazzband 48 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 49 | uses: pypa/gh-action-pypi-publish@master 50 | with: 51 | user: jazzband 52 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 53 | repository_url: https://jazzband.co/projects/django-celery-monitor/upload 54 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | ================ 4 | Change history 5 | ================ 6 | 7 | .. _version-1.1.2: 8 | 9 | :release-date: 2017-05-18 11:30 a.m. UTC+2 10 | :release-by: Jannis Leidel 11 | 12 | - More packaging fixes. Sigh. 13 | 14 | .. _version-1.1.1: 15 | 16 | :release-date: 2017-05-18 10:30 a.m. UTC+2 17 | :release-by: Jannis Leidel 18 | 19 | - Fix the folder that the extra stylesheet file was stored in. 20 | 21 | .. _version-1.1.0: 22 | 23 | :release-date: 2017-05-11 10:25 p.m. UTC+2 24 | :release-by: Jannis Leidel 25 | 26 | - Use ``SELECT FOR UPDATE`` SQL statements for updating the task and worker 27 | states to improve resilliance against race conditions by multiple 28 | simultaneously running cameras. 29 | 30 | - Move worker state cache from in-process dictionary into database side 31 | timestamp to decide whether to do another worker update or not. 32 | 33 | - Improved code structure by moving all utilities into same module. 34 | 35 | .. _version-1.0.2: 36 | 37 | :release-date: 2017-05-08 16:05 a.m. UTC+2 38 | :release-by: Jannis Leidel 39 | 40 | - Import Django models inline to prevent import time side effect. 41 | 42 | - Run django.setup() when installing the Camera. 43 | 44 | .. _version-1.0.1: 45 | 46 | :release-date: 2017-05-03 10:17 a.m. UTC+2 47 | :release-by: Jannis Leidel 48 | 49 | - Fix the Python package manifest. 50 | 51 | - Fix README rendering. 52 | 53 | .. _version-1.0.0: 54 | 55 | 1.0.0 56 | ===== 57 | :release-date: 2017-05-03 08:35 a.m. UTC+2 58 | :release-by: Jannis Leidel 59 | 60 | - Initial release by extracting the monitor code from the old django-celery app. 61 | 62 | - Add ability to override the expiry timedelta for the task monitor via the 63 | Celery configuration. 64 | 65 | - Add Python 3.6 and Django 1.11 to text matrix. Supported versions of Django 66 | 1.8 LTS, 1.9, 1.10 and 1.11 LTS. Supported versions of Python are 2.7, 3.4, 67 | 3.5 and 3.6 (for Django 1.11). 68 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /django_celery_monitor/humanize.py: -------------------------------------------------------------------------------- 1 | """Some helpers to humanize values.""" 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from datetime import datetime 5 | 6 | from django.utils.translation import ungettext, ugettext as _ 7 | from django.utils.timezone import now 8 | 9 | 10 | def pluralize_year(n): 11 | """Return a string with the number of yeargs ago.""" 12 | return ungettext(_('{num} year ago'), _('{num} years ago'), n) 13 | 14 | 15 | def pluralize_month(n): 16 | """Return a string with the number of months ago.""" 17 | return ungettext(_('{num} month ago'), _('{num} months ago'), n) 18 | 19 | 20 | def pluralize_week(n): 21 | """Return a string with the number of weeks ago.""" 22 | return ungettext(_('{num} week ago'), _('{num} weeks ago'), n) 23 | 24 | 25 | def pluralize_day(n): 26 | """Return a string with the number of days ago.""" 27 | return ungettext(_('{num} day ago'), _('{num} days ago'), n) 28 | 29 | 30 | OLDER_CHUNKS = ( 31 | (365.0, pluralize_year), 32 | (30.0, pluralize_month), 33 | (7.0, pluralize_week), 34 | (1.0, pluralize_day), 35 | ) 36 | 37 | 38 | def naturaldate(date, include_seconds=False): 39 | """Convert datetime into a human natural date string.""" 40 | if not date: 41 | return '' 42 | 43 | right_now = now() 44 | today = datetime(right_now.year, right_now.month, 45 | right_now.day, tzinfo=right_now.tzinfo) 46 | delta = right_now - date 47 | delta_midnight = today - date 48 | 49 | days = delta.days 50 | hours = delta.seconds // 3600 51 | minutes = delta.seconds // 60 52 | seconds = delta.seconds 53 | 54 | if days < 0: 55 | return _('just now') 56 | 57 | if days == 0: 58 | if hours == 0: 59 | if minutes > 0: 60 | return ungettext( 61 | _('{minutes} minute ago'), 62 | _('{minutes} minutes ago'), minutes 63 | ).format(minutes=minutes) 64 | else: 65 | if include_seconds and seconds: 66 | return ungettext( 67 | _('{seconds} second ago'), 68 | _('{seconds} seconds ago'), seconds 69 | ).format(seconds=seconds) 70 | return _('just now') 71 | else: 72 | return ungettext( 73 | _('{hours} hour ago'), _('{hours} hours ago'), hours 74 | ).format(hours=hours) 75 | 76 | if delta_midnight.days == 0: 77 | return _('yesterday at {time}').format(time=date.strftime('%H:%M')) 78 | 79 | count = 0 80 | for chunk, pluralizefun in OLDER_CHUNKS: 81 | if days >= chunk: 82 | count = int(round((delta_midnight.days + 1) / chunk, 0)) 83 | fmt = pluralizefun(count) 84 | return fmt.format(num=count) 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Jannis Leidel. All Rights Reserved. 2 | Copyright (c) 2015-2016 Ask Solem. All Rights Reserved. 3 | Copyright (c) 2012-2014 GoPivotal, Inc. All Rights Reserved. 4 | Copyright (c) 2009-2012 Ask Solem. All Rights Reserved. 5 | 6 | django_celery_monitor is licensed under The BSD License (3 Clause, also known as 7 | the new BSD license). The license is an OSI approved Open Source 8 | license and is GPL-compatible(1). 9 | 10 | The license text can also be found here: 11 | http://www.opensource.org/licenses/BSD-3-Clause 12 | 13 | License 14 | ======= 15 | 16 | Redistribution and use in source and binary forms, with or without 17 | modification, are permitted provided that the following conditions are met: 18 | * Redistributions of source code must retain the above copyright 19 | notice, this list of conditions and the following disclaimer. 20 | * Redistributions in binary form must reproduce the above copyright 21 | notice, this list of conditions and the following disclaimer in the 22 | documentation and/or other materials provided with the distribution. 23 | * Neither the name of Ask Solem nor the 24 | names of its contributors may be used to endorse or promote products 25 | derived from this software without specific prior written permission. 26 | 27 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 28 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 29 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 30 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Ask Solem OR CONTRIBUTORS 31 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 32 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 33 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 34 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 35 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 36 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 37 | POSSIBILITY OF SUCH DAMAGE. 38 | 39 | Documentation License 40 | ===================== 41 | 42 | The documentation portion of django_celery_monitor (the rendered contents of the 43 | "docs" directory of a software distribution or checkout) is supplied 44 | under the "Creative Commons Attribution-ShareAlike 4.0 45 | International" (CC BY-SA 4.0) License as described by 46 | http://creativecommons.org/licenses/by-sa/4.0/ 47 | 48 | Footnotes 49 | ========= 50 | (1) A GPL-compatible license makes it possible to 51 | combine django_celery_monitor with other software that is released 52 | under the GPL, it does not mean that we're distributing 53 | django_celery_monitor under the GPL license. The BSD license, unlike the GPL, 54 | let you distribute a modified version without making your 55 | changes open source. 56 | -------------------------------------------------------------------------------- /tests/proj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for Test project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.9.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | from __future__ import absolute_import, unicode_literals 13 | 14 | import os 15 | import sys 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | sys.path.insert(0, os.path.abspath(os.path.join(BASE_DIR, os.pardir))) 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = 'u($kbs9$irs0)436gbo9%!b&#zyd&70tx!n7!i&fl6qun@z1_l' 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 'django_celery_monitor', 43 | ] 44 | 45 | MIDDLEWARE_CLASSES = [ 46 | ] 47 | 48 | ROOT_URLCONF = 'proj.urls' 49 | 50 | TEMPLATES = [ 51 | { 52 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 53 | 'DIRS': [], 54 | 'APP_DIRS': True, 55 | 'OPTIONS': { 56 | 'context_processors': [ 57 | 'django.template.context_processors.debug', 58 | 'django.template.context_processors.request', 59 | 'django.contrib.auth.context_processors.auth', 60 | 'django.contrib.messages.context_processors.messages', 61 | ], 62 | }, 63 | }, 64 | ] 65 | 66 | WSGI_APPLICATION = 'proj.wsgi.application' 67 | 68 | 69 | # Database 70 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 71 | 72 | DATABASES = { 73 | 'default': { 74 | 'ENGINE': 'django.db.backends.sqlite3', 75 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 76 | 'OPTIONS': { 77 | 'timeout': 1000, 78 | }, 79 | } 80 | } 81 | 82 | CACHES = { 83 | 'default': { 84 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 85 | }, 86 | 'dummy': { 87 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 88 | }, 89 | } 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 93 | 94 | django_auth = 'django.contrib.auth.password_validation.' 95 | 96 | AUTH_PASSWORD_VALIDATORS = [ 97 | ] 98 | 99 | 100 | # Internationalization 101 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 102 | 103 | LANGUAGE_CODE = 'en-us' 104 | 105 | TIME_ZONE = 'UTC' 106 | 107 | USE_I18N = True 108 | 109 | USE_L10N = True 110 | 111 | USE_TZ = True 112 | 113 | 114 | # Static files (CSS, JavaScript, Images) 115 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 116 | 117 | STATIC_URL = '/static/' 118 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | ========= 2 | AUTHORS 3 | ========= 4 | :order: sorted 5 | 6 | Aaron Ross 7 | Adam Endicott 8 | Alex Stapleton 9 | Alvaro Vega 10 | Andrew Frankel 11 | Andrew Watts 12 | Andrii Kostenko 13 | Anton Novosyolov 14 | Ask Solem 15 | Augusto Becciu 16 | Ben Firshman 17 | Brad Jasper 18 | Brett Gibson 19 | Brian Rosner 20 | Charlie DeTar 21 | Christopher Grebs 22 | Dan LaMotte 23 | Darjus Loktevic 24 | David Fischer 25 | David Ziegler 26 | Diego Andres Sanabria Martin 27 | Dmitriy Krasilnikov 28 | Donald Stufft 29 | Eldon Stegall 30 | Eugene Nagornyi 31 | Felix Berger 33 | Glenn Washburn 34 | Gnrhxni 35 | Greg Taylor 36 | Grégoire Cachet 37 | Hari 38 | Idan Zalzberg 39 | Ionel Maries Cristian 40 | Jannis Leidel 41 | Jason Baker 42 | Jay States 43 | Jeff Balogh 44 | Jeff Fischer 45 | Jeffrey Hu 46 | Jens Alm 47 | Jerzy Kozera 48 | Jesper Noehr 49 | John Andrews 50 | John Watson 51 | Jonas Haag 52 | Jonatan Heyman 53 | Josh Drake 54 | José Moreira 55 | Jude Nagurney 56 | Justin Quick 57 | Keith Perkins 58 | Kirill Panshin 59 | Mark Hellewell 60 | Mark Lavin 61 | Mark Stover 62 | Maxim Bodyansky 63 | Michael Elsdoerfer 64 | Michael van Tellingen 65 | Mikhail Korobov 66 | Olivier Tabone 67 | Patrick Altman 68 | Piotr Bulinski 69 | Piotr Sikora 70 | Reza Lotun 71 | Rockallite Wulf 72 | Roger Barnes 73 | Roman Imankulov 74 | Rune Halvorsen 75 | Sam Cooke 76 | Scott Rubin 77 | Sean Creeley 78 | Serj Zavadsky 79 | Simon Charette 80 | Spencer Ellinor 81 | Theo Spears 82 | Timo Sugliani 83 | Vincent Driessen 84 | Vitaly Babiy 85 | Vladislav Poluhin 86 | Weipin Xia 87 | Wes Turner 88 | Wes Winham 89 | Williams Mendez 90 | WoLpH 91 | dongweiming 92 | zeez 93 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJ=django_celery_monitor 2 | PYTHON=python 3 | PYTEST=py.test 4 | GIT=git 5 | TOX=tox 6 | ICONV=iconv 7 | FLAKE8=flake8 8 | FLAKEPLUS=flakeplus 9 | PYDOCSTYLE=pydocstyle 10 | 11 | TESTDIR=t 12 | SPHINX_DIR=docs/ 13 | SPHINX_BUILDDIR="${SPHINX_DIR}/_build" 14 | SPHINX_HTMLDIR="${SPHINX_BUILDDIR}/html" 15 | DOCUMENTATION=Documentation 16 | FLAKEPLUSTARGET=2.7 17 | 18 | all: help 19 | 20 | help: 21 | @echo "docs - Build documentation." 22 | @echo "test-all - Run tests for all supported python versions." 23 | @echo "distcheck ---------- - Check distribution for problems." 24 | @echo " test - Run unittests using current python." 25 | @echo " lint ------------ - Check codebase for problems." 26 | @echo " apicheck - Check API reference coverage." 27 | @echo " configcheck - Check configuration reference coverage." 28 | @echo " flakes -------- - Check code for syntax and style errors." 29 | @echo " flakecheck - Run flake8 on the source code." 30 | @echo " flakepluscheck - Run flakeplus on the source code." 31 | @echo " pep257check - Run pydocstyle on the source code." 32 | @echo "clean-dist --------- - Clean all distribution build artifacts." 33 | @echo " clean-git-force - Remove all uncomitted files." 34 | @echo " clean ------------ - Non-destructive clean" 35 | @echo " clean-pyc - Remove .pyc/__pycache__ files" 36 | @echo " clean-docs - Remove documentation build artifacts." 37 | @echo " clean-build - Remove setup artifacts." 38 | @echo "bump - Bump patch version number." 39 | @echo "bump-minor - Bump minor version number." 40 | @echo "bump-major - Bump major version number." 41 | @echo "release - Make PyPI release." 42 | 43 | clean: clean-docs clean-pyc clean-build 44 | 45 | clean-dist: clean clean-git-force 46 | 47 | bump: 48 | bumpversion patch 49 | 50 | bump-minor: 51 | bumpversion minor 52 | 53 | bump-major: 54 | bumpversion major 55 | 56 | release: 57 | python setup.py register sdist bdist_wheel upload --sign 58 | 59 | Documentation: 60 | (cd "$(SPHINX_DIR)"; $(MAKE) html) 61 | mv "$(SPHINX_HTMLDIR)" $(DOCUMENTATION) 62 | 63 | docs: Documentation 64 | 65 | clean-docs: 66 | -rm -rf "$(SPHINX_BUILDDIR)" 67 | 68 | lint: flakecheck apicheck configcheck 69 | 70 | apicheck: 71 | (cd "$(SPHINX_DIR)"; $(MAKE) apicheck) 72 | 73 | configcheck: 74 | true 75 | 76 | flakecheck: 77 | $(FLAKE8) "$(PROJ)" "$(TESTDIR)" 78 | 79 | flakediag: 80 | -$(MAKE) flakecheck 81 | 82 | pep257check: 83 | $(PYDOCSTYLE) "$(PROJ)" 84 | 85 | flakepluscheck: 86 | $(FLAKEPLUS) --$(FLAKEPLUSTARGET) "$(PROJ)" "$(TESTDIR)" 87 | 88 | flakeplusdiag: 89 | -$(MAKE) flakepluscheck 90 | 91 | flakes: flakediag flakeplusdiag pep257check 92 | 93 | clean-pyc: 94 | -find . -type f -a \( -name "*.pyc" -o -name "*$$py.class" \) | xargs rm 95 | -find . -type d -name "__pycache__" | xargs rm -r 96 | 97 | removepyc: clean-pyc 98 | 99 | clean-build: 100 | rm -rf build/ dist/ .eggs/ *.egg-info/ .tox/ .coverage cover/ 101 | 102 | clean-git: 103 | $(GIT) clean -xdn 104 | 105 | clean-git-force: 106 | $(GIT) clean -xdf 107 | 108 | test-all: clean-pyc 109 | $(TOX) 110 | 111 | test: 112 | $(PYTHON) setup.py test 113 | 114 | build: 115 | $(PYTHON) setup.py sdist bdist_wheel 116 | 117 | distcheck: lint test clean 118 | 119 | dist: clean-dist build 120 | -------------------------------------------------------------------------------- /django_celery_monitor/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities.""" 2 | # -- XXX This module must not use translation as that causes 3 | # -- a recursive loader import! 4 | from __future__ import absolute_import, unicode_literals 5 | 6 | from datetime import datetime 7 | from pprint import pformat 8 | 9 | from django.conf import settings 10 | from django.db.models import DateTimeField, Func 11 | from django.utils import timezone 12 | from django.utils.html import escape 13 | 14 | try: 15 | from django.db.models.functions import Now 16 | except ImportError: 17 | 18 | class Now(Func): 19 | """A backport of the Now function from Django 1.9.x.""" 20 | 21 | template = 'CURRENT_TIMESTAMP' 22 | 23 | def __init__(self, output_field=None, **extra): 24 | if output_field is None: 25 | output_field = DateTimeField() 26 | super(Now, self).__init__(output_field=output_field, **extra) 27 | 28 | def as_postgresql(self, compiler, connection): 29 | # Postgres' CURRENT_TIMESTAMP means "the time at the start of the 30 | # transaction". We use STATEMENT_TIMESTAMP to be cross-compatible 31 | # with other databases. 32 | self.template = 'STATEMENT_TIMESTAMP()' 33 | return self.as_sql(compiler, connection) 34 | 35 | 36 | def make_aware(value): 37 | """Make the given datetime aware of a timezone.""" 38 | if settings.USE_TZ: 39 | # naive datetimes are assumed to be in UTC. 40 | if timezone.is_naive(value): 41 | value = timezone.make_aware(value, timezone.utc) 42 | # then convert to the Django configured timezone. 43 | default_tz = timezone.get_default_timezone() 44 | value = timezone.localtime(value, default_tz) 45 | return value 46 | 47 | 48 | def correct_awareness(value): 49 | """Fix the given datetime timezone awareness.""" 50 | if isinstance(value, datetime): 51 | if settings.USE_TZ: 52 | return make_aware(value) 53 | elif timezone.is_aware(value): 54 | default_tz = timezone.get_default_timezone() 55 | return timezone.make_naive(value, default_tz) 56 | return value 57 | 58 | 59 | def fromtimestamp(value): 60 | """Return an aware or naive datetime from the given timestamp.""" 61 | if settings.USE_TZ: 62 | return make_aware(datetime.utcfromtimestamp(value)) 63 | else: 64 | return datetime.fromtimestamp(value) 65 | 66 | 67 | FIXEDWIDTH_STYLE = '''\ 68 | {2} \ 70 | ''' 71 | 72 | 73 | def _attrs(**kwargs): 74 | def _inner(fun): 75 | for attr_name, attr_value in kwargs.items(): 76 | setattr(fun, attr_name, attr_value) 77 | return fun 78 | return _inner 79 | 80 | 81 | def display_field(short_description, admin_order_field, 82 | allow_tags=True, **kwargs): 83 | """Set some display_field attributes.""" 84 | return _attrs(short_description=short_description, 85 | admin_order_field=admin_order_field, 86 | allow_tags=allow_tags, **kwargs) 87 | 88 | 89 | def action(short_description, **kwargs): 90 | """Set some admin action attributes.""" 91 | return _attrs(short_description=short_description, **kwargs) 92 | 93 | 94 | def fixedwidth(field, name=None, pt=6, width=16, maxlen=64, pretty=False): 95 | """Render a field with a fixed width.""" 96 | @display_field(name or field, field) 97 | def f(task): 98 | val = getattr(task, field) 99 | if pretty: 100 | val = pformat(val, width=width) 101 | if val.startswith("u'") or val.startswith('u"'): 102 | val = val[2:-1] 103 | shortval = val.replace(',', ',\n') 104 | shortval = shortval.replace('\n', '|br/|') 105 | 106 | if len(shortval) > maxlen: 107 | shortval = shortval[:maxlen] + '...' 108 | styled = FIXEDWIDTH_STYLE.format( 109 | escape(val[:255]), pt, escape(shortval), 110 | ) 111 | return styled.replace('|br/|', '
') 112 | return f 113 | -------------------------------------------------------------------------------- /django_celery_monitor/managers.py: -------------------------------------------------------------------------------- 1 | """The model managers.""" 2 | from __future__ import absolute_import, unicode_literals 3 | from datetime import timedelta 4 | 5 | from celery import states 6 | from celery.events.state import Task 7 | from celery.utils.time import maybe_timedelta 8 | from django.db import models, router, transaction 9 | 10 | from .utils import Now 11 | 12 | 13 | class ExtendedQuerySet(models.QuerySet): 14 | """A custom model queryset that implements a few helpful methods.""" 15 | 16 | def select_for_update_or_create(self, defaults=None, **kwargs): 17 | """Extend update_or_create with select_for_update. 18 | 19 | Look up an object with the given kwargs, updating one with defaults 20 | if it exists, otherwise create a new one. 21 | Return a tuple (object, created), where created is a boolean 22 | specifying whether an object was created. 23 | 24 | This is a backport from Django 1.11 25 | (https://code.djangoproject.com/ticket/26804) to support 26 | select_for_update when getting the object. 27 | """ 28 | defaults = defaults or {} 29 | lookup, params = self._extract_model_params(defaults, **kwargs) 30 | self._for_write = True 31 | with transaction.atomic(using=self.db): 32 | try: 33 | obj = self.select_for_update().get(**lookup) 34 | except self.model.DoesNotExist: 35 | obj, created = self._create_object_from_params(lookup, params) 36 | if created: 37 | return obj, created 38 | for k, v in defaults.items(): 39 | setattr(obj, k, v() if callable(v) else v) 40 | obj.save(using=self.db) 41 | return obj, False 42 | 43 | 44 | class WorkerStateQuerySet(ExtendedQuerySet): 45 | """A custom model queryset for the WorkerState model with some helpers.""" 46 | 47 | def update_heartbeat(self, hostname, heartbeat, update_freq): 48 | with transaction.atomic(): 49 | # check if there was an update in the last n seconds? 50 | interval = Now() - timedelta(seconds=update_freq) 51 | recent_worker_updates = self.filter( 52 | hostname=hostname, 53 | last_update__gte=interval, 54 | ) 55 | if recent_worker_updates.exists(): 56 | # if yes, get the latest update and move on 57 | obj = recent_worker_updates.get() 58 | else: 59 | # if no, update the worker state and move on 60 | obj, _ = self.select_for_update_or_create( 61 | hostname=hostname, 62 | defaults={'last_heartbeat': heartbeat}, 63 | ) 64 | return obj 65 | 66 | 67 | class TaskStateQuerySet(ExtendedQuerySet): 68 | """A custom model queryset for the TaskState model with some helpers.""" 69 | 70 | def active(self): 71 | """Return all active task states.""" 72 | return self.filter(hidden=False) 73 | 74 | def expired(self, states, expires): 75 | """Return all expired task states.""" 76 | return self.filter( 77 | state__in=states, 78 | tstamp__lte=Now() - maybe_timedelta(expires), 79 | ) 80 | 81 | def expire_by_states(self, states, expires): 82 | """Expire task with one of the given states.""" 83 | if expires is not None: 84 | return self.expired(states, expires).update(hidden=True) 85 | 86 | def purge(self): 87 | """Purge all expired task states.""" 88 | with transaction.atomic(): 89 | self.using( 90 | router.db_for_write(self.model) 91 | ).filter(hidden=True).delete() 92 | 93 | def update_state(self, state, task_id, defaults): 94 | with transaction.atomic(): 95 | obj, created = self.select_for_update_or_create( 96 | task_id=task_id, 97 | defaults=defaults, 98 | ) 99 | if created: 100 | return obj 101 | 102 | if states.state(state) < states.state(obj.state): 103 | keep = Task.merge_rules[states.RECEIVED] 104 | else: 105 | keep = {} 106 | for key, value in defaults.items(): 107 | if key not in keep: 108 | setattr(obj, key, value) 109 | obj.save(update_fields=tuple(defaults.keys())) 110 | return obj 111 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | import sys 7 | import codecs 8 | 9 | import setuptools 10 | import setuptools.command.test 11 | 12 | try: 13 | import platform 14 | _pyimp = platform.python_implementation 15 | except (AttributeError, ImportError): 16 | def _pyimp(): 17 | return 'Python' 18 | 19 | NAME = 'django_celery_monitor' 20 | 21 | E_UNSUPPORTED_PYTHON = '%s 1.0 requires %%s %%s or later!' % (NAME,) 22 | 23 | PYIMP = _pyimp() 24 | PY26_OR_LESS = sys.version_info < (2, 7) 25 | PY3 = sys.version_info[0] == 3 26 | PY33_OR_LESS = PY3 and sys.version_info < (3, 4) 27 | PYPY_VERSION = getattr(sys, 'pypy_version_info', None) 28 | PYPY = PYPY_VERSION is not None 29 | PYPY24_ATLEAST = PYPY_VERSION and PYPY_VERSION >= (2, 4) 30 | 31 | if PY26_OR_LESS: 32 | raise Exception(E_UNSUPPORTED_PYTHON % (PYIMP, '2.7')) 33 | elif PY33_OR_LESS and not PYPY24_ATLEAST: 34 | raise Exception(E_UNSUPPORTED_PYTHON % (PYIMP, '3.4')) 35 | 36 | # -*- Classifiers -*- 37 | 38 | classes = """ 39 | Development Status :: 5 - Production/Stable 40 | License :: OSI Approved :: BSD License 41 | Programming Language :: Python 42 | Programming Language :: Python :: 2 43 | Programming Language :: Python :: 2.7 44 | Programming Language :: Python :: 3 45 | Programming Language :: Python :: 3.4 46 | Programming Language :: Python :: 3.5 47 | Programming Language :: Python :: 3.6 48 | Programming Language :: Python :: Implementation :: CPython 49 | Programming Language :: Python :: Implementation :: PyPy 50 | Framework :: Django 51 | Framework :: Django :: 1.8 52 | Framework :: Django :: 1.9 53 | Framework :: Django :: 1.10 54 | Framework :: Django :: 1.11 55 | Operating System :: OS Independent 56 | Topic :: Communications 57 | Topic :: System :: Distributed Computing 58 | Topic :: Software Development :: Libraries :: Python Modules 59 | """ 60 | classifiers = [s.strip() for s in classes.split('\n') if s] 61 | 62 | # -*- Distribution Meta -*- 63 | 64 | re_meta = re.compile(r'__(\w+?)__\s*=\s*(.*)') 65 | re_doc = re.compile(r'^"""(.+?)"""') 66 | 67 | 68 | def add_default(m): 69 | attr_name, attr_value = m.groups() 70 | return ((attr_name, attr_value.strip("\"'")),) 71 | 72 | 73 | def add_doc(m): 74 | return (('doc', m.groups()[0]),) 75 | 76 | pats = {re_meta: add_default, 77 | re_doc: add_doc} 78 | here = os.path.abspath(os.path.dirname(__file__)) 79 | with open(os.path.join(here, NAME, '__init__.py')) as meta_fh: 80 | meta = {} 81 | for line in meta_fh: 82 | if line.strip() == '# -eof meta-': 83 | break 84 | for pattern, handler in pats.items(): 85 | m = pattern.match(line.strip()) 86 | if m: 87 | meta.update(handler(m)) 88 | 89 | # -*- Installation Requires -*- 90 | 91 | 92 | def strip_comments(l): 93 | return l.split('#', 1)[0].strip() 94 | 95 | 96 | def _pip_requirement(req): 97 | if req.startswith('-r '): 98 | _, path = req.split() 99 | return reqs(*path.split('/')) 100 | return [req] 101 | 102 | 103 | def _reqs(*f): 104 | return [ 105 | _pip_requirement(r) for r in ( 106 | strip_comments(l) for l in open( 107 | os.path.join(os.getcwd(), 'requirements', *f)).readlines() 108 | ) if r] 109 | 110 | 111 | def reqs(*f): 112 | return [req for subreq in _reqs(*f) for req in subreq] 113 | 114 | # -*- Long Description -*- 115 | 116 | if os.path.exists('README.rst'): 117 | long_description = codecs.open('README.rst', 'r', 'utf-8').read() 118 | else: 119 | long_description = 'See http://pypi.python.org/pypi/%s' % (NAME,) 120 | 121 | # -*- %%% -*- 122 | 123 | 124 | class pytest(setuptools.command.test.test): 125 | user_options = [('pytest-args=', 'a', 'Arguments to pass to py.test')] 126 | 127 | def initialize_options(self): 128 | setuptools.command.test.test.initialize_options(self) 129 | self.pytest_args = [] 130 | 131 | def run_tests(self): 132 | import pytest 133 | sys.exit(pytest.main(self.pytest_args)) 134 | 135 | 136 | setuptools.setup( 137 | name=NAME, 138 | packages=setuptools.find_packages(exclude=['tests', 'tests.*']), 139 | version=meta['version'], 140 | description=meta['doc'], 141 | long_description=long_description, 142 | keywords='celery django events monitoring', 143 | author=meta['author'], 144 | author_email=meta['contact'], 145 | url=meta['homepage'], 146 | platforms=['any'], 147 | license='BSD', 148 | classifiers=classifiers, 149 | install_requires=reqs('default.txt'), 150 | tests_require=reqs('test.txt'), 151 | cmdclass={'test': pytest}, 152 | zip_safe=False, 153 | include_package_data=True, 154 | ) 155 | -------------------------------------------------------------------------------- /django_celery_monitor/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='TaskState', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name='ID')), 23 | ('state', models.CharField( 24 | choices=[('FAILURE', 'FAILURE'), 25 | ('PENDING', 'PENDING'), 26 | ('RECEIVED', 'RECEIVED'), 27 | ('RETRY', 'RETRY'), 28 | ('REVOKED', 'REVOKED'), 29 | ('STARTED', 'STARTED'), 30 | ('SUCCESS', 'SUCCESS')], 31 | db_index=True, 32 | max_length=64, 33 | verbose_name='state', 34 | )), 35 | ('task_id', models.CharField( 36 | max_length=36, 37 | unique=True, 38 | verbose_name='UUID', 39 | )), 40 | ('name', models.CharField( 41 | db_index=True, 42 | max_length=200, 43 | null=True, 44 | verbose_name='name', 45 | )), 46 | ('tstamp', models.DateTimeField( 47 | db_index=True, 48 | verbose_name='event received at', 49 | )), 50 | ('args', models.TextField( 51 | null=True, 52 | verbose_name='Arguments', 53 | )), 54 | ('kwargs', models.TextField( 55 | null=True, 56 | verbose_name='Keyword arguments', 57 | )), 58 | ('eta', models.DateTimeField( 59 | null=True, 60 | verbose_name='ETA', 61 | )), 62 | ('expires', models.DateTimeField( 63 | null=True, 64 | verbose_name='expires', 65 | )), 66 | ('result', models.TextField( 67 | null=True, 68 | verbose_name='result', 69 | )), 70 | ('traceback', models.TextField( 71 | null=True, 72 | verbose_name='traceback', 73 | )), 74 | ('runtime', models.FloatField( 75 | help_text='in seconds if task succeeded', 76 | null=True, 77 | verbose_name='execution time', 78 | )), 79 | ('retries', models.IntegerField( 80 | default=0, 81 | verbose_name='number of retries', 82 | )), 83 | ('hidden', models.BooleanField( 84 | db_index=True, 85 | default=False, 86 | editable=False, 87 | )), 88 | ], 89 | options={ 90 | 'ordering': ['-tstamp'], 91 | 'get_latest_by': 'tstamp', 92 | 'verbose_name_plural': 'tasks', 93 | 'verbose_name': 'task', 94 | }, 95 | ), 96 | migrations.CreateModel( 97 | name='WorkerState', 98 | fields=[ 99 | ('id', models.AutoField( 100 | auto_created=True, 101 | primary_key=True, 102 | serialize=False, 103 | verbose_name='ID', 104 | )), 105 | ('hostname', models.CharField( 106 | max_length=255, 107 | unique=True, 108 | verbose_name='hostname', 109 | )), 110 | ('last_heartbeat', models.DateTimeField( 111 | db_index=True, 112 | null=True, 113 | verbose_name='last heartbeat', 114 | )), 115 | ], 116 | options={ 117 | 'ordering': ['-last_heartbeat'], 118 | 'get_latest_by': 'last_heartbeat', 119 | 'verbose_name_plural': 'workers', 120 | 'verbose_name': 'worker', 121 | }, 122 | ), 123 | migrations.AddField( 124 | model_name='taskstate', 125 | name='worker', 126 | field=models.ForeignKey( 127 | null=True, 128 | on_delete=django.db.models.deletion.CASCADE, 129 | to='celery_monitor.WorkerState', 130 | verbose_name='worker' 131 | ), 132 | ), 133 | ] 134 | -------------------------------------------------------------------------------- /django_celery_monitor/camera.py: -------------------------------------------------------------------------------- 1 | """The Celery events camera.""" 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from datetime import timedelta 5 | 6 | from celery import states 7 | from celery.events.snapshot import Polaroid 8 | from celery.utils.imports import symbol_by_name 9 | from celery.utils.log import get_logger 10 | from celery.utils.time import maybe_iso8601 11 | 12 | from .utils import fromtimestamp, correct_awareness 13 | 14 | WORKER_UPDATE_FREQ = 60 # limit worker timestamp write freq. 15 | SUCCESS_STATES = frozenset([states.SUCCESS]) 16 | 17 | NOT_SAVED_ATTRIBUTES = frozenset(['name', 'args', 'kwargs', 'eta']) 18 | 19 | logger = get_logger(__name__) 20 | debug = logger.debug 21 | 22 | 23 | class Camera(Polaroid): 24 | """The Celery events Polaroid snapshot camera.""" 25 | 26 | clear_after = True 27 | worker_update_freq = WORKER_UPDATE_FREQ 28 | 29 | def __init__(self, *args, **kwargs): 30 | super(Camera, self).__init__(*args, **kwargs) 31 | # Expiry can be timedelta or None for never expire. 32 | self.app.add_defaults({ 33 | 'monitors_expire_success': timedelta(days=1), 34 | 'monitors_expire_error': timedelta(days=3), 35 | 'monitors_expire_pending': timedelta(days=5), 36 | }) 37 | 38 | @property 39 | def TaskState(self): 40 | """Return the data model to store task state in.""" 41 | return symbol_by_name('django_celery_monitor.models.TaskState') 42 | 43 | @property 44 | def WorkerState(self): 45 | """Return the data model to store worker state in.""" 46 | return symbol_by_name('django_celery_monitor.models.WorkerState') 47 | 48 | def django_setup(self): 49 | import django 50 | django.setup() 51 | 52 | def install(self): 53 | super(Camera, self).install() 54 | self.django_setup() 55 | 56 | @property 57 | def expire_task_states(self): 58 | """Return a twople of Celery task states and expiration timedeltas.""" 59 | return ( 60 | (SUCCESS_STATES, self.app.conf.monitors_expire_success), 61 | (states.EXCEPTION_STATES, self.app.conf.monitors_expire_error), 62 | (states.UNREADY_STATES, self.app.conf.monitors_expire_pending), 63 | ) 64 | 65 | def get_heartbeat(self, worker): 66 | try: 67 | heartbeat = worker.heartbeats[-1] 68 | except IndexError: 69 | return 70 | return fromtimestamp(heartbeat) 71 | 72 | def handle_worker(self, hostname_worker): 73 | hostname, worker = hostname_worker 74 | return self.WorkerState.objects.update_heartbeat( 75 | hostname, 76 | heartbeat=self.get_heartbeat(worker), 77 | update_freq=self.worker_update_freq, 78 | ) 79 | 80 | def handle_task(self, uuid_task, worker=None): 81 | """Handle snapshotted event.""" 82 | uuid, task = uuid_task 83 | if task.worker and task.worker.hostname: 84 | worker = self.handle_worker( 85 | (task.worker.hostname, task.worker), 86 | ) 87 | 88 | defaults = { 89 | 'name': task.name, 90 | 'args': task.args, 91 | 'kwargs': task.kwargs, 92 | 'eta': correct_awareness(maybe_iso8601(task.eta)), 93 | 'expires': correct_awareness(maybe_iso8601(task.expires)), 94 | 'state': task.state, 95 | 'tstamp': fromtimestamp(task.timestamp), 96 | 'result': task.result or task.exception, 97 | 'traceback': task.traceback, 98 | 'runtime': task.runtime, 99 | 'worker': worker 100 | } 101 | # Some fields are only stored in the RECEIVED event, 102 | # so we should remove these from default values, 103 | # so that they are not overwritten by subsequent states. 104 | [defaults.pop(attr, None) for attr in NOT_SAVED_ATTRIBUTES 105 | if defaults[attr] is None] 106 | return self.update_task(task.state, task_id=uuid, defaults=defaults) 107 | 108 | def update_task(self, state, task_id, defaults=None): 109 | defaults = defaults or {} 110 | if not defaults.get('name'): 111 | return 112 | return self.TaskState.objects.update_state( 113 | state=state, 114 | task_id=task_id, 115 | defaults=defaults, 116 | ) 117 | 118 | def on_shutter(self, state): 119 | 120 | def _handle_tasks(): 121 | for i, task in enumerate(state.tasks.items()): 122 | self.handle_task(task) 123 | 124 | for worker in state.workers.items(): 125 | self.handle_worker(worker) 126 | _handle_tasks() 127 | 128 | def on_cleanup(self): 129 | expired = ( 130 | self.TaskState.objects.expire_by_states(states, expires) 131 | for states, expires in self.expire_task_states 132 | ) 133 | dirty = sum(item for item in expired if item is not None) 134 | if dirty: 135 | debug('Cleanup: Marked %s objects as dirty.', dirty) 136 | self.TaskState.objects.purge() 137 | debug('Cleanup: %s objects purged.', dirty) 138 | return dirty 139 | return 0 140 | -------------------------------------------------------------------------------- /django_celery_monitor/models.py: -------------------------------------------------------------------------------- 1 | """The data models for the task and worker states.""" 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from time import time, mktime, gmtime 5 | 6 | from django.db import models 7 | from django.utils.translation import ugettext_lazy as _ 8 | from django.conf import settings 9 | 10 | from celery import states 11 | from celery.events.state import heartbeat_expires 12 | from celery.five import python_2_unicode_compatible 13 | 14 | from . import managers 15 | 16 | ALL_STATES = sorted(states.ALL_STATES) 17 | TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES)) 18 | 19 | 20 | @python_2_unicode_compatible 21 | class WorkerState(models.Model): 22 | """The data model to store the worker state in.""" 23 | 24 | #: The hostname of the Celery worker. 25 | hostname = models.CharField(_('hostname'), max_length=255, unique=True) 26 | #: A :class:`~datetime.datetime` describing when the worker was last seen. 27 | last_heartbeat = models.DateTimeField(_('last heartbeat'), null=True, 28 | db_index=True) 29 | last_update = models.DateTimeField(_('last update'), auto_now=True) 30 | 31 | #: A :class:`~django_celery_monitor.managers.ExtendedManager` instance 32 | #: to query the :class:`~django_celery_monitor.models.WorkerState` model. 33 | objects = managers.WorkerStateQuerySet.as_manager() 34 | 35 | class Meta: 36 | """Model meta-data.""" 37 | 38 | verbose_name = _('worker') 39 | verbose_name_plural = _('workers') 40 | get_latest_by = 'last_heartbeat' 41 | ordering = ['-last_heartbeat'] 42 | 43 | def __str__(self): 44 | return self.hostname 45 | 46 | def __repr__(self): 47 | return ''.format(self) 48 | 49 | def is_alive(self): 50 | """Return whether the worker is currently alive or not.""" 51 | if self.last_heartbeat: 52 | # Use UTC timestamp if USE_TZ is true, or else use local timestamp 53 | timestamp = mktime(gmtime()) if settings.USE_TZ else time() 54 | return timestamp < heartbeat_expires(self.heartbeat_timestamp) 55 | return False 56 | 57 | @property 58 | def heartbeat_timestamp(self): 59 | return mktime(self.last_heartbeat.timetuple()) 60 | 61 | 62 | @python_2_unicode_compatible 63 | class TaskState(models.Model): 64 | """The data model to store the task state in.""" 65 | 66 | #: The :mod:`task state ` as returned by Celery. 67 | state = models.CharField( 68 | _('state'), max_length=64, choices=TASK_STATE_CHOICES, db_index=True, 69 | ) 70 | #: The task :func:`UUID `. 71 | task_id = models.CharField(_('UUID'), max_length=36, unique=True) 72 | #: The :ref:`task name `. 73 | name = models.CharField( 74 | _('name'), max_length=200, null=True, db_index=True, 75 | ) 76 | #: A :class:`~datetime.datetime` describing when the task was received. 77 | tstamp = models.DateTimeField(_('event received at'), db_index=True) 78 | #: The positional :ref:`task arguments `. 79 | args = models.TextField(_('Arguments'), null=True) 80 | #: The keyword :ref:`task arguments `. 81 | kwargs = models.TextField(_('Keyword arguments'), null=True) 82 | #: An optional :class:`~datetime.datetime` describing the 83 | #: :ref:`ETA ` for its processing. 84 | eta = models.DateTimeField(_('ETA'), null=True) 85 | #: An optional :class:`~datetime.datetime` describing when the task 86 | #: :ref:`expires `. 87 | expires = models.DateTimeField(_('expires'), null=True) 88 | #: The result of the task. 89 | result = models.TextField(_('result'), null=True) 90 | #: The Python error traceback if raised. 91 | traceback = models.TextField(_('traceback'), null=True) 92 | #: The task runtime in seconds. 93 | runtime = models.FloatField( 94 | _('execution time'), null=True, 95 | help_text=_('in seconds if task succeeded'), 96 | ) 97 | #: The number of retries. 98 | retries = models.IntegerField(_('number of retries'), default=0) 99 | #: The worker responsible for the execution of the task. 100 | worker = models.ForeignKey( 101 | WorkerState, null=True, verbose_name=_('worker'), 102 | on_delete=models.CASCADE, 103 | ) 104 | #: Whether the task has been expired and will be purged by the 105 | #: event framework. 106 | hidden = models.BooleanField(editable=False, default=False, db_index=True) 107 | 108 | #: A :class:`~django_celery_monitor.managers.TaskStateManager` instance 109 | #: to query the :class:`~django_celery_monitor.models.TaskState` model. 110 | objects = managers.TaskStateQuerySet.as_manager() 111 | 112 | class Meta: 113 | """Model meta-data.""" 114 | 115 | verbose_name = _('task') 116 | verbose_name_plural = _('tasks') 117 | get_latest_by = 'tstamp' 118 | ordering = ['-tstamp'] 119 | 120 | def __str__(self): 121 | name = self.name or 'UNKNOWN' 122 | s = '{0.state:<10} {0.task_id:<36} {1}'.format(self, name) 123 | if self.eta: 124 | s += ' eta:{0.eta}'.format(self) 125 | return s 126 | 127 | def __repr__(self): 128 | return ''.format( 129 | self, self.name or 'UNKNOWN', 130 | ) 131 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | Celery Monitoring for Django 3 | ============================ 4 | 5 | :Version: 1.1.2 6 | :Web: https://django-celery-monitor.readthedocs.io/ 7 | :Download: https://pypi.org/project/django_celery_monitor/ 8 | :Source: https://github.com/jazzband/django-celery-monitor 9 | :Keywords: django, celery, events, monitoring 10 | 11 | |jazzband| |build-status| |coverage| |license| |wheel| |pyversion| |pyimp| 12 | 13 | About 14 | ===== 15 | 16 | This extension enables you to monitor Celery tasks and workers. 17 | 18 | It defines two models (``django_celery_monitor.models.WorkerState`` and 19 | ``django_celery_monitor.models.TaskState``) used to store worker and task states 20 | and you can query this database table like any other Django model. 21 | It provides a Camera class (``django_celery_monitor.camera.Camera``) to be 22 | used with the Celery events command line tool to automatically populate the 23 | two models with the current state of the Celery workers and tasks. 24 | 25 | History 26 | ======= 27 | 28 | This package is a Celery 4 compatible port of the Django admin based 29 | monitoring feature that was included in the old 30 | `django-celery `_ package which 31 | is only compatible with Celery < 4.0. 32 | Other parts of django-celery were released as 33 | `django-celery-beat `_ 34 | (Database-backed Periodic Tasks) and 35 | `django-celery-results `_ 36 | (Celery result backends for Django). 37 | 38 | Installation 39 | ============ 40 | 41 | You can install django_celery_monitor either via the Python Package Index (PyPI) 42 | or from source. 43 | 44 | To install using `pip`,: 45 | 46 | .. code-block:: console 47 | 48 | $ pip install -U django_celery_monitor 49 | 50 | Usage 51 | ===== 52 | 53 | To use this with your project you need to follow these steps: 54 | 55 | #. Install the django_celery_monitor library: 56 | 57 | .. code-block:: console 58 | 59 | $ pip install django_celery_monitor 60 | 61 | #. Add ``django_celery_monitor`` to ``INSTALLED_APPS`` in your 62 | Django project's ``settings.py``:: 63 | 64 | INSTALLED_APPS = ( 65 | ..., 66 | 'django_celery_monitor', 67 | ) 68 | 69 | Note that there is no dash in the module name, only underscores. 70 | 71 | #. Create the Celery database tables by performing a database migrations: 72 | 73 | .. code-block:: console 74 | 75 | $ python manage.py migrate celery_monitor 76 | 77 | #. Go to the Django admin of your site and look for the "Celery Monitor" 78 | section. 79 | 80 | Starting the monitoring process 81 | =============================== 82 | 83 | To enable taking snapshots of the current state of tasks and workers you'll 84 | want to run the Celery events command with the appropriate camera class 85 | ``django_celery_monitor.camera.Camera``: 86 | 87 | .. code-block:: console 88 | 89 | $ celery -A proj events -l info --camera django_celery_monitor.camera.Camera --frequency=2.0 90 | 91 | For a complete listing of the command-line options available see: 92 | 93 | .. code-block:: console 94 | 95 | $ celery events --help 96 | 97 | Configuration 98 | ============= 99 | 100 | There are a few settings that regulate how long the task monitor should keep 101 | state entries in the database. Either of the three should be a 102 | ``datetime.timedelta`` value or ``None``. 103 | 104 | - ``monitor_task_success_expires`` -- Defaults to ``timedelta(days=1)`` (1 day) 105 | 106 | The period of time to retain monitoring information about tasks with a 107 | ``SUCCESS`` result. 108 | 109 | - ``monitor_task_error_expires`` -- Defaults to ``timedelta(days=3)`` (3 days) 110 | 111 | The period of time to retain monitoring information about tasks with an 112 | errornous result (one of the following event states: ``RETRY``, ``FAILURE``, 113 | ``REVOKED``. 114 | 115 | - ``monitor_task_pending_expires`` -- Defaults to ``timedelta(days=5)`` (5 days) 116 | 117 | The period of time to retain monitoring information about tasks with a 118 | pending result (one of the following event states: ``PENDING``, ``RECEIVED``, 119 | ``STARTED``, ``REJECTED``, ``RETRY``. 120 | 121 | In your Celery configuration simply set them to override the defaults, e.g.:: 122 | 123 | from datetime import timedelta 124 | 125 | monitor_task_success_expires = timedelta(days=7) 126 | 127 | .. |jazzband| image:: https://jazzband.co/static/img/badge.svg 128 | :target: https://jazzband.co/ 129 | :alt: Jazzband 130 | 131 | .. |build-status| image:: https://github.com/jazzband/django-celery-monitor/workflows/Test/badge.svg 132 | :target: https://github.com/jazzband/django-celery-monitor/actions 133 | :alt: GitHub Actions 134 | 135 | .. |coverage| image:: https://codecov.io/github/jazzband/django-celery-monitor/coverage.svg?branch=master 136 | :target: https://codecov.io/github/jazzband/django-celery-monitor?branch=master 137 | 138 | .. |license| image:: https://img.shields.io/pypi/l/django-celery-monitor.svg 139 | :alt: BSD License 140 | :target: https://opensource.org/licenses/BSD-3-Clause 141 | 142 | .. |wheel| image:: https://img.shields.io/pypi/wheel/django-celery-monitor.svg 143 | :alt: django-celery-monitor can be installed via wheel 144 | :target: http://pypi.python.org/pypi/django_celery_monitor/ 145 | 146 | .. |pyversion| image:: https://img.shields.io/pypi/pyversions/django-celery-monitor.svg 147 | :alt: Supported Python versions. 148 | :target: http://pypi.python.org/pypi/django_celery_monitor/ 149 | 150 | .. |pyimp| image:: https://img.shields.io/pypi/implementation/django-celery-monitor.svg 151 | :alt: Support Python implementations. 152 | :target: http://pypi.python.org/pypi/django_celery_monitor/ 153 | 154 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " epub3 to make an epub3" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | @echo " apicheck to verify that all modules are present in autodoc" 51 | @echo " configcheck to verify that all modules are present in autodoc" 52 | @echo " spelling to run a spell checker on the documentation" 53 | 54 | .PHONY: clean 55 | clean: 56 | rm -rf $(BUILDDIR)/* 57 | 58 | .PHONY: html 59 | html: 60 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 63 | 64 | .PHONY: dirhtml 65 | dirhtml: 66 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 67 | @echo 68 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 69 | 70 | .PHONY: singlehtml 71 | singlehtml: 72 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 73 | @echo 74 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 75 | 76 | .PHONY: pickle 77 | pickle: 78 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 79 | @echo 80 | @echo "Build finished; now you can process the pickle files." 81 | 82 | .PHONY: json 83 | json: 84 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 85 | @echo 86 | @echo "Build finished; now you can process the JSON files." 87 | 88 | .PHONY: htmlhelp 89 | htmlhelp: 90 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 91 | @echo 92 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 93 | ".hhp project file in $(BUILDDIR)/htmlhelp." 94 | 95 | .PHONY: qthelp 96 | qthelp: 97 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 98 | @echo 99 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 100 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 101 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PROJ.qhcp" 102 | @echo "To view the help file:" 103 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PROJ.qhc" 104 | 105 | .PHONY: applehelp 106 | applehelp: 107 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 108 | @echo 109 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 110 | @echo "N.B. You won't be able to view it unless you put it in" \ 111 | "~/Library/Documentation/Help or install it in your application" \ 112 | "bundle." 113 | 114 | .PHONY: devhelp 115 | devhelp: 116 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 117 | @echo 118 | @echo "Build finished." 119 | @echo "To view the help file:" 120 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PROJ" 121 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PROJ" 122 | @echo "# devhelp" 123 | 124 | .PHONY: epub 125 | epub: 126 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 127 | @echo 128 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 129 | 130 | .PHONY: epub3 131 | epub3: 132 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 133 | @echo 134 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 135 | 136 | .PHONY: latex 137 | latex: 138 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 139 | @echo 140 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 141 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 142 | "(use \`make latexpdf' here to do that automatically)." 143 | 144 | .PHONY: latexpdf 145 | latexpdf: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through pdflatex..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: latexpdfja 152 | latexpdfja: 153 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 154 | @echo "Running LaTeX files through platex and dvipdfmx..." 155 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 156 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 157 | 158 | .PHONY: text 159 | text: 160 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 161 | @echo 162 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 163 | 164 | .PHONY: man 165 | man: 166 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 167 | @echo 168 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 169 | 170 | .PHONY: texinfo 171 | texinfo: 172 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 173 | @echo 174 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 175 | @echo "Run \`make' in that directory to run these through makeinfo" \ 176 | "(use \`make info' here to do that automatically)." 177 | 178 | .PHONY: info 179 | info: 180 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 181 | @echo "Running Texinfo files through makeinfo..." 182 | make -C $(BUILDDIR)/texinfo info 183 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 184 | 185 | .PHONY: gettext 186 | gettext: 187 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 188 | @echo 189 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 190 | 191 | .PHONY: changes 192 | changes: 193 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 194 | @echo 195 | @echo "The overview file is in $(BUILDDIR)/changes." 196 | 197 | .PHONY: linkcheck 198 | linkcheck: 199 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 200 | @echo 201 | @echo "Link check complete; look for any errors in the above output " \ 202 | "or in $(BUILDDIR)/linkcheck/output.txt." 203 | 204 | .PHONY: doctest 205 | doctest: 206 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 207 | @echo "Testing of doctests in the sources finished, look at the " \ 208 | "results in $(BUILDDIR)/doctest/output.txt." 209 | 210 | .PHONY: coverage 211 | coverage: 212 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 213 | @echo "Testing of coverage in the sources finished, look at the " \ 214 | "results in $(BUILDDIR)/coverage/python.txt." 215 | 216 | .PHONY: apicheck 217 | apicheck: 218 | $(SPHINXBUILD) -b apicheck $(ALLSPHINXOPTS) $(BUILDDIR)/apicheck 219 | 220 | .PHONY: configcheck 221 | configcheck: 222 | $(SPHINXBUILD) -b configcheck $(ALLSPHINXOPTS) $(BUILDDIR)/configcheck 223 | 224 | .PHONY: spelling 225 | spelling: 226 | SPELLCHECK=1 $(SPHINXBUILD) -b spelling $(ALLSPHINXOPTS) $(BUILDDIR)/spelling 227 | 228 | .PHONY: xml 229 | xml: 230 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 231 | @echo 232 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 233 | 234 | .PHONY: pseudoxml 235 | pseudoxml: 236 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 237 | @echo 238 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 239 | -------------------------------------------------------------------------------- /django_celery_monitor/admin.py: -------------------------------------------------------------------------------- 1 | """Result Task Admin interface.""" 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from __future__ import absolute_import, unicode_literals 5 | 6 | from django.contrib import admin 7 | from django.contrib.admin import helpers 8 | from django.contrib.admin.views import main as main_views 9 | from django.shortcuts import render_to_response 10 | from django.template import RequestContext 11 | from django.utils.encoding import force_text 12 | from django.utils.html import escape 13 | from django.utils.translation import ugettext_lazy as _ 14 | 15 | from celery import current_app 16 | from celery import states 17 | from celery.task.control import broadcast, revoke, rate_limit 18 | from celery.utils.text import abbrtask 19 | 20 | from .models import TaskState, WorkerState 21 | from .humanize import naturaldate 22 | from .utils import action, display_field, fixedwidth, make_aware 23 | 24 | 25 | TASK_STATE_COLORS = {states.SUCCESS: 'green', 26 | states.FAILURE: 'red', 27 | states.REVOKED: 'magenta', 28 | states.STARTED: 'yellow', 29 | states.RETRY: 'orange', 30 | 'RECEIVED': 'blue'} 31 | NODE_STATE_COLORS = {'ONLINE': 'green', 32 | 'OFFLINE': 'gray'} 33 | 34 | 35 | class MonitorList(main_views.ChangeList): 36 | """A custom changelist to set the page title automatically.""" 37 | 38 | def __init__(self, *args, **kwargs): 39 | super(MonitorList, self).__init__(*args, **kwargs) 40 | self.title = self.model_admin.list_page_title 41 | 42 | 43 | @display_field(_('state'), 'state') 44 | def colored_state(task): 45 | """Return the task state colored with HTML/CSS according to its level. 46 | 47 | See ``django_celery_monitor.admin.TASK_STATE_COLORS`` for the colors. 48 | """ 49 | state = escape(task.state) 50 | color = TASK_STATE_COLORS.get(task.state, 'black') 51 | return '{1}'.format(color, state) 52 | 53 | 54 | @display_field(_('state'), 'last_heartbeat') 55 | def node_state(node): 56 | """Return the worker state colored with HTML/CSS according to its level. 57 | 58 | See ``django_celery_monitor.admin.NODE_STATE_COLORS`` for the colors. 59 | """ 60 | state = node.is_alive() and 'ONLINE' or 'OFFLINE' 61 | color = NODE_STATE_COLORS[state] 62 | return '{1}'.format(color, state) 63 | 64 | 65 | @display_field(_('ETA'), 'eta') 66 | def eta(task): 67 | """Return the task ETA as a grey "none" if none is provided.""" 68 | if not task.eta: 69 | return 'none' 70 | return escape(make_aware(task.eta)) 71 | 72 | 73 | @display_field(_('when'), 'tstamp') 74 | def tstamp(task): 75 | """Better timestamp rendering. 76 | 77 | Converts the task timestamp to the local timezone and renders 78 | it as a "natural date" -- a human readable version. 79 | """ 80 | value = make_aware(task.tstamp) 81 | return '
{1}
'.format( 82 | escape(str(value)), escape(naturaldate(value)), 83 | ) 84 | 85 | 86 | @display_field(_('name'), 'name') 87 | def name(task): 88 | """Return the task name and abbreviates it to maximum of 16 characters.""" 89 | short_name = abbrtask(task.name, 16) 90 | return '
{1}
'.format( 91 | escape(task.name), escape(short_name), 92 | ) 93 | 94 | 95 | class ModelMonitor(admin.ModelAdmin): 96 | """Base class for task and worker monitors.""" 97 | 98 | can_add = False 99 | can_delete = False 100 | 101 | def get_changelist(self, request, **kwargs): 102 | """Return the custom change list class we defined above.""" 103 | return MonitorList 104 | 105 | def change_view(self, request, object_id, extra_context=None): 106 | """Make sure the title is set correctly.""" 107 | extra_context = extra_context or {} 108 | extra_context.setdefault('title', self.detail_title) 109 | return super(ModelMonitor, self).change_view( 110 | request, object_id, extra_context=extra_context, 111 | ) 112 | 113 | def has_delete_permission(self, request, obj=None): 114 | """Short-circuiting the permission checks based on class attribute.""" 115 | if not self.can_delete: 116 | return False 117 | return super(ModelMonitor, self).has_delete_permission(request, obj) 118 | 119 | def has_add_permission(self, request): 120 | """Short-circuiting the permission checks based on class attribute.""" 121 | if not self.can_add: 122 | return False 123 | return super(ModelMonitor, self).has_add_permission(request) 124 | 125 | 126 | @admin.register(TaskState) 127 | class TaskMonitor(ModelMonitor): 128 | """The Celery task monitor.""" 129 | 130 | detail_title = _('Task detail') 131 | list_page_title = _('Tasks') 132 | rate_limit_confirmation_template = ( 133 | 'django_celery_monitor/confirm_rate_limit.html' 134 | ) 135 | date_hierarchy = 'tstamp' 136 | fieldsets = ( 137 | (None, { 138 | 'fields': ('state', 'task_id', 'name', 'args', 'kwargs', 139 | 'eta', 'runtime', 'worker', 'tstamp'), 140 | 'classes': ('extrapretty', ), 141 | }), 142 | ('Details', { 143 | 'classes': ('collapse', 'extrapretty'), 144 | 'fields': ('result', 'traceback', 'expires'), 145 | }), 146 | ) 147 | list_display = ( 148 | fixedwidth('task_id', name=_('UUID'), pt=8), 149 | colored_state, 150 | name, 151 | fixedwidth('args', pretty=True), 152 | fixedwidth('kwargs', pretty=True), 153 | eta, 154 | tstamp, 155 | 'worker', 156 | ) 157 | readonly_fields = ( 158 | 'state', 'task_id', 'name', 'args', 'kwargs', 159 | 'eta', 'runtime', 'worker', 'result', 'traceback', 160 | 'expires', 'tstamp', 161 | ) 162 | list_filter = ('state', 'name', 'tstamp', 'eta', 'worker') 163 | search_fields = ('name', 'task_id', 'args', 'kwargs', 'worker__hostname') 164 | actions = ['revoke_tasks', 165 | 'terminate_tasks', 166 | 'kill_tasks', 167 | 'rate_limit_tasks'] 168 | 169 | class Media: 170 | """Just some extra colors.""" 171 | 172 | css = {'all': ('django_celery_monitor/style.css', )} 173 | 174 | @action(_('Revoke selected tasks')) 175 | def revoke_tasks(self, request, queryset): 176 | with current_app.default_connection() as connection: 177 | for state in queryset: 178 | revoke(state.task_id, connection=connection) 179 | 180 | @action(_('Terminate selected tasks')) 181 | def terminate_tasks(self, request, queryset): 182 | with current_app.default_connection() as connection: 183 | for state in queryset: 184 | revoke(state.task_id, connection=connection, terminate=True) 185 | 186 | @action(_('Kill selected tasks')) 187 | def kill_tasks(self, request, queryset): 188 | with current_app.default_connection() as connection: 189 | for state in queryset: 190 | revoke(state.task_id, connection=connection, 191 | terminate=True, signal='KILL') 192 | 193 | @action(_('Rate limit selected tasks')) 194 | def rate_limit_tasks(self, request, queryset): 195 | tasks = set([task.name for task in queryset]) 196 | opts = self.model._meta 197 | app_label = opts.app_label 198 | if request.POST.get('post'): 199 | rate = request.POST['rate_limit'] 200 | with current_app.default_connection() as connection: 201 | for task_name in tasks: 202 | rate_limit(task_name, rate, connection=connection) 203 | return None 204 | 205 | context = { 206 | 'title': _('Rate limit selection'), 207 | 'queryset': queryset, 208 | 'object_name': force_text(opts.verbose_name), 209 | 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, 210 | 'opts': opts, 211 | 'app_label': app_label, 212 | } 213 | 214 | return render_to_response( 215 | self.rate_limit_confirmation_template, context, 216 | context_instance=RequestContext(request), 217 | ) 218 | 219 | def get_actions(self, request): 220 | actions = super(TaskMonitor, self).get_actions(request) 221 | actions.pop('delete_selected', None) 222 | return actions 223 | 224 | def get_queryset(self, request): 225 | qs = super(TaskMonitor, self).get_queryset(request) 226 | return qs.select_related('worker') 227 | 228 | 229 | @admin.register(WorkerState) 230 | class WorkerMonitor(ModelMonitor): 231 | """The Celery worker monitor.""" 232 | 233 | can_add = True 234 | detail_title = _('Node detail') 235 | list_page_title = _('Worker Nodes') 236 | list_display = ('hostname', node_state) 237 | readonly_fields = ('last_heartbeat', ) 238 | actions = ['shutdown_nodes', 239 | 'enable_events', 240 | 'disable_events'] 241 | 242 | @action(_('Shutdown selected worker nodes')) 243 | def shutdown_nodes(self, request, queryset): 244 | broadcast('shutdown', destination=[n.hostname for n in queryset]) 245 | 246 | @action(_('Enable event mode for selected nodes.')) 247 | def enable_events(self, request, queryset): 248 | broadcast('enable_events', 249 | destination=[n.hostname for n in queryset]) 250 | 251 | @action(_('Disable event mode for selected nodes.')) 252 | def disable_events(self, request, queryset): 253 | broadcast('disable_events', 254 | destination=[n.hostname for n in queryset]) 255 | 256 | def get_actions(self, request): 257 | actions = super(WorkerMonitor, self).get_actions(request) 258 | actions.pop('delete_selected', None) 259 | return actions 260 | -------------------------------------------------------------------------------- /tests/unit/test_camera.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from datetime import datetime, timedelta 4 | from itertools import count 5 | from time import time 6 | 7 | import pytest 8 | 9 | from celery import states 10 | from celery.events import Event as _Event 11 | from celery.events.state import State, Worker, Task 12 | from celery.utils import gen_unique_id 13 | 14 | from django.test.utils import override_settings 15 | from django.utils import timezone 16 | 17 | from django_celery_monitor import camera, models 18 | from django_celery_monitor.utils import make_aware 19 | 20 | 21 | _ids = count(0) 22 | _clock = count(1) 23 | 24 | 25 | def Event(*args, **kwargs): 26 | kwargs.setdefault('clock', next(_clock)) 27 | kwargs.setdefault('local_received', time()) 28 | return _Event(*args, **kwargs) 29 | 30 | 31 | @pytest.mark.usefixtures('depends_on_current_app') 32 | @pytest.mark.django_db 33 | class test_Camera: 34 | Camera = camera.Camera 35 | 36 | def create_task(self, worker, **kwargs): 37 | d = dict(uuid=gen_unique_id(), 38 | name='django_celery_monitor.test.task{0}'.format(next(_ids)), 39 | worker=worker) 40 | return Task(**dict(d, **kwargs)) 41 | 42 | @pytest.fixture(autouse=True) 43 | def setup_app(self, app): 44 | self.app = app 45 | self.state = State() 46 | self.cam = self.Camera(self.state) 47 | 48 | def test_constructor(self): 49 | cam = self.Camera(State()) 50 | assert cam.state 51 | assert cam.freq 52 | assert cam.cleanup_freq 53 | assert cam.logger 54 | 55 | def test_get_heartbeat(self): 56 | worker = Worker(hostname='fuzzie') 57 | assert self.cam.get_heartbeat(worker) is None 58 | t1 = time() 59 | t2 = time() 60 | t3 = time() 61 | for t in t1, t2, t3: 62 | worker.event('heartbeat', t, t, {}) 63 | self.state.workers[worker.hostname] = worker 64 | assert ( 65 | self.cam.get_heartbeat(worker) == make_aware( 66 | datetime.fromtimestamp(t3) 67 | ) 68 | ) 69 | 70 | def test_handle_worker(self): 71 | worker = Worker(hostname='fuzzie') 72 | worker.event('online', time(), time(), {}) 73 | old_last_update = timezone.now() - timedelta(hours=1) 74 | models.WorkerState.objects.all().update(last_update=old_last_update) 75 | 76 | m = self.cam.handle_worker((worker.hostname, worker)) 77 | assert m 78 | assert m.hostname 79 | assert m.last_heartbeat 80 | assert m.last_update != old_last_update 81 | assert m.is_alive() 82 | assert str(m) == str(m.hostname) 83 | assert repr(m) 84 | 85 | def test_handle_task_received(self): 86 | worker = Worker(hostname='fuzzie') 87 | worker.event('online', time(), time(), {}) 88 | self.cam.handle_worker((worker.hostname, worker)) 89 | 90 | task = self.create_task(worker) 91 | task.event('received', time(), time(), {}) 92 | assert task.state == states.RECEIVED 93 | mt = self.cam.handle_task((task.uuid, task)) 94 | assert mt.name == task.name 95 | assert str(mt) 96 | assert repr(mt) 97 | mt.eta = timezone.now() 98 | assert 'eta' in str(mt) 99 | assert mt in models.TaskState.objects.active() 100 | 101 | def test_handle_task(self): 102 | worker1 = Worker(hostname='fuzzie') 103 | worker1.event('online', time(), time(), {}) 104 | mw = self.cam.handle_worker((worker1.hostname, worker1)) 105 | task1 = self.create_task(worker1) 106 | task1.event('received', time(), time(), {}) 107 | mt = self.cam.handle_task((task1.uuid, task1)) 108 | assert mt.worker == mw 109 | 110 | worker2 = Worker(hostname=None) 111 | task2 = self.create_task(worker2) 112 | task2.event('received', time(), time(), {}) 113 | mt = self.cam.handle_task((task2.uuid, task2)) 114 | assert mt.worker is None 115 | 116 | task1.event('succeeded', time(), time(), {'result': 42}) 117 | assert task1.state == states.SUCCESS 118 | assert task1.result == 42 119 | mt = self.cam.handle_task((task1.uuid, task1)) 120 | assert mt.name == task1.name 121 | assert mt.result == 42 122 | 123 | task3 = self.create_task(worker1, name=None) 124 | task3.event('revoked', time(), time(), {}) 125 | mt = self.cam.handle_task((task3.uuid, task3)) 126 | assert mt is None 127 | 128 | def test_handle_task_timezone(self): 129 | worker = Worker(hostname='fuzzie') 130 | worker.event('online', time(), time(), {}) 131 | self.cam.handle_worker((worker.hostname, worker)) 132 | 133 | tstamp = 1464793200.0 # 2016-06-01T15:00:00Z 134 | 135 | with override_settings(USE_TZ=True, TIME_ZONE='Europe/Helsinki'): 136 | task = self.create_task( 137 | worker, 138 | eta='2016-06-01T15:16:17.654321+00:00', 139 | expires='2016-07-01T15:16:17.765432+03:00', 140 | ) 141 | task.event('received', tstamp, tstamp, {}) 142 | mt = self.cam.handle_task((task.uuid, task)) 143 | assert ( 144 | mt.tstamp == datetime( 145 | 2016, 6, 1, 15, 0, 0, tzinfo=timezone.utc 146 | ) 147 | ) 148 | assert ( 149 | mt.eta == datetime( 150 | 2016, 6, 1, 15, 16, 17, 654321, tzinfo=timezone.utc 151 | ) 152 | ) 153 | assert ( 154 | mt.expires == datetime( 155 | 2016, 7, 1, 12, 16, 17, 765432, tzinfo=timezone.utc 156 | ) 157 | ) 158 | 159 | task = self.create_task(worker, eta='2016-06-04T15:16:17.654321') 160 | task.event('received', tstamp, tstamp, {}) 161 | mt = self.cam.handle_task((task.uuid, task)) 162 | assert ( 163 | mt.eta == datetime( 164 | 2016, 6, 4, 15, 16, 17, 654321, tzinfo=timezone.utc 165 | ) 166 | ) 167 | 168 | with override_settings(USE_TZ=False, TIME_ZONE='Europe/Helsinki'): 169 | task = self.create_task( 170 | worker, 171 | eta='2016-06-01T15:16:17.654321+00:00', 172 | expires='2016-07-01T15:16:17.765432+03:00', 173 | ) 174 | task.event('received', tstamp, tstamp, {}) 175 | mt = self.cam.handle_task((task.uuid, task)) 176 | assert mt.tstamp == datetime(2016, 6, 1, 18, 0, 0) 177 | assert mt.eta == datetime(2016, 6, 1, 18, 16, 17, 654321) 178 | assert mt.expires == datetime(2016, 7, 1, 15, 16, 17, 765432) 179 | 180 | task = self.create_task(worker, eta='2016-06-04T15:16:17.654321') 181 | task.event('received', tstamp, tstamp, {}) 182 | mt = self.cam.handle_task((task.uuid, task)) 183 | assert mt.eta == datetime(2016, 6, 4, 15, 16, 17, 654321) 184 | 185 | def assert_expires(self, dec, expired, tasks=10): 186 | # Cleanup leftovers from previous tests 187 | self.cam.on_cleanup() 188 | 189 | worker = Worker(hostname='fuzzie') 190 | worker.event('online', time(), time(), {}) 191 | for total in range(tasks): 192 | task = self.create_task(worker) 193 | task.event('received', time() - dec, time() - dec, {}) 194 | task.event('succeeded', time() - dec, time() - dec, {'result': 42}) 195 | assert task.name 196 | assert self.cam.handle_task((task.uuid, task)) 197 | assert self.cam.on_cleanup() == expired 198 | 199 | def test_on_cleanup_expires(self, dec=332000): 200 | self.assert_expires(dec, 10) 201 | 202 | def test_on_cleanup_does_not_expire_new(self, dec=0): 203 | self.assert_expires(dec, 0) 204 | 205 | def test_on_shutter(self): 206 | state = self.state 207 | cam = self.cam 208 | 209 | ws = ['worker1.ex.com', 'worker2.ex.com', 'worker3.ex.com'] 210 | uus = [gen_unique_id() for i in range(50)] 211 | 212 | events = [Event('worker-online', hostname=ws[0]), 213 | Event('worker-online', hostname=ws[1]), 214 | Event('worker-online', hostname=ws[2]), 215 | Event('task-received', 216 | uuid=uus[0], name='A', hostname=ws[0]), 217 | Event('task-started', 218 | uuid=uus[0], name='A', hostname=ws[0]), 219 | Event('task-received', 220 | uuid=uus[1], name='B', hostname=ws[1]), 221 | Event('task-revoked', 222 | uuid=uus[2], name='C', hostname=ws[2])] 223 | 224 | for event in events: 225 | event['local_received'] = time() 226 | state.event(event) 227 | cam.on_shutter(state) 228 | 229 | for host in ws: 230 | worker = models.WorkerState.objects.get(hostname=host) 231 | assert worker.is_alive() 232 | 233 | t1 = models.TaskState.objects.get(task_id=uus[0]) 234 | assert t1.state == states.STARTED 235 | assert t1.name == 'A' 236 | t2 = models.TaskState.objects.get(task_id=uus[1]) 237 | assert t2.state == states.RECEIVED 238 | t3 = models.TaskState.objects.get(task_id=uus[2]) 239 | assert t3.state == states.REVOKED 240 | 241 | events = [Event('task-succeeded', 242 | uuid=uus[0], hostname=ws[0], result=42), 243 | Event('task-failed', 244 | uuid=uus[1], exception="KeyError('foo')", 245 | hostname=ws[1]), 246 | Event('worker-offline', hostname=ws[0])] 247 | list(map(state.event, events)) 248 | # reset the date the last update was done 249 | models.WorkerState.objects.all().update( 250 | last_update=timezone.now() - timedelta(hours=1) 251 | ) 252 | cam.on_shutter(state) 253 | 254 | w1 = models.WorkerState.objects.get(hostname=ws[0]) 255 | assert not w1.is_alive() 256 | 257 | t1 = models.TaskState.objects.get(task_id=uus[0]) 258 | assert t1.state == states.SUCCESS 259 | assert t1.result == '42' 260 | assert t1.worker == w1 261 | 262 | t2 = models.TaskState.objects.get(task_id=uus[1]) 263 | assert t2.state == states.FAILURE 264 | assert t2.result == "KeyError('foo')" 265 | assert t2.worker.hostname == ws[1] 266 | 267 | cam.on_shutter(state) 268 | --------------------------------------------------------------------------------