├── .copier └── package.yml ├── .editorconfig ├── .env.example ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .vscode ├── extensions.json └── settings.json.example ├── AUTHORS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Justfile ├── LICENSE ├── README.md ├── RELEASING.md ├── docs ├── _static │ └── css │ │ └── custom.css ├── conf.py ├── development │ └── just.md └── index.md ├── noxfile.py ├── pyproject.toml ├── src └── django_q_registry │ ├── __init__.py │ ├── _typing.py │ ├── apps.py │ ├── conf.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── setup_periodic_tasks.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── py.typed │ └── registry.py └── tests ├── __init__.py ├── conftest.py ├── settings.py ├── test_conf.py ├── test_models.py ├── test_registry.py ├── test_setup_periodic_tasks.py └── test_version.py /.copier/package.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v2024.24 3 | _src_path: gh:westerveltco/django-twc-package 4 | author_email: josh@joshthomas.dev 5 | author_name: Josh Thomas 6 | current_version: 0.5.0 7 | django_versions: 8 | - '4.2' 9 | - '5.0' 10 | docs_domain: westervelt.dev 11 | github_owner: westerveltco 12 | github_repo: django-q-registry 13 | module_name: django_q_registry 14 | package_description: A Django app to register periodic Django Q tasks. 15 | package_name: django-q-registry 16 | python_versions: 17 | - '3.8' 18 | - '3.9' 19 | - '3.10' 20 | - '3.11' 21 | - '3.12' 22 | - '3.13' 23 | test_django_main: true 24 | versioning_scheme: SemVer 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{,.}{j,J}ustfile] 12 | indent_size = 4 13 | 14 | [*.{py,rst,ini,md}] 15 | indent_size = 4 16 | 17 | [*.py] 18 | line_length = 120 19 | multi_line_output = 3 20 | 21 | [*.{css,html,js,json,jsx,sass,scss,svelte,ts,tsx,yml,yaml}] 22 | indent_size = 2 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | 27 | [{Makefile,*.bat}] 28 | indent_style = tab 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-q-registry/c309f866e7485dcfbedd769dc9bc0755ecdaec81/.env.example -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @westerveltco/oss 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | timezone: America/Chicago 8 | labels: 9 | - 🤖 dependabot 10 | groups: 11 | gha: 12 | patterns: 13 | - "*" 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | test: 12 | uses: ./.github/workflows/test.yml 13 | 14 | pypi: 15 | if: ${{ github.event_name == 'release' }} 16 | runs-on: ubuntu-latest 17 | needs: test 18 | environment: release 19 | permissions: 20 | contents: read 21 | id-token: write 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: 3.12 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install -U pip uv 32 | python -m uv pip install --system hatch 33 | 34 | - name: Build package 35 | run: | 36 | hatch build 37 | 38 | - name: Publish to PyPI 39 | uses: pypa/gh-action-pypi-publish@release/v1 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | workflow_call: 8 | 9 | concurrency: 10 | group: test-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PYTHONUNBUFFERED: "1" 15 | FORCE_COLOR: "1" 16 | 17 | jobs: 18 | generate-matrix: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | matrix: ${{ steps.set-matrix.outputs.matrix }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: 3.9 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install -U pip uv 32 | python -m uv pip install --system nox 33 | 34 | - id: set-matrix 35 | run: | 36 | echo "matrix=$(python -m nox -l --json | jq -c '[.[] | select(.name == "tests") | {"python-version": .python, "django-version": .call_spec.django}] | {include: .}')" >> $GITHUB_OUTPUT 37 | 38 | test: 39 | name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 40 | runs-on: ubuntu-latest 41 | needs: generate-matrix 42 | strategy: 43 | fail-fast: false 44 | matrix: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - uses: actions/setup-python@v5 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | allow-prereleases: true 52 | 53 | - name: Install dependencies 54 | run: | 55 | python -m pip install -U pip uv 56 | python -m uv pip install --system nox 57 | 58 | - name: Run tests 59 | run: | 60 | python -m nox --session "tests(python='${{ matrix.python-version }}', django='${{ matrix.django-version }}')" 61 | 62 | tests: 63 | runs-on: ubuntu-latest 64 | needs: test 65 | if: always() 66 | steps: 67 | - name: OK 68 | if: ${{ !(contains(needs.*.result, 'failure')) }} 69 | run: exit 0 70 | - name: Fail 71 | if: ${{ contains(needs.*.result, 'failure') }} 72 | run: exit 1 73 | 74 | types: 75 | runs-on: ubuntu-latest 76 | steps: 77 | - uses: actions/checkout@v4 78 | 79 | - uses: actions/setup-python@v5 80 | with: 81 | python-version: 3.9 82 | 83 | - name: Install dependencies 84 | run: | 85 | python -m pip install -U pip uv 86 | python -m uv pip install --system nox 87 | 88 | - name: Run mypy 89 | run: | 90 | python -m nox --session "mypy" 91 | 92 | coverage: 93 | runs-on: ubuntu-latest 94 | steps: 95 | - uses: actions/checkout@v4 96 | 97 | - uses: actions/setup-python@v5 98 | with: 99 | python-version: 3.9 100 | 101 | - name: Install dependencies 102 | run: | 103 | python -m pip install -U pip uv 104 | python -m uv pip install --system nox 105 | 106 | - name: Run coverage 107 | run: | 108 | python -m nox --session "coverage" 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Logs 163 | logs 164 | *.log 165 | npm-debug.log* 166 | yarn-debug.log* 167 | yarn-error.log* 168 | pnpm-debug.log* 169 | lerna-debug.log* 170 | 171 | node_modules 172 | dist 173 | dist-ssr 174 | *.local 175 | 176 | # Editor directories and files 177 | .vscode/* 178 | !.vscode/extensions.json 179 | !.vscode/*.example 180 | .idea 181 | .DS_Store 182 | *.suo 183 | *.ntvs* 184 | *.njsproj 185 | *.sln 186 | *.sw? 187 | 188 | staticfiles/ 189 | mediafiles/ 190 | 191 | # pyright config for nvim-lspconfig 192 | pyrightconfig.json 193 | 194 | # mise 195 | .mise*.toml 196 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-toml 11 | - id: check-yaml 12 | 13 | - repo: https://github.com/adamchainz/django-upgrade 14 | rev: 1.25.0 15 | hooks: 16 | - id: django-upgrade 17 | args: [--target-version, "4.2"] 18 | 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: v0.11.12 21 | hooks: 22 | - id: ruff 23 | args: [--fix] 24 | - id: ruff-format 25 | 26 | - repo: https://github.com/adamchainz/blacken-docs 27 | rev: 1.19.1 28 | hooks: 29 | - id: blacken-docs 30 | alias: autoformat 31 | additional_dependencies: 32 | - black==22.12.0 33 | 34 | - repo: https://github.com/pre-commit/mirrors-prettier 35 | rev: v4.0.0-alpha.8 36 | hooks: 37 | - id: prettier 38 | # lint the following with prettier: 39 | # - javascript 40 | # - typescript 41 | # - JSX/TSX 42 | # - CSS 43 | # - yaml 44 | # ignore any minified code 45 | files: '^(?!.*\.min\..*)(?P[\w-]+(\.[\w-]+)*\.(js|jsx|ts|tsx|yml|yaml|css))$' 46 | 47 | - repo: https://github.com/djlint/djLint 48 | rev: v1.36.4 49 | hooks: 50 | - id: djlint-reformat-django 51 | - id: djlint-django 52 | 53 | - repo: local 54 | hooks: 55 | - id: rustywind 56 | name: rustywind Tailwind CSS class linter 57 | language: node 58 | additional_dependencies: 59 | - rustywind@0.21.0 60 | entry: rustywind 61 | args: [--write] 62 | types_or: [html, jsx, tsx] 63 | 64 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 65 | rev: v2.14.0 66 | hooks: 67 | - id: pretty-format-toml 68 | args: [--autofix] 69 | 70 | - repo: https://github.com/abravalheri/validate-pyproject 71 | rev: v0.24.1 72 | hooks: 73 | - id: validate-pyproject 74 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | jobs: 8 | pre_build: 9 | - asdf plugin add just && asdf install just latest && asdf global just latest 10 | - just _cog 11 | os: ubuntu-22.04 12 | tools: 13 | python: "3.12" 14 | 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | formats: 19 | - pdf 20 | - epub 21 | 22 | python: 23 | install: 24 | - method: pip 25 | path: . 26 | extra_requirements: 27 | - docs 28 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode", 6 | "monosans.djlint", 7 | "ms-python.black-formatter", 8 | "ms-python.pylint", 9 | "ms-python.python", 10 | "ms-python.vscode-pylance", 11 | "skellock.just", 12 | "tamasfe.even-better-toml" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.associations": { 4 | "Justfile": "just" 5 | }, 6 | "ruff.organizeImports": true, 7 | "[django-html][handlebars][hbs][mustache][jinja][jinja-html][nj][njk][nunjucks][twig]": { 8 | "editor.defaultFormatter": "monosans.djlint" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | - Josh Thomas 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project attempts to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 18 | 19 | ## [Unreleased] 20 | 21 | ## [0.5.0] 22 | 23 | ### Added 24 | 25 | - Support for Django 5.1 and 5.2. 26 | 27 | ### Removed 28 | 29 | - Dropped support for Python 3.8. 30 | 31 | ## [0.4.0] 32 | 33 | ### Added 34 | 35 | - Support for Python 3.13. 36 | 37 | ### Changed 38 | 39 | - Bumped `django-twc-package` template version to 2024.24. 40 | - Refactored how app settings are accessed within library to use a frozen `dataclass`. 41 | 42 | ## [0.3.2] 43 | 44 | ### Added 45 | 46 | - Added a `py.typed` file for static type checkers. 47 | 48 | ## [0.3.1] 49 | 50 | ### Fixed 51 | 52 | - Correctly JSON serialize `Task` kwargs when going from the in-memory representation contained in the task registry to actual model instances in the database. First reported by [@joshuadavidthomas](https://github.com/joshuadavidthomas) in [#30](https://github.com/westerveltco/django-q-registry/issues/30). 53 | 54 | ## [0.3.0] 55 | 56 | ### Changed 57 | 58 | - Now using v2024.18 of `django-twc-package`. 59 | 60 | ### Removed 61 | 62 | - Dropped support for Django 3.2. 63 | 64 | ## [0.2.1] 65 | 66 | ### Added 67 | 68 | - Added a `TaskRegistry.created_tasks` attribute to store the `Task` instances created by the `TaskRegistry`. 69 | 70 | ### Changed 71 | 72 | - Now using v2024.12 of `django-twc-package`. 73 | 74 | ### Fixed 75 | 76 | - Fixed a bug in the `setup_periodic_tasks` management command where newly created tasks via `Task.objects.create_from_registry` were immediately deleted via `Task.objects.delete_dangling_objects`. Newly created tasks are now added to the `TaskRegistry.created_tasks` attribute and are only deleted if they are not in the `TaskRegistry.created_tasks` attribute. 77 | 78 | ## [0.2.0] 79 | 80 | ### Added 81 | 82 | - Refactored the `django_q_registry.registry.Task` dataclass into a `django_q_registry.models.Task` Django model. This should make it more flexible and robust for registering tasks and the associated `django_q.models.Schedule` instances. 83 | 84 | ### Changed 85 | 86 | - Now using [`django-twc-package`](https://github.com/westerveltco/django-twc-package) template for repository and package structure. 87 | - The default for the `Q_REGISTRY["PERIOIDIC_TASK_SUFFIX"]` app setting has been changed from `"- CRON"` to `"- QREGISTRY"`. 88 | - All database logic has been moved from the `TaskRegistry` to the `setup_periodic_tasks` management command. 89 | - GitHub Actions `test` workflow now uses the output of `nox -l --json` to dynamically generate the test matrix. 90 | 91 | ### Fixed 92 | 93 | - Fixed a bug in the hashing of a `Task` where the `hash` function was passed unhashable values (e.g. a `dict`). Thanks to [@Tobi-De](https://github.com/Tobi-De) for the bug report ([#6](https://github.com/westerveltco/django-q-registry/issues/6)). 94 | 95 | ## [0.1.0] 96 | 97 | Initial release! 98 | 99 | ### Added 100 | 101 | - Initial documentation. 102 | - Initial tests. 103 | - Initial CI/CD (GitHub Actions). 104 | - A registry for Django Q2 periodic tasks. 105 | - `registry.register` function for registering periodic tasks with a convenience decorator `register_task`. 106 | - A `TASKS` setting for registering periodic tasks from Django settings. 107 | - Autodiscovery of periodic tasks from a Django project's `tasks.py` files. 108 | - A `setup_periodic_tasks` management command for setting up periodic tasks in the Django Q2 broker. 109 | 110 | ### New Contributors 111 | 112 | - Josh Thomas (maintainer) 113 | 114 | [unreleased]: https://github.com/westerveltco/django-q-registry/compare/v0.5.0...HEAD 115 | [0.1.0]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.1.0 116 | [0.2.0]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.2.0 117 | [0.2.1]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.2.1 118 | [0.3.0]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.3.0 119 | [0.3.1]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.3.1 120 | [0.3.2]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.3.2 121 | [0.4.0]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.4.0 122 | [0.5.0]: https://github.com/westerveltco/django-q-registry/releases/tag/v0.5.0 123 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome! Besides code contributions, this includes things like documentation improvements, bug reports, and feature requests. 4 | 5 | You should first check if there is a [GitHub issue](https://github.com/westerveltco/django-q-registry/issues) already open or related to what you would like to contribute. If there is, please comment on that issue to let others know you are working on it. If there is not, please open a new issue to discuss your contribution. 6 | 7 | Not all contributions need to start with an issue, such as typo fixes in documentation or version bumps to Python or Django that require no internal code changes, but generally, it is a good idea to open an issue first. 8 | 9 | We adhere to Django's Code of Conduct in all interactions and expect all contributors to do the same. Please read the [Code of Conduct](https://www.djangoproject.com/conduct/) before contributing. 10 | 11 | ## Setup 12 | 13 | The following setup steps assume you are using a Unix-like operating system, such as Linux or macOS, and that you have a [supported](README.md#requirements) version of Python installed. If you are using Windows, you will need to adjust the commands accordingly. If you do not have Python installed, you can visit [python.org](https://www.python.org/) for instructions on how to install it for your operating system. 14 | 15 | 1. Fork the repository and clone it locally. 16 | 2. Create a virtual environment and activate it. You can use whatever tool you prefer for this. Below is an example using the Python standard library's `venv` module: 17 | 18 | ```shell 19 | python -m venv venv 20 | source venv/bin/activate 21 | ``` 22 | 23 | 3. Install `django-q-registry` and the `dev` dependencies in editable mode: 24 | 25 | ```shell 26 | python -m pip install --editable '.[dev]' 27 | # or using [just](#just) 28 | just bootstrap 29 | ``` 30 | 31 | ## Testing 32 | 33 | We use [`pytest`](https://docs.pytest.org/) for testing and [`nox`](https://nox.thea.codes/) to run the tests in multiple environments. 34 | 35 | To run the test suite against the default versions of Python (lower bound of supported versions) and Django (lower bound of LTS versions), run: 36 | 37 | ```shell 38 | python -m nox --session "test" 39 | # or using [just](#just) 40 | just test 41 | ``` 42 | 43 | To run the test suite against all supported versions of Python and Django, run: 44 | 45 | ```shell 46 | python -m nox --session "tests" 47 | # or using [just](#just) 48 | just testall 49 | ``` 50 | 51 | ## `just` 52 | 53 | [`just`](https://github.com/casey/just) is a command runner that is used to run common commands, similar to `make` or `invoke`. A `Justfile` is provided at the base of the repository, which contains commands for common development tasks, such as running the test suite or linting. 54 | 55 | To see a list of all available commands, ensure `just` is installed and run the following command at the base of the repository: 56 | 57 | ```shell 58 | just 59 | ``` 60 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load := true 2 | 3 | @_default: 4 | just --list 5 | 6 | # ---------------------------------------------------------------------- 7 | # DEPENDENCIES 8 | # ---------------------------------------------------------------------- 9 | 10 | bootstrap: 11 | @just pup 12 | @just install 13 | 14 | install: 15 | python -m uv pip install --editable '.[dev]' 16 | 17 | pup: 18 | python -m pip install --upgrade pip uv 19 | 20 | # ---------------------------------------------------------------------- 21 | # TESTING/TYPES 22 | # ---------------------------------------------------------------------- 23 | 24 | test *ARGS: 25 | python -m nox --session "test" -- "{{ ARGS }}" 26 | 27 | testall *ARGS: 28 | python -m nox --session "tests" -- "{{ ARGS }}" 29 | 30 | coverage: 31 | python -m nox --session "coverage" 32 | 33 | types: 34 | python -m nox --session "mypy" 35 | 36 | # ---------------------------------------------------------------------- 37 | # DJANGO 38 | # ---------------------------------------------------------------------- 39 | 40 | manage *COMMAND: 41 | #!/usr/bin/env python 42 | import sys 43 | 44 | try: 45 | from django.conf import settings 46 | from django.core.management import execute_from_command_line 47 | except ImportError as exc: 48 | raise ImportError( 49 | "Couldn't import Django. Are you sure it's installed and " 50 | "available on your PYTHONPATH environment variable? Did you " 51 | "forget to activate a virtual environment?" 52 | ) from exc 53 | 54 | settings.configure( 55 | INSTALLED_APPS=[ 56 | "django.contrib.contenttypes", 57 | "django_q", 58 | "django_q_registry" 59 | ], 60 | SECRET_KEY="not needed", 61 | ) 62 | execute_from_command_line(sys.argv + "{{ COMMAND }}".split(" ")) 63 | 64 | alias mm := makemigrations 65 | 66 | makemigrations *APPS: 67 | @just manage makemigrations {{ APPS }} 68 | 69 | migrate *ARGS: 70 | @just manage migrate {{ ARGS }} 71 | 72 | # ---------------------------------------------------------------------- 73 | # DOCS 74 | # ---------------------------------------------------------------------- 75 | 76 | @docs-install: 77 | @just pup 78 | python -m uv pip install 'django-q-registry[docs] @ .' 79 | 80 | @docs-serve: 81 | #!/usr/bin/env sh 82 | just _cog 83 | if [ -f "/.dockerenv" ]; then 84 | sphinx-autobuild docs docs/_build/html --host "0.0.0.0" 85 | else 86 | sphinx-autobuild docs docs/_build/html --host "localhost" 87 | fi 88 | 89 | @docs-build LOCATION="docs/_build/html": 90 | just _cog 91 | sphinx-build docs {{ LOCATION }} 92 | 93 | _cog: 94 | cog -r docs/development/just.md 95 | 96 | # ---------------------------------------------------------------------- 97 | # UTILS 98 | # ---------------------------------------------------------------------- 99 | 100 | # format justfile 101 | fmt: 102 | just --fmt --unstable 103 | 104 | # run pre-commit on all files 105 | lint: 106 | python -m nox --session "lint" 107 | 108 | # ---------------------------------------------------------------------- 109 | # COPIER 110 | # ---------------------------------------------------------------------- 111 | 112 | # apply a copier template to project 113 | copier-copy TEMPLATE_PATH DESTINATION_PATH=".": 114 | copier copy {{ TEMPLATE_PATH }} {{ DESTINATION_PATH }} 115 | 116 | # update the project using a copier answers file 117 | copier-update ANSWERS_FILE *ARGS: 118 | copier update --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 119 | 120 | # loop through all answers files and update the project using copier 121 | @copier-update-all *ARGS: 122 | for file in `ls .copier/`; do just copier-update .copier/$file "{{ ARGS }}"; done 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Josh Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-q-registry 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/django-q-registry)](https://pypi.org/project/django-q-registry/) 4 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-q-registry) 5 | ![Django Version](https://img.shields.io/badge/django-4.2%20%7C%205.0-%2344B78B?labelColor=%23092E20) 6 | 7 | 8 | 9 | 10 | A Django app to register periodic Django Q tasks. 11 | 12 | ## Requirements 13 | 14 | - Python 3.8, 3.9, 3.10, 3.11, 3.12, 3.13 15 | - Django 4.2, 5.0 16 | - Django Q2 1.4.3+ 17 | - This package has only been tested with the Django ORM broker. 18 | 19 | ## Installation 20 | 21 | 1. Install the package from PyPI: 22 | 23 | ```bash 24 | python -m pip install django-q-registry 25 | ``` 26 | 27 | 2. Add the app to your Django project's `INSTALLED_APPS`: 28 | 29 | ```python 30 | INSTALLED_APPS = [ 31 | ..., 32 | "django_q_registry", 33 | ..., 34 | ] 35 | ``` 36 | 37 | ## Getting Started 38 | 39 | ### Registering Periodic Tasks 40 | 41 | There are three supported ways to register periodic tasks: 42 | 43 | 1. In a `tasks.py` file in a Django app, using the `@register_task` decorator: 44 | 45 | ```python 46 | # tasks.py 47 | from django.core.mail import send_mail 48 | from django_q.models import Schedule 49 | from django_q_registry import register_task 50 | 51 | 52 | @register_task( 53 | name="Send periodic test email", 54 | schedule_type=Schedule.CRON, 55 | # https://crontab.guru/#*/5_*_*_*_* 56 | cron="*/5 * * * *", 57 | ) 58 | def send_test_email(): 59 | send_mail( 60 | subject="Test email", 61 | message="This is a test email.", 62 | from_email="noreply@example.com", 63 | recipient_list=["johndoe@example.com"], 64 | ) 65 | ``` 66 | 67 | 2. In a `tasks.py` file in a Django app, using the `registry.register` function directly: 68 | 69 | ```python 70 | # tasks.py 71 | from django.core.mail import send_mail 72 | from django_q.models import Schedule 73 | from django_q_registry.registry import registry 74 | 75 | 76 | registry.register( 77 | send_mail, 78 | name="Send periodic test email", 79 | kwargs={ 80 | "subject": "Test email", 81 | "message": "This is a test email.", 82 | "from_email": "noreply@example.com", 83 | "recipient_list": ["janedoe@example.com"], 84 | }, 85 | schedule_type=Schedule.CRON, 86 | # https://crontab.guru/#*/5_*_*_*_* 87 | cron="*/5 * * * *", 88 | ) 89 | ``` 90 | 91 | 3. In a Django project's `settings.py` file, using the `Q_REGISTRY["TASKS"]` setting: 92 | 93 | ```python 94 | # settings.py 95 | from django_q.models import Schedule 96 | 97 | 98 | Q_REGISTRY = { 99 | "TASKS": [ 100 | { 101 | "name": "Send periodic test email", 102 | "func": "django.core.mail.send_mail", 103 | "kwargs": { 104 | "subject": "Test email", 105 | "message": "This is a test email.", 106 | "from_email": "noreply@example.com", 107 | "recipient_list": ["janedoe@example.com"], 108 | }, 109 | "schedule_type": Schedule.CRON, 110 | # https://crontab.guru/#*/5_*_*_*_* 111 | "cron": "*/5 * * * *", 112 | }, 113 | ], 114 | } 115 | ``` 116 | 117 | ### Setting up Periodic Tasks in Production 118 | 119 | At some point in your project's deployment process, run the `setup_periodic_tasks` management command: 120 | 121 | ```bash 122 | python manage.py migrate 123 | python manage.py setup_periodic_tasks 124 | ``` 125 | 126 | This command automatically registers periodic tasks from `tasks.py` files in Django apps, and from the `Q_REGISTRY["TASKS"]` setting. It also cleans up any periodic tasks that are no longer registered. 127 | 128 | ## Documentation 129 | 130 | Please refer to the [documentation](https://django-q-registry.westervelt.dev/) for more information. 131 | 132 | ## License 133 | 134 | `django-q-registry` is licensed under the MIT license. See the [`LICENSE`](LICENSE) file for more information. 135 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing a New Version 2 | 3 | When it comes time to cut a new release, follow these steps: 4 | 5 | 1. Create a new git branch off of `main` for the release. 6 | 7 | Prefer the convention `release-`, where `` is the next incremental version number (e.g. `release-v0.2.0` for version 0.2.0). 8 | 9 | ```shell 10 | git checkout -b release-v 11 | ``` 12 | 13 | However, the branch name is not *super* important, as long as it is not `main`. 14 | 15 | 2. Update the version number across the project using the `bumpver` tool. See [this section](#choosing-the-next-version-number) for more details about choosing the correct version number. 16 | 17 | The `pyproject.toml` in the base of the repository contains a `[tool.bumpver]` section that configures the `bumpver` tool to update the version number wherever it needs to be updated and to create a commit with the appropriate commit message. 18 | 19 | `bumpver` is included as a development dependency, so you should already have it installed if you have installed the development dependencies for this project. If you do not have the development dependencies installed, you can install them with either of the following commands: 20 | 21 | ```shell 22 | python -m pip install --editable '.[dev]' 23 | # or using [just](CONTRIBUTING.md#just) 24 | just bootstrap 25 | ``` 26 | 27 | Then, run `bumpver` to update the version number, with the appropriate command line arguments. See the [`bumpver` documentation](https://github.com/mbarkhau/bumpver) for more details. 28 | 29 | **Note**: For any of the following commands, you can add the command line flag `--dry` to preview the changes without actually making the changes. 30 | 31 | Here are the most common commands you will need to run: 32 | 33 | ```shell 34 | bumpver update --patch # for a patch release 35 | bumpver update --minor # for a minor release 36 | bumpver update --major # for a major release 37 | ``` 38 | 39 | To release a tagged version, such as a beta or release candidate, you can run: 40 | 41 | ```shell 42 | bumpver update --tag=beta 43 | # or 44 | bumpver update --tag=rc 45 | ``` 46 | 47 | Running these commands on a tagged version will increment the tag appropriately, but will not increment the version number. 48 | 49 | To go from a tagged release to a full release, you can run: 50 | 51 | ```shell 52 | bumpver update --tag=final 53 | ``` 54 | 55 | 3. Ensure the [CHANGELOG](CHANGELOG.md) is up to date. If updates are needed, add them now in the release branch. 56 | 57 | 4. Create a pull request from the release branch to `main`. 58 | 59 | 5. Once CI has passed and all the checks are green ✅, merge the pull request. 60 | 61 | 6. Draft a [new release](https://github.com/westerveltco/django-q-registry/releases/new) on GitHub. 62 | 63 | Use the version number with a leading `v` as the tag name (e.g. `v0.2.0`). 64 | 65 | Allow GitHub to generate the release title and release notes, using the 'Generate release notes' button above the text box. If this is a final release coming from a tagged release (or multiple tagged releases), make sure to copy the release notes from the previous tagged release(s) to the new release notes (after the release notes already generated for this final release). 66 | 67 | If this is a tagged release, make sure to check the 'Set as a pre-release' checkbox. 68 | 69 | 7. Once you are satisfied with the release, publish the release. As part of the publication process, GitHub Actions will automatically publish the new version of the package to PyPI. 70 | 71 | ## Choosing the Next Version Number 72 | 73 | We try our best to adhere to [Semantic Versioning](https://semver.org/), but we do not promise to follow it perfectly (and let's be honest, this is the case with a lot of projects using SemVer). 74 | 75 | In general, use your best judgement when choosing the next version number. If you are unsure, you can always ask for a second opinion from another contributor. 76 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | // no idea if this will screw a lot up with the furo theme 2 | // but this does fix the line of badges in the README. only 3 | // one of them has a link which furo makes the vertical-align 4 | // different than just a standard img 5 | p a.reference img { 6 | vertical-align: inherit; 7 | } 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import sys 11 | 12 | # import django 13 | 14 | # -- Path setup -------------------------------------------------------------- 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | 23 | # -- Django setup ----------------------------------------------------------- 24 | # This is required to import Django code in Sphinx using autodoc. 25 | 26 | # os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 27 | # django.setup() 28 | 29 | 30 | # -- Project information ----------------------------------------------------- 31 | 32 | project = "django-q-registry" 33 | copyright = "2023, Josh Thomas" 34 | author = "Josh Thomas" 35 | 36 | 37 | # -- General configuration --------------------------------------------------- 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "myst_parser", 44 | "sphinx_copybutton", 45 | "sphinx_inline_tabs", 46 | "sphinx.ext.autodoc", 47 | "sphinx.ext.napoleon", 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = [] 57 | 58 | 59 | # -- MyST configuration ------------------------------------------------------ 60 | myst_heading_anchors = 3 61 | 62 | copybutton_selector = "div.copy pre" 63 | copybutton_prompt_text = "$ " 64 | 65 | 66 | # -- Options for HTML output ------------------------------------------------- 67 | 68 | # The theme to use for HTML and HTML Help pages. See the documentation for 69 | # a list of builtin themes. 70 | # 71 | html_theme = "furo" 72 | 73 | # Add any paths that contain custom static files (such as style sheets) here, 74 | # relative to this directory. They are copied after the builtin static files, 75 | # so a file named "default.css" will overwrite the builtin "default.css". 76 | html_static_path = ["_static"] 77 | 78 | html_css_files = [ 79 | "css/custom.css", 80 | ] 81 | 82 | html_title = project 83 | 84 | html_theme_options = { 85 | "footer_icons": [ 86 | { 87 | "name": "GitHub", 88 | "url": "https://github.com/westerveltco/django-q-registry", 89 | "html": """ 90 | 91 | 92 | 93 | """, 94 | "class": "", 95 | }, 96 | ], 97 | } 98 | 99 | html_sidebars = { 100 | "**": [ 101 | "sidebar/brand.html", 102 | "sidebar/search.html", 103 | "sidebar/scroll-start.html", 104 | "sidebar/navigation.html", 105 | "sidebar/scroll-end.html", 106 | "sidebar/variant-selector.html", 107 | ] 108 | } 109 | -------------------------------------------------------------------------------- /docs/development/just.md: -------------------------------------------------------------------------------- 1 | # Justfile 2 | 3 | This project uses [Just](https://github.com/casey/just) as a command runner. 4 | 5 | The following commands are available: 6 | 7 | 19 | 20 | 21 | ## Commands 22 | 23 | ```{code-block} shell 24 | :class: copy 25 | 26 | $ just --list 27 | ``` 28 | 37 | 38 | 39 | 61 | 62 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | 3 | ``` 4 | 5 | ```{toctree} 6 | :hidden: 7 | :maxdepth: 3 8 | :caption: Development 9 | 10 | development/just.md 11 | ``` 12 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | import nox 7 | 8 | nox.options.default_venv_backend = "uv|virtualenv" 9 | nox.options.reuse_existing_virtualenvs = True 10 | 11 | PY39 = "3.9" 12 | PY310 = "3.10" 13 | PY311 = "3.11" 14 | PY312 = "3.12" 15 | PY313 = "3.13" 16 | PY_VERSIONS = [PY39, PY310, PY311, PY312, PY313] 17 | PY_DEFAULT = PY_VERSIONS[0] 18 | PY_LATEST = PY_VERSIONS[-1] 19 | 20 | DJ42 = "4.2" 21 | DJ50 = "5.0" 22 | DJ51 = "5.1" 23 | DJ52 = "5.2a1" 24 | DJMAIN = "main" 25 | DJMAIN_MIN_PY = PY312 26 | DJ_VERSIONS = [DJ42, DJ50, DJ51, DJ52, DJMAIN] 27 | DJ_LTS = [ 28 | version for version in DJ_VERSIONS if version.endswith(".2") and version != DJMAIN 29 | ] 30 | DJ_DEFAULT = DJ_LTS[0] 31 | DJ_LATEST = DJ_VERSIONS[-2] 32 | 33 | 34 | def version(ver: str) -> tuple[int, ...]: 35 | """Convert a string version to a tuple of ints, e.g. "3.10" -> (3, 10)""" 36 | return tuple(map(int, ver.split("."))) 37 | 38 | 39 | def should_skip(python: str, django: str) -> bool: 40 | """Return True if the test should be skipped""" 41 | 42 | if django == DJMAIN and version(python) < version(DJMAIN_MIN_PY): 43 | # Django main requires Python 3.12+ 44 | return True 45 | 46 | if django == DJ52 and version(python) < version(PY310): 47 | # Django 5.2 requires Python 3.10+ 48 | return True 49 | 50 | if django == DJ51 and version(python) < version(PY310): 51 | # Django 5.1 requires Python 3.10+ 52 | return True 53 | 54 | if django == DJ50 and version(python) < version(PY310): 55 | # Django 5.0 requires Python 3.10+ 56 | return True 57 | 58 | return False 59 | 60 | 61 | @nox.session 62 | def test(session): 63 | session.notify(f"tests(python='{PY_DEFAULT}', django='{DJ_DEFAULT}')") 64 | 65 | 66 | @nox.session 67 | @nox.parametrize( 68 | "python,django", 69 | [ 70 | (python, django) 71 | for python in PY_VERSIONS 72 | for django in DJ_VERSIONS 73 | if not should_skip(python, django) 74 | ], 75 | ) 76 | def tests(session, django): 77 | session.install("django-q-registry[dev] @ .") 78 | 79 | if django == DJMAIN: 80 | session.install( 81 | "django @ https://github.com/django/django/archive/refs/heads/main.zip" 82 | ) 83 | else: 84 | session.install(f"django=={django}") 85 | 86 | if session.posargs: 87 | session.run("python", "-m", "pytest", *session.posargs) 88 | else: 89 | session.run("python", "-m", "pytest") 90 | 91 | 92 | @nox.session 93 | def coverage(session): 94 | session.install("django-q-registry[dev] @ .") 95 | session.run("python", "-m", "pytest", "--cov=django_q_registry") 96 | 97 | try: 98 | summary = os.environ["GITHUB_STEP_SUMMARY"] 99 | with Path(summary).open("a") as output_buffer: 100 | output_buffer.write("") 101 | output_buffer.write("### Coverage\n\n") 102 | output_buffer.flush() 103 | session.run( 104 | "python", 105 | "-m", 106 | "coverage", 107 | "report", 108 | "--skip-covered", 109 | "--skip-empty", 110 | "--format=markdown", 111 | stdout=output_buffer, 112 | ) 113 | except KeyError: 114 | session.run( 115 | "python", "-m", "coverage", "html", "--skip-covered", "--skip-empty" 116 | ) 117 | 118 | session.run("python", "-m", "coverage", "report") 119 | 120 | 121 | @nox.session 122 | def lint(session): 123 | session.install("django-q-registry[lint] @ .") 124 | session.run("python", "-m", "pre_commit", "run", "--all-files") 125 | 126 | 127 | @nox.session 128 | def mypy(session): 129 | session.install("django-q-registry[dev] @ .") 130 | session.run("python", "-m", "mypy", ".") 131 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling"] 4 | 5 | [project] 6 | authors = [{name = "Josh Thomas", email = "josh@joshthomas.dev"}] 7 | classifiers = [ 8 | "Development Status :: 4 - Beta", 9 | "Framework :: Django", 10 | "Framework :: Django :: 4.2", 11 | "Framework :: Django :: 5.0", 12 | "Framework :: Django :: 5.1", 13 | "Framework :: Django :: 5.2", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: Implementation :: CPython" 25 | ] 26 | dependencies = ["django>=4.2", "django_q2>=1.4.3"] 27 | description = "A Django app to register periodic Django Q tasks." 28 | dynamic = ["version"] 29 | keywords = [] 30 | license = {file = "LICENSE"} 31 | name = "django-q-registry" 32 | readme = "README.md" 33 | requires-python = ">=3.9" 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "bumpver", 38 | "copier", 39 | "copier-templates-extensions", 40 | "coverage[toml]", 41 | "django-stubs", 42 | "django-stubs-ext", 43 | "faker", 44 | "hatch", 45 | "mypy", 46 | "model-bakery", 47 | "nox[uv]", 48 | "pytest", 49 | "pytest-cov", 50 | "pytest-django", 51 | "pytest-randomly", 52 | "pytest-reverse", 53 | "pytest-xdist", 54 | "ruff" 55 | ] 56 | docs = [ 57 | "cogapp", 58 | "furo", 59 | "myst-parser", 60 | "sphinx", 61 | "sphinx-autobuild", 62 | "sphinx-copybutton", 63 | "sphinx-inline-tabs" 64 | ] 65 | lint = ["pre-commit"] 66 | 67 | [project.urls] 68 | Documentation = "https://django-q-registry.westervelt.dev/" 69 | Issues = "https://github.com/westerveltco/django-q-registry/issues" 70 | Source = "https://github.com/westerveltco/django-q-registry" 71 | 72 | [tool.bumpver] 73 | commit = true 74 | commit_message = ":bookmark: bump version {old_version} -> {new_version}" 75 | current_version = "0.5.0" 76 | push = false # set to false for CI 77 | tag = false 78 | version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" 79 | 80 | [tool.bumpver.file_patterns] 81 | ".copier/package.yml" = ['current_version: {version}'] 82 | "src/django_q_registry/__init__.py" = ['__version__ = "{version}"'] 83 | "tests/test_version.py" = ['assert __version__ == "{version}"'] 84 | 85 | [tool.coverage.paths] 86 | source = ["src"] 87 | 88 | [tool.coverage.report] 89 | exclude_lines = [ 90 | "pragma: no cover", 91 | "if DEBUG:", 92 | "if not DEBUG:", 93 | "if settings.DEBUG:", 94 | "if TYPE_CHECKING:", 95 | 'def __str__\(self\)\s?\-?\>?\s?\w*\:' 96 | ] 97 | fail_under = 75 98 | 99 | [tool.coverage.run] 100 | omit = ["src/django_q_registry/migrations/*", "tests/*"] 101 | source = ["django_q_registry"] 102 | 103 | [tool.django-stubs] 104 | django_settings_module = "tests.settings" 105 | strict_settings = false 106 | 107 | [tool.djlint] 108 | blank_line_after_tag = "endblock,endpartialdef,extends,load" 109 | blank_line_before_tag = "block,partialdef" 110 | custom_blocks = "partialdef" 111 | ignore = "H031" # Don't require `meta` tag keywords 112 | indent = 2 113 | profile = "django" 114 | 115 | [tool.hatch.build] 116 | exclude = [".*", "Justfile"] 117 | 118 | [tool.hatch.build.targets.wheel] 119 | packages = ["src/django_q_registry"] 120 | 121 | [tool.hatch.version] 122 | path = "src/django_q_registry/__init__.py" 123 | 124 | [tool.mypy] 125 | check_untyped_defs = true 126 | exclude = ["docs", "tests", "migrations", "venv", ".venv"] 127 | mypy_path = "src/" 128 | no_implicit_optional = true 129 | plugins = ["mypy_django_plugin.main"] 130 | warn_redundant_casts = true 131 | warn_unused_configs = true 132 | warn_unused_ignores = true 133 | 134 | [[tool.mypy.overrides]] 135 | ignore_errors = true 136 | ignore_missing_imports = true 137 | module = ["*.migrations.*", "django_q.*", "docs.*", "tests.*"] 138 | 139 | [tool.mypy_django_plugin] 140 | ignore_missing_model_attributes = true 141 | 142 | [tool.pytest.ini_options] 143 | addopts = "--create-db -n auto --dist loadfile --doctest-modules" 144 | django_find_project = false 145 | norecursedirs = ".* bin build dist *.egg htmlcov logs node_modules templates venv" 146 | python_files = "tests.py test_*.py *_tests.py" 147 | pythonpath = "src" 148 | testpaths = ["tests"] 149 | 150 | [tool.ruff] 151 | # Exclude a variety of commonly ignored directories. 152 | exclude = [ 153 | ".bzr", 154 | ".direnv", 155 | ".eggs", 156 | ".git", 157 | ".github", 158 | ".hg", 159 | ".mypy_cache", 160 | ".ruff_cache", 161 | ".svn", 162 | ".tox", 163 | ".venv", 164 | "__pypackages__", 165 | "_build", 166 | "build", 167 | "dist", 168 | "migrations", 169 | "node_modules", 170 | "venv" 171 | ] 172 | extend-include = ["*.pyi?"] 173 | indent-width = 4 174 | # Same as Black. 175 | line-length = 88 176 | # Assume Python >3.8 177 | target-version = "py38" 178 | 179 | [tool.ruff.format] 180 | # Like Black, indent with spaces, rather than tabs. 181 | indent-style = "space" 182 | # Like Black, automatically detect the appropriate line ending. 183 | line-ending = "auto" 184 | # Like Black, use double quotes for strings. 185 | quote-style = "double" 186 | 187 | [tool.ruff.lint] 188 | # Allow unused variables when underscore-prefixed. 189 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 190 | # Allow autofix for all enabled rules (when `--fix`) is provided. 191 | fixable = ["A", "B", "C", "D", "E", "F", "I"] 192 | ignore = ["E501", "E741"] # temporary 193 | select = [ 194 | "B", # flake8-bugbear 195 | "E", # Pycodestyle 196 | "F", # Pyflakes 197 | "I", # isort 198 | "UP" # pyupgrade 199 | ] 200 | unfixable = [] 201 | 202 | [tool.ruff.lint.isort] 203 | force-single-line = true 204 | known-first-party = ["django_q_registry"] 205 | required-imports = ["from __future__ import annotations"] 206 | 207 | [tool.ruff.lint.per-file-ignores] 208 | # Tests can use magic values, assertions, and relative imports 209 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 210 | 211 | [tool.ruff.lint.pyupgrade] 212 | # Preserve types, even if a file imports `from __future__ import annotations`. 213 | keep-runtime-typing = true 214 | -------------------------------------------------------------------------------- /src/django_q_registry/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django_q_registry.registry import register_task 4 | 5 | __all__ = [ 6 | "register_task", 7 | ] 8 | 9 | __version__ = "0.5.0" 10 | -------------------------------------------------------------------------------- /src/django_q_registry/_typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | if sys.version_info >= (3, 12): 6 | from typing import override as typing_override 7 | else: # pragma: no cover 8 | from typing_extensions import ( 9 | override as typing_override, # pyright: ignore[reportUnreachable] 10 | ) 11 | 12 | override = typing_override 13 | -------------------------------------------------------------------------------- /src/django_q_registry/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class DjangoQRegistryConfig(AppConfig): 7 | default_auto_field = "django.db.models.BigAutoField" 8 | name = "django_q_registry" 9 | label = "django_q_registry" 10 | verbose_name = "Django Q Registry" 11 | 12 | def ready(self): 13 | from django_q_registry.registry import registry 14 | 15 | registry.autodiscover_tasks() 16 | -------------------------------------------------------------------------------- /src/django_q_registry/conf.py: -------------------------------------------------------------------------------- 1 | # pyright: reportAny=false 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from dataclasses import field 6 | from typing import Any 7 | 8 | from django.conf import settings 9 | 10 | from ._typing import override 11 | 12 | DJANGO_Q_REGISTRY_SETTINGS_NAME = "Q_REGISTRY" 13 | 14 | 15 | @dataclass(frozen=True) 16 | class AppSettings: 17 | PERIODIC_TASK_SUFFIX: str = " - QREGISTRY" 18 | TASKS: list[dict[str, Any]] = field(default_factory=list) 19 | 20 | @override 21 | def __getattribute__(self, __name: str) -> object: 22 | user_settings = getattr(settings, DJANGO_Q_REGISTRY_SETTINGS_NAME, {}) 23 | return user_settings.get(__name, super().__getattribute__(__name)) 24 | 25 | 26 | app_settings = AppSettings() 27 | -------------------------------------------------------------------------------- /src/django_q_registry/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-q-registry/c309f866e7485dcfbedd769dc9bc0755ecdaec81/src/django_q_registry/management/__init__.py -------------------------------------------------------------------------------- /src/django_q_registry/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-q-registry/c309f866e7485dcfbedd769dc9bc0755ecdaec81/src/django_q_registry/management/commands/__init__.py -------------------------------------------------------------------------------- /src/django_q_registry/management/commands/setup_periodic_tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from django_q_registry.models import Task 6 | from django_q_registry.registry import registry 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Save all registered tasks to the database, create or update the associated schedules, and delete any dangling tasks and schedules." 11 | 12 | def handle(self, *args, **kwargs): # noqa: ARG002 13 | Task.objects.create_from_registry(registry) 14 | Task.objects.delete_dangling_objects(registry) 15 | -------------------------------------------------------------------------------- /src/django_q_registry/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2024-02-15 17:00 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ("django_q", "0017_task_cluster_alter"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Task", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=100)), 28 | ("func", models.CharField(max_length=256)), 29 | ("kwargs", models.JSONField(default=dict)), 30 | ( 31 | "q_schedule", 32 | models.OneToOneField( 33 | null=True, 34 | on_delete=django.db.models.deletion.SET_NULL, 35 | related_name="registered_task", 36 | to="django_q.schedule", 37 | ), 38 | ), 39 | ], 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /src/django_q_registry/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-q-registry/c309f866e7485dcfbedd769dc9bc0755ecdaec81/src/django_q_registry/migrations/__init__.py -------------------------------------------------------------------------------- /src/django_q_registry/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | from typing import Any 6 | from typing import Callable 7 | 8 | from django.core.serializers.json import DjangoJSONEncoder 9 | from django.db import models 10 | from django_q.models import Schedule 11 | 12 | from django_q_registry.conf import app_settings 13 | from django_q_registry.registry import TaskRegistry 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class TaskQuerySet(models.QuerySet): 19 | def create_in_memory( 20 | self, func: Callable[..., Any], kwargs: dict[str, Any] 21 | ) -> Task: 22 | """ 23 | Returns a new Task instance with no primary key, for use in `django_q_registry.registry.TaskRegistry`. 24 | 25 | This is to be used when registering a task to the registry, but not yet saving it to the database. 26 | Useful to avoid the database hit on startup when tasks are actually registered to the `TaskRegistry`, 27 | as well as the potential for the app registry not being ready yet. Plus, it allows for the `Task` 28 | object to be hashed and compared for equality to avoid duplicate tasks in the registry and database. 29 | 30 | Args: 31 | func: 32 | The function to be called when the task is executed. Corresponds to the `func` argument in 33 | `django_q.models.Schedule`. 34 | kwargs: 35 | The keyword arguments to be passed to `django_q.models.Schedule` when creating a new `Schedule` 36 | instance for the `Task` instance. Corresponds to the remaining fields in 37 | `django_q.models.Schedule`. One special case is the `name` field that can be passed in here to 38 | specify the name of the `Task` and `Schedule` instance. If not passed in, the `name` field 39 | will be set to the name of the `func`. 40 | 41 | Given the following `kwargs`: 42 | 43 | { 44 | "name": "my_task", 45 | "repeats": 1, 46 | "schedule_type": "D", 47 | # ... 48 | } 49 | 50 | Would result in the following `Task`:` 51 | 52 | Task( 53 | name="my_task", 54 | func="...", 55 | kwargs={ 56 | "repeats": 1, 57 | "schedule_type": "D", 58 | }, 59 | ) 60 | 61 | Which would then result in the following `Schedule`, once the `Task` is saved to the database: 62 | 63 | Schedule( 64 | name="my_task", 65 | func="...", 66 | repeats=1, 67 | schedule_type="D", 68 | ) 69 | 70 | Returns: 71 | A new `Task` instance with no primary key, to be used later within the 72 | `django_q_registry.registry.TaskRegistry` to actually create the `Task` instance in the database. 73 | """ 74 | 75 | return Task( 76 | name=kwargs.pop("name", func.__name__), 77 | func=f"{func.__module__}.{func.__name__}", 78 | kwargs=kwargs, 79 | ) 80 | 81 | def create_from_registry(self, registry: TaskRegistry) -> TaskQuerySet: 82 | """ 83 | Given a `TaskRegistry` that contains a set of in-memory `Task` instances, save them to the database 84 | and add them to the `TaskRegistry.created_tasks` attribute. 85 | 86 | Note that this method operates on `Task` instances that only exist in-memory and do not exist in the 87 | database yet. If a `Task` instance is passed in that already exists, it will be logged as an error 88 | and ignored. 89 | 90 | Duplicates are determined by the `Task` instances' `name`, `func`, and `kwargs` fields. If a `Task` 91 | instance with the same `name`, `func`, and `kwargs` fields already exists in the database or is 92 | passed in twice to this method, it will be updated and not duplicated. This means that multiple 93 | `Tasks` can be registered with the same `name`, `func`, and `kwargs` fields, but only one `Task` 94 | will be created. See `Task.__eq__` for more information. 95 | 96 | Args: 97 | registry: 98 | A TaskRegistry instance containing all of the `Task` instances that are currently registered 99 | in-memory, but not yet in the database. 100 | 101 | Returns: 102 | A TaskQuerySet containing all of the `Task` instances that were saved to the database. 103 | """ 104 | 105 | task_objs = [] 106 | 107 | for task in registry.registered_tasks: 108 | if task.pk: 109 | logger.error(f"Task {task.pk} has already been registered") 110 | continue 111 | 112 | obj, _ = self.update_or_create( 113 | name=task.name, 114 | func=task.func, 115 | kwargs=json.dumps(task.kwargs, cls=DjangoJSONEncoder), 116 | ) 117 | 118 | if obj.q_schedule is None: 119 | obj.q_schedule = Schedule.objects.create( 120 | **task.to_schedule_dict(), 121 | ) 122 | obj.save() 123 | else: 124 | Schedule.objects.filter(pk=obj.q_schedule.pk).update( 125 | **task.to_schedule_dict(), 126 | ) 127 | 128 | task_objs.append(obj) 129 | 130 | return_qs = self.filter(pk__in=[task.pk for task in task_objs]) 131 | 132 | registry.update_created_tasks(return_qs) 133 | 134 | return return_qs 135 | 136 | def exclude_registered(self, registry: TaskRegistry) -> TaskQuerySet: 137 | """ 138 | Get all `Task` instances that are no longer registered in the `TaskRegistry`. 139 | 140 | This method will return all `Task` instances that are not contained in the 141 | `TaskRegistry.created_tasks` attribute, for use in cleaning up the database of any `Task` 142 | instances that are no longer registered. 143 | 144 | Args: 145 | registry: 146 | A TaskRegistry instance containing all of the `Task` instances that are currently registered 147 | and in the database. 148 | 149 | Returns: 150 | A TaskQuerySet containing all of the `Task` instances that are no longer registered in the 151 | `TaskRegistry`. 152 | """ 153 | 154 | return self.exclude(pk__in=[task.pk for task in registry.created_tasks]) 155 | 156 | def delete_dangling_objects(self, registry: TaskRegistry) -> None: 157 | """ 158 | Delete all `Task` instances from the database and the associated `django_q.models.Schedule` instances 159 | no longer associated with a `TaskRegistry`. 160 | """ 161 | to_delete = self.exclude_registered(registry) 162 | 163 | q_schedule_pks = to_delete.values_list("q_schedule", flat=True) 164 | 165 | to_delete.delete() 166 | 167 | suffix = app_settings.PERIODIC_TASK_SUFFIX 168 | legacy_suffix = " - CRON" 169 | 170 | # clean up legacy registered schedules 171 | Schedule.objects.filter(name__endswith=legacy_suffix).delete() 172 | # clean up dangling schedules 173 | Schedule.objects.filter( 174 | models.Q(name__endswith=suffix) & models.Q(registered_task__isnull=True) 175 | ).delete() 176 | # clean up schedules of tasks that were just deleted 177 | Schedule.objects.filter(pk__in=q_schedule_pks).delete() 178 | 179 | 180 | class Task(models.Model): 181 | q_schedule = models.OneToOneField( 182 | "django_q.Schedule", 183 | on_delete=models.SET_NULL, 184 | null=True, 185 | related_name="registered_task", 186 | ) 187 | name = models.CharField( 188 | max_length=100 # max_length inherited from `django_q.models.Schedule` 189 | ) 190 | func = models.CharField( 191 | max_length=256 # max_length inherited from `django_q.models.Schedule` 192 | ) 193 | kwargs = models.JSONField(default=dict) 194 | 195 | objects = TaskQuerySet.as_manager() 196 | 197 | def __hash__(self) -> int: 198 | if self.pk is not None: 199 | return super().__hash__() 200 | return hash( 201 | ( 202 | self.name, 203 | self.func, 204 | tuple(json.dumps(self.kwargs, cls=DjangoJSONEncoder)), 205 | ) 206 | ) 207 | 208 | def __eq__(self, other) -> bool: 209 | """ 210 | Compare two `Task` instances for equality. 211 | 212 | If the `Task` exists in the database, then use the default equality comparison which compares the 213 | primary keys of the `Task` instances. 214 | 215 | Else, compare the `name`, `func`, and `kwargs` fields of the `Task` instances for equality. Two `Task` 216 | instances are considered equal if they have the same `name`, `func`, and `kwargs` fields. 217 | 218 | So there can be two `Task` instances with the same `name`, `func`, and/or `kwargs` fields, but as long 219 | as one of those fields is different, they will be considered different `Task` instances. 220 | 221 | >>> task_1 = Task(name="test", func="test_task", kwargs={"foo": "bar"}) 222 | >>> task_2 = Task(name="test", func="test_task", kwargs={"foo": "bar"}) 223 | >>> task_1 == task_2 224 | True 225 | >>> diff_name_1 = Task(name="test_1", func="test_task", kwargs={"foo": "bar"}) 226 | >>> diff_name_2 = Task(name="test_2", func="test_task", kwargs={"foo": "bar"}) 227 | >>> diff_name_1 == diff_name_2 228 | False 229 | >>> diff_func_1 = Task(name="test", func="test_task_1", kwargs={"foo": "bar"}) 230 | >>> diff_func_2 = Task(name="test", func="test_task_2", kwargs={"foo": "bar"}) 231 | >>> diff_func_1 == diff_func_2 232 | False 233 | >>> diff_kwargs_1 = Task(name="test", func="test_task", kwargs={"foo": "bar"}) 234 | >>> diff_kwargs_2 = Task(name="test", func="test_task", kwargs={"baz": "qux"}) 235 | >>> diff_kwargs_1 == diff_kwargs_2 236 | False 237 | 238 | """ 239 | 240 | if self.pk is not None: 241 | return super().__eq__(other) 242 | return ( 243 | self.name == other.name 244 | and self.func == other.func 245 | and self.kwargs == other.kwargs 246 | ) 247 | 248 | def to_schedule_dict(self) -> dict[str, Any]: 249 | return { 250 | "name": f"{self.name}{app_settings.PERIODIC_TASK_SUFFIX}", 251 | "func": self.func, 252 | **self.kwargs, 253 | } 254 | -------------------------------------------------------------------------------- /src/django_q_registry/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-q-registry/c309f866e7485dcfbedd769dc9bc0755ecdaec81/src/django_q_registry/py.typed -------------------------------------------------------------------------------- /src/django_q_registry/registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | from dataclasses import dataclass 5 | from dataclasses import field 6 | from functools import wraps 7 | from typing import TYPE_CHECKING 8 | from typing import Any 9 | from typing import Callable 10 | from typing import cast 11 | 12 | from django.conf import settings 13 | 14 | from django_q_registry.conf import app_settings 15 | 16 | if TYPE_CHECKING: 17 | from django_q_registry.models import Task 18 | from django_q_registry.models import TaskQuerySet 19 | 20 | 21 | @dataclass 22 | class TaskRegistry: 23 | registered_tasks: set[Task] = field(default_factory=set) 24 | created_tasks: set[Task] = field(default_factory=set) 25 | 26 | def __post_init__(self): 27 | self._register_settings() 28 | 29 | def register(self, *args, **kwargs): 30 | """ 31 | Register a task to be run periodically. Can be used as a function or a decorator. 32 | 33 | This is essentially the same as `django_q.tasks.schedule` but with the added benefit of being able to 34 | use it as a decorator while also having a registry of all registered tasks. 35 | 36 | If used as a function, the first argument must be the function to be registered. 37 | 38 | The name kwarg is optional, and will default to the name of the function if not provided. 39 | 40 | Example: 41 | 42 | from django.core.mail import send_mail 43 | from django_q.models import Schedule 44 | 45 | from django_q_registry.registry import TaskRegistry 46 | 47 | 48 | registry = TaskRegistry() 49 | @registry.register( 50 | name="Send periodic test email", 51 | schedule_type=Schedule.CRON, 52 | # https://crontab.guru/#*/5_*_*_*_* 53 | cron="*/5 * * * *", 54 | ) 55 | def send_test_email(): 56 | send_mail( 57 | subject="Test email", 58 | message="This is a test email.", 59 | from_email="noreply@example.com", 60 | recipient_list=["johndoe@example.com"], 61 | ) 62 | 63 | # or 64 | 65 | registry.register( 66 | send_mail, 67 | name="Send periodic test email", 68 | kwargs={ 69 | "subject": "Test email", 70 | "message": "This is a test email.", 71 | "from_email": "noreply@example.com", 72 | "recipient_list": ["janedoe@example.com"], 73 | }, 74 | schedule_type=Schedule.CRON, 75 | # https://crontab.guru/#*/5_*_*_*_* 76 | cron="*/5 * * * *", 77 | ) 78 | """ 79 | if len(args) == 1 and callable(args[0]): 80 | return self._register_task(args[0], **kwargs) 81 | else: 82 | return self._register_decorator(**kwargs) 83 | 84 | def _register_decorator(self, **kwargs): 85 | def decorator(func: Callable): 86 | self._register_task(func, **kwargs) 87 | 88 | @wraps(func) 89 | def wrapper(*args, **kwargs): 90 | return func(*args, **kwargs) 91 | 92 | return wrapper 93 | 94 | return decorator 95 | 96 | def _register_settings(self): 97 | for task_dict in app_settings.TASKS: 98 | self._register_task( 99 | func=task_dict.pop("func"), 100 | **task_dict, 101 | ) 102 | 103 | def _register_task(self, func: Callable[..., Any] | str, **kwargs): 104 | """ 105 | Register a task to the `registered_tasks` class attribute and return the function. Do not create the 106 | `Task` object in the database yet, to avoid the database being hit on registration -- plus the 107 | potential for the app registry not being ready yet. 108 | 109 | The actual `Task` object will be persisted to the database, either created or updated, in the 110 | `register_all` method which is meant to be manually run as part of the `setup_periodic_tasks` 111 | management command. 112 | """ 113 | # imported here to avoid `AppRegistryNotReady` exception, since the `registry` is imported 114 | # and used in this app config's `ready` method 115 | from django_q_registry.models import Task 116 | 117 | if not callable(func) and not isinstance(func, str): 118 | msg = f"{func} is not a string or callable." 119 | raise TypeError(msg) 120 | 121 | if isinstance(func, str): 122 | try: 123 | module_path, function_name = func.rsplit(".", 1) 124 | module = importlib.import_module(module_path) 125 | func = getattr(module, function_name) 126 | except (AttributeError, ImportError, ValueError) as err: 127 | raise ImportError(f"Could not import {func}.") from err 128 | 129 | # make mypy happy 130 | func = cast(Callable[..., Any], func) 131 | 132 | self.registered_tasks.add(Task.objects.create_in_memory(func, kwargs)) 133 | 134 | return func 135 | 136 | def autodiscover_tasks(self): 137 | """ 138 | Autodiscover tasks from all apps in INSTALLED_APPS. 139 | 140 | This is a simplified version of Celery's autodiscover_tasks function. 141 | """ 142 | for app_name in settings.INSTALLED_APPS: 143 | tasks_module = f"{app_name}.tasks" 144 | try: 145 | importlib.import_module(tasks_module) 146 | except ImportError: 147 | continue 148 | 149 | def update_created_tasks(self, tasks: TaskQuerySet) -> None: 150 | """ 151 | Update the `created_tasks` class attribute with the tasks that were created in the database. 152 | 153 | Args: 154 | tasks: 155 | A queryset of `Task` objects that were created in the database. 156 | """ 157 | self.created_tasks = set(tasks) 158 | 159 | 160 | registry = TaskRegistry() 161 | register_task = registry.register 162 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-q-registry/c309f866e7485dcfbedd769dc9bc0755ecdaec81/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import multiprocessing 5 | 6 | from django.conf import settings 7 | 8 | from .settings import DEFAULT_SETTINGS 9 | 10 | pytest_plugins = [] # type: ignore 11 | 12 | 13 | def pytest_configure(config): 14 | logging.disable(logging.CRITICAL) 15 | 16 | settings.configure(**DEFAULT_SETTINGS, **TEST_SETTINGS) 17 | 18 | 19 | TEST_SETTINGS = { 20 | "INSTALLED_APPS": [ 21 | "django.contrib.contenttypes", 22 | "django_q", 23 | "django_q_registry", 24 | ], 25 | "Q_CLUSTER": { 26 | "name": "ORM", 27 | "workers": multiprocessing.cpu_count() * 2 + 1, 28 | "timeout": 60, 29 | "retry": 120, 30 | "queue_limit": 50, 31 | "bulk": 10, 32 | "orm": "default", 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DEFAULT_SETTINGS = { 4 | "ALLOWED_HOSTS": ["*"], 5 | "DEBUG": False, 6 | "CACHES": { 7 | "default": { 8 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 9 | } 10 | }, 11 | "DATABASES": { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": ":memory:", 15 | } 16 | }, 17 | "EMAIL_BACKEND": "django.core.mail.backends.locmem.EmailBackend", 18 | "LOGGING_CONFIG": None, 19 | "PASSWORD_HASHERS": [ 20 | "django.contrib.auth.hashers.MD5PasswordHasher", 21 | ], 22 | "SECRET_KEY": "not-a-secret", 23 | } 24 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | from django.test import override_settings 5 | 6 | from django_q_registry.conf import app_settings 7 | 8 | 9 | def test_default_settings(): 10 | """Should be empty by default.""" 11 | q_registry_settings = getattr(settings, "Q_REGISTRY", {}) 12 | assert q_registry_settings == {} 13 | 14 | 15 | def test_default_app_settings(): 16 | assert app_settings.PERIODIC_TASK_SUFFIX == " - QREGISTRY" 17 | assert app_settings.TASKS == [] 18 | 19 | 20 | @override_settings( 21 | Q_REGISTRY={ 22 | "PERIODIC_TASK_SUFFIX": " - TEST", 23 | } 24 | ) 25 | def test_user_set_suffix(): 26 | assert app_settings.PERIODIC_TASK_SUFFIX == " - TEST" 27 | 28 | 29 | @override_settings( 30 | Q_REGISTRY={ 31 | "TASKS": [ 32 | { 33 | "name": "test", 34 | "func": "tests.test_conf.test_user_set_tasks", 35 | }, 36 | { 37 | "name": "test2", 38 | "func": "tests.test_conf.test_user_set_tasks", 39 | }, 40 | ], 41 | }, 42 | ) 43 | def test_user_set_tasks(): 44 | assert len(app_settings.TASKS) == 2 45 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | from datetime import datetime 5 | 6 | import pytest 7 | from django_q.models import Schedule 8 | from model_bakery import baker 9 | 10 | from django_q_registry.conf import app_settings 11 | from django_q_registry.models import Task 12 | from django_q_registry.registry import TaskRegistry 13 | 14 | pytestmark = pytest.mark.django_db 15 | 16 | 17 | class TestTaskQuerySet: 18 | def test_create_in_memory(self): 19 | def test_task(): 20 | pass 21 | 22 | task_kwargs = { 23 | "name": "test", 24 | "foo": "bar", 25 | } 26 | 27 | in_memory_task = Task.objects.create_in_memory(test_task, task_kwargs) 28 | 29 | assert not in_memory_task.pk 30 | assert in_memory_task.name == "test" 31 | assert in_memory_task.func == "tests.test_models.test_task" 32 | 33 | task_instance = Task( 34 | name="test", 35 | func="tests.test_models.test_task", 36 | kwargs={"foo": "bar"}, 37 | ) 38 | 39 | assert task_instance == in_memory_task 40 | 41 | def test_create_in_memory_no_name(self): 42 | def test_task(): 43 | pass 44 | 45 | test_kwargs = { 46 | "foo": "bar", 47 | } 48 | 49 | in_memory_task = Task.objects.create_in_memory(test_task, test_kwargs) 50 | 51 | assert in_memory_task.name == "test_task" 52 | 53 | def test_create_from_registry(self): 54 | def test_task(): 55 | pass 56 | 57 | def test_foo(): 58 | pass 59 | 60 | def test_baz(): 61 | pass 62 | 63 | tasks = [] 64 | 65 | # duplicate tasks 66 | # +1 67 | tasks.extend( 68 | [ 69 | baker.prepare( 70 | "django_q_registry.Task", 71 | name="test_task", 72 | func="tests.test_models.test_task", 73 | kwargs={"kwargs": {"foo": "bar"}}, 74 | ) 75 | for _ in range(2) 76 | ] 77 | ) 78 | # completely unique tasks 79 | # +2 80 | tasks.append( 81 | baker.prepare( 82 | "django_q_registry.Task", 83 | name="test_foo", 84 | func="tests.test_models.test_foo", 85 | kwargs={"kwargs": {"foo": "bar"}}, 86 | ) 87 | ) 88 | tasks.append( 89 | baker.prepare( 90 | "django_q_registry.Task", 91 | name="test_baz", 92 | func="tests.test_models.test_baz", 93 | kwargs={"kwargs": {"baz": "qux"}}, 94 | ) 95 | ) 96 | # similar tasks with different names but same function and kwargs 97 | # +2 98 | tasks.extend( 99 | [ 100 | baker.prepare("django_q_registry.Task", name=f"different_names_{_}") 101 | for _ in range(2) 102 | ] 103 | ) 104 | # similar tasks with different functions but same names and kwargs 105 | # +2 106 | tasks.extend( 107 | [ 108 | baker.prepare( 109 | "django_q_registry.Task", 110 | func=f"tests.test_models.{test_func.__name__}", 111 | ) 112 | for test_func in [test_foo, test_baz] 113 | ] 114 | ) 115 | # similar tasks with different kwargs but same names and functions 116 | # +2 117 | tasks.extend( 118 | [ 119 | baker.prepare("django_q_registry.Task", kwargs={"kwargs": {k: v}}) 120 | for k, v in {"foo": "bar", "baz": "qux"}.items() 121 | ] 122 | ) 123 | 124 | registry = TaskRegistry(registered_tasks=set(tasks)) 125 | 126 | registered_tasks = Task.objects.create_from_registry(registry) 127 | 128 | assert len(registered_tasks) == 9 129 | 130 | def test_register_existing_task(self, caplog): 131 | existing_task = baker.make("django_q_registry.Task") 132 | registry = TaskRegistry(registered_tasks=set([existing_task])) 133 | 134 | with caplog.at_level("ERROR"): 135 | Task.objects.create_from_registry(registry) 136 | 137 | assert f"Task {existing_task.pk} has already been registered" in caplog.text 138 | 139 | def test_register_existing_task_with_new_task(self, caplog): 140 | new_task = baker.prepare("django_q_registry.Task", name="new_task") 141 | existing_task = baker.make("django_q_registry.Task", name="existing_task") 142 | registry = TaskRegistry(registered_tasks=set([new_task, existing_task])) 143 | 144 | with caplog.at_level("ERROR"): 145 | Task.objects.create_from_registry(registry) 146 | 147 | assert Task.objects.count() == 2 148 | assert Task.objects.filter(name=new_task.name).exists() 149 | assert f"Task {existing_task.pk} has already been registered" in caplog.text 150 | 151 | def test_register_schedule_creation(self): 152 | tasks = baker.prepare("django_q_registry.Task", _quantity=3) 153 | 154 | assert Schedule.objects.count() == 0 155 | 156 | registry = TaskRegistry(registered_tasks=set(tasks)) 157 | 158 | registered_tasks = Task.objects.create_from_registry(registry) 159 | 160 | assert ( 161 | Schedule.objects.filter( 162 | registered_task__in=[task.pk for task in registered_tasks] 163 | ).count() 164 | == 3 165 | ) 166 | 167 | def test_register_schedule_update(self): 168 | schedule = baker.make("django_q.Schedule") 169 | task = baker.prepare("django_q_registry.Task", q_schedule=schedule) 170 | registry = TaskRegistry(registered_tasks=set([task])) 171 | 172 | registered_tasks = Task.objects.create_from_registry(registry) 173 | 174 | assert ( 175 | Schedule.objects.filter( 176 | registered_task__in=[task.pk for task in registered_tasks] 177 | ).count() 178 | == 1 179 | ) 180 | 181 | def test_exclude_registered(self): 182 | registry = TaskRegistry( 183 | created_tasks=set(baker.make("django_q_registry.Task", _quantity=3)) 184 | ) 185 | 186 | excluded_tasks = Task.objects.exclude_registered(registry) 187 | 188 | assert excluded_tasks.count() == 0 189 | 190 | def test_exclude_registered_with_unregistered_tasks(self): 191 | registry = TaskRegistry( 192 | created_tasks=set(baker.make("django_q_registry.Task", _quantity=3)) 193 | ) 194 | unregistered_tasks = baker.make("django_q_registry.Task", _quantity=3) 195 | 196 | excluded_tasks = Task.objects.exclude_registered(registry) 197 | 198 | assert excluded_tasks.count() == 3 199 | assert all(task in unregistered_tasks for task in excluded_tasks) 200 | 201 | def test_delete_dangling_objects_tasks(self): 202 | schedules = baker.make("django_q.Schedule", _quantity=3) 203 | registry = TaskRegistry( 204 | created_tasks=set( 205 | baker.make( 206 | "django_q_registry.Task", 207 | q_schedule=itertools.cycle(schedules), 208 | _quantity=len(schedules), 209 | ) 210 | ) 211 | ) 212 | baker.make("django_q_registry.Task", _quantity=3) 213 | 214 | Task.objects.delete_dangling_objects(registry) 215 | 216 | assert Task.objects.count() == len(schedules) 217 | 218 | def test_delete_dangling_objects_schedules(self): 219 | schedules = baker.make("django_q.Schedule", _quantity=3) 220 | registry = TaskRegistry( 221 | created_tasks=set( 222 | baker.make( 223 | "django_q_registry.Task", 224 | q_schedule=itertools.cycle(schedules), 225 | _quantity=len(schedules), 226 | ) 227 | ) 228 | ) 229 | baker.make( 230 | "django_q.Schedule", 231 | name=itertools.cycle( 232 | [ 233 | f"dangling_schedule{i}{app_settings.PERIODIC_TASK_SUFFIX}" 234 | for i in range(3) 235 | ] 236 | ), 237 | _quantity=3, 238 | ) 239 | 240 | Task.objects.delete_dangling_objects(registry) 241 | 242 | assert Schedule.objects.count() == len(schedules) 243 | 244 | def test_delete_dangling_objects_legacy_schedules(self): 245 | schedules = baker.make("django_q.Schedule", _quantity=3) 246 | registry = TaskRegistry( 247 | created_tasks=set( 248 | baker.make( 249 | "django_q_registry.Task", 250 | q_schedule=itertools.cycle(schedules), 251 | _quantity=len(schedules), 252 | ) 253 | ) 254 | ) 255 | baker.make( 256 | "django_q.Schedule", 257 | name=itertools.cycle([f"dangling_schedule{i} - CRON" for i in range(3)]), 258 | _quantity=3, 259 | ) 260 | 261 | Task.objects.delete_dangling_objects(registry) 262 | 263 | assert Schedule.objects.count() == len(schedules) 264 | 265 | def test_create_in_memory_with_datetime(self): 266 | # sanity check for this related issue: 267 | # https://github.com/westerveltco/django-q-registry/issues/30 268 | def test_task(): 269 | pass 270 | 271 | task_kwargs = { 272 | "datetime": datetime(2024, 5, 8), 273 | } 274 | 275 | in_memory_task = Task.objects.create_in_memory(test_task, task_kwargs) 276 | 277 | assert not in_memory_task.pk 278 | assert in_memory_task.kwargs["datetime"] == task_kwargs["datetime"] 279 | 280 | 281 | class TestTask: 282 | def test_hash_in_memory(self): 283 | task = Task( 284 | name="test", 285 | func="tests.test_task.test_hash", 286 | kwargs={"foo": "bar"}, 287 | ) 288 | assert isinstance(hash(task), int) 289 | 290 | def test_hash_in_db(self): 291 | task = baker.make( 292 | "django_q_registry.Task", 293 | name="test", 294 | func="tests.test_task.test_hash", 295 | kwargs={"foo": "bar"}, 296 | ) 297 | assert isinstance(hash(task), int) 298 | 299 | def test_task_equality_in_memory(self): 300 | task1 = Task( 301 | name="test", 302 | func="tests.test_task.test_task_equality", 303 | kwargs={"foo": "bar"}, 304 | ) 305 | task2 = Task( 306 | name="test", 307 | func="tests.test_task.test_task_equality", 308 | kwargs={"foo": "bar"}, 309 | ) 310 | 311 | assert task1 == task2 312 | 313 | def test_different_kwargs_in_memory(self): 314 | task1 = Task( 315 | name="test", 316 | func="tests.test_task.test_different_kwargs", 317 | kwargs={"foo": "bar"}, 318 | ) 319 | task2 = Task( 320 | name="test", 321 | func="tests.test_task.test_different_kwargs", 322 | kwargs={"baz": "qux"}, 323 | ) 324 | 325 | assert task1 != task2 326 | 327 | def test_same_kwargs_in_db(self): 328 | task1 = baker.make( 329 | "django_q_registry.Task", 330 | name="test", 331 | func="tests.test_task.test_task_equality", 332 | kwargs={"foo": "bar"}, 333 | ) 334 | task2 = baker.make( 335 | "django_q_registry.Task", 336 | name="test", 337 | func="tests.test_task.test_task_equality", 338 | kwargs={"foo": "bar"}, 339 | ) 340 | 341 | assert task1 != task2 342 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.test import override_settings 5 | from django_q.models import Schedule 6 | from model_bakery import baker 7 | 8 | from django_q_registry.models import Task 9 | from django_q_registry.registry import TaskRegistry 10 | 11 | 12 | @pytest.fixture 13 | def registry(): 14 | # clearing the registry before each test, since the registry 15 | # autodiscovers all tasks in project/apps. we could mock or 16 | # add a `pytest_is_running` check to the autodiscover_tasks 17 | # method, but this is clearer I think? -JT 18 | ret = TaskRegistry() 19 | ret.registered_tasks.clear() 20 | return ret 21 | 22 | 23 | def test_decoration(registry): 24 | @registry.register(name="test_task") 25 | def test_task(): 26 | return "test" 27 | 28 | assert len(registry.registered_tasks) == 1 29 | 30 | 31 | def test_function_call(registry): 32 | def test_task(): 33 | return "test" 34 | 35 | registry.register(test_task, name="test_task") 36 | 37 | assert len(registry.registered_tasks) == 1 38 | 39 | 40 | @override_settings( 41 | Q_REGISTRY={ 42 | "TASKS": [ 43 | { 44 | "func": "tests.test_registry.test_settings", 45 | "name": "Task from settings", 46 | }, 47 | ], 48 | } 49 | ) 50 | def test_settings(registry): 51 | registry._register_settings() 52 | 53 | assert len(registry.registered_tasks) == 1 54 | 55 | 56 | def test_function_is_callable(registry): 57 | @registry.register(name="test_task") 58 | def test_task(): 59 | return "test" 60 | 61 | assert test_task() == "test" 62 | 63 | 64 | def test_function_is_not_callable_or_string(registry): 65 | with pytest.raises(TypeError): 66 | registry._register_task(func=5) 67 | 68 | 69 | def test_function_string_is_not_formatted_correctly(registry): 70 | with pytest.raises(ImportError): 71 | registry._register_task(func="test_task") 72 | 73 | 74 | def test_function_str_is_not_callable(registry): 75 | with pytest.raises(ImportError): 76 | registry._register_task(func="tests.test_task") 77 | 78 | 79 | def test_function_is_callable_with_args(registry): 80 | @registry.register(name="test_task") 81 | def test_task(arg): 82 | return arg 83 | 84 | assert test_task("test") == "test" 85 | 86 | 87 | def test_function_name(registry): 88 | @registry.register(name="test_task") 89 | def test_task(): 90 | return "test" 91 | 92 | assert test_task.__name__ == "test_task" 93 | 94 | 95 | def test_register_no_name(registry): 96 | @registry.register() 97 | def test_task(): 98 | return "test" 99 | 100 | tasks = list(registry.registered_tasks) 101 | 102 | assert len(tasks) == 1 103 | assert tasks[0].name == "test_task" 104 | 105 | 106 | @pytest.mark.django_db 107 | def test_register_all_legacy_suffix(registry): 108 | # add a task to the registry 109 | def test_task(): 110 | return "test" 111 | 112 | registry.register(test_task, name="test_task") 113 | 114 | # simulate both the new and legacy scheduled tasks already being in the db 115 | schedule = baker.make( 116 | "django_q.Schedule", 117 | name="test_task - QREGISTRY", 118 | func="tests.test_registry.test_task", 119 | ) 120 | registry.registered_tasks.add( 121 | baker.make("django_q_registry.Task", q_schedule=schedule) 122 | ) 123 | 124 | baker.make( 125 | "django_q.Schedule", 126 | name="test_task - CRON", 127 | func="tests.test_registry.test_task", 128 | ) 129 | 130 | assert Schedule.objects.count() == 2 131 | 132 | Task.objects.create_from_registry(registry) 133 | Task.objects.delete_dangling_objects(registry) 134 | 135 | assert Schedule.objects.count() == 1 136 | assert Schedule.objects.first().name == "test_task - QREGISTRY" 137 | 138 | 139 | def test_issue_6_regression(registry): 140 | # https://github.com/westerveltco/django-q-registry/issues/6 141 | from django.core.mail import send_mail 142 | from django.utils import timezone 143 | 144 | base_kwargs = { 145 | "from_email": "from@example.com", 146 | "recipient_list": ["to@example.com"], 147 | } 148 | now = timezone.now() 149 | 150 | registry.register( 151 | send_mail, 152 | name="Send periodic test email", 153 | next_run=now, 154 | schedule_type=Schedule.MINUTES, 155 | minutes=5, 156 | kwargs={ 157 | "subject": "Test email from reminders", 158 | "message": "This is a test email.", 159 | **base_kwargs, 160 | }, 161 | ) 162 | 163 | assert len(registry.registered_tasks) == 1 164 | 165 | task = list(registry.registered_tasks)[0] 166 | 167 | assert task == Task( 168 | name="Send periodic test email", 169 | func="django.core.mail.send_mail", 170 | kwargs={ 171 | "next_run": now, 172 | "schedule_type": Schedule.MINUTES, 173 | "minutes": 5, 174 | "kwargs": { 175 | "subject": "Test email from reminders", 176 | "message": "This is a test email.", 177 | "from_email": "from@example.com", 178 | "recipient_list": ["to@example.com"], 179 | }, 180 | }, 181 | ) 182 | -------------------------------------------------------------------------------- /tests/test_setup_periodic_tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | from datetime import datetime 5 | 6 | import pytest 7 | from django_q.models import Schedule 8 | from model_bakery import baker 9 | 10 | from django_q_registry.conf import app_settings 11 | from django_q_registry.management.commands import setup_periodic_tasks 12 | from django_q_registry.models import Task 13 | from django_q_registry.registry import registry 14 | 15 | pytestmark = pytest.mark.django_db 16 | 17 | 18 | @registry.register(name="test_task") 19 | def task(): 20 | return "test" 21 | 22 | 23 | # https://github.com/westerveltco/django-q-registry/issues/30 24 | @registry.register( 25 | name="Issue 30 regression", 26 | next_run=datetime(2024, 5, 8), 27 | repeats=-1, 28 | schedule_type=Schedule.QUARTERLY, 29 | ) 30 | def issue_30_regression(): 31 | return "test" 32 | 33 | 34 | def test_setup_periodic_tasks(): 35 | assert len(registry.registered_tasks) == 2 36 | assert Task.objects.count() == 0 37 | assert Schedule.objects.count() == 0 38 | 39 | setup_periodic_tasks.Command().handle() 40 | 41 | assert len(registry.registered_tasks) == 2 42 | assert len(registry.created_tasks) == 2 43 | assert Task.objects.count() == 2 44 | assert Schedule.objects.count() == 2 45 | 46 | 47 | def test_setup_periodic_tasks_dangling_tasks(): 48 | baker.make("django_q_registry.Task", _quantity=3) 49 | 50 | assert len(registry.registered_tasks) == 2 51 | assert Task.objects.count() == 3 52 | 53 | setup_periodic_tasks.Command().handle() 54 | 55 | assert len(registry.registered_tasks) == 2 56 | assert len(registry.created_tasks) == 2 57 | assert Task.objects.count() == 2 58 | 59 | 60 | def test_setup_periodic_tasks_dangling_schedules(): 61 | baker.make( 62 | "django_q.Schedule", 63 | name=itertools.cycle( 64 | [ 65 | f"dangling_schedule{i}{app_settings.PERIODIC_TASK_SUFFIX}" 66 | for i in range(3) 67 | ] 68 | ), 69 | _quantity=3, 70 | ) 71 | 72 | assert len(registry.registered_tasks) == 2 73 | assert Schedule.objects.count() == 3 74 | 75 | setup_periodic_tasks.Command().handle() 76 | 77 | assert len(registry.registered_tasks) == 2 78 | assert len(registry.created_tasks) == 2 79 | assert Schedule.objects.count() == 2 80 | 81 | 82 | def test_setup_periodic_tasks_dangling_legacy_schedules(): 83 | schedules = baker.make("django_q.Schedule", _quantity=3) 84 | baker.make( 85 | "django_q.Schedule", 86 | name=itertools.cycle([f"dangling_schedule{i} - CRON" for i in range(3)]), 87 | _quantity=len(schedules), 88 | ) 89 | 90 | assert len(registry.registered_tasks) == 2 91 | assert Schedule.objects.count() == 6 92 | 93 | setup_periodic_tasks.Command().handle() 94 | 95 | assert len(registry.registered_tasks) == 2 96 | assert len(registry.created_tasks) == 2 97 | assert Schedule.objects.count() == 2 + len(schedules) 98 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django_q_registry import __version__ 4 | 5 | 6 | def test_version(): 7 | assert __version__ == "0.5.0" 8 | --------------------------------------------------------------------------------