├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .toml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── conf.py ├── doc ├── core-concepts.rst ├── home.rst ├── how-to.rst ├── introduction-to-automations.rst └── reference.rst ├── index.rst ├── make.bat ├── pyproject.toml ├── requirements.txt ├── runtests.py ├── setup.cfg └── src ├── automations ├── __init__.py ├── admin.py ├── apps.py ├── cms_automations │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── cms_apps.py │ ├── cms_plugins.py │ ├── locale │ │ └── de_DE │ │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20210506_1957.py │ │ ├── 0003_auto_20210511_0825.py │ │ ├── 0004_auto_20210511_1042.py │ │ ├── 0005_auto_20211121_1838.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── automations │ │ │ └── cms │ │ │ └── empty_template.html │ ├── tests.py │ └── views.py ├── flow.py ├── locale │ └── de_DE │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── automation_delete_history.py │ │ └── automation_step.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20210506_1957.py │ ├── 0003_auto_20210511_0825.py │ ├── 0004_auto_20210511_1042.py │ ├── 0005_automationmodel_key.py │ ├── 0006_auto_20211121_1357.py │ └── __init__.py ├── models.py ├── settings.py ├── templates │ ├── automations │ │ ├── base.html │ │ ├── dashboard.html │ │ ├── error_report.html │ │ ├── form_view.html │ │ ├── history.html │ │ ├── includes │ │ │ ├── dashboard.html │ │ │ ├── dashboard_error.html │ │ │ ├── dashboard_item.html │ │ │ ├── form_view.html │ │ │ ├── history.html │ │ │ ├── history_item.html │ │ │ ├── task_item.html │ │ │ └── task_list.html │ │ ├── preformatted_traceback.html │ │ ├── task_list.html │ │ └── traceback.html │ └── base.html ├── templatetags │ └── atm_tags.py ├── tests │ ├── __init__.py │ ├── methods.py │ ├── models.py │ ├── test_automations.py │ └── urls.py ├── urls.py └── views.py └── django_automations.egg-info ├── PKG-INFO ├── SOURCES.txt ├── dependency_links.txt ├── requires.txt └── top_level.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | raise NotImplementedError 4 | raise ImproperlyConfigured 5 | raise Http404 6 | raise PermissionDenied 7 | def __repr__ 8 | def __str__ 9 | pragma: no cover 10 | assert 11 | 12 | 13 | [run] 14 | omit = 15 | */cms_automations/* 16 | */urls.py 17 | */apps.py 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '39 18 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [3.7, 3.8, 3.9, '3.10'] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install flake8 pytest coverage 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | - name: Test with pytest 38 | run: | 39 | coverage run --source=src/automations ./runtests.py 40 | - uses: codecov/codecov-action@v1 41 | with: 42 | token: ${{ secrets.CODECOV }} # not required for public repos 43 | name: codecov-umbrella # optional 44 | fail_ci_if_error: true # optional (default = false) 45 | verbose: true # optional (default = false) 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/* 2 | *.pyc 3 | /venv/* 4 | /dist/* 5 | /_build/* 6 | /build/ 7 | /_static/ 8 | /_templates/ 9 | /dist/ 10 | /manage.py 11 | /db.sqlite3 12 | /pkg/ 13 | /.coverage 14 | /htmlcov/ 15 | /src/django_automations.egg-info/ 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pycqa/isort 3 | rev: 5.10.1 4 | hooks: 5 | - id: isort 6 | name: isort (python) 7 | - repo: https://github.com/ambv/black 8 | rev: 21.12b0 9 | hooks: 10 | - id: black 11 | language_version: python3.9 12 | - repo: https://gitlab.com/pycqa/flake8 13 | rev: 3.9.2 14 | hooks: 15 | - id: flake8 16 | - repo: https://github.com/rtts/djhtml 17 | rev: v1.4.10 18 | hooks: 19 | - id: djhtml 20 | -------------------------------------------------------------------------------- /.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | exclude = ''' 3 | /( 4 | \.git 5 | | \.hg 6 | | \.tox 7 | | \venv 8 | | _build 9 | | build 10 | | dist 11 | )/ 12 | ''' 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Fabian Braun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | global-exclude *.py[cod] __pycache__ *.so -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-automations) 2 | ![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-automations) 3 | ![GitHub](https://img.shields.io/github/license/fsbraun/django-automations) 4 | 5 | [![PyPI](https://img.shields.io/pypi/v/django-automations)](https://pypi.org/project/django-automations/) 6 | [![Read the Docs](https://img.shields.io/readthedocs/django-automations)](https://django-automations.readthedocs.io/en/latest/) 7 | [![codecov](https://codecov.io/gh/fsbraun/django-automations/branch/master/graph/badge.svg?token=DSA28NEFL9)](https://codecov.io/gh/fsbraun/django-automations) 8 | 9 | # Django-automations 10 | 11 | A lightweight framework to collect all processes of your django app in one place. 12 | 13 | Use cases: 14 | 15 | * Marketing automations, customer journeys 16 | * Simple business processes which require user interactions 17 | * Running regular tasks 18 | 19 | Django-automations works with plain Django but also integrates with Django-CMS. 20 | 21 | ## Key features 22 | 23 | * Describe automations as python classes 24 | 25 | * Bind automations to models from other Django apps 26 | 27 | * Use Django forms for user interaction 28 | 29 | * Create transparency through extendable dashboard 30 | 31 | * Declare automations as unique or unique for a certain data set 32 | 33 | * Start automations on signals or when, e.g., user visits a page 34 | 35 | * Send messages between automations 36 | 37 | ## Requirements 38 | 39 | * **Python**: 3.7, 3.8, 3.9, 3.10 40 | * **Django**: 3.2, 4.0, 4.1 41 | 42 | ## Feedback 43 | 44 | This project is in a early stage. All feedback is welcome! Please mail me at fsbraun(at)gmx.de 45 | 46 | # Installation 47 | 48 | Install the package from PyPI: 49 | 50 | pip install django-automations 51 | 52 | Add `automations` to your installed apps in `settings.py`: 53 | 54 | INSTALLED_APPS = ( 55 | ..., 56 | 'automations', 57 | 'automations.cms_automations', # ONLY IF YOU USE DJANGO-CMS! 58 | ) 59 | 60 | Only include the "sub app" `automations.cms_automations` if you are using Django CMS. 61 | 62 | The last step is to run the necessary migrations using the `manage.py` command: 63 | 64 | python manage.py migrate automations 65 | 66 | 67 | # Usage 68 | 69 | The basic idea is to add an automation layer to Django's model, view, template structure. The automation layer collects 70 | in one place all business processes which in a Django app often are distributed across models, views and any glue code. 71 | 72 | **Automations** consist of **tasks** which are carried out one after another. **Modifiers** affect, e.g. when a task is 73 | carried out. 74 | 75 | from automations import flow 76 | from automations.flow import Automation 77 | from automations.flow import this 78 | 79 | # "this" can be used in a class definition as a replacement for "self" 80 | 81 | from . import forms 82 | 83 | class ProcessInput(Automation): 84 | """The process steps are defined by sequentially adding the corresponding nodes""" 85 | start = flow.Execute(this.get_user_input) # Collect input a user has supplied 86 | check = flow.If( 87 | this.does_not_need_approval # Need approval? 88 | ).Then(this.process) # No? Continue later 89 | approval = flow.Form(forms.ApprovalForm).Group(name="admins") # Let admins approve 90 | process = flow.Execute(this.process_input) # Generate output 91 | end = flow.End() 92 | 93 | critical = 10_000 94 | 95 | def get_user_input(task_instance): 96 | ... 97 | 98 | def does_not_need_approval(task_instance): 99 | return not (task_instance.data['amount'] > self.critical) 100 | 101 | def process_input(task_instance): 102 | ... 103 | 104 | # Documentation 105 | 106 | See the [documentation on readthedocs.io](https://django-automations.readthedocs.io/). 107 | -------------------------------------------------------------------------------- /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 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Django Automations' 21 | copyright = '2021, Fabian Braun' 22 | author = 'Fabian Braun' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | "sphinx_rtd_theme", 32 | "sphinx.ext.autosectionlabel", 33 | ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = 'sphinx_rtd_theme' 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ['_static'] -------------------------------------------------------------------------------- /doc/core-concepts.rst: -------------------------------------------------------------------------------- 1 | Core concepts 2 | ############# 3 | 4 | Automations are processes 5 | ************************* 6 | Automations are nothing but well-defined processes designed to reach a goal. While often it is not technically difficult to implement processes with Django projects, maintenance can become quite complex over time if 7 | 8 | * different tasks of the process are hidden at different places in the code base. 9 | * documentation of the automations is not complete or out of sync 10 | * multiple users or user groups are involved in one process and need to share information. 11 | 12 | Commonly, business processes are described using events, tasks, and gateways. 13 | 14 | Events 15 | ====== 16 | Events can start an automation or can change its course once it is running. Django automations can be started by Django signals (see `Django documentation `_), programmatically by instantiating an automation, or by sending it a message. 17 | 18 | Tasks 19 | ===== 20 | 21 | Tasks describe work that has to be done, either by the Django application or by an user: Send an email or update a model instance are respective examples. 22 | 23 | Tasks are the single units of work that is not or cannot be broken down to a further level. It is referred to as an atomic activity. A task is the lowest level activity illustrated on a process diagram. A set of tasks may represent a high-level procedure. Tasks are are atomic transactions from a Django viewpoint. 24 | 25 | Work to be done by a user are represented by Django forms where the user has to fill in the results of her work, e.g., do an approval, fill in billing details etc. 26 | 27 | 28 | 29 | Gateways 30 | ======== 31 | 32 | Gateways change the flow of an automation, e.g., depending on a condition. Django automations offers ``If()``, ``.Then()``, ``.Else()`` constructs. The ``Split()``/``Join()`` gateway allows to parallelize the automation. 33 | 34 | Example issue discussion cycle 35 | ****************************** 36 | 37 | Assume you have a Django app that collects issues on a list and each week it creates an 38 | issue list for discussion. 39 | 40 | .. image:: https://upload.wikimedia.org/wikipedia/commons/c/c0/BPMN-DiscussionCycle.jpg 41 | :alt: Business process Issue discussion cycle 42 | 43 | Django Automations allows to describe and document the process in one place, a python class (what else?). 44 | 45 | This process can be translated into an automation like this 46 | 47 | .. code-block:: python 48 | 49 | class IssueDiscussion(flow.Automation): 50 | issue_list = models.IssueList 51 | 52 | start = flow.Execute(this.publish_announcement) 53 | parallel = flow.Split().Next(this.moderation).Next(this.warning).Next(this.conf_call) 54 | 55 | moderation = flow.If(this.no_new_mails).Then(this.repeat) 56 | moderate = flow.Form(forms.MailModeration).Group(name='Moderators') 57 | repeat = (flow.If(this.moderation_deadline_reached) 58 | .Then(this.join) 59 | .Else(this.moderation) 60 | ).AfterWaitingFor(datetime.timedelta(hours=1)) 61 | 62 | warning = (flow.Execute(this.send_deadline_warning) 63 | .AfterWaitingFor(datetime.timedelta(days=6)) 64 | .Next(this.join)) 65 | 66 | conf_call = flow.If(this.conf_call_this_week).Then(this.moderate_call).Else(this.join) 67 | moderate_call = flow.Execute(this.block_calendar) 68 | 69 | join = flow.Join() 70 | evaluate = flow.Execute(this.evaluate) 71 | end = flow.End() 72 | 73 | 74 | Automation states 75 | ***************** 76 | 77 | Automations have a state, i.e. they execute at one or more tasks. All execution points share the same attached model instances and (simple) state data. As many instances of an automation may be executed concurrently as necessary each instance has its own state. 78 | 79 | Let's say you wanted to automate the signup process for a webinar. Then a single session of a webinar with date and time might be the model instance you wanted to attach to the automation. This means each session of the webinar would also have an independent automation instance managing the signup process including sending out the webinar link, reminding people when the webinar starts or offering a recording after the webinar. While during the process the session does not change the list of people who have signed up changes but still always refers to the same 80 | webinar session. 81 | 82 | Django automations has two optional ways of storing state data. The first one is **binding model instances to an automation instance** allowing for all form of data Django models can handle. Additionally each automation instance has **a json-serializable dictionary attached** called ``data``. Since it is stored in a Django ``JSONField`` it only may contain combination of basic types (like ``int``, ``real``, ``list``, ``dict``, ``None``). This data dictionary is also used to store results of form interactions or for automation methods to keep intermediate states to communicate with each other. 83 | 84 | Modifiers 85 | ********* 86 | 87 | When interacting with humans, an automation will have to wait for input but also give humans time to digest and react. Modifiers control the speed at which an automation is executed: How long to wait before sending a reminder, or how long to give time before escalating the need for important information to the user's superior. The timing of each step is controlled by "modifiers" which, e.g., pause an automation before continuing. 88 | 89 | Request-response cycle and scheduling 90 | ************************************* 91 | 92 | Practically all automations pause or wait for other processes to finish most of the time. 93 | 94 | From time to time, the automations have to be checked if they can advance. This is the task of a scheduler outside this package. The scheduler may, e.g., call the class method ``models.AutomationModel.run``. Additionally, Django Automations offers a :ref:`new management command` ``python manage.py automation_step`` that can be invoked by an external scheduler. 95 | 96 | Also, an automation may advance, e.g., after an processing form has been filled and validated. Then the automation may advance within the request-response cycle of the POST request of the form. To keep the web app responsive, all automation steps need to be fast. Optionally, Django Automations allows to spawn threads for the background processes, or if serious calculations have to be done, outsourced to a worker task. 97 | 98 | .. note:: 99 | 100 | Django Automations is not a task scheduler for background worker processes. It is a framework that defines what tasks have to be done at what time under what conditions. It works, however, well with background worker processes. 101 | 102 | Currently, there is no native integration with, say, Celery. However, this might be an extension which would be welcomed. 103 | 104 | Debugging automations 105 | ********************* 106 | 107 | Django-automations offers some help in debugging automations. Since 108 | they run invisible to the user and developer errors caught and tracebacks 109 | stored for debugging purposes. 110 | 111 | To make them available, it is necessary to give the respective users the 112 | permissions ``automations.change_automationmodel`` **and** ``automations.change_automationtaskmodel``. 113 | 114 | .. note:: 115 | 116 | Automations are not debugged by actually changing instances of these models. Automations need to 117 | be changed on the source code. -------------------------------------------------------------------------------- /doc/home.rst: -------------------------------------------------------------------------------- 1 | Why Django Automations 2 | ###################### 3 | 4 | Business logic 5 | ************** 6 | 7 | The Django framework rightfully is an extremely popular web development 8 | framework. Django makes it easier to build better Web apps more quickly 9 | and with less code, as they state themselves. 10 | 11 | The key elements are **models**, **views** and **templates**. Models represent 12 | the persistent data which is stored in a database backend. Views convert these 13 | data into human readable form: contexts. The context is rendered as html using templates. 14 | 15 | This setup leads to business logic being scattered around in a project: Bits 16 | and pieces of the same process appear in different views which in turn access 17 | several models and their logic. 18 | 19 | Django automations aims to add another layer where business logic can be 20 | maintained centrally. Just like models live in ``models.py``, views in ``views.py`` 21 | automations are made to live in an app's ``automations.py``. 22 | 23 | Automations connect different tasks, may they be automatic or require 24 | user-interaction, to lead to a predefined result. Conditionals allow to 25 | branch according to the specific needs. 26 | 27 | The objective is to integrate and automate business processes with less code. 28 | Certain tasks either can be assigned to specific users or user groups or 29 | automatically carried out by your Django app. 30 | 31 | Implementation 32 | ************** 33 | 34 | The implementation is done with a few objectives in mind: 35 | 36 | * **Lightweight:** Django automations builds on proven Django elements: Models to keep the state of the processes and forms to manage user interaction. Ability to bind to other Django models, however, no need for migrations if bindings are changed or state information is added. 37 | * **Python syntax:** Just like models or forms are Python classes, automations are Python classes built in a similar way (from Nodes instead of ModelFields or FormFields) 38 | * **Easy extensibility:** To keep the core light, it is designed to allow for easy customization in a project. 39 | 40 | Benefits 41 | ******** 42 | 43 | * **Transparency:** Business logic in one place 44 | * **Maintainability:** Changes in business logic do not happen in several models or views, just in ``automations.py``. 45 | * **Time savings:** Less code to write 46 | * **Extendability:** Automations can be extended while "running" as long as the names of existing states remain the same. 47 | 48 | Enjoy! 49 | ****** -------------------------------------------------------------------------------- /doc/how-to.rst: -------------------------------------------------------------------------------- 1 | How-to guides 2 | ############# 3 | 4 | How to modify automations already running 5 | ***************************************** 6 | 7 | Automations can be updated, improved and changed even when they are running if a few rules are followed. 8 | 9 | Each automation instance is represented by a model instance of ``models.AutomationModel``. For each task that is executed an instance of ``models.AutomationTaskModel`` is created. For each unfinished automation there is at least one unfinished task. If the automations contains ``Split()`` nodes there might be more than one unfinished task. Typically these tasks are waiting either for a user interaction, a condition to become true, or until a certain amount of time passes. 10 | 11 | This implies that **it is always possible to add nodes** to the automation. New nodes will be executed as soon as an previous task is finished and the automation pointer moves forward to the new node. 12 | 13 | Also, **it is possible to change existing nodes**. However, this will only affect automation instances that have not yet processed the node. This leaves a record for the automations where the same node name corresponds to a different task and may render evaluation of automation results difficult. 14 | 15 | .. warning:: 16 | 17 | Nodes can only be removed from an automation if no instance is pointing to that node. Since this is difficult to guarantee the following process ensures integrity. 18 | 19 | Hence, to remove a node from an automation with existing instances follow this process: 20 | 21 | 1. Change the node you want to remove from an automation to ``flow.Execute()`` without any modifiers. This is a no-operation. 22 | 23 | 2. Run ``./manage.py automation_step``. This causes all automation instances with an open task at the node you want to delete to process the no-op and move to the next task. 24 | 25 | 3. Remove the node. Removing is safe now since an automation instance coming to the node immediately will execute to no-op and move to the next task. -------------------------------------------------------------------------------- /doc/introduction-to-automations.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | ############### 3 | 4 | Automations 5 | *********** 6 | 7 | 8 | In Django, a model is the single, definitive source of information about 9 | your data. View encapsulate the logic responsible for processing a user’s 10 | request and for returning the response. Templates are used to create HTML 11 | from views. Forms define how model instances can be edited and created. 12 | 13 | Automations describe (business) processes that define what model 14 | manipulations or form interactions have to be executed to reach a 15 | specific goal in your applications. Automations glue together different 16 | parts of your Django project while keeping all required code in one place. 17 | Just like models live in ``models.py``, views in ``views.py``, forms in 18 | ``forms.py`` automations live in an apps's ``automations.py``. 19 | 20 | The basics: 21 | 22 | #. Automations are Python subclasses of ``flow.Automation`` 23 | #. Each attribute represents a task node, quite similar to Django models 24 | #. All instances of automations are executed. Their states are kept in two models used by all Automations, quite in contrast to Django models where there is a one-to-one correspondence between model and table. 25 | 26 | Preparing your Django project 27 | ***************************** 28 | 29 | Before using the Django automations app, you need to install the package 30 | using pip. Then `automations has to be added to your project's 31 | ``INSTALLED_APPS`` in ``settings.py``: 32 | 33 | .. code-block:: python 34 | 35 | INSTALLED_APPS = ( 36 | ..., 37 | 'automations', 38 | 'automations.cms_automations', # ONLY IF YOU USE DJANGO-CMS! 39 | ) 40 | 41 | 42 | .. note:: 43 | Only include the "sub app" ``automations.cms_automations`` if you are using Django CMS. This sub-application will add additional plugins for Django CMS that integrate nicely with Django Automations. 44 | 45 | 46 | 47 | Finally, run 48 | 49 | .. code-block:: bash 50 | 51 | python manage.py migrate automations 52 | python manage.py migrate cms_automations 53 | 54 | to create Django Automations' database tables. 55 | 56 | Simple example: WebinarWorkflow 57 | ******************************* 58 | 59 | This automation executes four consecutive tasks before terminating. These tasks have a timely pattern: The reminder is only sent shortly before the webinar begins. The replay offer is sent after the webinar, 2.5 hours after the reminder. 60 | 61 | .. code-block:: python 62 | 63 | import datetime 64 | 65 | from automations import flow 66 | from automations.flow import this 67 | 68 | from . import webinar 69 | 70 | class WebinarWorkflow(flow.Automation): 71 | start = flow.Execute(this.init) 72 | send_welcome_mail = flow.Execute(webinar.send_welcome_mail) 73 | send_reminder = (flow.Execute(webinar.send_reminder_mail) 74 | .AfterWaitingUntil(webinar.reminder_time)) 75 | send_replay_offer = (flow.Execute(webinar.send_replay_mail) 76 | .AfterWaitingFor(datetime.timedelta(minutes=150))) 77 | end = flow.End() 78 | 79 | def init(self, task): 80 | ... 81 | 82 | This defines the WebinarWorkflow. Only once a class object is created, the 83 | ``WebinarWorkflow`` automation will be executed. Programmatically, you can 84 | create an object by saying ``webinar_workflow = WebinarWorkflow()``. 85 | 86 | Nodes 87 | ***** 88 | 89 | Each task of an automation is expressed by a ``flow.Node``. In the example above 90 | two node classes are used: ``flow.Execute`` and ``flow.End()``. By making a node 91 | an attribute of an ``Automation`` class it gets bound to it. Some nodes 92 | take parameters, like ``flow.Execute``, some do not, like ``flow.End()``. 93 | 94 | .. note:: 95 | * Nodes are processed in their order of declaration in the automation class (unless specified differently, see below). 96 | * Each node has a name (``start``, ``send_welcome_mail``, ...). Each running instance of the automation has a state (or program counter) which corresponds to the name of the node which is to be executed next. 97 | * Since at the declaration of the ``Automation`` attributes no object has been created there is no ``self`` reference. The ``this`` object replaces ``self`` during the declaration of the automation class. (``this`` objects are replaced by ``self``-references at the time of execution of the automation.) 98 | * To allow for timed execution, some sort of scheduler is needed in the project. 99 | 100 | Node types 101 | ========== 102 | 103 | Django Automations has some built-in node types (see [reference](reference)). 104 | 105 | * ``flow.Execute()`` executes a Python callable, typically a method of the automation class to perform the task. 106 | * ``flow.End()`` terminates the execution of the current automation object. 107 | 108 | More nodes are: 109 | 110 | * ``flow.Repeat()`` declares an infinite loop to define regular worker processes. 111 | * ``flow.If`` allows conditional branching within the automation. 112 | * ``flow.Split()`` allows to split the execution of the automation in 2 or more concurring paths. 113 | * ``flow.Join()`` waits until all paths that have started at the same previous ``Split()`` have converged again. (All splitted paths must be join before ending an automation!) 114 | * ``flow.Form()`` requires a specific user or a user of a group of users to fill in a form before the automation continues. 115 | * ``flow.ModelForm()`` is a simplified front end of ``flow.Form()`` to create or edit model instances. 116 | * ``flow.SendMessage()`` allows to communicate with other automations. 117 | 118 | 119 | Modifier 120 | ======== 121 | 122 | Each node can be modified using modifiers. Modifiers are methods of the ``Node`` 123 | class which return ``self`` and therefore can be chained together. This well-known 124 | pattern from JavaScript allows a node to be modified multiple times. 125 | 126 | Modifiers can add conditions which have to be fulfilled before the execution of 127 | the task begins. Typical conditions include passing of a certain amount of time 128 | or reaching a certain date and time. Other uses include defining the next node 129 | that is to be executed (a little bit like goto). 130 | 131 | Modifiers for all nodes (with the exception for ``flow.Form`` and 132 | ``flow.ModelForm``) are 133 | 134 | * ``.Next(node)`` sets the node to continue with after finishing this node. If omitted the automation continues with the chronologically next node of the class. ``.Next`` resembles a goto statement. ``.Next`` takes a string or a ``This`` object as a parameter. A string denotes the name of the next node. The this object allows for a different syntax. ``.Next("next_node")`` and ``.Next(this.next_node)`` are equivalent. 135 | * ``.AsSoonAs(condition)`` waits for condition before continuing the automation. If condition is ``False`` the automation is interrupted and ``condition`` is checked the next time the automation instance is run. 136 | * ``.AfterWaitingUntil(datetime)`` stops the automation until the specific datetime has passed. Note that depending on how the scheduler runs the automation there might be a significant time slip between ``datetime`` and the real execution time. It is only guaranteed that the node is not executed before. ``datetime`` may be a callable. 137 | * ``.AfterWaitingFor(timedelta)`` stops the automation for a specific amount of time. This is equivalent to ``.AfterWaitingUntil(lambda x: now()+timedelta)``. 138 | * ``.SkipIf`` leaves a node unprocessed if a condition is fulfilled. 139 | 140 | Other nodes implement additional modifiers, e.g., ``.Then()`` and 141 | ``.Else()`` in the ``If()`` node. A different example is 142 | ``.OnError(next_node)`` in the ``flow.Execute()`` node which defines where to jump should the execution of the specified method raise an exception. 143 | 144 | Node inheritance 145 | ================ 146 | 147 | Especially the ``flow.Execute`` node can be easily subclassed to create specific 148 | and speaking nodes. E.g., in the above example it might be useful to create a 149 | node ``SendMail``: 150 | 151 | .. code-block:: python 152 | 153 | class SendMail(flow.Execute): 154 | def method(self, task_instance, mail_id): 155 | """here goes the code to be executed""" 156 | 157 | 158 | Meta options 159 | ============ 160 | 161 | Similar to Django's meta options, Django Automations allows to define verbose names for each automation. 162 | 163 | 164 | .. code-block:: python 165 | 166 | class WebinarWorkflow(flow.Automation): 167 | class Meta: 168 | verbose_name = _("Webinar preparation") 169 | 170 | start = flow.Execute(this.init) 171 | ... 172 | 173 | Verbose names can appear in Django Automations' views. If no verbose name 174 | is given the standard name "Automation " plus the class name is used. In 175 | this example it is ``Automation WebinarWorkflow``. 176 | 177 | -------------------------------------------------------------------------------- /index.rst: -------------------------------------------------------------------------------- 1 | .. Django Automations documentation master file, created by 2 | sphinx-quickstart on Wed Apr 28 13:13:49 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django Automations' documentation! 7 | ############################################# 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | doc/home 14 | doc/introduction-to-automations 15 | doc/core-concepts 16 | doc/how-to 17 | doc/reference 18 | 19 | 20 | Indices and tables 21 | ################## 22 | 23 | * :ref:`genindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3 2 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import warnings 5 | from optparse import OptionParser 6 | 7 | import django 8 | from django.conf import settings 9 | from django.core.management import call_command 10 | 11 | 12 | def runtests(test_path="automations"): 13 | if not settings.configured: 14 | # Choose database for settings 15 | DATABASES = { 16 | "default": { 17 | "ENGINE": "django.db.backends.sqlite3", 18 | "NAME": ":memory:", 19 | } 20 | } 21 | # test_db = os.environ.get('DB', 'sqlite') 22 | # if test_db == 'mysql': 23 | # DATABASES['default'].update({ 24 | # 'ENGINE': 'django.db.backends.mysql', 25 | # 'NAME': 'modeltranslation', 26 | # 'USER': 'root', 27 | # }) 28 | # elif test_db == 'postgres': 29 | # DATABASES['default'].update({ 30 | # 'ENGINE': 'django.db.backends.postgresql', 31 | # 'USER': 'postgres', 32 | # 'NAME': 'modeltranslation', 33 | # }) 34 | 35 | # Configure test environment 36 | settings.configure( 37 | SECRET_KEY="verysecretkeyfortesting", 38 | DATABASES=DATABASES, 39 | INSTALLED_APPS=( 40 | "django.contrib.contenttypes", 41 | "django.contrib.auth", 42 | "django.contrib.sessions", 43 | "automations", 44 | ), 45 | ROOT_URLCONF="automations.tests.urls", # tests override urlconf, but it still needs to be defined 46 | LANGUAGES=( 47 | ("en", "English"), 48 | ("de", "Deutsch"), 49 | ), 50 | MIDDLEWARE=( 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.contrib.auth.middleware.AuthenticationMiddleware", 53 | ), 54 | TEMPLATES=[ 55 | { 56 | "BACKEND": "django.template.backends.django.DjangoTemplates", 57 | "DIRS": ( 58 | "src/automations/templates", 59 | # insert your TEMPLATE_DIRS here 60 | ), 61 | "OPTIONS": { 62 | "context_processors": ( 63 | "django.contrib.auth.context_processors.auth", 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.i18n", 66 | "django.template.context_processors.media", 67 | "django.template.context_processors.static", 68 | "django.template.context_processors.tz", 69 | "django.template.context_processors.request", 70 | ), 71 | }, 72 | }, 73 | ], 74 | ) 75 | 76 | django.setup() 77 | warnings.simplefilter("always", DeprecationWarning) 78 | failures = call_command( 79 | "test", test_path, interactive=False, failfast=False, verbosity=2 80 | ) 81 | 82 | sys.exit(bool(failures)) 83 | 84 | 85 | if __name__ == "__main__": 86 | sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), "src")) 87 | parser = OptionParser() 88 | 89 | (options, args) = parser.parse_args() 90 | runtests(*args) 91 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # replace with your username: 3 | name = django-automations 4 | version = 0.9.4.1 5 | author = Fabian Braun 6 | author_email = fsbraun@gmx.de 7 | description = Processes and automations for your Django project 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | url = https://github.com/fsbraun/django-automations 11 | project_urls = 12 | Bug Tracker = https://github.com/fsbraun/django-automations/issues 13 | Documentation = https://django-automations.readthedocs.io/en/latest/ 14 | classifiers = 15 | Development Status :: 5 - Production/Stable 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3.7 18 | Programming Language :: Python :: 3.8 19 | Programming Language :: Python :: 3.9 20 | Programming Language :: Python :: 3.10 21 | Framework :: Django 22 | Framework :: Django :: 3.0 23 | Framework :: Django :: 3.1 24 | Framework :: Django :: 3.2 25 | License :: OSI Approved :: MIT License 26 | Operating System :: OS Independent 27 | keywords = 28 | django_automations 29 | workflow 30 | automation 31 | 32 | [options] 33 | include_package_data = True 34 | package_dir = 35 | = src 36 | packages = automations 37 | python_requires = >=3.7 38 | install_requires = 39 | Django>=3.0 40 | [flake8] 41 | max-line-length = 88 42 | select = C,E,F,W,B,B950 43 | extend-ignore = E203, E501 44 | [isort] 45 | profile = black -------------------------------------------------------------------------------- /src/automations/__init__.py: -------------------------------------------------------------------------------- 1 | """Lightweight framework to collect all processes of your django app in one place. 2 | See documentation at https://django-automations.readthedocs.io/en/latest/ for more info. 3 | """ 4 | VERSION = (0, 9, 4) 5 | __version__ = ".".join(map(str, VERSION)) 6 | -------------------------------------------------------------------------------- /src/automations/admin.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from django.contrib import admin 3 | 4 | # Register your models here. 5 | -------------------------------------------------------------------------------- /src/automations/apps.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from django.apps import AppConfig 3 | from django.conf import settings 4 | from django.core.checks import Error 5 | from django.core.checks import Tags as DjangoTags 6 | from django.core.checks import register 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | class Tags(DjangoTags): 11 | automations_settings_tag = "automations_settings" 12 | 13 | 14 | def checks_atm_settings(app_configs, **kwargs): 15 | """Checks that either all or none of the group/permissions settings are set""" 16 | errors = [] 17 | group_and_permission_settings = ( 18 | hasattr(settings, "ATM_GROUP_MODEL"), 19 | hasattr(settings, "ATM_USER_WITH_PERMISSIONS_FORM_METHOD"), 20 | hasattr(settings, "ATM_USER_WITH_PERMISSIONS_MODEL_METHOD"), 21 | ) 22 | 23 | if any(group_and_permission_settings) and not all(group_and_permission_settings): 24 | errors = [ 25 | ( 26 | Error( 27 | "Django Automations settings incorrectly configured", 28 | hint=_( 29 | "Either all or none of the following settings must be present: ATM_GROUP_MODEL, " 30 | "ATM_USER_WITH_PERMISSIONS_FORM_METHOD, ATM_USER_WITH_PERMISSIONS_MODEL_METHOD" 31 | ), 32 | id="automations.E001", 33 | ) 34 | ) 35 | ] 36 | return errors 37 | 38 | 39 | class AutomationsConfig(AppConfig): 40 | name = "automations" 41 | verbose_name = _("Automations") 42 | default_auto_field = "django.db.models.AutoField" 43 | 44 | def ready(self): 45 | super().ready() 46 | register(Tags.automations_settings_tag)(checks_atm_settings) 47 | -------------------------------------------------------------------------------- /src/automations/cms_automations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsbraun/django-automations/3fde447de8032f0ad1d76eb706fd521174d05415/src/automations/cms_automations/__init__.py -------------------------------------------------------------------------------- /src/automations/cms_automations/admin.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from django.contrib import admin 4 | 5 | # Register your models here. 6 | -------------------------------------------------------------------------------- /src/automations/cms_automations/apps.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from django.apps import AppConfig 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class CmsAutomationsConfig(AppConfig): 7 | name = "automations.cms_automations" 8 | default_auto_field = "django.db.models.AutoField" 9 | verbose_name = _("CMS Automations") 10 | -------------------------------------------------------------------------------- /src/automations/cms_automations/cms_apps.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from cms.app_base import CMSApp 3 | from cms.apphook_pool import apphook_pool 4 | from django.utils.translation import gettext as _ 5 | 6 | 7 | class AutomationsApphook(CMSApp): 8 | name = _("Django Automations") 9 | app_name = "automations" 10 | 11 | def get_urls(self, page=None, langague=None, **kwargs): 12 | return ["automations.urls"] 13 | 14 | 15 | apphook_pool.register(AutomationsApphook) 16 | -------------------------------------------------------------------------------- /src/automations/cms_automations/cms_plugins.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | """Soft dependency on django-automations_cms: Define plugins for open tasks""" 4 | import logging 5 | import threading 6 | 7 | from cms.plugin_base import CMSPluginBase 8 | from cms.plugin_pool import plugin_pool 9 | from django import forms 10 | from django.utils.translation import gettext_lazy as _ 11 | 12 | from .. import flow, models, views 13 | from . import models as cms_models 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @plugin_pool.register_plugin 19 | class AutomationTaskList(CMSPluginBase): 20 | name = _("Task list") 21 | module = _("Automations") 22 | model = cms_models.AutomationTasksPlugin 23 | allow_children = False 24 | require_parent = False 25 | 26 | def render(self, context, instance, placeholder): 27 | qs = models.AutomationTaskModel.get_open_tasks(context["request"].user) 28 | context.update( 29 | dict(tasks=qs, count=len(qs), always_inform=instance.always_inform) 30 | ) 31 | return context 32 | 33 | def get_render_template(self, context, instance, placeholder): 34 | return instance.template 35 | 36 | 37 | @plugin_pool.register_plugin 38 | class AutomationDashboard(CMSPluginBase): 39 | name = _("Dashboard") 40 | module = _("Automations") 41 | allow_children = False 42 | require_parent = False 43 | render_template = "automations/includes/dashboard.html" 44 | 45 | def render(self, context, instance, placeholder): 46 | view = views.TaskDashboardView(request=context["request"]) 47 | context.update(view.get_context_data()) 48 | return context 49 | 50 | 51 | def get_task_choices(pattern, convert, subclass=None): 52 | status_choices = [] 53 | for cls_name, verbose_name in flow.get_automations(): 54 | cls = models.get_automation_class(cls_name) 55 | if getattr(cls, "publish_receivers", False): 56 | choices = [] 57 | if subclass is not None and hasattr(cls, subclass): 58 | cls = getattr(cls, subclass) 59 | for item in dir(cls): 60 | if pattern(item): 61 | attr = getattr(cls, item) 62 | tpl = convert(attr, item, cls_name) 63 | if isinstance(tpl, (tuple, list)): 64 | choices.append(tuple(tpl)) 65 | if choices: 66 | status_choices.append((verbose_name, tuple(choices))) 67 | return tuple(status_choices) # make immutable 68 | 69 | 70 | def get_task_status_choices(): 71 | def convert(attr, item, _): 72 | if isinstance(attr, str): 73 | attr = attr, item.replace("_", " ").capitalize() 74 | return attr 75 | 76 | return get_task_choices( 77 | lambda x: x.endswith("_template") and x != "dashboard_template", 78 | convert=convert, 79 | subclass="Meta", 80 | ) 81 | 82 | 83 | def get_task_receiver_choices(): 84 | def convert(attr, item, cls_name): 85 | if callable(attr) and len(item) > 8: 86 | return cls_name + "." + item[8:], item[8:].replace("_", " ").capitalize() 87 | return None 88 | 89 | return get_task_choices(lambda x: x.startswith("receive_"), convert=convert) 90 | 91 | 92 | def get_automation_model(get_params): 93 | key = get_params.get("key", None) 94 | if key is not None: 95 | try: 96 | automation_instance = models.AutomationModel.objects.get(key=key) 97 | return automation_instance 98 | except models.AutomationModel.DoesNotExist: 99 | return None 100 | return None 101 | 102 | 103 | def get_valid_automation_model(context, template): 104 | model_instance = get_automation_model(context.get("request", dict()).GET) 105 | if model_instance: 106 | cls = models.get_automation_class(model_instance.automation_class) 107 | if hasattr(cls, "Meta"): 108 | for item in dir(cls.Meta): 109 | if item.endswith("_template") and item != "dashboard_template": 110 | attr = getattr(cls.Meta, item) 111 | if ( 112 | isinstance(attr, str) 113 | and attr == template 114 | or isinstance(attr, (tuple, list)) 115 | and attr[0] == template 116 | ): 117 | return model_instance 118 | return None 119 | 120 | 121 | class EditTaskData(forms.ModelForm): 122 | class Meta: 123 | model = cms_models.AutomationStatusPlugin 124 | widgets = { 125 | "template": forms.Select(choices=get_task_status_choices()), 126 | "name": forms.HiddenInput(), 127 | } 128 | fields = "__all__" 129 | 130 | def clean_name(self): 131 | choices = {} 132 | for __, chapter in get_task_status_choices(): 133 | choices.update({key: value for key, value in chapter}) 134 | return choices.get(self.data["template"], "") 135 | 136 | 137 | class AutomationStatus(CMSPluginBase): 138 | name = _("Status") 139 | module = _("Automations") 140 | model = cms_models.AutomationStatusPlugin 141 | allow_children = False 142 | require_parent = False 143 | text_enabled = True 144 | form = EditTaskData 145 | render_template = None 146 | 147 | def render(self, context, instance, placeholder): 148 | self.render_template = instance.template 149 | 150 | automation_model = get_valid_automation_model(context, self.render_template) 151 | if automation_model is not None: 152 | automation = automation_model.instance 153 | else: 154 | automation = None 155 | context.update( 156 | dict( 157 | automation=automation, 158 | automation_model=automation_model, 159 | instance=instance, 160 | ) 161 | ) 162 | return context 163 | 164 | 165 | plugin_pool.register_plugin(AutomationStatus) 166 | 167 | 168 | class EditAutomationHook(forms.ModelForm): 169 | class Meta: 170 | model = cms_models.AutomationHookPlugin 171 | widgets = { 172 | "automation": forms.Select(choices=get_task_receiver_choices()), 173 | } 174 | fields = "__all__" 175 | 176 | 177 | class AutomationHook(CMSPluginBase): 178 | name = _("Send message") 179 | module = _("Automations") 180 | model = cms_models.AutomationHookPlugin 181 | allow_children = False 182 | require_parent = False 183 | render_template = "automations/cms/empty_template.html" 184 | form = EditAutomationHook 185 | 186 | def render(self, context, instance, placeholder): 187 | request = context["request"] 188 | automation, message = instance.automation.rsplit(".", 1) 189 | try: 190 | cls = models.get_automation_class(automation) 191 | if not issubclass(cls, flow.Automation): 192 | raise AttributeError 193 | except (AttributeError, ModuleNotFoundError): 194 | return {"error": _("Automation class not present: %s") % automation} 195 | if ( 196 | instance.operation 197 | == cms_models.AutomationHookPlugin.OperationChoices.message 198 | ): 199 | model_instance = get_automation_model(request.GET) 200 | if model_instance: 201 | threading.Thread( 202 | target=lambda: cls.dispatch_message( 203 | model_instance, message, instance.token, request 204 | ), 205 | ).start() 206 | elif ( 207 | instance.operation == cms_models.AutomationHookPlugin.OperationChoices.start 208 | ): 209 | threading.Thread( 210 | target=lambda: cls.create_on_message(message, instance.token, request) 211 | ).start() 212 | elif ( 213 | instance.operation 214 | == cms_models.AutomationHookPlugin.OperationChoices.broadcast 215 | ): 216 | threading.Thread( 217 | target=lambda: cls.broadcast_message(message, instance.token, request) 218 | ).start() 219 | return context 220 | 221 | 222 | plugin_pool.register_plugin(AutomationHook) 223 | 224 | 225 | class AutomationsDashboard(CMSPluginBase): 226 | module = _("Automations") 227 | name = _("Automations dashboard") 228 | render_template = "automations/includes/dashboard.html" 229 | allow_children = False 230 | require_parent = False 231 | 232 | def render(self, context, instance, placeholder): 233 | view = views.TaskDashboardView(request=context["request"]) 234 | context.update(view.get_context_data()) 235 | return context 236 | -------------------------------------------------------------------------------- /src/automations/cms_automations/locale/de_DE/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsbraun/django-automations/3fde447de8032f0ad1d76eb706fd521174d05415/src/automations/cms_automations/locale/de_DE/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/automations/cms_automations/locale/de_DE/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-12-14 17:28+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: cms_automations/apps.py:9 21 | msgid "CMS Automations" 22 | msgstr "CMS Automatisierung" 23 | 24 | #: cms_automations/cms_apps.py:8 25 | #, fuzzy 26 | #| msgid "Automations" 27 | msgid "Django Automations" 28 | msgstr "Automatisierung" 29 | 30 | #: cms_automations/cms_plugins.py:19 31 | msgid "Task list" 32 | msgstr "Aufgaben-Liste" 33 | 34 | #: cms_automations/cms_plugins.py:20 cms_automations/cms_plugins.py:40 35 | #: cms_automations/cms_plugins.py:142 cms_automations/cms_plugins.py:182 36 | #: cms_automations/cms_plugins.py:229 37 | msgid "Automations" 38 | msgstr "Automatisierung" 39 | 40 | #: cms_automations/cms_plugins.py:39 41 | msgid "Dashboard" 42 | msgstr "Dashboard" 43 | 44 | #: cms_automations/cms_plugins.py:141 45 | msgid "Status" 46 | msgstr "Status" 47 | 48 | #: cms_automations/cms_plugins.py:181 49 | msgid "Send message" 50 | msgstr "Nachricht senden" 51 | 52 | #: cms_automations/cms_plugins.py:197 53 | #, python-format 54 | msgid "Automation class not present: %s" 55 | msgstr "Automation-Klasse nicht gefunden: %s" 56 | 57 | #: cms_automations/cms_plugins.py:230 58 | #, fuzzy 59 | #| msgid "Automations" 60 | msgid "Automations dashboard" 61 | msgstr "Automatisierungs-Dashboard" 62 | 63 | #: cms_automations/models.py:14 64 | msgid "Template" 65 | msgstr "Vorlage" 66 | 67 | #: cms_automations/models.py:18 68 | msgid "Always inform" 69 | msgstr "Immer informieren" 70 | 71 | #: cms_automations/models.py:20 72 | msgid "If deactivated plugin will out output anything if no task is available." 73 | msgstr "" 74 | "Wenn deaktiviert wird das Plugin nichts anzeigen, wenn keine Aufgabe " 75 | "verfügbar ist." 76 | 77 | #: cms_automations/models.py:27 78 | msgid "Start automaton" 79 | msgstr "Automatisierung starten" 80 | 81 | #: cms_automations/models.py:28 82 | msgid "Send message to automation" 83 | msgstr "Nachricht an Automatisierung senden" 84 | 85 | #: cms_automations/models.py:29 86 | msgid "Broadcast message to all automations" 87 | msgstr "Nachrichten-Broadcast an alle Automatisierungen" 88 | 89 | #: cms_automations/models.py:34 90 | msgid "Operation" 91 | msgstr "Kommando" 92 | 93 | #: cms_automations/models.py:40 94 | msgid "Automation" 95 | msgstr "Automation" 96 | 97 | #: cms_automations/models.py:46 98 | msgid "Optional token" 99 | msgstr "Optionales Token" 100 | 101 | #: cms_automations/models.py:57 102 | msgid "Task data" 103 | msgstr "Aufgaben-Datensatz" 104 | -------------------------------------------------------------------------------- /src/automations/cms_automations/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-02 11:09 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("cms", "0022_auto_20180620_1551"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="AutomationHookPlugin", 18 | fields=[ 19 | ( 20 | "cmsplugin_ptr", 21 | models.OneToOneField( 22 | auto_created=True, 23 | on_delete=django.db.models.deletion.CASCADE, 24 | parent_link=True, 25 | primary_key=True, 26 | related_name="cms_automations_automationhookplugin", 27 | serialize=False, 28 | to="cms.cmsplugin", 29 | ), 30 | ), 31 | ( 32 | "automation", 33 | models.CharField(max_length=128, verbose_name="Automation"), 34 | ), 35 | ( 36 | "token", 37 | models.CharField( 38 | blank=True, max_length=128, verbose_name="Optional token" 39 | ), 40 | ), 41 | ], 42 | options={ 43 | "abstract": False, 44 | }, 45 | bases=("cms.cmsplugin",), 46 | ), 47 | migrations.CreateModel( 48 | name="AutomationStatusPlugin", 49 | fields=[ 50 | ( 51 | "cmsplugin_ptr", 52 | models.OneToOneField( 53 | auto_created=True, 54 | on_delete=django.db.models.deletion.CASCADE, 55 | parent_link=True, 56 | primary_key=True, 57 | related_name="cms_automations_automationstatusplugin", 58 | serialize=False, 59 | to="cms.cmsplugin", 60 | ), 61 | ), 62 | ( 63 | "template", 64 | models.CharField(max_length=128, verbose_name="Task data"), 65 | ), 66 | ("name", models.CharField(blank=True, max_length=128)), 67 | ], 68 | options={ 69 | "abstract": False, 70 | }, 71 | bases=("cms.cmsplugin",), 72 | ), 73 | migrations.CreateModel( 74 | name="AutomationTasksPlugin", 75 | fields=[ 76 | ( 77 | "cmsplugin_ptr", 78 | models.OneToOneField( 79 | auto_created=True, 80 | on_delete=django.db.models.deletion.CASCADE, 81 | parent_link=True, 82 | primary_key=True, 83 | related_name="cms_automations_automationtasksplugin", 84 | serialize=False, 85 | to="cms.cmsplugin", 86 | ), 87 | ), 88 | ( 89 | "template", 90 | models.CharField( 91 | choices=[ 92 | ("automations/includes/task_list.html", "Default template") 93 | ], 94 | default="automations/includes/task_list.html", 95 | max_length=128, 96 | verbose_name="Template", 97 | ), 98 | ), 99 | ( 100 | "always_inform", 101 | models.BooleanField( 102 | default=True, 103 | help_text="If deactivated plugin will out output anything if no task is available.", 104 | verbose_name="Always inform", 105 | ), 106 | ), 107 | ], 108 | options={ 109 | "abstract": False, 110 | }, 111 | bases=("cms.cmsplugin",), 112 | ), 113 | ] 114 | -------------------------------------------------------------------------------- /src/automations/cms_automations/migrations/0002_auto_20210506_1957.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-06 17:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("cms_automations", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="automationhookplugin", 15 | name="operation", 16 | field=models.IntegerField( 17 | choices=[ 18 | (0, "Automatisierung starten"), 19 | (1, "Nachricht an Automatisierung"), 20 | ], 21 | default=1, 22 | verbose_name="Operation", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="automationhookplugin", 27 | name="automation", 28 | field=models.CharField(max_length=128, verbose_name="Automatisierung"), 29 | ), 30 | migrations.AlterField( 31 | model_name="automationhookplugin", 32 | name="token", 33 | field=models.CharField( 34 | blank=True, max_length=128, verbose_name="Optionales Token" 35 | ), 36 | ), 37 | migrations.AlterField( 38 | model_name="automationstatusplugin", 39 | name="template", 40 | field=models.CharField(max_length=128, verbose_name="Daten der Aufgabe"), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /src/automations/cms_automations/migrations/0003_auto_20210511_0825.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-11 08:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("cms_automations", "0002_auto_20210506_1957"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="automationhookplugin", 15 | name="automation", 16 | field=models.CharField(max_length=128, verbose_name="Automation"), 17 | ), 18 | migrations.AlterField( 19 | model_name="automationhookplugin", 20 | name="operation", 21 | field=models.IntegerField( 22 | choices=[ 23 | (0, "Start automaton"), 24 | (1, "Send message to automation"), 25 | (2, "Broadcast message to all automations"), 26 | ], 27 | default=1, 28 | verbose_name="Operation", 29 | ), 30 | ), 31 | migrations.AlterField( 32 | model_name="automationhookplugin", 33 | name="token", 34 | field=models.CharField( 35 | blank=True, max_length=128, verbose_name="Optional token" 36 | ), 37 | ), 38 | migrations.AlterField( 39 | model_name="automationstatusplugin", 40 | name="template", 41 | field=models.CharField(max_length=128, verbose_name="Task data"), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /src/automations/cms_automations/migrations/0004_auto_20210511_1042.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-11 08:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("cms_automations", "0003_auto_20210511_0825"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="automationhookplugin", 15 | name="automation", 16 | field=models.CharField(max_length=128, verbose_name="Automatisierung"), 17 | ), 18 | migrations.AlterField( 19 | model_name="automationhookplugin", 20 | name="operation", 21 | field=models.IntegerField( 22 | choices=[ 23 | (0, "Automatisierung starten"), 24 | (1, "Nachricht an Automatisierung"), 25 | (2, "Broadcast message to all automations"), 26 | ], 27 | default=1, 28 | verbose_name="Operation", 29 | ), 30 | ), 31 | migrations.AlterField( 32 | model_name="automationhookplugin", 33 | name="token", 34 | field=models.CharField( 35 | blank=True, max_length=128, verbose_name="Optionales Token" 36 | ), 37 | ), 38 | migrations.AlterField( 39 | model_name="automationstatusplugin", 40 | name="template", 41 | field=models.CharField(max_length=128, verbose_name="Daten der Aufgabe"), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /src/automations/cms_automations/migrations/0005_auto_20211121_1838.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-11-21 18:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("cms_automations", "0004_auto_20210511_1042"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="automationhookplugin", 15 | name="automation", 16 | field=models.CharField(max_length=128, verbose_name="Automation"), 17 | ), 18 | migrations.AlterField( 19 | model_name="automationhookplugin", 20 | name="operation", 21 | field=models.IntegerField( 22 | choices=[ 23 | (0, "Start automaton"), 24 | (1, "Send message to automation"), 25 | (2, "Broadcast message to all automations"), 26 | ], 27 | default=1, 28 | verbose_name="Operation", 29 | ), 30 | ), 31 | migrations.AlterField( 32 | model_name="automationhookplugin", 33 | name="token", 34 | field=models.CharField( 35 | blank=True, max_length=128, verbose_name="Optional token" 36 | ), 37 | ), 38 | migrations.AlterField( 39 | model_name="automationstatusplugin", 40 | name="template", 41 | field=models.CharField(max_length=128, verbose_name="Task data"), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /src/automations/cms_automations/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsbraun/django-automations/3fde447de8032f0ad1d76eb706fd521174d05415/src/automations/cms_automations/migrations/__init__.py -------------------------------------------------------------------------------- /src/automations/cms_automations/models.py: -------------------------------------------------------------------------------- 1 | from cms.models import CMSPlugin 2 | from django.db import models 3 | from django.utils.translation import gettext as _ 4 | 5 | from .. import settings 6 | 7 | 8 | class AutomationTasksPlugin(CMSPlugin): 9 | template = models.CharField( 10 | max_length=settings.MAX_FIELD_LENGTH, 11 | choices=settings.TASK_LIST_TEMPLATES, 12 | default=settings.TASK_LIST_TEMPLATES[0][0], 13 | blank=False, 14 | verbose_name=_("Template"), 15 | ) 16 | always_inform = models.BooleanField( 17 | default=True, 18 | verbose_name=_("Always inform"), 19 | help_text=_( 20 | "If deactivated plugin will out output anything if no task is available." 21 | ), 22 | ) 23 | 24 | 25 | class AutomationHookPlugin(CMSPlugin): # pragma: no cover 26 | class OperationChoices(models.IntegerChoices): 27 | start = 0, _("Start automaton") 28 | message = 1, _("Send message to automation") 29 | broadcast = 2, _("Broadcast message to all automations") 30 | 31 | operation = models.IntegerField( 32 | default=OperationChoices.message, 33 | choices=OperationChoices.choices, 34 | verbose_name=_("Operation"), 35 | ) 36 | 37 | automation = models.CharField( 38 | blank=False, 39 | max_length=settings.MAX_FIELD_LENGTH, 40 | verbose_name=_("Automation"), 41 | ) 42 | 43 | token = models.CharField( 44 | max_length=settings.MAX_FIELD_LENGTH, 45 | blank=True, 46 | verbose_name=_("Optional token"), 47 | ) 48 | 49 | def __str__(self): 50 | return self.automation.split(".")[-1] 51 | 52 | 53 | class AutomationStatusPlugin(CMSPlugin): # pragma: no cover 54 | template = models.CharField( 55 | blank=False, 56 | max_length=settings.MAX_FIELD_LENGTH, 57 | verbose_name=_("Task data"), 58 | ) 59 | name = models.CharField( 60 | blank=True, 61 | max_length=settings.MAX_FIELD_LENGTH, 62 | ) 63 | 64 | def __str__(self): 65 | return self.name if self.name else super().__str__() 66 | -------------------------------------------------------------------------------- /src/automations/cms_automations/templates/automations/cms/empty_template.html: -------------------------------------------------------------------------------- 1 | {% if error %}{% spaceless %} 2 |
3 | {{ error }} 4 |
5 | {% endspaceless %}{% endif %} 6 | -------------------------------------------------------------------------------- /src/automations/cms_automations/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/automations/cms_automations/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /src/automations/flow.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import datetime 3 | import functools 4 | import json 5 | import logging 6 | import sys 7 | import threading 8 | from copy import copy 9 | from types import MethodType 10 | 11 | from django.conf import settings as project_settings 12 | from django.contrib.auth import get_user_model 13 | from django.core.exceptions import ( 14 | ImproperlyConfigured, 15 | MultipleObjectsReturned, 16 | ObjectDoesNotExist, 17 | ) 18 | from django.db.models import Model, Q 19 | from django.db.transaction import atomic 20 | from django.utils.timezone import now 21 | from django.views.debug import ExceptionReporter 22 | 23 | from . import models, settings 24 | 25 | """To allow forward references in Automation object "this" is defined""" 26 | 27 | 28 | """Log exceptions""" 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | def get_error_report(exc_type, exc_value, exc_traceback): 33 | er = ExceptionReporter(None, exc_type, exc_value, exc_traceback) 34 | return dict(error=er.get_traceback_text(), html=er.get_traceback_html()) 35 | 36 | 37 | class ThisAttribute: 38 | """Wrapper for forward-reference to a named attribute""" 39 | 40 | def __init__(self, attr): 41 | self.attr = attr 42 | 43 | def __repr__(self): 44 | return f"this.{self.attr}" 45 | 46 | 47 | class This: 48 | """Generator for reference to a named attribute""" 49 | 50 | def __getattr__(self, item): 51 | return ThisAttribute(item) 52 | 53 | 54 | this = This() 55 | """Global instance""" 56 | 57 | """ 58 | """ 59 | 60 | 61 | def on_execution_path(m): 62 | """Decorator to ensure automatic pausing of automations in 63 | case of WaitUntil, PauseFor and When""" 64 | 65 | @functools.wraps(m) 66 | def wrapper(self, task, *args, **kwargs): 67 | try: 68 | return None if task is None else m(self, task, *args, **kwargs) 69 | except Exception as err: 70 | if isinstance(err, ImproperlyConfigured): 71 | raise err 72 | if task is not None: 73 | self.store_result(task, repr(err), get_error_report(*sys.exc_info())) 74 | self.release_lock(task) 75 | self._automation._db.finished = True 76 | self._automation._db.save() 77 | logger.error( 78 | "Automation failed with error and was aborted", exc_info=True 79 | ) 80 | return None 81 | 82 | return wrapper 83 | 84 | 85 | class Node: 86 | """Parent class for all nodes""" 87 | 88 | def __init__(self, *args, **kwargs): 89 | self._conditions = [] 90 | self._next = None 91 | self._wait = None 92 | self._skipif = [] 93 | self._skipafter = None 94 | self._leave = False 95 | self._model_defaults = dict(locked=0) 96 | self.description = kwargs.pop("description", "") 97 | 98 | def modifiers(self): 99 | mods = [] 100 | if self._conditions: 101 | mods.append("If") 102 | if self._next: 103 | mods.append("Next") 104 | if self._wait: 105 | mods.append("Wait") 106 | if self._skipif: 107 | mods.append("SkipIf") 108 | if self._skipafter: 109 | mods.append("SkipAfter") 110 | return mods 111 | 112 | @staticmethod 113 | def eval(sth, task): 114 | return sth(task) if callable(sth) else sth 115 | 116 | def ready(self, automation_instance, name): 117 | """is called by the newly initialized Automation instance to bind the nodes to the instance.""" 118 | self._automation = automation_instance 119 | self._name = name 120 | self._conditions = [self.resolve(condition) for condition in self._conditions] 121 | 122 | def get_automation_name(self): 123 | """returns the name of the Automation instance class the node is bound to""" 124 | return self._automation.__class__.__name__ 125 | 126 | @property 127 | def node_name(self): 128 | return self.__class__.__name__ 129 | 130 | def __getattribute__(self, item): 131 | value = super().__getattribute__(item) 132 | if isinstance(value, ThisAttribute) or ( 133 | isinstance(value, str) and value.startswith("self.") 134 | ): 135 | value = self.resolve(value) 136 | setattr(self, item, value) # remember 137 | return value 138 | 139 | def resolve(self, value): 140 | if isinstance(value, ThisAttribute): # This object? 141 | value = getattr(self._automation, value.attr) # get automation attribute 142 | elif isinstance(value, str) and value.startswith( 143 | "self." 144 | ): # String literal instead of this 145 | value = getattr(self._automation, value[5:]) 146 | return value 147 | 148 | @atomic 149 | def enter(self, prev_task=None): 150 | assert ( 151 | prev_task is None or prev_task.finished is not None 152 | ), "Node entered w/o previous node left" 153 | db = self._automation._db 154 | assert isinstance(db, models.AutomationModel) 155 | task, _ = db.automationtaskmodel_set.get_or_create( 156 | previous=prev_task, 157 | status=self._name, 158 | defaults=self._model_defaults, 159 | ) 160 | self._leave = False 161 | if task.locked > 0: 162 | return None 163 | task.locked += 1 164 | task.save() 165 | return task 166 | 167 | @atomic 168 | def release_lock(self, task: models.AutomationTaskModel): 169 | task.locked -= 1 170 | task.save() 171 | return None 172 | 173 | @staticmethod 174 | def store_result(task: models.AutomationTaskModel, message, result): 175 | task.message = message[0 : settings.MAX_FIELD_LENGTH] 176 | try: # Check if result is json serializable 177 | json.dumps(result) # Raise error if not json-serializable 178 | task.result = result # if it is, store it 179 | except TypeError: 180 | task.result = None 181 | task.save() 182 | 183 | def leave(self, task: models.AutomationTaskModel): 184 | if task is not None: 185 | task.finished = now() 186 | self.release_lock(task) 187 | if task is not None or self._leave: 188 | if self._next is None: 189 | next_node = self._automation._iter[self] 190 | else: 191 | next_node = self._next 192 | if next_node is None: 193 | raise ImproperlyConfigured(f"No End() node after {self._name}") 194 | return next_node 195 | 196 | def pause_automation(self, earliest_execution): 197 | if ( 198 | self._automation._db.paused_until 199 | and self._automation._db.paused_until >= now() 200 | ): 201 | self._automation._db.paused_until = min( 202 | self._automation._db.paused_until, earliest_execution 203 | ) 204 | else: 205 | self._automation._db.paused_until = earliest_execution 206 | 207 | @on_execution_path 208 | def when_handler(self, task): 209 | for condition in self._conditions: 210 | if not self.eval(condition, task): 211 | return self.release_lock(task) 212 | return task 213 | 214 | @on_execution_path 215 | def wait_handler(self, task: models.AutomationTaskModel): 216 | if self._wait is None: 217 | return task 218 | earliest_execution = self.eval(self._wait, task) 219 | if earliest_execution < now(): 220 | return task 221 | self.pause_automation(earliest_execution) 222 | self._automation._db.save() 223 | return self.release_lock(task) 224 | 225 | @on_execution_path 226 | def skip_handler(self, task: models.AutomationTaskModel): 227 | def skip(): 228 | task.finished = now() 229 | task.message = "skipped" 230 | self.release_lock(task) 231 | self._leave = True 232 | return self.release_lock(task) 233 | 234 | if self._skipafter is not None: 235 | latest_execution = task.created + self.eval(self._skipafter, task) 236 | if latest_execution < now(): 237 | return skip() 238 | for item in self._skipif: 239 | if self.eval(item, task): 240 | return skip() 241 | return task 242 | 243 | def execute(self, task: models.AutomationTaskModel): 244 | return self.when_handler(self.wait_handler(self.skip_handler(task))) 245 | 246 | def Next(self, next_node): 247 | if self._next is not None: 248 | raise ImproperlyConfigured("Multiple .Next statements") 249 | self._next = next_node 250 | return self 251 | 252 | def AsSoonAs(self, condition): 253 | self._conditions.append(condition) 254 | return self 255 | 256 | def AfterWaitingUntil(self, time): 257 | if self._wait is not None: 258 | raise ImproperlyConfigured( 259 | "Multiple .AfterWaitingUntil or .AfterWaitingFor statements" 260 | ) 261 | self._wait = time 262 | return self 263 | 264 | def AfterWaitingFor(self, timedelta): 265 | if self._wait is not None: 266 | raise ImproperlyConfigured( 267 | "Multiple .AfterWaitingUntil or .AfterWaitingFor statements" 268 | ) 269 | self._wait = lambda x: x.created + self.eval(timedelta, x) 270 | return self 271 | 272 | def SkipIf(self, condition): 273 | self._skipif.append(condition) 274 | return self 275 | 276 | def SkipAfter(self, timedelta): 277 | if self._skipafter is not None: 278 | raise ImproperlyConfigured("Multiple .SkipAfter statements") 279 | self._skipafter = timedelta 280 | return self 281 | 282 | def __repr__(self): 283 | if getattr(self, "_automation", False) and self._automation: 284 | return f"<{self._name}: {self._automation} {self.node_name} node>" 285 | else: 286 | return f"" 287 | 288 | 289 | class End(Node): 290 | def execute(self, task): 291 | self._automation._db.finished = True 292 | self._automation._db.save() 293 | return task 294 | 295 | def leave(self, task): 296 | task.finished = now() 297 | task.locked = 0 298 | task.save() 299 | return None # Stops execution 300 | 301 | 302 | class Repeat(Node): 303 | def __init__(self, start=None, **kwargs): 304 | if start is None: 305 | start = self._automation.start 306 | 307 | super().__init__(**kwargs) 308 | self._next = start 309 | self._interval = None 310 | self._startpoint = None 311 | 312 | @on_execution_path 313 | def repeat_handler(self, task): 314 | if self._startpoint is None: 315 | self._startpoint = now() 316 | elif now() < self._startpoint: 317 | return self.release_lock(task) 318 | db = self._automation._db 319 | if db.paused_until: 320 | if now() < db.paused_until: 321 | return self.release_lock(task) 322 | else: 323 | db.paused_until = self._startpoint 324 | while self._automation._db.paused_until < now(): 325 | db.paused_until += self._interval 326 | db.save() 327 | return task 328 | 329 | def execute(self, task: models.AutomationTaskModel): 330 | task = super().execute(task) 331 | return self.repeat_handler(task) 332 | 333 | def At(self, hour, minute): 334 | if self._interval is None: 335 | raise ImproperlyConfigured( 336 | "Repeat().At: interval statement necessary before" 337 | ) 338 | if self._interval < datetime.timedelta(days=1): 339 | raise ImproperlyConfigured("Repeat().At: interval >= one day required") 340 | if self._startpoint is not None: 341 | raise ImproperlyConfigured("Repeat(): Only one .At modifier possible") 342 | self._startpoint = now() 343 | self._startpoint.replace(hour=hour, minute=minute) 344 | return self 345 | 346 | def EveryHour(self, hours=1): 347 | if self._interval is not None: 348 | raise ImproperlyConfigured("Repeat(): Multiple interval statements") 349 | self._interval = datetime.timedelta(hours=hours) 350 | return self 351 | 352 | def EveryNMinutes(self, minutes): 353 | if self._interval is not None: 354 | raise ImproperlyConfigured("Repeat(): Multiple interval statements") 355 | self._interval = datetime.timedelta(minutes=minutes) 356 | return self 357 | 358 | def EveryNDays(self, days): 359 | if self._interval is not None: 360 | raise ImproperlyConfigured("Repeat(): Multiple interval statements") 361 | self._interval = datetime.timedelta(days=days) 362 | return self 363 | 364 | def EveryDay(self): 365 | return self.EveryNDays(days=1) 366 | 367 | 368 | class Split(Node): 369 | """Spawn several tasks which have to be joined by a Join() node""" 370 | 371 | def __init__(self, **kwargs): 372 | super().__init__(**kwargs) 373 | self._splits = [] 374 | 375 | def Next(self, node): 376 | self._splits.append(node) 377 | return self 378 | 379 | def execute(self, task: models.AutomationTaskModel): 380 | task = super().execute(task) 381 | if task: 382 | assert ( 383 | len(self._splits) > 0 384 | ), "at least one .Next statement needed for Split()" 385 | db = self._automation._db 386 | tasks = list( 387 | db.automationtaskmodel_set.create( # Create splits 388 | previous=task, 389 | status=self.resolve(split)._name, 390 | locked=0, 391 | ) 392 | for split in self._splits 393 | ) 394 | self.store_result(task, "Split", [task.id for task in tasks]) 395 | self.leave(task) 396 | for task in tasks: 397 | self._automation.run( 398 | task.previous, getattr(self._automation, task.status) 399 | ) # Run other splits 400 | return None 401 | return task 402 | 403 | 404 | class Join(Node): 405 | """Collect tasks spawned by Split""" 406 | 407 | def __init__(self, **kwargs): 408 | super().__init__(**kwargs) 409 | 410 | def execute(self, task: models.AutomationTaskModel): 411 | task = super().execute(task) 412 | if task: 413 | split_task = self.get_split(task) 414 | if split_task is None: 415 | raise ImproperlyConfigured("Join() without Split()") 416 | all_splits = [] 417 | for open_task in self._automation._db.automationtaskmodel_set.filter( 418 | finished=None, 419 | ): 420 | split = self.get_split(open_task) 421 | if split and split.id == split_task.id: 422 | all_splits.append(open_task) 423 | assert len(all_splits) > 0, "Internal error: at least one split expected" 424 | if ( 425 | len(all_splits) > 1 426 | ): # more than one split at the moment: close this split 427 | self.leave(task) 428 | self.store_result(task, "Open Join", []) # Flag as open 429 | return None 430 | else: 431 | all_path_ends = self._automation._db.automationtaskmodel_set.filter( 432 | message="Open Join", status=task.status # Find open 433 | ) 434 | self.store_result( 435 | task, "Joined", [tsk.id for tsk in all_path_ends] + [task.id] 436 | ) 437 | all_path_ends.update(message="Joined") # Join closed: clear flag 438 | return task 439 | 440 | def get_split(self, task): 441 | split_task = task.previous 442 | joins = 1 443 | while split_task is not None: 444 | node = getattr(self._automation, split_task.status) 445 | if isinstance(node, Join): 446 | joins += 1 447 | elif isinstance(node, Split): 448 | joins -= 1 449 | if joins == 0: 450 | return split_task 451 | split_task = split_task.previous # Go back the history 452 | return None 453 | 454 | 455 | class Execute(Node): 456 | def __init__(self, *args, **kwargs): 457 | super().__init__(**kwargs) 458 | self._on_error = None 459 | self.args = args 460 | self.kwargs = kwargs 461 | self._err = None 462 | 463 | def method(self, task, *args, **kwargs): 464 | func = args[0] 465 | if not callable(func): 466 | raise ImproperlyConfigured( 467 | f"Execute: expected callable, got {func.__class__.__name__}" 468 | ) 469 | return func(task, *self.args[1:], **self.kwargs) 470 | 471 | @on_execution_path 472 | def execute_handler(self, task: models.AutomationTaskModel): 473 | def func(task, *args, **kwargs): 474 | self._err = None 475 | try: 476 | result = self.method(task, *args, **kwargs) 477 | self.store_result(task, "OK", result) 478 | except Exception as err: 479 | if isinstance(err, ImproperlyConfigured): 480 | raise err 481 | self._err = err 482 | self.store_result(task, repr(err), get_error_report(*sys.exc_info())) 483 | 484 | if self.args is not None and len(self.args) > 0: # Empty arguments: No-op 485 | args = (self.resolve(value) for value in self.args) 486 | kwargs = {key: self.resolve(value) for key, value in self.kwargs.items()} 487 | if kwargs.get("threaded", False): 488 | assert ( 489 | self._on_error is None 490 | ), "No .OnError statement on threaded executions" 491 | threading.Thread( 492 | target=func, args=[task] + list(args), kwargs=kwargs 493 | ).start() 494 | else: 495 | func(task, *args, **kwargs) 496 | if self._err: 497 | if self._on_error: 498 | self._next = self._on_error 499 | else: 500 | self.release_lock(task) 501 | self._automation._db.finished = True 502 | self._automation._db.save() 503 | return None 504 | return task 505 | 506 | def execute(self, task: models.AutomationTaskModel): 507 | task = super().execute(task) 508 | return self.execute_handler(task) 509 | 510 | def OnError(self, next_node): 511 | if self._on_error is not None: 512 | raise ImproperlyConfigured("Multiple .OnError statements") 513 | self._on_error = next_node 514 | return self 515 | 516 | 517 | class If(Execute): 518 | def __init__(self, condition, **kwargs): 519 | super().__init__(None, **kwargs) 520 | self._condition = condition 521 | self._then = None 522 | self._else = None 523 | self._func = lambda x: None 524 | 525 | def Then(self, *clause_args, **clause_kwargs): 526 | if self._then is not None: 527 | raise ImproperlyConfigured("Multiple .Then statements") 528 | self._then = (clause_args, clause_kwargs) 529 | return self 530 | 531 | def Else(self, *clause_args, **clause_kwargs): 532 | if self._else is not None: 533 | raise ImproperlyConfigured("Multiple .Else statements") 534 | self._else = (clause_args, clause_kwargs) 535 | return self 536 | 537 | @on_execution_path 538 | def if_handler(self, task: models.AutomationTaskModel): 539 | if self._then is None: 540 | raise ImproperlyConfigured("Missing .Then statement") 541 | this_path = self.eval(self._condition, task) 542 | task.message = str(bool(this_path)) 543 | clause = self._then if this_path else self._else 544 | if clause is not None: 545 | opt_args, opt_kwargs = clause 546 | if len(opt_args) == 1 and len(opt_kwargs) == 0: 547 | resolved = self.resolve(opt_args[0]) 548 | if isinstance(resolved, Node) and not callable(resolved): 549 | self.Next(resolved) 550 | return task 551 | self.args = opt_args 552 | self.kwargs = opt_kwargs 553 | return self.execute_handler(task) 554 | return task 555 | 556 | def execute(self, task: models.AutomationTaskModel): 557 | # Do not execute super() since If inherits from Execute and there 558 | # is nothing to execute, call Node.execute instead 559 | task = Node.execute(self, task) 560 | return self.if_handler(task) 561 | 562 | 563 | class Form(Node): 564 | def __init__( 565 | self, 566 | form, 567 | template_name=None, 568 | context=None, 569 | repeated_form=False, 570 | success_url=None, 571 | **kwargs, 572 | ): 573 | super().__init__(**kwargs) 574 | self._model_defaults = dict( 575 | locked=-1, requires_interaction=True 576 | ) # Start w/o lock, but interaction needed 577 | self._form = form 578 | self._success_url = success_url 579 | self._context = context if context is not None else {} 580 | self._repeated_form = repeated_form 581 | self._template_name = template_name 582 | self._user = None 583 | self._group = None 584 | self._permissions = [] 585 | self._form_kwargs = {} 586 | self._run = True 587 | 588 | def execute(self, task: models.AutomationTaskModel): 589 | task = super().execute(task) 590 | 591 | if task is not None: 592 | if self._user is None and self._group and not self._permissions: 593 | raise ImproperlyConfigured( 594 | "From: at least one .User, .Group, .Permission has to be specified" 595 | ) 596 | task.interaction_user = self.get_user() 597 | task.interaction_group = self.get_group() 598 | 599 | if task.requires_interaction: # Not yet validated -> pause 600 | return self.release_lock(task) 601 | return task # Continue with validated form 602 | 603 | def is_valid(self, task: models.AutomationTaskModel, request, form): 604 | task.automation.data[f"_{self._name}_validated"] = dict( 605 | user_id=request.user.id, time=now().isoformat() 606 | ) 607 | task.automation.save() 608 | task.requires_interaction = False 609 | task.save() 610 | 611 | def User(self, **kwargs): 612 | if self._user is not None: 613 | raise ImproperlyConfigured("Only one .User modifier for Form") 614 | self._user = kwargs 615 | return self 616 | 617 | def Group(self, **kwargs): 618 | if self._group is not None: 619 | raise ImproperlyConfigured("Only one .Group modifier for Form") 620 | self._group = kwargs 621 | return self 622 | 623 | def get_user(self): 624 | User = get_user_model() 625 | return User.objects.get(**self._user) if self._user is not None else None 626 | 627 | def get_group(self): 628 | Group = settings.get_group_model() 629 | return Group.objects.get(**self._group) if self._group is not None else None 630 | 631 | def Permission(self, permission): 632 | self._permissions.append(permission) 633 | return self 634 | 635 | def get_users_with_permission(self): 636 | """ 637 | Given a flow.Form instance, which has access to a list of permission codenames 638 | (self._permissions), the assigned user(self._user), and assigned group 639 | (self._group), returns a QuerySet of users with applicable permissions that meet 640 | the requirements for access. 641 | """ 642 | from django.contrib.auth.models import Permission 643 | 644 | User = get_user_model() 645 | 646 | perm = Permission.objects.filter(codename__in=self._permissions) 647 | filter = Q(groups__permissions__in=perm) | Q(user_permissions__in=perm) 648 | if self._user is not None: 649 | filter = filter & Q(**self._user) 650 | if self._group is not None: 651 | filter = filter & Q(group_set__contains=self._group) 652 | users = User.objects.filter(filter).distinct() 653 | return users 654 | 655 | 656 | def swap_users_with_permission_form_method(settings_conf): 657 | """ 658 | Function to swap `get_users_with_permission` method within Form if needed. 659 | """ 660 | from django.utils.module_loading import import_string 661 | 662 | users_with_permission_method = settings.get_users_with_permission_form_method( 663 | settings=settings_conf 664 | ) 665 | 666 | if users_with_permission_method is not None: 667 | 668 | if callable(users_with_permission_method): 669 | Form.get_users_with_permission = MethodType( 670 | users_with_permission_method, 671 | Form, 672 | ) 673 | else: 674 | Form.get_users_with_permission = MethodType( 675 | import_string(users_with_permission_method), 676 | Form, 677 | ) 678 | 679 | 680 | # Swap Form.users_with_permission_method method if needed 681 | swap_users_with_permission_form_method(settings_conf=project_settings) 682 | 683 | 684 | class ModelForm(Form): 685 | def __init__(self, form, key, template_name=None, **kwargs): 686 | super().__init__(form, template_name, **kwargs) 687 | if hasattr(Automation, key): 688 | raise ImproperlyConfigured( 689 | f"Chose different key for ModelForm node: {key} is a property of " 690 | f"flow.Automations" 691 | ) 692 | self._instance_key = key 693 | self._form_kwargs = self.get_model_from_kwargs 694 | 695 | def get_model_from_kwargs(self, task): 696 | model = self._form.Meta.model # Get model from Form's Meta class 697 | if self._instance_key in task.data: 698 | instances = model.objects.filter(id=task.data[self._instance_key]) 699 | return dict(instance=instances[0] if len(instances) == 1 else None) 700 | else: 701 | return dict() 702 | 703 | 704 | class SendMessage(Node): 705 | def __init__( 706 | self, target, message, token=None, allow_multiple_receivers=False, **kwargs 707 | ): 708 | self._target = target 709 | self._message = message 710 | self._token = token 711 | self._allow_multiple_receivers = allow_multiple_receivers 712 | self.kwargs = kwargs 713 | super().__init__() 714 | 715 | @on_execution_path 716 | def send_handler(self, task): 717 | cls = self._target 718 | if isinstance(cls, str): 719 | cls = models.get_automation_class(cls) 720 | if issubclass(cls, Automation): 721 | results = cls.broadcast_message( 722 | self._message, self._token, data=self.kwargs 723 | ) 724 | elif isinstance(cls, Automation) or isinstance(cls, int): 725 | results = [ 726 | cls.dispatch_message(self._message, self._token, data=self.kwargs) 727 | ] 728 | else: 729 | raise ImproperlyConfigured("") 730 | self.store_result(task, "OK", dict(results=results)) 731 | return task 732 | 733 | def execute(self, task: models.AutomationTaskModel): 734 | task = super().execute(task) 735 | return self.send_handler(task) 736 | 737 | 738 | # 739 | # Automation class 740 | # 741 | # 742 | 743 | 744 | def on_signal(signal, start=None, **kwargs): 745 | """decorator for automations to connect to Django signals""" 746 | 747 | def decorator(cls): 748 | cls.on(signal, start, **kwargs) 749 | return cls 750 | 751 | return decorator 752 | 753 | 754 | class Automation: 755 | model_class = models.AutomationModel 756 | unique = False 757 | 758 | def __init__(self, **kwargs): 759 | super().__init__() 760 | prev = None 761 | self._iter = {} 762 | self._start = {} 763 | for name, attr in self.__class__.__dict__.items(): 764 | if isinstance(attr, Node): # Init nodes 765 | self._iter[prev] = attr 766 | attr.ready(self, name) 767 | prev = attr 768 | if isinstance(attr, type) and issubclass( 769 | attr, Model 770 | ): # Attach to Model instance 771 | at_c = copy(attr) # Create copies of name and Model (attr) 772 | nm_c = copy(name) 773 | setattr( 774 | self.__class__, 775 | name, # Replace property by get_model_instance 776 | property(lambda slf: slf.get_model_instance(at_c, nm_c), self), 777 | ) 778 | if name in kwargs and not isinstance( 779 | kwargs[name], int 780 | ): # Convert instance to id 781 | kwargs[name] = kwargs[name].id 782 | self._iter[prev] = None # Last item 783 | autorun = kwargs.pop("autorun", True) 784 | if "automation" in kwargs: 785 | if isinstance(kwargs.get("automation"), models.AutomationModel): 786 | self._db = kwargs.pop("automation") 787 | else: 788 | self._db = self.model_class.objects.get( 789 | key=kwargs.pop("automation"), 790 | ) 791 | assert self._db.automation_class == self.get_automation_class_name(), ( 792 | f"Wrong automation class: expected {self.get_automation_class_name()} " 793 | f"got {self._db.automation_class}" 794 | ) 795 | assert not kwargs, ( 796 | f"Too many arguments for automation {self.__class__.__name__}. " 797 | "If 'automation' is given, no parameters allowed" 798 | ) 799 | elif "automation_id" in kwargs: # Attach to automation in DB 800 | self._db = self.model_class.objects.get(id=kwargs.pop("automation_id")) 801 | assert not kwargs, ( 802 | f"Too many arguments for automation {self.__class__.__name__}. " 803 | "If 'automation_id' is given, no parameters allowed" 804 | ) 805 | elif self.unique is True: # Create or get singleton in DB 806 | self._db, created = self.model_class.objects.get_or_create( 807 | automation_class=self.get_automation_class_name(), 808 | ) 809 | if created: 810 | self._db.data = kwargs 811 | self._db.finished = False 812 | self._db.save() 813 | else: 814 | assert not kwargs, ( 815 | f"Too many arguments for automation {self.__class__.__name__}. " 816 | "If 'automation' is given, no parameters allowed" 817 | ) 818 | elif self.unique: 819 | assert isinstance( 820 | self.unique, (list, tuple) 821 | ), ".unique can be bool, list, tuple or None" 822 | for key in self.unique: 823 | assert key not in ( 824 | "automation", 825 | "automation_id", 826 | "autorun", 827 | ), f"'{key}' cannot be parameter to distinguish unique automations. Chose a different name." 828 | assert key in kwargs, ( 829 | "to ensure unique property, " 830 | "create automation with '%s=...' parameter" % key 831 | ) 832 | qs = self.model_class.objects.filter(finished=False) 833 | self._create_model_properties(kwargs) 834 | for instance in qs: 835 | identical = sum( 836 | ( 837 | 0 838 | if key not in instance.data or instance.data[key] != kwargs[key] 839 | else 1 840 | for key in self.unique 841 | ) 842 | ) 843 | if identical == len(self.unique): 844 | self._db = instance 845 | break 846 | else: 847 | self._db = self.model_class.objects.create( 848 | automation_class=self.get_automation_class_name(), 849 | finished=False, 850 | data=kwargs, 851 | ) 852 | else: 853 | self._create_model_properties(kwargs) 854 | self._db = self.model_class.objects.create( 855 | automation_class=self.get_automation_class_name(), 856 | finished=False, 857 | data=kwargs, 858 | ) 859 | assert self._db is not None, "Internal error" 860 | if autorun and not self.finished(): 861 | self.run() 862 | 863 | def _create_model_properties(self, kwargs): 864 | for name, value in kwargs.items(): 865 | if isinstance(value, Model): 866 | model_class = value.__class__ 867 | cname = copy(name) # name might reference something else later 868 | setattr( 869 | self.__class__, 870 | name, # Replace property by get_model_instance 871 | property(lambda slf: slf.get_model_instance(model_class, cname)), 872 | ) 873 | kwargs[name] = kwargs[name].id 874 | 875 | def get_model_instance(self, model, name): 876 | if not hasattr(self, "_" + name): 877 | setattr(self, "_" + name, model.objects.get(id=self._db.data[name])) 878 | return getattr(self, "_" + name) 879 | 880 | def get_automation_class_name(self): 881 | return self.__module__ + "." + self.__class__.__name__ 882 | 883 | @property 884 | def id(self): 885 | return self._db.id 886 | 887 | @property 888 | def data(self): 889 | assert self._db is not None, "Automation not bound to database" 890 | return self._db.data 891 | 892 | def save(self, *args, **kwargs): 893 | return self._db.save(*args, **kwargs) 894 | 895 | def nice(self, task=None, next_task=None): 896 | """Run automation steps in a background thread to, e.g., do not block 897 | the request response cycle""" 898 | threading.Thread( 899 | target=self.run, kwargs=dict(task=task, next_task=next_task) 900 | ).start() 901 | 902 | def run(self, task=None, next_node=None): 903 | """Execute automation until external responses are necessary""" 904 | assert not self.finished(), ValueError( 905 | "Trying to run an already finished or killed automation" 906 | ) 907 | 908 | if next_node is None: 909 | last_tasks = self._db.automationtaskmodel_set.filter(finished=None) 910 | if len(last_tasks) == 0: # Start 911 | last, next_node = None, self._iter[None] # First 912 | else: 913 | for last_task in last_tasks: 914 | node = getattr(self, last_task.status) 915 | self.run(last_task.previous, node) 916 | return 917 | 918 | while next_node is not None: 919 | task = next_node.enter(task) 920 | task = next_node.execute(task) 921 | last, next_node = task, next_node.leave(task) 922 | return last 923 | 924 | @classmethod 925 | def get_verbose_name(cls): 926 | if hasattr(cls, "Meta"): 927 | if hasattr(cls.Meta, "verbose_name"): 928 | return cls.Meta.verbose_name 929 | return f"Automation {cls.__name__}" 930 | 931 | @classmethod 932 | def get_verbose_name_plural(cls): 933 | if hasattr(cls, "Meta"): 934 | if hasattr(cls.Meta, "verbose_name_plural"): 935 | return cls.Meta.verbose_name_plural 936 | return f"Automations {cls.__name__}" 937 | 938 | def killed(self): 939 | return self._db is None 940 | 941 | def finished(self): 942 | return self.killed() or self._db.finished 943 | 944 | def send_message(self, message, token, data=None): 945 | """RECEIVES message and dispatches it within the class 946 | Called send_message so that sending a message to an automation 947 | is `automation.send_message(...)""" 948 | if self.__class__.satisfies_data_requirements(message, data): 949 | if not self.finished(): 950 | method = getattr(self, "receive_" + message) 951 | return method(token, data) 952 | return None 953 | 954 | @classmethod 955 | def satisfies_data_requirements(cls, message, get): 956 | if hasattr(cls, "receive_" + message): 957 | method = getattr(cls, "receive_" + message) 958 | if not hasattr(method, "data_requirements"): 959 | return True 960 | accessor = get.GET if hasattr(get, "GET") else get 961 | for param, type_class in method.data_requirements.items(): 962 | if param not in accessor: 963 | return False 964 | if not isinstance(accessor[param], type_class): # Try simple conversion 965 | try: 966 | type_class(accessor[param]) 967 | except (ValueError, TypeError): 968 | return False 969 | return True 970 | return False 971 | 972 | def kill(self): 973 | """Deletes the automation instance in models.AutomationModel""" 974 | self._db.delete() 975 | self._db = None 976 | 977 | def get_key(self): 978 | assert self._db, "no key for killed instance" 979 | return self._db.get_key() 980 | 981 | @classmethod 982 | def dispatch_message(cls, automation, message, token, data): 983 | if cls.satisfies_data_requirements(message, data) and automation is not None: 984 | try: 985 | if isinstance(automation, int): 986 | automation = cls(automation_id=automation) 987 | elif isinstance(automation, (str, models.AutomationModel)): 988 | automation = cls(automation=automation) 989 | except (ObjectDoesNotExist, MultipleObjectsReturned): 990 | return None 991 | assert isinstance(automation, cls), ( 992 | f"Wrong class to dispatch message: " 993 | f"{automation.__class__.__name__} found, " 994 | f"{cls.__name__} expected" 995 | ) 996 | return automation.send_message(message, token, data) 997 | 998 | @classmethod 999 | def broadcast_message(cls, message, token, data): 1000 | results = [] 1001 | if cls.satisfies_data_requirements(message, data): 1002 | for automation in models.AutomationModel.objects.filter( 1003 | finished=False, 1004 | automation_class=cls.__module__ + "." + cls.__name__, 1005 | ): 1006 | automation = cls(automation=automation) 1007 | result = automation.send_message(message, token, data) 1008 | results.append(result) 1009 | if isinstance(result, str) and result == "received": 1010 | break 1011 | return results 1012 | 1013 | @classmethod 1014 | def create_on_message(cls, message, token, data): 1015 | if cls.satisfies_data_requirements(message, data): 1016 | kwargs = dict() 1017 | accessor = data.GET if hasattr(data, "GET") else data 1018 | if isinstance(cls.unique, (list, tuple)): 1019 | for param in cls.unique: 1020 | if param in accessor: 1021 | kwargs[param] = accessor.get(param) 1022 | instance = cls(autorun=False, **kwargs) # Create 1023 | instance.send_message( 1024 | message, token, data 1025 | ) # Allow message to be processes before .. 1026 | if not instance.finished(): 1027 | instance.run() # ... automation is run 1028 | return None if instance.killed() else instance 1029 | return None 1030 | 1031 | @classmethod 1032 | def on(cls, signal, start=None, **kwargs): 1033 | def creator(sender, **sargs): 1034 | cls.on_signal(start, sender, **sargs) 1035 | 1036 | signal.connect(creator, weak=False, **kwargs) 1037 | 1038 | @classmethod 1039 | def on_signal(cls, start, sender, **kwargs): 1040 | instance = cls() # Instantiate class 1041 | if hasattr(instance, "started_by_signal") and callable( 1042 | instance.started_by_signal 1043 | ): 1044 | instance.started_by_signal( 1045 | sender, kwargs 1046 | ) # initialize based on sender data 1047 | instance.run(None, None if start is None else getattr(instance, start)) # run 1048 | 1049 | def __str__(self): 1050 | return self.__class__.__name__ 1051 | 1052 | 1053 | def get_automations(app=None): 1054 | def check_module(mod): 1055 | for item in dir(mod): 1056 | attr = getattr(mod, item) 1057 | if isinstance(attr, type) and issubclass(attr, Automation): 1058 | automation_list.append( 1059 | (attr.__module__ + "." + attr.__name__, attr.get_verbose_name()) 1060 | ) 1061 | 1062 | automation_list = [] 1063 | if app is None: 1064 | for name, mod in sys.modules.items(): 1065 | if name.rsplit(".")[-1] == "automations": 1066 | check_module(mod) 1067 | else: 1068 | apps = app.split(".") 1069 | 1070 | mod = __import__(apps[0]) 1071 | for next in apps[1:]: 1072 | mod = getattr(mod, next) 1073 | 1074 | check_module(mod) 1075 | if hasattr(mod, "automations"): 1076 | mod = getattr(mod, "automations") 1077 | check_module(mod) 1078 | 1079 | return automation_list 1080 | 1081 | 1082 | def require_data_parameters(**kwargs): 1083 | """decorates Automation class receiver methods to set the data_requirement attribute 1084 | It is checked by cls.satisfies_data_requirements""" 1085 | 1086 | def decorator(method): 1087 | method.data_requirements = kwargs 1088 | return method 1089 | 1090 | return decorator 1091 | -------------------------------------------------------------------------------- /src/automations/locale/de_DE/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsbraun/django-automations/3fde447de8032f0ad1d76eb706fd521174d05415/src/automations/locale/de_DE/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/automations/locale/de_DE/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-12-14 17:28+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: apps.py:29 21 | msgid "" 22 | "Either all or none of the following settings must be present: " 23 | "ATM_GROUP_MODEL, ATM_USER_WITH_PERMISSIONS_FORM_METHOD, " 24 | "ATM_USER_WITH_PERMISSIONS_MODEL_METHOD" 25 | msgstr "" 26 | 27 | #: apps.py:41 28 | msgid "Automations" 29 | msgstr "Automatisierung" 30 | 31 | #: models.py:37 32 | msgid "Process class" 33 | msgstr "Prozess-Klasse" 34 | 35 | #: models.py:41 templates/automations/includes/dashboard_item.html:6 36 | msgid "Finished" 37 | msgstr "Beendet" 38 | 39 | #: models.py:44 templates/automations/history.html:16 40 | msgid "Data" 41 | msgstr "Daten" 42 | 43 | #: models.py:48 44 | msgid "Unique hash" 45 | msgstr "Eindeutiger Hash-Wert" 46 | 47 | #: models.py:54 48 | msgid "Paused until" 49 | msgstr "Pausiert bis" 50 | 51 | #: models.py:122 52 | msgid "Previous task" 53 | msgstr "Vorherige Aufgabe" 54 | 55 | #: models.py:127 56 | msgid "Status" 57 | msgstr "Status" 58 | 59 | #: models.py:131 60 | msgid "Locked" 61 | msgstr "Gesperrt" 62 | 63 | #: models.py:134 64 | msgid "Requires interaction" 65 | msgstr "Verlangt Interaktion" 66 | 67 | #: models.py:140 68 | msgid "Assigned user" 69 | msgstr "Zugewiesener Nutzer" 70 | 71 | #: models.py:146 72 | msgid "Assigned group" 73 | msgstr "Zugewiesene Gruppe" 74 | 75 | #: models.py:150 76 | msgid "Required permissions" 77 | msgstr "Benötigte Permissions" 78 | 79 | #: models.py:151 80 | msgid "List of permissions of the form app_label.codename" 81 | msgstr "Liste von Permissions in der Form app_label.codename" 82 | 83 | #: models.py:161 84 | msgid "Message" 85 | msgstr "Nachricht" 86 | 87 | #: models.py:165 88 | msgid "Result" 89 | msgstr "Ergebnis" 90 | 91 | #: settings.py:22 92 | msgid "Default template" 93 | msgstr "Standardvorlage" 94 | 95 | #: templates/automations/dashboard.html:3 96 | #: templates/automations/task_list.html:12 97 | msgid "Automations dashboard" 98 | msgstr "Automatisierungs-Dashboard" 99 | 100 | #: templates/automations/error_report.html:3 101 | #, fuzzy 102 | #| msgid "Automations" 103 | msgid "Automations error report" 104 | msgstr "Automatisierung" 105 | 106 | #: templates/automations/error_report.html:7 107 | msgid "No automations stopped with an error." 108 | msgstr "" 109 | 110 | #: templates/automations/history.html:9 111 | 112 | msgid "Automation id:" 113 | msgstr "Automatisierungs-ID:" 114 | 115 | #: templates/automations/history.html:10 116 | msgid "Updated:" 117 | msgstr "Aktualisiert:" 118 | 119 | #: templates/automations/history.html:11 120 | msgid "Paused until:" 121 | msgstr "Pausiert bis:" 122 | 123 | #: templates/automations/history.html:12 124 | msgid "Created:" 125 | msgstr "Erstellt:" 126 | 127 | #: templates/automations/includes/dashboard_item.html:5 128 | msgid "Running" 129 | msgstr "Laufend" 130 | 131 | #: templates/automations/includes/form_view.html:8 132 | msgid "Back" 133 | msgstr "Zurück" 134 | 135 | #: templates/automations/includes/form_view.html:10 136 | msgid "OK" 137 | msgstr "OK" 138 | 139 | #: templates/automations/includes/history_item.html:4 140 | msgid "running" 141 | msgstr "läuft" 142 | 143 | #: templates/automations/includes/task_item.html:6 144 | msgid "View" 145 | msgstr "Ansehen" 146 | 147 | #: templates/automations/preformatted_traceback.html:4 148 | msgid "Traceback" 149 | msgstr "Aufrufhierarchie beim Fehler" 150 | 151 | #: templates/automations/preformatted_traceback.html:7 152 | msgid "No traceback available" 153 | msgstr "Keine Aufrufhierarchie verfügbar" 154 | 155 | #: templates/automations/task_list.html:3 156 | msgid "Open tasks for" 157 | msgstr "Offene Aufgaben für" 158 | 159 | #: templates/automations/task_list.html:6 160 | msgid "Currently no tasks for you" 161 | msgstr "Derzeit keine offenen Aufgaben" 162 | 163 | #: tests/models.py:7 164 | msgid "Test Group Name" 165 | msgstr "" 166 | 167 | #: tests/models.py:10 168 | msgid "test group" 169 | msgstr "" 170 | 171 | #: tests/models.py:11 172 | msgid "test groups" 173 | msgstr "" 174 | 175 | #: tests/models.py:15 176 | msgid "Test User Username" 177 | msgstr "" 178 | 179 | #: tests/models.py:16 180 | #, fuzzy 181 | #| msgid "Your e-mail address" 182 | msgid "email address" 183 | msgstr "Deine E-Mail-Adresse" 184 | 185 | #: tests/models.py:25 tests/models.py:29 186 | msgid "superuser status" 187 | msgstr "" 188 | 189 | #: tests/models.py:46 190 | msgid "test user" 191 | msgstr "" 192 | 193 | #: tests/models.py:47 194 | msgid "test users" 195 | msgstr "" 196 | 197 | #: tests/models.py:51 198 | msgid "Test Permission Slug" 199 | msgstr "" 200 | 201 | #: tests/models.py:58 202 | #, fuzzy 203 | #| msgid "Required permissions" 204 | msgid "test permission" 205 | msgstr "Benötigte Permissions" 206 | 207 | #: tests/models.py:59 208 | #, fuzzy 209 | #| msgid "Required permissions" 210 | msgid "test permissions" 211 | msgstr "Benötigte Permissions" 212 | 213 | #: tests/test_automations.py:69 214 | msgid "First name" 215 | msgstr "Vorname" 216 | 217 | #: tests/test_automations.py:73 218 | msgid "Your e-mail address" 219 | msgstr "Deine E-Mail-Adresse" 220 | 221 | #: tests/test_automations.py:77 222 | msgid "Chose session" 223 | msgstr "Sitzung wählen" 224 | 225 | #: views.py:136 226 | msgid "Obsolete automation %s" 227 | msgstr "Nicht mehr gültige Automatisierung %s" 228 | 229 | #: views.py:140 230 | #, python-format 231 | msgid "Obsolete automations %s" 232 | msgstr "Nicht mehr gültige Automatisierungen %s" 233 | 234 | #: views.py:157 235 | #, python-format 236 | msgid "Last %d days" 237 | msgstr "Vergangene %d Tage" 238 | -------------------------------------------------------------------------------- /src/automations/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsbraun/django-automations/3fde447de8032f0ad1d76eb706fd521174d05415/src/automations/management/__init__.py -------------------------------------------------------------------------------- /src/automations/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsbraun/django-automations/3fde447de8032f0ad1d76eb706fd521174d05415/src/automations/management/commands/__init__.py -------------------------------------------------------------------------------- /src/automations/management/commands/automation_delete_history.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from automations.models import AutomationModel 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Delete Automations older than the specified number of days (default=30)" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "days_old", 16 | type=int, 17 | nargs="?", 18 | help="The minumum age of an Automation (in days) before it is deleted", 19 | default=30, 20 | ) 21 | 22 | def handle(self, *args, **kwargs): 23 | days_old = kwargs["days_old"] 24 | total, info_dict = AutomationModel.delete_history(days_old) 25 | 26 | automation_count = info_dict.get("automations.AutomationModel", 0) 27 | task_count = info_dict.get("automations.AutomationTaskModel", 0) 28 | 29 | self.stdout.write( 30 | f"{total} total objects deleted, including {automation_count} AutomationModel " 31 | f"instances, and {task_count} AutomationTaskModel instances" 32 | ) 33 | -------------------------------------------------------------------------------- /src/automations/management/commands/automation_step.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | from django.core.management import BaseCommand 4 | 5 | from automations.models import AutomationModel 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Touch every automation to proceed." 12 | 13 | def handle(self, *args, **options): 14 | AutomationModel.run() 15 | -------------------------------------------------------------------------------- /src/automations/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-02 08:56 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("auth", "0012_alter_user_first_name_max_length"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="AutomationModel", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "automation_class", 32 | models.CharField(max_length=256, verbose_name="Process class"), 33 | ), 34 | ( 35 | "finished", 36 | models.BooleanField(default=False, verbose_name="Finished"), 37 | ), 38 | ("data", models.JSONField(default=dict, verbose_name="Data")), 39 | ( 40 | "paused_until", 41 | models.DateTimeField(null=True, verbose_name="Paused until"), 42 | ), 43 | ("created", models.DateTimeField(auto_now_add=True)), 44 | ("updated", models.DateTimeField(auto_now=True)), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name="AutomationTaskModel", 49 | fields=[ 50 | ( 51 | "id", 52 | models.AutoField( 53 | auto_created=True, 54 | primary_key=True, 55 | serialize=False, 56 | verbose_name="ID", 57 | ), 58 | ), 59 | ( 60 | "status", 61 | models.CharField(blank=True, max_length=256, verbose_name="Status"), 62 | ), 63 | ("locked", models.IntegerField(default=0, verbose_name="Locked")), 64 | ( 65 | "interaction_permissions", 66 | models.JSONField( 67 | default=list, 68 | help_text="List of permissions of the form app_label.codename", 69 | verbose_name="Required permissions", 70 | ), 71 | ), 72 | ("created", models.DateTimeField(auto_now_add=True)), 73 | ("finished", models.DateTimeField(null=True)), 74 | ( 75 | "message", 76 | models.CharField( 77 | blank=True, max_length=128, verbose_name="Message" 78 | ), 79 | ), 80 | ( 81 | "result", 82 | models.JSONField( 83 | blank=True, default=dict, null=True, verbose_name="Result" 84 | ), 85 | ), 86 | ( 87 | "automation", 88 | models.ForeignKey( 89 | on_delete=django.db.models.deletion.CASCADE, 90 | to="automations.automationmodel", 91 | ), 92 | ), 93 | ( 94 | "interaction_group", 95 | models.ForeignKey( 96 | null=True, 97 | on_delete=django.db.models.deletion.PROTECT, 98 | to="auth.group", 99 | verbose_name="Assigned group", 100 | ), 101 | ), 102 | ( 103 | "interaction_user", 104 | models.ForeignKey( 105 | null=True, 106 | on_delete=django.db.models.deletion.PROTECT, 107 | to=settings.AUTH_USER_MODEL, 108 | verbose_name="Assigned user", 109 | ), 110 | ), 111 | ( 112 | "previous", 113 | models.ForeignKey( 114 | null=True, 115 | on_delete=django.db.models.deletion.SET_NULL, 116 | to="automations.automationtaskmodel", 117 | verbose_name="Previous task", 118 | ), 119 | ), 120 | ], 121 | ), 122 | ] 123 | -------------------------------------------------------------------------------- /src/automations/migrations/0002_auto_20210506_1957.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-06 17:57 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("automations", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="automationmodel", 16 | name="automation_class", 17 | field=models.CharField(max_length=256, verbose_name="Prezess-Klasse"), 18 | ), 19 | migrations.AlterField( 20 | model_name="automationmodel", 21 | name="data", 22 | field=models.JSONField(default=dict, verbose_name="Daten"), 23 | ), 24 | migrations.AlterField( 25 | model_name="automationmodel", 26 | name="finished", 27 | field=models.BooleanField(default=False, verbose_name="Beendet"), 28 | ), 29 | migrations.AlterField( 30 | model_name="automationmodel", 31 | name="paused_until", 32 | field=models.DateTimeField(null=True, verbose_name="Pausiert bis"), 33 | ), 34 | migrations.AlterField( 35 | model_name="automationtaskmodel", 36 | name="locked", 37 | field=models.IntegerField(default=0, verbose_name="Gesperrt"), 38 | ), 39 | migrations.AlterField( 40 | model_name="automationtaskmodel", 41 | name="message", 42 | field=models.CharField( 43 | blank=True, max_length=128, verbose_name="Nachricht" 44 | ), 45 | ), 46 | migrations.AlterField( 47 | model_name="automationtaskmodel", 48 | name="previous", 49 | field=models.ForeignKey( 50 | null=True, 51 | on_delete=django.db.models.deletion.SET_NULL, 52 | to="automations.automationtaskmodel", 53 | verbose_name="Vorherige Aufgabe", 54 | ), 55 | ), 56 | migrations.AlterField( 57 | model_name="automationtaskmodel", 58 | name="result", 59 | field=models.JSONField( 60 | blank=True, default=dict, null=True, verbose_name="Ergebnis" 61 | ), 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /src/automations/migrations/0003_auto_20210511_0825.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-11 08:25 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("automations", "0002_auto_20210506_1957"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="automationtaskmodel", 16 | name="requires_interaction", 17 | field=models.BooleanField( 18 | default=False, verbose_name="Requires interaction" 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="automationmodel", 23 | name="automation_class", 24 | field=models.CharField(max_length=256, verbose_name="Process class"), 25 | ), 26 | migrations.AlterField( 27 | model_name="automationmodel", 28 | name="data", 29 | field=models.JSONField(default=dict, verbose_name="Data"), 30 | ), 31 | migrations.AlterField( 32 | model_name="automationmodel", 33 | name="finished", 34 | field=models.BooleanField(default=False, verbose_name="Finished"), 35 | ), 36 | migrations.AlterField( 37 | model_name="automationmodel", 38 | name="paused_until", 39 | field=models.DateTimeField(null=True, verbose_name="Paused until"), 40 | ), 41 | migrations.AlterField( 42 | model_name="automationtaskmodel", 43 | name="locked", 44 | field=models.IntegerField(default=0, verbose_name="Locked"), 45 | ), 46 | migrations.AlterField( 47 | model_name="automationtaskmodel", 48 | name="message", 49 | field=models.CharField(blank=True, max_length=128, verbose_name="Message"), 50 | ), 51 | migrations.AlterField( 52 | model_name="automationtaskmodel", 53 | name="previous", 54 | field=models.ForeignKey( 55 | null=True, 56 | on_delete=django.db.models.deletion.SET_NULL, 57 | to="automations.automationtaskmodel", 58 | verbose_name="Previous task", 59 | ), 60 | ), 61 | migrations.AlterField( 62 | model_name="automationtaskmodel", 63 | name="result", 64 | field=models.JSONField( 65 | blank=True, default=dict, null=True, verbose_name="Result" 66 | ), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /src/automations/migrations/0004_auto_20210511_1042.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-11 08:42 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("automations", "0003_auto_20210511_0825"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="automationmodel", 16 | name="automation_class", 17 | field=models.CharField(max_length=256, verbose_name="Prezess-Klasse"), 18 | ), 19 | migrations.AlterField( 20 | model_name="automationmodel", 21 | name="data", 22 | field=models.JSONField(default=dict, verbose_name="Daten"), 23 | ), 24 | migrations.AlterField( 25 | model_name="automationmodel", 26 | name="finished", 27 | field=models.BooleanField(default=False, verbose_name="Beendet"), 28 | ), 29 | migrations.AlterField( 30 | model_name="automationmodel", 31 | name="paused_until", 32 | field=models.DateTimeField(null=True, verbose_name="Pausiert bis"), 33 | ), 34 | migrations.AlterField( 35 | model_name="automationtaskmodel", 36 | name="locked", 37 | field=models.IntegerField(default=0, verbose_name="Gesperrt"), 38 | ), 39 | migrations.AlterField( 40 | model_name="automationtaskmodel", 41 | name="message", 42 | field=models.CharField( 43 | blank=True, max_length=128, verbose_name="Nachricht" 44 | ), 45 | ), 46 | migrations.AlterField( 47 | model_name="automationtaskmodel", 48 | name="previous", 49 | field=models.ForeignKey( 50 | null=True, 51 | on_delete=django.db.models.deletion.SET_NULL, 52 | to="automations.automationtaskmodel", 53 | verbose_name="Vorherige Aufgabe", 54 | ), 55 | ), 56 | migrations.AlterField( 57 | model_name="automationtaskmodel", 58 | name="result", 59 | field=models.JSONField( 60 | blank=True, default=dict, null=True, verbose_name="Ergebnis" 61 | ), 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /src/automations/migrations/0005_automationmodel_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-11 19:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("automations", "0004_auto_20210511_1042"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="automationmodel", 15 | name="key", 16 | field=models.CharField( 17 | default="", max_length=64, verbose_name="_Unique hash" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /src/automations/migrations/0006_auto_20211121_1357.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-21 13:57 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("automations", "0005_automationmodel_key"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="automationmodel", 16 | name="automation_class", 17 | field=models.CharField(max_length=256, verbose_name="Process class"), 18 | ), 19 | migrations.AlterField( 20 | model_name="automationmodel", 21 | name="data", 22 | field=models.JSONField(default=dict, verbose_name="Data"), 23 | ), 24 | migrations.AlterField( 25 | model_name="automationmodel", 26 | name="finished", 27 | field=models.BooleanField(default=False, verbose_name="Finished"), 28 | ), 29 | migrations.AlterField( 30 | model_name="automationmodel", 31 | name="key", 32 | field=models.CharField( 33 | default="", max_length=64, verbose_name="Unique hash" 34 | ), 35 | ), 36 | migrations.AlterField( 37 | model_name="automationmodel", 38 | name="paused_until", 39 | field=models.DateTimeField(null=True, verbose_name="Paused until"), 40 | ), 41 | migrations.AlterField( 42 | model_name="automationtaskmodel", 43 | name="locked", 44 | field=models.IntegerField(default=0, verbose_name="Locked"), 45 | ), 46 | migrations.AlterField( 47 | model_name="automationtaskmodel", 48 | name="message", 49 | field=models.CharField(blank=True, max_length=128, verbose_name="Message"), 50 | ), 51 | migrations.AlterField( 52 | model_name="automationtaskmodel", 53 | name="previous", 54 | field=models.ForeignKey( 55 | null=True, 56 | on_delete=django.db.models.deletion.SET_NULL, 57 | to="automations.automationtaskmodel", 58 | verbose_name="Previous task", 59 | ), 60 | ), 61 | migrations.AlterField( 62 | model_name="automationtaskmodel", 63 | name="result", 64 | field=models.JSONField( 65 | blank=True, default=dict, null=True, verbose_name="Result" 66 | ), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /src/automations/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsbraun/django-automations/3fde447de8032f0ad1d76eb706fd521174d05415/src/automations/migrations/__init__.py -------------------------------------------------------------------------------- /src/automations/models.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import datetime 3 | import hashlib 4 | import sys 5 | from logging import getLogger 6 | from types import MethodType 7 | 8 | from django.conf import settings as project_settings 9 | from django.contrib.auth import get_user_model 10 | from django.db import models 11 | from django.db.models import Q 12 | from django.utils.module_loading import import_string 13 | from django.utils.timezone import now 14 | from django.utils.translation import gettext as _ 15 | 16 | from . import settings 17 | 18 | # Create your models here. 19 | 20 | logger = getLogger(__name__) 21 | 22 | User = get_user_model() 23 | Group = settings.get_group_model() 24 | 25 | 26 | def get_automation_class(dotted_name): 27 | components = dotted_name.rsplit(".", 1) 28 | cls = __import__(components[0], fromlist=[components[-1]]) 29 | cls = getattr(cls, components[-1]) 30 | return cls 31 | 32 | 33 | class AutomationModel(models.Model): 34 | automation_class = models.CharField( 35 | max_length=256, 36 | blank=False, 37 | verbose_name=_("Process class"), 38 | ) 39 | finished = models.BooleanField( 40 | default=False, 41 | verbose_name=_("Finished"), 42 | ) 43 | data = models.JSONField( 44 | verbose_name=_("Data"), 45 | default=dict, 46 | ) 47 | key = models.CharField( 48 | verbose_name=_("Unique hash"), 49 | default="", 50 | max_length=64, 51 | ) 52 | paused_until = models.DateTimeField( 53 | null=True, 54 | verbose_name=_("Paused until"), 55 | ) 56 | created = models.DateTimeField( 57 | auto_now_add=True, 58 | ) 59 | updated = models.DateTimeField( 60 | auto_now=True, 61 | ) 62 | 63 | _automation_class = None 64 | 65 | def save(self, *args, **kwargs): 66 | self.key = self.get_key() 67 | return super().save(*args, **kwargs) 68 | 69 | def get_automation_class(self): 70 | if self._automation_class is None: 71 | self._automation_class = get_automation_class(self.automation_class) 72 | return self._automation_class 73 | 74 | @property 75 | def instance(self): 76 | return self.get_automation_class()(automation=self) 77 | 78 | @classmethod 79 | def run(cls, timestamp=None): 80 | if timestamp is None: 81 | timestamp = now() 82 | automations = cls.objects.filter( 83 | finished=False, 84 | ).filter(Q(paused_until__lte=timestamp) | Q(paused_until=None)) 85 | 86 | for automation in automations: 87 | klass = import_string(automation.automation_class) 88 | instance = klass(automation_id=automation.id, autorun=False) 89 | logger.info(f"Running automation {automation.automation_class}") 90 | try: 91 | instance.run() 92 | except Exception as e: # pragma: no cover 93 | automation.finished = True 94 | automation.save() 95 | logger.error(f"Error: {repr(e)}", exc_info=sys.exc_info()) 96 | 97 | def get_key(self): 98 | return hashlib.sha1( 99 | f"{self.automation_class}-{self.id}".encode("utf-8") 100 | ).hexdigest() 101 | 102 | @classmethod 103 | def delete_history(cls, days=30): 104 | automations = cls.objects.filter( 105 | finished=True, updated__lt=now() - datetime.timedelta(days=days) 106 | ) 107 | return automations.delete() 108 | 109 | def __str__(self): 110 | return f"" 111 | 112 | 113 | class AutomationTaskModel(models.Model): 114 | automation = models.ForeignKey( 115 | AutomationModel, 116 | on_delete=models.CASCADE, 117 | ) 118 | previous = models.ForeignKey( 119 | "automations.AutomationTaskModel", 120 | on_delete=models.SET_NULL, 121 | null=True, 122 | verbose_name=_("Previous task"), 123 | ) 124 | status = models.CharField( 125 | max_length=256, 126 | blank=True, 127 | verbose_name=_("Status"), 128 | ) 129 | locked = models.IntegerField( 130 | default=0, 131 | verbose_name=_("Locked"), 132 | ) 133 | requires_interaction = models.BooleanField( 134 | default=False, verbose_name=_("Requires interaction") 135 | ) 136 | interaction_user = models.ForeignKey( 137 | settings.AUTH_USER_MODEL, 138 | null=True, 139 | on_delete=models.PROTECT, 140 | verbose_name=_("Assigned user"), 141 | ) 142 | interaction_group = models.ForeignKey( 143 | Group, 144 | null=True, 145 | on_delete=models.PROTECT, 146 | verbose_name=_("Assigned group"), 147 | ) 148 | interaction_permissions = models.JSONField( 149 | default=list, 150 | verbose_name=_("Required permissions"), 151 | help_text=_("List of permissions of the form app_label.codename"), 152 | ) 153 | created = models.DateTimeField( 154 | auto_now_add=True, 155 | ) 156 | finished = models.DateTimeField( 157 | null=True, 158 | ) 159 | message = models.CharField( 160 | max_length=settings.MAX_FIELD_LENGTH, 161 | verbose_name=_("Message"), 162 | blank=True, 163 | ) 164 | result = models.JSONField( 165 | verbose_name=_("Result"), 166 | null=True, 167 | blank=True, 168 | default=dict, 169 | ) 170 | 171 | @property 172 | def data(self): 173 | return self.automation.data 174 | 175 | def hours_since_created(self): 176 | """returns the number of hours since creation of node, 0 if finished""" 177 | if self.finished: 178 | return 0 179 | return (now() - self.created).total_seconds() / 3600 180 | 181 | def get_node(self): 182 | instance = self.automation.instance 183 | return getattr(instance, self.status) 184 | 185 | def get_previous_tasks(self): 186 | if self.message == "Joined" and self.result: 187 | return self.__class__.objects.filter(id__in=self.result) 188 | return [self.previous] if self.previous else [] 189 | 190 | def get_next_tasks(self): 191 | return self.automationtaskmodel_set.all() 192 | 193 | @classmethod 194 | def get_open_tasks(cls, user): 195 | candidates = cls.objects.filter(finished=None, requires_interaction=True) 196 | return tuple( 197 | task for task in candidates if user in task.get_users_with_permission() 198 | ) 199 | 200 | def get_users_with_permission( 201 | self, 202 | include_superusers=True, 203 | backend="django.contrib.auth.backends.ModelBackend", 204 | ): 205 | """ 206 | Given an AutomationTaskModel instance, which has access to a list of permission 207 | codenames (self.interaction_permissions), the assigned user (self.interaction_user), 208 | and assigned group (self.interaction_group), returns a QuerySet of users with 209 | applicable permissions that meet the requirements for access. 210 | """ 211 | users = User.objects.all() 212 | for permission in self.interaction_permissions: 213 | users &= User.objects.with_perm( 214 | permission, include_superusers=False, backend=backend 215 | ) 216 | if self.interaction_user is not None: 217 | users = users.filter(id=self.interaction_user_id) 218 | if self.interaction_group is not None: 219 | users = users.filter(groups=self.interaction_group) 220 | if include_superusers: 221 | users |= User.objects.filter(is_superuser=True) 222 | return users 223 | 224 | def __str__(self): 225 | return f"" 226 | 227 | def __repr__(self): 228 | return self.__str__() 229 | 230 | 231 | def swap_users_with_permission_model_method(model, settings_conf): 232 | """ 233 | Function to swap `get_users_with_permission` method within model if needed. 234 | """ 235 | from django.utils.module_loading import import_string 236 | 237 | users_with_permission_method = settings.get_users_with_permission_model_method( 238 | settings=settings_conf 239 | ) 240 | 241 | if users_with_permission_method is not None: 242 | 243 | if callable(users_with_permission_method): 244 | model.get_users_with_permission = MethodType( 245 | users_with_permission_method, 246 | model, 247 | ) 248 | else: 249 | model.get_users_with_permission = MethodType( 250 | import_string(users_with_permission_method), 251 | model, 252 | ) 253 | 254 | 255 | # Swap AutomationTaskModel.get_users_with_permission method if needed 256 | swap_users_with_permission_model_method( 257 | AutomationTaskModel, settings_conf=project_settings 258 | ) 259 | -------------------------------------------------------------------------------- /src/automations/settings.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from django.apps import apps as django_apps 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | MAX_FIELD_LENGTH = 128 9 | 10 | FORM_VIEW_CONTEXT = getattr( 11 | settings, 12 | "ATM_FORM_VIEW_CONTEXT", 13 | dict( 14 | submit_classes="btn btn-primary float-right float-end", 15 | back_classes="btn btn-outline-primary", 16 | ), 17 | ) 18 | 19 | TASK_LIST_TEMPLATES = getattr( 20 | settings, 21 | "ATM_TASK_LIST_TEMPLATES", 22 | (("automations/includes/task_list.html", _("Default template")),), 23 | ) 24 | 25 | AUTH_USER_MODEL = getattr(settings, "AUTH_USER_MODEL", "auth.User") 26 | 27 | 28 | def get_group_model(settings=settings): 29 | """ 30 | Return the Group or alternate grouping model that is active in this project. 31 | """ 32 | GROUP_MODEL = getattr(settings, "ATM_GROUP_MODEL", "auth.Group") 33 | 34 | try: 35 | return django_apps.get_model(GROUP_MODEL, require_ready=False) 36 | except ValueError: 37 | raise ImproperlyConfigured( 38 | "ATM_GROUP_MODEL must be of the form 'app_label.model_name'" 39 | ) 40 | except LookupError: 41 | raise ImproperlyConfigured( 42 | f"ATM_GROUP_MODEL refers to model '{GROUP_MODEL}' that has not been installed" 43 | ) 44 | 45 | 46 | def get_users_with_permission_form_method(settings=settings): 47 | return getattr( 48 | settings, 49 | "ATM_USER_WITH_PERMISSIONS_FORM_METHOD", 50 | None, 51 | ) 52 | 53 | 54 | def get_users_with_permission_model_method(settings=settings): 55 | return getattr( 56 | settings, 57 | "ATM_USER_WITH_PERMISSIONS_MODEL_METHOD", 58 | None, 59 | ) 60 | -------------------------------------------------------------------------------- /src/automations/templates/automations/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %}{% spaceless %} 3 |
4 |
5 |
6 | {% block content_automations %}{% endblock %} 7 |
8 |
9 |
10 | {% endspaceless %}{% endblock %} 11 | -------------------------------------------------------------------------------- /src/automations/templates/automations/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "automations/base.html" %}{% load i18n %} 2 | {% block content_automations %} 3 |

{% trans "Automations dashboard" %} {{ timespan }}

4 | {% include "automations/includes/dashboard.html" %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /src/automations/templates/automations/error_report.html: -------------------------------------------------------------------------------- 1 | {% extends "automations/base.html" %}{% load i18n %} 2 | {% block content_automations %} 3 |

{% trans "Automations error report" %}

4 | {% if automations %} 5 | {% include "automations/includes/dashboard_error.html" %} 6 | {% else %} 7 | {% trans "No automations stopped with an error." %} 8 | {% endif %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /src/automations/templates/automations/form_view.html: -------------------------------------------------------------------------------- 1 | {% extends "automations/base.html" %} 2 | {% block content_automations %}{% include "automations/includes/form_view.html" %}{% endblock %} -------------------------------------------------------------------------------- /src/automations/templates/automations/history.html: -------------------------------------------------------------------------------- 1 | {% extends "automations/base.html" %}{% load i18n %} 2 | {% block content_automations %}{% spaceless %} 3 |
4 |

5 | {{ automation.get_automation_class.get_verbose_name }} 6 | {{ automation.automation_class }} 7 |

8 |
    9 |
  • {% trans "Automation id:" %} {{ automation.id }}
  • 10 |
  • {% trans "Updated:" %} {{ automation.updated }}
  • 11 |
  • {% trans "Paused until:" %} {{ automation.paused_until }}
  • 12 |
  • {% trans "Created:" %} {{ automation.created }}
  • 13 |
14 | {% if automation.data %} 15 |
16 |

{% trans "Data" %}

17 |
{{ automation.data }}
18 |
19 | {% endif %} 20 |
21 | {% include "automations/includes/history.html" with tasks=tasks %} 22 | 23 | {% endspaceless %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/dashboard.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% spaceless %} 2 |
3 | {% for item in automations %} 4 | {% if item.running %} 5 |
6 | {% if item.dashboard_template %} 7 | {% include item.dashboard_template with automation=item %} 8 | {% else %} 9 | {% include "automations/includes/dashboard_item.html" with automation=item %} 10 | {% endif %} 11 |
12 | {% endif %} 13 | {% endfor %} 14 |
15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/dashboard_error.html: -------------------------------------------------------------------------------- 1 | {% load i18n l10n %}{% spaceless %} 2 |
3 | {% for automation, errors in automations %} 4 |
5 |
6 |
7 | {{ automation.get_automation_class.get_verbose_name }} 8 | {{ errors|length }} 9 |
10 | 23 |
24 |
25 | {% endfor %} 26 |
27 | {% endspaceless %} 28 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/dashboard_item.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% spaceless %} 2 |
3 |
{{ automation.verbose_name }}
4 |
    5 |
  • {% trans "Running" %}: {{ automation.running.count }}
  • 6 |
  • {% trans "Finished" %}: {{ automation.finished.count }}
  • 7 |
8 |
9 | {% endspaceless %} 10 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/form_view.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_tags i18n %}{% spaceless %} 2 |
3 | {% csrf_token %} 4 |
5 | {% crispy form %} 6 |
7 | {% if request.GET.back %} 8 | {% trans "Back" %} 9 | {% endif %} 10 | 11 |
12 | {% endspaceless %} 13 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/history.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% for task in tasks reversed %} 3 | {% if task|length > 1 %} 4 |
5 | {% for sub_tree in task %} 6 |
7 | {% include "automations/includes/history.html" with tasks=sub_tree %} 8 |
9 | {% endfor %} 10 |
11 | {% else %} 12 | {% include "automations/includes/history_item.html" with task=task %} 13 | {% endif %} 14 | {% endfor %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/history_item.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% spaceless %} 2 |
3 | {% with node=task.get_node %} 4 |

{{ task.status }} = flow.{{ node.node_name }}() 5 | {% if task.finished %}{{ task.finished }}{% else %}{% trans "running" %}{% endif %}

6 |
7 | {% if node.description %}

{{ node.description }}

{% endif %} 8 | {% if task.message == "OK" and task.result %} 9 |
{{ task.result }}
10 | {% elif "Error" in task.message %} 11 | 12 | {{ task.message }} 13 | 14 | {% else %} 15 |
{{ task.message }}
16 | {% endif %} 17 |
18 | {% if node.modifiers %} 19 | 29 | {% endif %} 30 | {% endwith %} 31 |
32 | {% endspaceless %} 33 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/task_item.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% spaceless %} 2 |
3 |
{{ task.automation.get_automation_class.get_verbose_name }}
4 |
5 |

{{ task.get_node.description }}

6 | {% trans "View" %} 7 |
8 |
9 | {% endspaceless %} 10 | -------------------------------------------------------------------------------- /src/automations/templates/automations/includes/task_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% spaceless %} 2 | {% if count %} 3 |
4 | {% for task in tasks %} 5 |
{% include "automations/includes/task_item.html" with task=task %}
6 | {% endfor %} 7 |
8 | {% else %} 9 | {% if always_inform %} 10 |
11 | {% trans "Currently no tasks for you" %} 12 |
13 | {% endif %} 14 | {% endif %} 15 | {% endspaceless %} 16 | -------------------------------------------------------------------------------- /src/automations/templates/automations/preformatted_traceback.html: -------------------------------------------------------------------------------- 1 | {% extends "automations/base.html" %}{% load i18n %} 2 | {% block content_automations %} 3 | {% if error %} 4 |

{% trans "Traceback" %}

5 |
{{ error }}
6 | {% else %} 7 |

{% trans "No traceback available" %}

8 | {% endif %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /src/automations/templates/automations/task_list.html: -------------------------------------------------------------------------------- 1 | {% extends "automations/base.html" %}{% load i18n %} 2 | {% block content_automations %}{% spaceless %} 3 |

{% trans "Open tasks for" %} {{ request.user.get_full_name }}

4 | {% if count == 0 %} 5 |
6 | {% trans "Currently no tasks for you" %} 7 |
8 | {% else %} 9 | {% include "automations/includes/task_list.html" %} 10 | {% endif %} 11 | {% if request.user.is_staff %} 12 | {% trans "Automations dashboard" %} 13 | {% endif %} 14 | {% endspaceless %}{% endblock %} 15 | -------------------------------------------------------------------------------- /src/automations/templates/automations/traceback.html: -------------------------------------------------------------------------------- 1 | {% if html %} 2 | {{ html|safe }} 3 | {% else %} 4 | {% include "automations/preformatted_traceback.html" with error=error %} 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /src/automations/templates/base.html: -------------------------------------------------------------------------------- 1 | {# only for testing #} 2 | {% block content %} 3 | {% endblock %} -------------------------------------------------------------------------------- /src/automations/templatetags/atm_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django import template 3 | 4 | from .. import models 5 | 6 | register = template.Library() 7 | 8 | 9 | # @register.simple_tag(takes_context=True) 10 | # def task_info(context, automation_class, info, debug=False): 11 | # get_data = context.get("request", dict()).GET 12 | # task_id, atm_id = get_int(get_data, "task_id"), get_int(get_data, "atm_id") 13 | # if automation is not None and automation.automation_class == automation_class: 14 | # cls = automation.get_automation_class() 15 | # if hasattr(cls, "public_data") and info in cls.public_data: 16 | # return automation.data.get(info, "") 17 | # return "-- Task info --" if debug else "" 18 | -------------------------------------------------------------------------------- /src/automations/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | 3 | 4 | def setup_test_app(package, label=None): 5 | """ 6 | Setup a Django test app for the provided package to allow test models 7 | tables to be created if the containing app has migrations. 8 | 9 | This function should be called from app.tests __init__ module and pass 10 | along __package__. 11 | 12 | Source https://code.djangoproject.com/ticket/7835#comment:46 13 | """ 14 | app_config = AppConfig.create(package) 15 | app_config.apps = apps 16 | if label is None: 17 | containing_app_config = apps.get_containing_app_config(package) 18 | label = f"{containing_app_config.label}_tests" 19 | if label in apps.app_configs: 20 | raise ValueError(f"There's already an app registered with the '{label}' label.") 21 | app_config.label = label 22 | apps.app_configs[app_config.label] = app_config 23 | app_config.import_models() 24 | apps.clear_cache() 25 | 26 | 27 | setup_test_app(__package__) 28 | -------------------------------------------------------------------------------- /src/automations/tests/methods.py: -------------------------------------------------------------------------------- 1 | def temp_get_users_with_permission_form(self): 2 | """Used to test that swapping the Form method works""" 3 | # Search string: ABC 4 | return () 5 | 6 | 7 | def temp_get_users_with_permission_model( 8 | self, 9 | include_superusers=True, 10 | backend="django.contrib.auth.backends.ModelBackend", 11 | ): 12 | """Used to test that swapping the model method works""" 13 | # Search string: XYZ 14 | return () 15 | -------------------------------------------------------------------------------- /src/automations/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.base_user import AbstractBaseUser 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class TestGroup(models.Model): 7 | name = models.CharField(_("Test Group Name"), max_length=100) 8 | 9 | class Meta: 10 | verbose_name = _("test group") 11 | verbose_name_plural = _("test groups") 12 | 13 | 14 | class TestUser(AbstractBaseUser): 15 | username = models.CharField(_("Test User Username"), max_length=100) 16 | email = models.EmailField(_("email address"), unique=True) 17 | group = models.ForeignKey( 18 | TestGroup, 19 | related_name="test_users", 20 | on_delete=models.CASCADE, 21 | null=True, 22 | blank=True, 23 | ) 24 | is_staff = models.BooleanField( 25 | _("superuser status"), 26 | default=False, 27 | ) 28 | is_superuser = models.BooleanField( 29 | _("superuser status"), 30 | default=False, 31 | ) 32 | 33 | USERNAME_FIELD = "email" 34 | REQUIRED_FIELDS = [] 35 | 36 | def has_perm(self, perm, obj=None): 37 | return True 38 | 39 | def has_perms(self, perm_list, obj=None): 40 | return True 41 | 42 | def has_module_perms(self, app_label): 43 | return False 44 | 45 | class Meta: 46 | verbose_name = _("test user") 47 | verbose_name_plural = _("test users") 48 | 49 | 50 | class TestPermission(models.Model): 51 | slug = models.SlugField(_("Test Permission Slug"), max_length=100) 52 | groups = models.ManyToManyField( 53 | TestGroup, 54 | related_name="test_permissions", 55 | ) 56 | 57 | class Meta: 58 | verbose_name = _("test permission") 59 | verbose_name_plural = _("test permissions") 60 | -------------------------------------------------------------------------------- /src/automations/tests/test_automations.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import datetime 3 | import inspect 4 | from io import StringIO 5 | from unittest.mock import patch 6 | 7 | import django.dispatch 8 | from django import forms 9 | from django.contrib.auth import get_user_model 10 | from django.contrib.auth.models import Group 11 | from django.core.management import execute_from_command_line 12 | from django.test import Client, RequestFactory, TestCase, override_settings 13 | from django.utils.timezone import now 14 | from django.utils.translation import gettext as _ 15 | 16 | from .. import flow, models, views 17 | from ..flow import this 18 | from ..models import AutomationModel, AutomationTaskModel, get_automation_class 19 | 20 | # Create your tests here. 21 | 22 | User = get_user_model() 23 | 24 | 25 | class Print(flow.Execute): 26 | @staticmethod 27 | def method(task_instance, *args): 28 | print(task_instance.status, *args) 29 | 30 | 31 | class TestAutomation(flow.Automation): 32 | start = flow.Execute(this.init).AsSoonAs(lambda x: True).AsSoonAs(this.cont) 33 | intermediate = flow.Execute("self.init2") 34 | func_if = ( 35 | flow.If(lambda x: x.data["more_participants"] == "test").Then().Else(this.print) 36 | ) 37 | if_clause = flow.If(lambda x: x.data["more_participants"] == "test").Then( 38 | "self.conditional" 39 | ) 40 | if2 = ( 41 | flow.If(lambda x: x.data["more_participants"] == "test") 42 | .Then(this.if_clause) 43 | .Else(this.end) 44 | ) 45 | end = flow.End() 46 | 47 | conditional = flow.Execute(this.init2).Next("self.if2") 48 | 49 | def init(self, task, *args, **kwargs): 50 | if "participants" not in self.data: 51 | self.data["participants"] = [] 52 | self.save() 53 | 54 | def init2(self, task): 55 | self.data["more_participants"] = "test" + self.data.get("more_participants", "") 56 | self.save() 57 | return task # Illegal since not json serializable 58 | 59 | def cont(self, task): 60 | return bool(self) and bool(task) # True 61 | 62 | def print(self, task): 63 | print("Hello", task.data) 64 | 65 | 66 | class TestForm(forms.Form): 67 | success_url = "https://www.google.de/" 68 | first_name = forms.CharField( 69 | label=_("First name"), 70 | max_length=80, 71 | ) 72 | email = forms.EmailField( 73 | label=_("Your e-mail address"), 74 | max_length=100, 75 | ) 76 | session = forms.IntegerField( 77 | label=_("Chose session"), 78 | ) 79 | 80 | 81 | class TestSplitJoin(flow.Automation): 82 | class Meta: 83 | verbose_name = "Allow to split and join" 84 | verbose_name_plural = "Allow splitS and joinS" 85 | 86 | start = Print("Hello, this is the single thread").AfterWaitingFor( 87 | datetime.timedelta(days=-1) 88 | ) 89 | l10 = Print("Line 10").AfterWaitingUntil(now() - datetime.timedelta(minutes=1)) 90 | split = flow.Split().Next("self.t10").Next("self.t20").Next("self.t30") 91 | 92 | join = flow.Join() 93 | l20 = Print("All joined now") 94 | l30 = flow.End() 95 | 96 | t10 = Print("Thread 10").Next(this.split_again) 97 | t20 = Print("Thread 20").Next(this.join) 98 | t30 = Print("Thread 30").Next(this.join) 99 | 100 | split_again = flow.Split().Next(this.t40).Next(this.t50) 101 | t40 = Print("Thread 40").Next(this.join_again) 102 | t50 = Print("Thread 50").Next(this.join_again) 103 | join_again = flow.Join() 104 | going_back = Print("Sub split joined").Next(this.join) 105 | 106 | 107 | class AtmTaskForm(forms.ModelForm): 108 | class Meta: 109 | model = AutomationTaskModel 110 | exclude = [] 111 | 112 | 113 | class FormTest(flow.Automation): 114 | class Meta: 115 | verbose_name = "Edit task #8" 116 | verbose_name_plural = "Edit tasks" 117 | 118 | start = flow.Execute(this.init_with_item) 119 | form = flow.Form(TestForm, context=dict(claim="Save")).User(id=0) 120 | form2 = ( 121 | flow.Form(TestForm, context=dict(claim="Save")) 122 | .User(id=0) 123 | .Permission("automations.create_automationmodel") 124 | ) 125 | end = flow.End() 126 | 127 | def init_with_item(self, task_instance): 128 | task_instance.data["task_id"] = 8 129 | self.save() 130 | 131 | 132 | class Looping(flow.Automation): 133 | start = flow.Split().Next("self.loop1").Next("self.loop2").Next("self.loop3") 134 | 135 | loop1 = flow.ModelForm(AtmTaskForm, "key_id") 136 | loop1_1 = flow.Repeat("self.loop1").EveryDay().At(21, 00) 137 | loop2 = flow.Repeat("self.loop2").EveryNMinutes(30) 138 | loop3 = flow.Repeat("self.loop3").EveryHour() 139 | 140 | 141 | class BoundToFail(flow.Automation): 142 | start = Print("Will divide by zero.").SkipAfter(datetime.timedelta(days=1)) 143 | div = flow.Execute(lambda x: 5 / 0).OnError(this.error_node) 144 | never = Print("This should NOT be printed") 145 | not_caught = flow.Execute(lambda x: 5 / 0) 146 | never_reached = Print("Will not reach this") 147 | end = flow.End() 148 | 149 | error_node = Print("Oh dear").Next(this.not_caught) 150 | 151 | 152 | class SingletonAutomation(flow.Automation): 153 | unique = True 154 | 155 | end = flow.End() 156 | 157 | 158 | class ByEmailSingletonAutomation(flow.Automation): 159 | unique = ("email",) 160 | 161 | end = flow.End() 162 | 163 | @flow.require_data_parameters(email=str, mails=int) 164 | def receive_test(self, token, data): 165 | pass 166 | 167 | 168 | class ModelTestCase(TestCase): 169 | def test_modelsetup(self): 170 | x = TestAutomation(autorun=False) 171 | qs = AutomationModel.objects.all() 172 | self.assertEqual(len(qs), 1) 173 | self.assertEqual( 174 | qs[0].automation_class, "automations.tests.test_automations.TestAutomation" 175 | ) 176 | 177 | self.assertEqual(get_automation_class(x._db.automation_class), TestAutomation) 178 | 179 | x.run() 180 | self.assertIn("more_participants", x.data) 181 | self.assertEqual(x.data["more_participants"], "testtest") 182 | self.assertIn("participants", x.data) 183 | 184 | self.assertEqual(x.get_verbose_name(), "Automation TestAutomation") 185 | self.assertEqual(x.get_verbose_name_plural(), "Automations TestAutomation") 186 | 187 | def test_get_group_model(self): 188 | """With no settings overridden, the default group model "auth.Group" can be retrieved""" 189 | from ..settings import get_group_model 190 | 191 | self.assertEqual(get_group_model(), Group) 192 | 193 | 194 | class AutomationTestCase(TestCase): 195 | def test_split_join(self): 196 | with patch("sys.stdout", new=StringIO()) as fake_out: 197 | atm = TestSplitJoin() 198 | output = fake_out.getvalue().splitlines() 199 | self.assertEqual(output[0], "start Hello, this is the single thread") 200 | self.assertEqual(output[1], "l10 Line 10") 201 | self.assertEqual(output[-1], "l20 All joined now") 202 | self.assertIn("t10 Thread 10", output) 203 | self.assertIn("t20 Thread 20", output) 204 | self.assertIn("t30 Thread 30", output) 205 | self.assertIn("t40 Thread 40", output) 206 | self.assertIn("t50 Thread 50", output) 207 | self.assertIn("going_back Sub split joined", output) 208 | 209 | self.assertEqual(atm.get_verbose_name(), "Allow to split and join") 210 | self.assertEqual(atm.get_verbose_name_plural(), "Allow splitS and joinS") 211 | 212 | 213 | class FormTestCase(TestCase): 214 | def setUp(self): 215 | # Every test needs access to the request factory. 216 | self.factory = RequestFactory() 217 | self.user = User.objects.create_user( 218 | username="jacob", email="jacob@…", password="top_secret" 219 | ) 220 | self.admin = User.objects.create_user( 221 | username="admin", 222 | email="admin@...", 223 | password="Even More Secr3t", 224 | is_superuser=True, 225 | ) 226 | 227 | def test_form(self): 228 | atm = FormTest(autorun=False) 229 | tasks = atm._db.automationtaskmodel_set.filter(finished=None) 230 | self.assertEqual(len(tasks), 0) 231 | atm.form._user = dict(id=self.user.id) # Fake User 232 | atm.form2._user = dict(id=self.user.id) # Fake User 233 | atm.run() 234 | users = atm.form.get_users_with_permission() 235 | self.assertEqual(len(users), 0) 236 | 237 | tasks = atm._db.automationtaskmodel_set.filter(finished=None) 238 | self.assertEqual(len(tasks), 1) # Form not validated 239 | 240 | # Create an instance of a GET request. 241 | request = self.factory.get(f"/{tasks[0].id}") 242 | 243 | # Recall that middleware are not supported. You can simulate a 244 | # logged-in user by setting request.user manually. 245 | request.user = self.user 246 | response = views.TaskView.as_view()(request, task_id=tasks[0].id) 247 | self.assertEqual(response.status_code, 200) 248 | 249 | form_data = dict(session=8, email="none@nowhere.com", first_name="Fred") 250 | request = self.factory.post(f"/{tasks[0].id}", data=form_data) 251 | request.user = self.user 252 | response = views.TaskView.as_view()(request, task_id=tasks[0].id) 253 | self.assertEqual(response.status_code, 302) # Success leads to redirect 254 | 255 | request = self.factory.get("/tasks") 256 | request.user = self.user 257 | response = views.TaskListView.as_view()(request) 258 | self.assertEqual(response.status_code, 200) 259 | 260 | request = self.factory.get("/tasks") 261 | request.user = self.user 262 | response = views.TaskListView.as_view()(request) 263 | self.assertEqual(response.status_code, 200) 264 | 265 | request = self.factory.get("/dashboard") 266 | request.user = self.admin 267 | response = views.TaskDashboardView.as_view()(request) 268 | self.assertEqual(response.status_code, 200) 269 | 270 | atm.run() 271 | self.assertEqual(len(atm.form2.get_users_with_permission()), 0) 272 | 273 | 274 | class HistoryTestCase(TestCase): 275 | def setUp(self): 276 | # Every test needs access to the request factory. 277 | self.client = Client() 278 | self.admin = User.objects.create_user( 279 | username="admin", 280 | email="admin@...", 281 | password="Even More Secr3t", 282 | is_staff=True, 283 | is_superuser=True, 284 | ) 285 | self.admin.save() 286 | self.assertEqual(self.admin.is_superuser, True) 287 | login = self.client.login(username="admin", password="Even More Secr3t") 288 | self.assertTrue(login, "Could not login") 289 | 290 | def test_history_test(self): 291 | atm = TestSplitJoin() 292 | response = self.client.get(f"/dashboard/{atm._db.id}") 293 | self.assertEqual(response.status_code, 200) 294 | self.assertIn("split_again = flow.Split()", response.content.decode("utf8")) 295 | 296 | def test_no_traceback_test(self): 297 | atm = TestSplitJoin() 298 | response = self.client.get( 299 | f"/dashboard/{atm._db.id}/traceback/{atm._db.automationtaskmodel_set.first().id}" 300 | ) 301 | self.assertEqual(response.status_code, 200) 302 | self.assertIn("No traceback available", response.content.decode("utf8")) 303 | 304 | def test_traceback_test(self): 305 | atm = BogusAutomation1() 306 | response = self.client.get( 307 | f"/dashboard/{atm._db.id}/traceback/{atm._db.automationtaskmodel_set.first().id}" 308 | ) 309 | self.assertEqual(response.status_code, 200) 310 | self.assertIn("Darn, this is not good", response.content.decode("utf8")) 311 | 312 | def test_error_view(self): 313 | BogusAutomation1() 314 | response = self.client.get("/errors") 315 | self.assertEqual(response.status_code, 200) 316 | self.assertIn("BogusAutomation1", response.content.decode("utf8")) 317 | 318 | 319 | test_signal = django.dispatch.Signal() 320 | 321 | 322 | @flow.on_signal(test_signal) 323 | class SignalAutomation(flow.Automation): 324 | def started_by_signal(self, *args, **kwargs): 325 | self.data["started"] = "yeah!" 326 | self.save() 327 | 328 | start = flow.Execute().AfterWaitingFor(datetime.timedelta(days=1)) 329 | end = flow.End() 330 | 331 | def receive_new_user(self, token, data=None): 332 | self.data["token"] = token 333 | self.save() 334 | return "received" 335 | 336 | 337 | class SendMessageAutomation(flow.Automation): 338 | start = flow.SendMessage(SignalAutomation, "new_user", "12345678") 339 | to_nowhere = flow.SendMessage( 340 | "automations.tests.test_automations.FormTest", "this_receiver_does_not_exist" 341 | ) 342 | end = flow.End() 343 | 344 | 345 | class SignalTestCase(TestCase): 346 | def test_signal(self): 347 | self.assertEqual( 348 | 0, 349 | len( 350 | AutomationModel.objects.filter( 351 | automation_class="automations.tests.test_automations.SignalAutomation", 352 | ) 353 | ), 354 | ) 355 | 356 | test_signal.send(self.__class__) 357 | 358 | inst = AutomationModel.objects.filter( 359 | automation_class="automations.tests.test_automations.SignalAutomation", 360 | ) 361 | self.assertEqual(1, len(inst)) 362 | self.assertEqual(inst[0].data.get("started", ""), "yeah!") 363 | SendMessageAutomation() 364 | inst = AutomationModel.objects.filter( 365 | automation_class="automations.tests.test_automations.SignalAutomation", 366 | ) 367 | self.assertEqual(1, len(inst)) 368 | self.assertEqual(inst[0].data.get("token", ""), "12345678") 369 | self.assertEqual( 370 | SignalAutomation.dispatch_message(1, "new_user", "", None), "received" 371 | ) 372 | self.assertEqual( 373 | SignalAutomation.dispatch_message(2, "new_user", "", None), None 374 | ) 375 | self.assertEqual( 376 | SignalAutomation.dispatch_message("non-existing-key", "new_user", "", None), 377 | None, 378 | ) 379 | self.assertGreater(len(inst[0].automationtaskmodel_set.all()), 0) 380 | 381 | 382 | class RepeatTest(TestCase): 383 | def test_repeat(self): 384 | atm = Looping() 385 | tasks = atm._db.automationtaskmodel_set.filter(finished=None) 386 | self.assertEqual(len(tasks), 3) 387 | atm.run() 388 | tasks = atm._db.automationtaskmodel_set.filter(finished=None) 389 | self.assertEqual(len(tasks), 3) 390 | 391 | def test_get_automations(self): 392 | self.assertEqual(len(flow.get_automations()), 0) 393 | self.assertEqual(len(flow.get_automations("automations.flow")), 1) 394 | tpl = flow.get_automations("automations.tests.test_automations") 395 | self.assertIn("Allow to split and join", (name for _, name in tpl)) 396 | 397 | 398 | class ManagementCommandStepTest(TestCase): 399 | def test_managment_step_command(self): 400 | with patch("sys.stdout", new=StringIO()) as fake_out: 401 | atm = TestSplitJoin() 402 | execute_from_command_line(["manage.py", "automation_step"]) 403 | output = fake_out.getvalue().splitlines() 404 | self.assertEqual(output[0], "start Hello, this is the single thread") 405 | self.assertEqual(output[1], "l10 Line 10") 406 | self.assertEqual(output[-1], "l20 All joined now") 407 | self.assertIn("t10 Thread 10", output) 408 | self.assertIn("t20 Thread 20", output) 409 | self.assertIn("t30 Thread 30", output) 410 | 411 | db_id = atm._db.id 412 | self.assertGreater( 413 | len(AutomationTaskModel.objects.filter(automation_id=db_id)), 0 414 | ) 415 | atm.kill() 416 | self.assertEqual( 417 | len(AutomationTaskModel.objects.filter(automation_id=db_id)), 0 418 | ) 419 | 420 | 421 | class ManagementCommandDeleteTest(TestCase): 422 | def test_managment_delete_command(self): 423 | with patch("sys.stdout", new=StringIO()) as fake_out: 424 | atm = TestSplitJoin() 425 | execute_from_command_line(["manage.py", "automation_delete_history", "0"]) 426 | output = fake_out.getvalue().splitlines() 427 | self.assertIn( 428 | "18 total objects deleted, including 1 AutomationModel instances, and 17 " 429 | "AutomationTaskModel instances", 430 | output, 431 | ) 432 | self.assertEqual(AutomationModel.objects.count(), 0) 433 | self.assertEqual(AutomationTaskModel.objects.count(), 0) 434 | atm.kill() 435 | 436 | 437 | class ExecutionErrorTest(TestCase): 438 | def test_managment_command(self): 439 | with patch("sys.stdout", new=StringIO()) as fake_out: 440 | BoundToFail() 441 | output = fake_out.getvalue().splitlines() 442 | self.assertEqual(output[0], "start Will divide by zero.") 443 | self.assertEqual(output[-1], "error_node Oh dear") 444 | self.assertNotIn("never This should NOT be printed", output) 445 | self.assertNotIn("never_reached Will not reach this", output) 446 | 447 | 448 | class SingletonTest(TestCase): 449 | def test_singleton(self): 450 | inst1 = SingletonAutomation(autorun=False) 451 | self.assertEqual( 452 | len( 453 | AutomationModel.objects.filter( 454 | automation_class="automations.tests.test_automations.SingletonAutomation" 455 | ) 456 | ), 457 | 1, 458 | ) 459 | inst2 = SingletonAutomation(autorun=False) 460 | self.assertEqual( 461 | len( 462 | AutomationModel.objects.filter( 463 | automation_class="automations.tests.test_automations.SingletonAutomation" 464 | ) 465 | ), 466 | 1, 467 | ) 468 | self.assertEqual(inst1._db, inst2._db) 469 | self.assertNotEqual(inst1, inst2) 470 | 471 | def test_rel_singleton(self): 472 | inst1 = ByEmailSingletonAutomation(email="none@nowhere.com", autorun=False) 473 | atm_name = inst1.get_automation_class_name() 474 | self.assertEqual(atm_name.rsplit(".", 1)[-1], inst1.end.get_automation_name()) 475 | self.assertEqual( 476 | len(AutomationModel.objects.filter(automation_class=atm_name)), 1 477 | ) 478 | inst2 = ByEmailSingletonAutomation(email="nowhere@none.com", autorun=False) 479 | self.assertEqual( 480 | len(AutomationModel.objects.filter(automation_class=atm_name)), 2 481 | ) 482 | self.assertNotEqual(inst1._db, inst2._db) 483 | inst3 = ByEmailSingletonAutomation(email="nowhere@none.com", autorun=False) 484 | self.assertEqual( 485 | len(AutomationModel.objects.filter(automation_class=atm_name)), 2 486 | ) 487 | self.assertEqual(inst2._db, inst3._db) 488 | 489 | self.assertTrue( 490 | ByEmailSingletonAutomation.satisfies_data_requirements( 491 | "test", dict(email="test", mails="2") 492 | ) 493 | ) 494 | self.assertTrue( 495 | ByEmailSingletonAutomation.satisfies_data_requirements( 496 | "test", dict(email="test", mails=2) 497 | ) 498 | ) 499 | self.assertFalse( 500 | ByEmailSingletonAutomation.satisfies_data_requirements( 501 | "test", dict(email="test", mails="t2") 502 | ) 503 | ) 504 | self.assertFalse( 505 | ByEmailSingletonAutomation.satisfies_data_requirements( 506 | "test", dict(mails="t2") 507 | ) 508 | ) 509 | self.assertFalse( 510 | ByEmailSingletonAutomation.satisfies_data_requirements( 511 | "nonexistent", dict(mails="t2") 512 | ) 513 | ) 514 | 515 | ByEmailSingletonAutomation.create_on_message( 516 | "test", None, dict(email="new", mails=2) 517 | ) 518 | self.assertEqual( 519 | len( 520 | AutomationModel.objects.filter( 521 | automation_class="automations.tests.test_automations.ByEmailSingletonAutomation" 522 | ) 523 | ), 524 | 3, 525 | ) 526 | 527 | ByEmailSingletonAutomation.create_on_message( 528 | "test", None, dict(email="also_new") 529 | ) 530 | self.assertEqual( 531 | len( 532 | AutomationModel.objects.filter( 533 | automation_class="automations.tests.test_automations.ByEmailSingletonAutomation" 534 | ) 535 | ), 536 | 3, 537 | ) 538 | 539 | AutomationModel.run() 540 | 541 | 542 | class BogusAutomation1(flow.Automation): 543 | start = flow.Execute(this.test).OnError(this.error) 544 | end = flow.End() 545 | error = flow.Execute(this.test) 546 | 547 | def test(self, task): 548 | raise SyntaxError("Darn, this is not good") 549 | 550 | def time(self, task): 551 | return True # not a datetime 552 | 553 | 554 | class BogusAutomation2(flow.Automation): 555 | start = flow.Execute(this.test) 556 | mid = flow.Execute(this.test).AfterWaitingUntil(this.time) 557 | end = flow.End().AfterWaitingUntil(this.time) 558 | 559 | def test(self, task): 560 | return "Truth" 561 | 562 | def time(self, task): 563 | return True # not a datetime 564 | 565 | 566 | class ErrorTest(TestCase): 567 | def test_errors(self): 568 | atm = BogusAutomation1() 569 | self.assertTrue(atm.finished()) 570 | self.assertEqual(len(atm._db.automationtaskmodel_set.all()), 2) 571 | self.assertEqual( 572 | atm._db.automationtaskmodel_set.all()[1].message, 573 | "SyntaxError('Darn, this is not good')", 574 | ) 575 | self.assertIn( 576 | "error", 577 | atm._db.automationtaskmodel_set.all()[1].result, 578 | ) 579 | self.assertIn( 580 | "html", 581 | atm._db.automationtaskmodel_set.all()[1].result, 582 | ) 583 | atm = BogusAutomation2() 584 | self.assertTrue(atm.finished()) 585 | self.assertEqual(len(atm._db.automationtaskmodel_set.all()), 2) 586 | self.assertEqual(atm._db.automationtaskmodel_set.all()[0].result, "Truth") 587 | self.assertEqual( 588 | atm._db.automationtaskmodel_set.all()[1].message, 589 | "TypeError(\"'<' not supported between instances of 'bool' and 'datetime.datetime'\")", 590 | ) 591 | 592 | 593 | class SkipAutomation(flow.Automation): 594 | start = Print("NOT SKIPPED").SkipIf(lambda x: False) 595 | second = ( 596 | Print("SKIPPED").SkipIf(True).AsSoonAs(False) 597 | ) # precedence of SkipIf over AsSoonAs 598 | third = Print("Clearly printed") 599 | forth = flow.Execute() # Noop 600 | end = flow.End() 601 | 602 | 603 | class SkipTest(TestCase): 604 | def test_skipif(self): 605 | with patch("sys.stdout", new=StringIO()) as fake_out: 606 | atm = SkipAutomation() 607 | output = fake_out.getvalue().splitlines() 608 | self.assertEqual(atm.get_key(), atm.get_key()) 609 | self.assertTrue(atm.finished()) 610 | self.assertEqual(len(output), 2) 611 | self.assertEqual(output[0], "start NOT SKIPPED") 612 | self.assertEqual(output[-1], "third Clearly printed") 613 | 614 | 615 | @override_settings( 616 | AUTH_USER_MODEL="automations_tests.TestUser", 617 | ATM_GROUP_MODEL="automations_tests.TestGroup", 618 | ATM_USER_WITH_PERMISSIONS_FORM_METHOD="automations.tests.methods.temp_get_users_with_permission_form", 619 | ATM_USER_WITH_PERMISSIONS_MODEL_METHOD="automations.tests.methods.temp_get_users_with_permission_model", 620 | ) 621 | class ModelSwapTestCase(TestCase): 622 | def setUp(self): 623 | from ..tests.models import TestGroup, TestPermission, TestUser 624 | 625 | # Every test needs access to the request factory. 626 | self.factory = RequestFactory() 627 | 628 | self.group = TestGroup.objects.create(name="Main Group") 629 | self.user = TestUser.objects.create( 630 | username="jacob", email="jacob@...", group=self.group, password="top_secret" 631 | ) 632 | self.admin = TestUser.objects.create( 633 | username="admin", 634 | email="admin@...", 635 | password="Even More Secr3t", 636 | is_staff=True, 637 | ) 638 | 639 | self.permission = TestPermission.objects.create(slug="some-critical-permission") 640 | self.permission.groups.add(self.group) 641 | self.permissions = [ 642 | self.permission.slug, 643 | ] 644 | 645 | def test_swappable_models(self): 646 | """The current group model can be retrieved when overridden""" 647 | from django.conf import settings 648 | 649 | from ..settings import get_group_model 650 | 651 | # Get models again based on overridden settings 652 | # noinspection PyPep8Naming 653 | UserModel = get_user_model() 654 | # noinspection PyPep8Naming 655 | GroupModel = get_group_model(settings=settings) 656 | 657 | self.assertEqual(self.permission.groups.count(), 1) 658 | self.assertEqual(self.group.test_permissions.count(), 1) 659 | 660 | group = GroupModel.objects.first() 661 | self.assertEqual(group.name, "Main Group") 662 | self.assertEqual(GroupModel.__name__, "TestGroup") 663 | 664 | user = UserModel.objects.get(username="admin") 665 | self.assertEqual(user.is_staff, True) 666 | self.assertEqual(user.email, "admin@...") 667 | self.assertEqual(UserModel.__name__, "TestUser") 668 | 669 | def test_method_swaps(self): 670 | from django.conf import settings 671 | 672 | from .. import flow 673 | 674 | # Manually call these here because they are not automatically re-run during tests after we override settings 675 | flow.swap_users_with_permission_form_method(settings_conf=settings) 676 | models.swap_users_with_permission_model_method( 677 | AutomationTaskModel, settings_conf=settings 678 | ) 679 | 680 | atm = FormTest(autorun=False) 681 | atm.form._user = dict(id=self.user.id) # Fake User 682 | 683 | form_method = inspect.getsource(atm.form.get_users_with_permission) 684 | self.assertIn("ABC", form_method) 685 | 686 | model_method = inspect.getsource(AutomationTaskModel.get_users_with_permission) 687 | self.assertIn("XYZ", model_method) 688 | 689 | 690 | class AutomationReprTest(TestCase): 691 | def test_automation_repr(self): 692 | class TinyAutomation(flow.Automation): 693 | start = flow.Execute(this.init) 694 | intermediate = flow.Execute(this.end) 695 | end = flow.End() 696 | 697 | def init(self, task, *args, **kwargs): 698 | print("Hello world!") 699 | 700 | automation_dict = str(TinyAutomation.__dict__) 701 | 702 | self.assertIn( 703 | "'__module__': 'automations.tests.test_automations'", automation_dict 704 | ) 705 | self.assertIn("'start': ", automation_dict) 706 | self.assertIn("'intermediate': ", automation_dict) 707 | self.assertIn("'end': ", automation_dict) 708 | self.assertIn( 709 | "'init': ", views.TaskView.as_view(), name="task"), 12 | path("errors", views.AutomationErrorsView.as_view(), name="error_report"), 13 | path("dashboard", views.TaskDashboardView.as_view(), name="dashboard"), 14 | path( 15 | "dashboard/", 16 | views.AutomationHistoryView.as_view(), 17 | name="history", 18 | ), 19 | path( 20 | "dashboard//traceback/", 21 | views.AutomationTracebackView.as_view(), 22 | name="traceback", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/automations/views.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | # Create your views here. 4 | import datetime 5 | import urllib.parse 6 | 7 | from django.contrib.auth.mixins import ( 8 | LoginRequiredMixin, 9 | PermissionRequiredMixin, 10 | UserPassesTestMixin, 11 | ) 12 | from django.core.exceptions import PermissionDenied 13 | from django.db.models import Q 14 | from django.forms import BaseForm 15 | from django.http import Http404 16 | from django.shortcuts import get_object_or_404, redirect 17 | from django.urls import reverse 18 | from django.utils.timezone import now 19 | from django.utils.translation import gettext as _ 20 | from django.views.generic import FormView, TemplateView 21 | 22 | from . import flow, models, settings 23 | 24 | 25 | class AutomationMixin: 26 | _automation_instance = None # Buffer class for specific task_instance 27 | _task_id = None 28 | 29 | def get_automation_instance(self, task): 30 | if self._automation_instance is None or self._task_id != task.id: 31 | self._automation_instance = task.automation.instance 32 | self._task_id = task.id 33 | return self._automation_instance 34 | 35 | 36 | class TaskView(LoginRequiredMixin, AutomationMixin, FormView): 37 | def bind_to_node(self): 38 | self.task = get_object_or_404( 39 | models.AutomationTaskModel, id=self.kwargs["task_id"] 40 | ) 41 | self.node = self.task.get_node() 42 | 43 | def get_form_kwargs(self): 44 | assert hasattr(self, "node"), "Not bound to node" 45 | kwargs = super().get_form_kwargs() 46 | task_kwargs = self.node._form_kwargs 47 | kwargs.update(task_kwargs(self.task) if callable(task_kwargs) else task_kwargs) 48 | return kwargs 49 | 50 | def get_form_class(self): 51 | if not hasattr(self, "node"): 52 | self.bind_to_node() 53 | form = self.node._form 54 | return form if issubclass(form, BaseForm) else form(self.task) 55 | 56 | def get_context_data(self, **kwargs): 57 | if not hasattr(self, "node"): 58 | self.bind_to_node() 59 | if not isinstance(self.node, flow.Form): 60 | raise Http404 61 | if self.request.user not in self.task.get_users_with_permission(): 62 | raise PermissionDenied 63 | if self.task.finished: 64 | raise Http404 # Need to display a message: s.o. else has completed form 65 | self.template_name = self.node._template_name or getattr( 66 | self.get_automation_instance(self.task), 67 | "default_template_name", 68 | "automations/form_view.html", 69 | ) 70 | context = super().get_context_data(**kwargs) 71 | context.update(settings.FORM_VIEW_CONTEXT) 72 | context.update(getattr(self.node._automation, "context", dict())) 73 | context.update(self.node._context) 74 | return context 75 | 76 | def form_valid(self, form): 77 | self.node.is_valid(self.task, self.request, form) 78 | if self.node._run: 79 | self.node._automation.run(self.task.previous, self.node) 80 | if getattr(self.node, "_success_url", None): 81 | return redirect(self.node._success_url) 82 | elif "back" in self.request.GET: 83 | url = urllib.parse.urlparse( 84 | self.request.GET.get("back") 85 | ) # prevent redirect 86 | this_site = urllib.parse.urlunparse( 87 | ("", "", url.path, url.params, url.query, url.fragment) 88 | ) 89 | return redirect(this_site) 90 | return super().form_valid(form) 91 | 92 | def get_success_url(self): 93 | return reverse("automations:task_list") 94 | 95 | 96 | class TaskListView(LoginRequiredMixin, TemplateView): 97 | template_name = "automations/task_list.html" 98 | 99 | def get_context_data(self, **kwargs): 100 | qs = models.AutomationTaskModel.get_open_tasks(self.request.user) 101 | return dict(error="", tasks=qs, count=len(qs)) 102 | 103 | 104 | class UserIsStaff(LoginRequiredMixin, UserPassesTestMixin): 105 | def test_func(self): 106 | return self.request.user.is_staff 107 | 108 | 109 | class TaskDashboardView(PermissionRequiredMixin, TemplateView): 110 | permission_required = ( 111 | "automations.view_automationmodel", 112 | "automations.view_automationtaskmodel", 113 | ) 114 | template_name = "automations/dashboard.html" 115 | 116 | def get_context_data(self, **kwargs): 117 | days = self.request.GET.get("history", "") 118 | days = int(days) if days.isnumeric() else 30 119 | if days > 0: 120 | qs = models.AutomationModel.objects.filter( 121 | Q(created__gte=now() - datetime.timedelta(days=days)) 122 | | Q(finished=False) # Not older than days # or still runnning 123 | ).order_by("-created") 124 | else: 125 | qs = models.AutomationModel.objects.order_by("-created") 126 | automations = [] 127 | for item in ( 128 | qs.order_by("automation_class").values("automation_class").distinct() 129 | ): 130 | qs_filtered = qs.filter(**item) 131 | try: 132 | automation = models.get_automation_class(item["automation_class"]) 133 | verbose_name = automation.get_verbose_name() 134 | verbose_name_plural = automation.get_verbose_name_plural() 135 | dashboard_template = getattr( 136 | getattr(automation, "Meta", None), "dashboard_template", "" 137 | ) 138 | dashboard = ( 139 | automation.get_dashboard_context(qs_filtered) 140 | if hasattr(automation, "get_dashboard_context") 141 | else dict() 142 | ) 143 | except AttributeError: 144 | verbose_name = ( 145 | _("Obsolete automation %s") 146 | % item["automation_class"].rsplit(".")[-1] 147 | ) 148 | verbose_name_plural = ( 149 | _("Obsolete automations %s") 150 | % item["automation_class"].rsplit(".")[-1] 151 | ) 152 | dashboard_template = "" 153 | dashboard = dict() 154 | if dashboard_template is not None: 155 | automations.append( 156 | dict( 157 | cls=item["automation_class"], 158 | verbose_name=verbose_name, 159 | verbose_name_plural=verbose_name_plural, 160 | running=qs_filtered.filter(finished=False), 161 | finished=qs_filtered.filter(finished=True), 162 | dashboard_template=dashboard_template, 163 | dashboard=dashboard, 164 | ) 165 | ) 166 | return dict(automations=automations, timespan=_("Last %d days") % days) 167 | 168 | 169 | class AutomationHistoryView(PermissionRequiredMixin, TemplateView): 170 | permission_required = ( 171 | "automations.change_automationmodel", 172 | "automations.change_automationtaskmodel", 173 | ) 174 | template_name = "automations/history.html" 175 | 176 | def build_tree(self, task): 177 | result = [] 178 | tasks = [task] 179 | while tasks: 180 | if len(tasks) > 1: # Split 181 | lst = [] 182 | for tsk in tasks: 183 | sub_tree, next_task = self.build_tree(tsk) 184 | lst.append(sub_tree) 185 | result.append(lst) 186 | if next_task: 187 | result.append(next_task) 188 | tasks = next_task.get_next_tasks() if next_task else [] 189 | else: 190 | task = tasks[0] 191 | if task.message == "Joined": # Closed Join 192 | return result, task 193 | result.append(task) 194 | tasks = task.get_next_tasks() 195 | return result, None 196 | 197 | def get_context_data(self, **kwargs): 198 | assert "automation_id" in kwargs 199 | automation = get_object_or_404( 200 | models.AutomationModel, id=kwargs.get("automation_id") 201 | ) 202 | task = automation.automationtaskmodel_set.get(previous=None) 203 | tasks, _ = self.build_tree(task) 204 | return dict( 205 | automation=automation, 206 | tasks=tasks, 207 | ) 208 | 209 | 210 | class AutomationTracebackView(PermissionRequiredMixin, TemplateView): 211 | permission_required = ( 212 | "automations.change_automationmodel", 213 | "automations.change_automationtaskmodel", 214 | ) 215 | template_name = "automations/traceback.html" 216 | 217 | def get_context_data(self, **kwargs): 218 | assert "automation_id" in kwargs 219 | assert "task_id" in kwargs 220 | automation = get_object_or_404( 221 | models.AutomationModel, id=kwargs.get("automation_id") 222 | ) 223 | task = get_object_or_404(models.AutomationTaskModel, id=kwargs.get("task_id")) 224 | if task.automation != automation: 225 | raise Http404() 226 | if isinstance(task.result, dict): 227 | return dict( 228 | automation=automation, 229 | error=task.result.get("error", None), 230 | html=task.result.get("html", None), 231 | ) 232 | return dict() 233 | 234 | 235 | class AutomationErrorsView(PermissionRequiredMixin, TemplateView): 236 | permission_required = ( 237 | "automations.change_automationmodel", 238 | "automations.change_automationtaskmodel", 239 | ) 240 | template_name = "automations/error_report.html" 241 | 242 | def get_context_data(self, **kwargs): 243 | tasks = models.AutomationTaskModel.objects.filter(message__contains="Error") 244 | automations = [] 245 | done = [] 246 | for task in tasks: 247 | if task.automation.id not in done: 248 | done.append(task.automation.id) 249 | automations.append( 250 | (task.automation, tasks.filter(automation=task.automation)) 251 | ) 252 | return dict(automations=automations) 253 | -------------------------------------------------------------------------------- /src/django_automations.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: django-automations 3 | Version: 0.9.4.1 4 | Summary: Processes and automations for your Django project 5 | Home-page: https://github.com/fsbraun/django-automations 6 | Author: Fabian Braun 7 | Author-email: fsbraun@gmx.de 8 | Project-URL: Bug Tracker, https://github.com/fsbraun/django-automations/issues 9 | Project-URL: Documentation, https://django-automations.readthedocs.io/en/latest/ 10 | Keywords: django_automations,workflow,automation 11 | Classifier: Development Status :: 5 - Production/Stable 12 | Classifier: Programming Language :: Python 13 | Classifier: Programming Language :: Python :: 3.7 14 | Classifier: Programming Language :: Python :: 3.8 15 | Classifier: Programming Language :: Python :: 3.9 16 | Classifier: Programming Language :: Python :: 3.10 17 | Classifier: Framework :: Django 18 | Classifier: Framework :: Django :: 3.0 19 | Classifier: Framework :: Django :: 3.1 20 | Classifier: Framework :: Django :: 3.2 21 | Classifier: License :: OSI Approved :: MIT License 22 | Classifier: Operating System :: OS Independent 23 | Requires-Python: >=3.7 24 | Description-Content-Type: text/markdown 25 | License-File: LICENSE 26 | 27 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-automations) 28 | ![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-automations) 29 | ![GitHub](https://img.shields.io/github/license/fsbraun/django-automations) 30 | 31 | [![PyPI](https://img.shields.io/pypi/v/django-automations)](https://pypi.org/project/django-automations/) 32 | [![Read the Docs](https://img.shields.io/readthedocs/django-automations)](https://django-automations.readthedocs.io/en/latest/) 33 | [![codecov](https://codecov.io/gh/fsbraun/django-automations/branch/master/graph/badge.svg?token=DSA28NEFL9)](https://codecov.io/gh/fsbraun/django-automations) 34 | 35 | # Django-automations 36 | 37 | A lightweight framework to collect all processes of your django app in one place. 38 | 39 | Use cases: 40 | 41 | * Marketing automations, customer journeys 42 | * Simple business processes which require user interactions 43 | * Running regular tasks 44 | 45 | Django-automations works with plain Django but also integrates with Django-CMS. 46 | 47 | ## Key features 48 | 49 | * Describe automations as python classes 50 | 51 | * Bind automations to models from other Django apps 52 | 53 | * Use Django forms for user interaction 54 | 55 | * Create transparency through extendable dashboard 56 | 57 | * Declare automations as unique or unique for a certain data set 58 | 59 | * Start automations on signals or when, e.g., user visits a page 60 | 61 | * Send messages between automations 62 | 63 | ## Requirements 64 | 65 | * **Python**: 3.7, 3.8, 3.9, 3.10 66 | * **Django**: 3.0, 3.1, 3.2 67 | 68 | ## Feedback 69 | 70 | This project is in a early stage. All feedback is welcome! Please mail me at fsbraun(at)gmx.de 71 | 72 | # Installation 73 | 74 | Install the package from PyPI: 75 | 76 | pip install django-automations 77 | 78 | Add `automations` to your installed apps in `settings.py`: 79 | 80 | INSTALLED_APPS = ( 81 | ..., 82 | 'automations', 83 | 'automations.cms_automations', # ONLY IF YOU USE DJANGO-CMS! 84 | ) 85 | 86 | Only include the "sub app" `automations.cms_automations` if you are using Django CMS. 87 | 88 | The last step is to run the necessary migrations using the `manage.py` command: 89 | 90 | python manage.py migrate automations 91 | 92 | 93 | # Usage 94 | 95 | The basic idea is to add an automation layer to Django's model, view, template structure. The automation layer collects 96 | in one place all business processes which in a Django app often are distributed across models, views and any glue code. 97 | 98 | **Automations** consist of **tasks** which are carried out one after another. **Modifiers** affect, e.g. when a task is 99 | carried out. 100 | 101 | from automations import flow 102 | from automations.flow import Automation 103 | from automations.flow import this 104 | 105 | # "this" can be used in a class definition as a replacement for "self" 106 | 107 | from . import forms 108 | 109 | class ProcessInput(Automation): 110 | """The process steps are defined by sequentially adding the corresponding nodes""" 111 | start = flow.Execute(this.get_user_input) # Collect input a user has supplied 112 | check = flow.If( 113 | this.does_not_need_approval # Need approval? 114 | ).Then(this.process) # No? Continue later 115 | approval = flow.Form(forms.ApprovalForm).Group(name="admins") # Let admins approve 116 | process = flow.Execute(this.process_input) # Generate output 117 | end = flow.End() 118 | 119 | critical = 10_000 120 | 121 | def get_user_input(task_instance): 122 | ... 123 | 124 | def does_not_need_approval(task_instance): 125 | return not (task_instance.data['amount'] > self.critical) 126 | 127 | def process_input(task_instance): 128 | ... 129 | 130 | # Documentation 131 | 132 | See the [documentation on readthedocs.io](https://django-automations.readthedocs.io/). 133 | -------------------------------------------------------------------------------- /src/django_automations.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | LICENSE 2 | MANIFEST.in 3 | README.md 4 | pyproject.toml 5 | setup.cfg 6 | src/automations/__init__.py 7 | src/automations/admin.py 8 | src/automations/apps.py 9 | src/automations/flow.py 10 | src/automations/models.py 11 | src/automations/settings.py 12 | src/automations/urls.py 13 | src/automations/views.py 14 | src/automations/cms_automations/__init__.py 15 | src/automations/cms_automations/admin.py 16 | src/automations/cms_automations/apps.py 17 | src/automations/cms_automations/cms_apps.py 18 | src/automations/cms_automations/cms_plugins.py 19 | src/automations/cms_automations/models.py 20 | src/automations/cms_automations/tests.py 21 | src/automations/cms_automations/views.py 22 | src/automations/cms_automations/locale/de_DE/LC_MESSAGES/django.mo 23 | src/automations/cms_automations/locale/de_DE/LC_MESSAGES/django.po 24 | src/automations/cms_automations/migrations/0001_initial.py 25 | src/automations/cms_automations/migrations/0002_auto_20210506_1957.py 26 | src/automations/cms_automations/migrations/0003_auto_20210511_0825.py 27 | src/automations/cms_automations/migrations/0004_auto_20210511_1042.py 28 | src/automations/cms_automations/migrations/0005_auto_20211121_1838.py 29 | src/automations/cms_automations/migrations/0006_auto_20220125_1002.py 30 | src/automations/cms_automations/migrations/__init__.py 31 | src/automations/cms_automations/templates/automations/cms/empty_template.html 32 | src/automations/locale/de_DE/LC_MESSAGES/django.mo 33 | src/automations/locale/de_DE/LC_MESSAGES/django.po 34 | src/automations/management/__init__.py 35 | src/automations/management/commands/__init__.py 36 | src/automations/management/commands/automation_delete_history.py 37 | src/automations/management/commands/automation_step.py 38 | src/automations/migrations/0001_initial.py 39 | src/automations/migrations/0002_auto_20210506_1957.py 40 | src/automations/migrations/0003_auto_20210511_0825.py 41 | src/automations/migrations/0004_auto_20210511_1042.py 42 | src/automations/migrations/0005_automationmodel_key.py 43 | src/automations/migrations/0006_auto_20211121_1357.py 44 | src/automations/migrations/0007_auto_20220125_1002.py 45 | src/automations/migrations/__init__.py 46 | src/automations/templates/base.html 47 | src/automations/templates/automations/base.html 48 | src/automations/templates/automations/dashboard.html 49 | src/automations/templates/automations/error_report.html 50 | src/automations/templates/automations/form_view.html 51 | src/automations/templates/automations/history.html 52 | src/automations/templates/automations/preformatted_traceback.html 53 | src/automations/templates/automations/task_list.html 54 | src/automations/templates/automations/traceback.html 55 | src/automations/templates/automations/includes/dashboard.html 56 | src/automations/templates/automations/includes/dashboard_error.html 57 | src/automations/templates/automations/includes/dashboard_item.html 58 | src/automations/templates/automations/includes/form_view.html 59 | src/automations/templates/automations/includes/history.html 60 | src/automations/templates/automations/includes/history_item.html 61 | src/automations/templates/automations/includes/task_item.html 62 | src/automations/templates/automations/includes/task_list.html 63 | src/automations/templatetags/atm_tags.py 64 | src/automations/tests/__init__.py 65 | src/automations/tests/methods.py 66 | src/automations/tests/models.py 67 | src/automations/tests/test_automations.py 68 | src/automations/tests/urls.py 69 | src/django_automations.egg-info/PKG-INFO 70 | src/django_automations.egg-info/SOURCES.txt 71 | src/django_automations.egg-info/dependency_links.txt 72 | src/django_automations.egg-info/requires.txt 73 | src/django_automations.egg-info/top_level.txt -------------------------------------------------------------------------------- /src/django_automations.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/django_automations.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | Django>=3.0 2 | -------------------------------------------------------------------------------- /src/django_automations.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | automations 2 | --------------------------------------------------------------------------------