├── .coveragerc ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.rst ├── docs ├── Makefile ├── _static │ └── demo-flow.png ├── conf.py ├── core_concepts.rst ├── example.rst ├── index.rst ├── install.rst ├── make.bat └── settings.rst ├── lbworkflow ├── __init__.py ├── admin.py ├── apps.py ├── core │ ├── __init__.py │ ├── datahelper.py │ ├── exceptions.py │ ├── helper.py │ ├── sendmsg.py │ ├── transition.py │ └── userparser.py ├── flowgen │ ├── __init__.py │ └── app_template │ │ ├── __init__.py-tpl │ │ ├── admin.py-tpl │ │ ├── forms.py-tpl │ │ ├── templates │ │ └── app_name │ │ │ ├── detail.html-tpl │ │ │ ├── form.html-tpl │ │ │ ├── inc_detail_info.html-tpl │ │ │ ├── list.html-tpl │ │ │ └── print.html-tpl │ │ ├── views.py-tpl │ │ ├── wf_views.py-tpl │ │ └── wfdata.py-tpl ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20171019_0549.py │ ├── 0003_auto_20200221_0438.py │ ├── 0004_processreportlink_uuid.py │ ├── 0005_auto_20211217_0304.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── config.py │ └── runtime.py ├── settings.py ├── simplewf │ ├── __init__.py │ ├── admin.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_simpleworkflow_id.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── simplewf │ │ │ ├── detail.html │ │ │ ├── form.html │ │ │ ├── inc_detail_info.html │ │ │ ├── list.html │ │ │ └── print.html │ ├── views.py │ └── wfdata.py ├── static │ ├── css │ │ └── lbworkflow.css │ └── js │ │ └── lbworkflow.js ├── templates │ └── lbworkflow │ │ ├── base.html │ │ ├── base_ext.html │ │ ├── base_form.html │ │ ├── base_formset.html │ │ ├── batch_transition_form.html │ │ ├── do_transition_form.html │ │ ├── flowchart.html │ │ ├── inc_wf_btns.html │ │ ├── inc_wf_history.html │ │ ├── inc_wf_status.html │ │ ├── list_wf.html │ │ ├── my_wf.html │ │ ├── report_list.html │ │ ├── start_wf.html │ │ ├── todo.html │ │ └── wf_base_detail.html ├── templatetags │ ├── __init__.py │ └── lbworkflow_tags.py ├── tests │ ├── __init__.py │ ├── issue │ │ ├── __init__.py │ │ ├── models.py │ │ └── wfdata.py │ ├── leave │ │ ├── __init__.py │ │ ├── forms.py │ │ ├── models.py │ │ ├── templates │ │ │ ├── base.html │ │ │ ├── base_ext.html │ │ │ ├── base_form.html │ │ │ ├── base_formset.html │ │ │ └── leave │ │ │ │ ├── detail.html │ │ │ │ ├── form.html │ │ │ │ ├── inc_detail_info.html │ │ │ │ ├── list.html │ │ │ │ └── print.html │ │ ├── views.py │ │ ├── wf_views.py │ │ └── wfdata.py │ ├── permissions.py │ ├── purchase │ │ ├── __init__.py │ │ ├── models.py │ │ └── wfdata.py │ ├── settings.py │ ├── test_base.py │ ├── test_flowchart.py │ ├── test_flowgen.py │ ├── test_models.py │ ├── test_permissions.py │ ├── test_process.py │ ├── test_simplewf.py │ ├── test_transition.py │ ├── test_userparser.py │ ├── test_views_list.py │ ├── urls.py │ └── wfdata.py ├── urls.py ├── views │ ├── __init__.py │ ├── flowchart.py │ ├── forms.py │ ├── generics.py │ ├── helper.py │ ├── list.py │ ├── permissions.py │ ├── processinstance.py │ └── transition.py └── wfdata.py ├── package.json ├── pyproject.toml ├── requirements ├── requirements-optionals.txt └── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── testproject ├── manage.py ├── testproject │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── wfgen.py ├── tox.ini └── yarn.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = lbworkflow 4 | omit = 5 | lbworkflow/tests/* 6 | */migrations/* 7 | 8 | [report] 9 | show_missing = True 10 | skip_covered = True 11 | omit = 12 | lbworkflow/tests/* 13 | */migrations/* 14 | 15 | [html] 16 | directory = coverage_html 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3 2 | vendor/ 3 | bower_components/ 4 | py3env/ 5 | lbattachments/ 6 | node_modules/ 7 | *.swp 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | env/ 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *,cover 55 | .hypothesis/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # dotenv 91 | .env 92 | 93 | # virtualenv 94 | .venv 95 | venv/ 96 | ENV/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # idea 105 | .idea/ 106 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^(coverage_html/|image_output/|models/|videos/) 2 | repos: 3 | - repo: git://github.com/pre-commit/pre-commit-hooks 4 | rev: v3.4.0 5 | hooks: 6 | - id: check-case-conflict 7 | - id: check-merge-conflict 8 | - id: check-symlinks 9 | - id: check-xml 10 | - id: check-yaml 11 | - id: detect-private-key 12 | - id: trailing-whitespace 13 | - id: debug-statements 14 | - id: end-of-file-fixer 15 | 16 | - repo: https://github.com/ambv/black 17 | rev: 20.8b1 18 | hooks: 19 | - id: black 20 | language_version: python3.8 21 | - repo: https://gitlab.com/pycqa/flake8 22 | rev: 3.9.0 23 | hooks: 24 | - id: flake8 25 | args: ['--config=setup.cfg'] 26 | additional_dependencies: [flake8-isort] 27 | types: [python] 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: bionic 3 | 4 | cache: pip 5 | 6 | install: 7 | - pip install --upgrade pip setuptools tox virtualenv coveralls 8 | - npm install -g bower 9 | 10 | script: 11 | - tox 12 | 13 | notifications: 14 | email: false 15 | 16 | matrix: 17 | include: 18 | # Linting 19 | - python: 3.7 20 | env: TOXENV=flake8 21 | - python: 3.7 22 | env: TOXENV=isort 23 | - python: 3.7 24 | env: TOXENV=docs 25 | 26 | # Tests 27 | - python: 3.5 28 | env: TOXENV=py35-django2x 29 | - python: 3.6 30 | env: TOXENV=py36-django2x 31 | - python: 3.7 32 | env: TOXENV=py37-django2x 33 | 34 | - python: 3.6 35 | env: TOXENV=py36-django30 36 | - python: 3.7 37 | env: TOXENV=py37-django30 38 | - python: 3.8 39 | env: TOXENV=py38-django30 40 | 41 | # Future (Should be in `allow_failures`) 42 | - python: 3.8 43 | env: TOXENV=py38-django_trunk 44 | allow_failures: 45 | - env: TOXENV=py38-django_trunk 46 | 47 | after_success: coveralls 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | MAINTAINER vicalloy "https://github.com/vicalloy" 3 | 4 | RUN apt-get update && apt-get install -y \ 5 | npm \ 6 | pkg-config \ 7 | --no-install-recommends && \ 8 | rm -rf /var/lib/apt/lists/* && \ 9 | npm install -g yarn 10 | 11 | RUN pip install --upgrade pip setuptools pipenv 12 | 13 | RUN mkdir /app 14 | WORKDIR /app 15 | 16 | COPY ./ ./ 17 | RUN yarn install 18 | RUN pipenv install -d --skip-lock --system 19 | 20 | RUN make wfgen 21 | RUN make reload_test_data 22 | 23 | EXPOSE 9000 24 | CMD ["make", "run"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 vicalloy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | 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. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.rst 3 | include LICENSE 4 | recursive-include lbworkflow/static * 5 | recursive-include lbworkflow/locale * 6 | recursive-include lbworkflow/templates * 7 | recursive-include lbworkflow/simplewf/templates * 8 | recursive-include lbworkflow/flowgen/app_template * 9 | global-exclude __pycache__ 10 | global-exclude *.py[co] 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | cd testproject;python manage.py runserver 0.0.0.0:9000 3 | 4 | pyenv: 5 | pip install pipenv --upgrade 6 | pipenv --python 3 7 | pipenv install -d --skip-lock 8 | pipenv shell 9 | 10 | black: 11 | black ./ 12 | 13 | test: 14 | coverage run ./runtests.py 15 | 16 | isort: 17 | isort ./lbworkflow 18 | 19 | upload: 20 | python setup.py sdist --formats=gztar upload 21 | 22 | wfgen: 23 | python testproject/wfgen.py 24 | 25 | wfgen_clean: 26 | python testproject/wfgen.py clean 27 | 28 | reload_test_data: 29 | cd testproject;python manage.py callfunc lbworkflow.wfdata.load_data 30 | cd testproject;python manage.py callfunc lbworkflow.simplewf.wfdata.load_data 31 | cd testproject;python manage.py callfunc lbworkflow.tests.wfdata.load_data 32 | cd testproject;python manage.py callfunc lbworkflow.tests.leave.wfdata.load_data 33 | cd testproject;python manage.py callfunc lbworkflow.tests.issue.wfdata.load_data 34 | cd testproject;python manage.py callfunc lbworkflow.tests.purchase.wfdata.load_data 35 | 36 | build_docker_image: 37 | docker build -t lbworkflow . 38 | 39 | create_docker_container: 40 | docker run -d -p 9000:9000 --name lbworkflow lbworkflow 41 | 42 | install-pre-commit: 43 | pre-commit install 44 | pre-commit run --all-files 45 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django-lb-workflow = {editable=true, extras=["options"], path = "."} 8 | 9 | [dev-packages] 10 | coverage = "*" 11 | flake8 = "==3.7.9" 12 | isort = "*" 13 | pre-commit = "*" 14 | black = "*" 15 | 16 | [pipenv] 17 | allow_prereleases = true 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-lb-workflow 2 | ================== 3 | 4 | .. image:: https://secure.travis-ci.org/vicalloy/django-lb-workflow.svg?branch=master 5 | :target: http://travis-ci.org/vicalloy/django-lb-workflow 6 | 7 | .. image:: https://coveralls.io/repos/github/vicalloy/django-lb-workflow/badge.svg?branch=master 8 | :target: https://coveralls.io/github/vicalloy/django-lb-workflow?branch=master 9 | 10 | Reusable workflow library for Django. 11 | 12 | ``django-lb-workflow`` supports Django 2.20+ on Python 3.5+. 13 | 14 | .. image:: https://github.com/vicalloy/django-lb-workflow/raw/master/docs/_static/demo-flow.png 15 | 16 | Demo site 17 | --------- 18 | 19 | Demo site: http://wf.haoluobo.com/ 20 | 21 | username: ``admin`` password: ``$password`` 22 | 23 | Switch to another user: http://wf.haoluobo.com/impersonate/search 24 | 25 | Stop switch: http://wf.haoluobo.com/impersonate/stop 26 | 27 | The code of demo site 28 | --------------------- 29 | 30 | Carrot Box: https://github.com/vicalloy/carrot-box/ 31 | 32 | It's a workflow platform, you can start a new project with it. 33 | 34 | 35 | Documentation 36 | ------------- 37 | 38 | Read the official docs here: http://django-lb-workflow.readthedocs.io/en/latest/ 39 | 40 | 41 | Installation 42 | ------------ 43 | 44 | Workflow is on PyPI so all you need is: :: 45 | 46 | pip install django-lb-workflow 47 | 48 | Pipenv 49 | ------ 50 | 51 | Install pipenv and create a virtualenv: :: 52 | 53 | pip3 install pipenv 54 | make pyenv 55 | 56 | Spawns a shell within the virtualenv: :: 57 | 58 | pipenv shell 59 | 60 | Testing 61 | ------- 62 | 63 | Running the test suite is as simple as: :: 64 | 65 | make test 66 | 67 | Run test project 68 | ---------------- 69 | 70 | Running the test project is as simple as: :: 71 | 72 | npm install 73 | python testproject/wfgen.py 74 | make run 75 | 76 | Demo for create a new flow 77 | -------------------------- 78 | 79 | You can find demo code in ``lbworkflow/tests/leave``. 80 | 81 | ``testproject/wfgen.py`` is a demo for how to generate base code for a flow. The model for this flow is in ``/lbworkflow/tests/issue``. 82 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = django-lb-workflow 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 | -------------------------------------------------------------------------------- /docs/_static/demo-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/docs/_static/demo-flow.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # django-lb-workflow documentation build configuration file, created by 5 | # sphinx-quickstart on Mon May 1 20:04:08 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = ".rst" 44 | 45 | # The master toctree document. 46 | master_doc = "index" 47 | 48 | # General information about the project. 49 | project = "django-lb-workflow" 50 | copyright = "2017, vicalloy" 51 | author = "vicalloy" 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = "" 59 | # The full version, including alpha/beta/rc tags. 60 | release = "" 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = "sphinx" 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ---------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = "alabaster" 87 | 88 | # Theme options are theme-specific and customize the look and feel of a theme 89 | # further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | html_static_path = ["_static"] 98 | 99 | 100 | # -- Options for HTMLHelp output ------------------------------------------ 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = "django-lb-workflowdoc" 104 | 105 | 106 | # -- Options for LaTeX output --------------------------------------------- 107 | 108 | latex_elements = { 109 | # The paper size ('letterpaper' or 'a4paper'). 110 | # 111 | # 'papersize': 'letterpaper', 112 | # The font size ('10pt', '11pt' or '12pt'). 113 | # 114 | # 'pointsize': '10pt', 115 | # Additional stuff for the LaTeX preamble. 116 | # 117 | # 'preamble': '', 118 | # Latex figure (float) alignment 119 | # 120 | # 'figure_align': 'htbp', 121 | } 122 | 123 | # Grouping the document tree into LaTeX files. List of tuples 124 | # (source start file, target name, title, 125 | # author, documentclass [howto, manual, or own class]). 126 | latex_documents = [ 127 | ( 128 | master_doc, 129 | "django-lb-workflow.tex", 130 | "django-lb-workflow Documentation", 131 | "vicalloy", 132 | "manual", 133 | ), 134 | ] 135 | 136 | 137 | # -- Options for manual page output --------------------------------------- 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | ( 143 | master_doc, 144 | "django-lb-workflow", 145 | "django-lb-workflow Documentation", 146 | [author], 147 | 1, 148 | ) 149 | ] 150 | 151 | 152 | # -- Options for Texinfo output ------------------------------------------- 153 | 154 | # Grouping the document tree into Texinfo files. List of tuples 155 | # (source start file, target name, title, author, 156 | # dir menu entry, description, category) 157 | texinfo_documents = [ 158 | ( 159 | master_doc, 160 | "django-lb-workflow", 161 | "django-lb-workflow Documentation", 162 | author, 163 | "django-lb-workflow", 164 | "One line description of project.", 165 | "Miscellaneous", 166 | ), 167 | ] 168 | -------------------------------------------------------------------------------- /docs/core_concepts.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Core concepts 3 | ============= 4 | 5 | .. _`core_concepts`: 6 | 7 | ``django-lb-workflow`` is ``Activity-Based Workflow``. 8 | Activity-based workflow systems have workflow processes comprised of activities 9 | to be completed in order to accomplish a goal. 10 | 11 | .. image:: _static/demo-flow.png 12 | 13 | Half Config 14 | ----------- 15 | 16 | ``django-lb-workflow`` is ``half config``. 17 | 18 | - ``Data model``/``action``/``Layout of form`` is written by code. 19 | - They are too complex to config and the change is not too often. 20 | - The node(activity) and transition is configurable. 21 | - The pattern is clear and the change is often. 22 | 23 | Data model 24 | ---------- 25 | 26 | Config 27 | ###### 28 | 29 | **Process** 30 | 31 | A process holds the map that describes the flow of work. 32 | 33 | The process map is made of nodes and transitions. The instances you create on the 34 | map will begin the flow in the draft node. Instances can be moved forward from node 35 | to node, going through transitions, until they reach the end node. 36 | 37 | **Node** 38 | 39 | Node is the states of an instance. 40 | 41 | **Transition** 42 | 43 | A Transition connects two node: a From and a To activity. 44 | 45 | Since the transition is oriented you can think at it as being a 46 | link starting from the From and ending in the To node. 47 | Linking the nodes in your process you will be able to draw the map. 48 | 49 | Each transition can have a condition that will be tested 50 | whether this transition is available. 51 | 52 | Each transition is associated to a app that define an action to perform. 53 | 54 | **App** 55 | 56 | An application is a python view that can be called by URL. 57 | 58 | Runtime 59 | ####### 60 | 61 | **ProcessInstance** 62 | 63 | A process instance is created when someone decides to do something, 64 | and doing this thing means start using a process defined in ``django-lb-workflow``. 65 | That's why it is called "process instance". The process is a class 66 | (=the definition of the process), and each time you want to 67 | "do what is defined in this process", that means you want to create 68 | an INSTANCE of this process. 69 | 70 | So from this point of view, an instance represents your dynamic 71 | part of a process. While the process definition contains the map 72 | of the workflow, the instance stores your usage, your history, 73 | your state of this process. 74 | 75 | **Task** 76 | 77 | A task object represents a task you are performing. 78 | 79 | **Event** 80 | 81 | A task perform log. 82 | 83 | **BaseWFObj** 84 | 85 | A abstract class for flow model. Every flow model should inherit from it. 86 | 87 | 88 | User Parser 89 | ----------- 90 | 91 | ``django-lb-workflow`` use a text field to config users for ``Node`` 92 | and user a parser to cover it to Django model. The default parser is 93 | ``lbworkflow.core.userparser.SimpleUserParser``. You can replace it with your implement. 94 | 95 | 96 | Views and Forms 97 | --------------- 98 | 99 | ``django-lb-workflow`` provide a set of views and forms to customized flow. 100 | 101 | Classes for create/edit/list process instance is in ``lbworkflow/views/generics.py``. 102 | 103 | Classes for customize transition is in ``lbworkflow/views/transition.py``. 104 | 105 | Classes for customize form is in ``lbworkflow/views/forms.py``. 106 | 107 | url provide by ``django-lb-workflow`` 108 | ##################################### 109 | 110 | you can find all url in ``lbworkflow/urls.py`` 111 | 112 | - Main entrance. 113 | - ``wf_todo`` List tasks that need current user to process. 114 | - ``wf_my_wf`` List processes that current user submitted. 115 | - ``wf_start_wf`` List the processes that current user can submit. 116 | - ``wf_report_list`` Each process have a default report. This url will list all report link. 117 | - Flow 118 | - ``wf_new [wf_code]`` Submit a new process. ``wf_code`` used to specify which process to submit. 119 | - ``wf_edit [pk]`` Edit a process. 120 | - ``wf_delete`` Delete a process. 121 | - ``wf_list [wf_code]`` Default report for a process. ``wf_code`` used to specify the process. 122 | - ``wf_detail [pk]`` Display the detail information for a process. 123 | - ``wf_print_detail [pk]`` A page to display process information used for print. 124 | - Actions(App) 125 | - ``wf_agree`` Agree a process. 126 | - ``wf_back_to`` Rollback process to previous node. 127 | - ``wf_reject`` Reject a process. 128 | - ``wf_give_up`` Give up a process. 129 | - ``wf_batch_agree`` 130 | - ``wf_batch_reject`` 131 | - ``wf_batch_give_up`` 132 | - ``wf_execute_transition`` Execute a transition for a process. 133 | - ``wf_execute_transition [wf_code] [trans_func]`` Execute a transition for a process with customize function. 134 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://secure.travis-ci.org/vicalloy/django-lb-workflow.svg?branch=master 2 | :target: http://travis-ci.org/vicalloy/django-lb-workflow 3 | 4 | .. image:: https://coveralls.io/repos/github/vicalloy/django-lb-workflow/badge.svg?branch=master 5 | :target: https://coveralls.io/github/vicalloy/django-lb-workflow?branch=master 6 | 7 | 8 | What is django-lb-workflow 9 | ========================== 10 | 11 | ``django-lb-workflow`` is a reusable workflow library for Django. 12 | 13 | django-lb-workflow's source code hosted on `GitHub `_. 14 | 15 | .. image:: _static/demo-flow.png 16 | 17 | Demo site 18 | --------- 19 | 20 | Demo site: http://wf.haoluobo.com/ 21 | 22 | username: ``admin`` password: ``password`` 23 | 24 | Switch to another user: http://wf.haoluobo.com/impersonate/search 25 | 26 | Stop switch: http://wf.haoluobo.com/impersonate/stop 27 | 28 | Contents 29 | -------- 30 | 31 | .. toctree:: 32 | :maxdepth: 2 33 | 34 | install 35 | example 36 | core_concepts 37 | settings 38 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | .. _`install`: 6 | 7 | Requirements 8 | ------------ 9 | 10 | * python>=3.4 11 | * django>=1.10 12 | * jsonfield>=1.0.1 13 | * xlsxwriter>=0.9.6 14 | * jinja2>=2.9.6 15 | * django-lbutils>=1.0.3 16 | * django-lbattachment>=1.0.2 17 | * django-stronghold 18 | 19 | The following packages are optional: 20 | 21 | * django-compressor>=2.1.1 22 | * django-bower>=5.2.0 23 | * django-crispy-forms>=1.6 24 | * django-lb-adminlte>=0.9.4 25 | * django-el-pagination>=3.0.1 26 | * django-impersonate 27 | 28 | Installing django-lb-workflow 29 | ------------------------------ 30 | 31 | Install latest stable version into your python path using pip or easy_install:: 32 | 33 | pip install --upgrade django-lb-workflow 34 | 35 | If you want to install ``django-lb-workflow`` with all option requires:: 36 | 37 | pip install --upgrade django-lb-workflow[options] 38 | 39 | If you want to install development version (unstable), you can do so doing:: 40 | 41 | pip install git+git://github.com/vicalloy/django-lb-workflow.git#egg=django-lb-workflow 42 | 43 | Or, if you'd like to install the development version as a git repository (so 44 | you can ``git pull`` updates, use the ``-e`` flag with ``pip install``, like 45 | so:: 46 | 47 | pip install -e git+git://github.com/vicalloy/django-lb-workflow.git#egg=django-lb-workflow 48 | 49 | Add ``lbworkflow`` to your ``INSTALLED_APPS`` in settings.py:: 50 | 51 | INSTALLED_APPS = ( 52 | ... 53 | 'lbworkflow', 54 | ) 55 | 56 | Add ``lbworkflow.urls`` to you ``url``:: 57 | 58 | urlpatterns = [ 59 | ... 60 | url(r'^wf/', include('lbworkflow.urls')), # url for lbworkflow 61 | url(r'^attachment/', include('lbattachment.urls')), # url for lbattachment 62 | ] 63 | 64 | **Others**: You should also config other required APPS, ex: ``django-el-pagination``. 65 | 66 | Sample code of using django-lb-workflow 67 | ---------------------------------------- 68 | 69 | You can find sample code of using django-lb-workflow in ``testproject/`` and ``lbworkflow/tests/``. 70 | -------------------------------------------------------------------------------- /docs/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 | set SPHINXPROJ=django-lb-workflow 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | The following settings are available for configuration through your project. 5 | 6 | All available settings can find in ``lbworkflow.settings`` 7 | 8 | List of available settings 9 | -------------------------- 10 | 11 | LBWF_APPS 12 | ~~~~~~~~~ 13 | Default: ``{}`` 14 | 15 | Specifies the APP of process. 16 | 17 | >>> {'leave': 'lbworkflow.tests.leave'}. 18 | 19 | ``leave`` is the wf_code of the process. 20 | ``lbworkflow.tests.leave`` is the app of the process. 21 | 22 | 23 | LBWF_USER_PARSER 24 | ~~~~~~~~~~~~~~~~ 25 | Default: ``lbworkflow.core.userparser.SimpleUserParser`` 26 | 27 | ``django-lb-workflow`` use a text field to config users for ``Node`` 28 | and user a parser to cover it to Django model. You can replace it with your implement. 29 | The parse must a subclass of ``lbworkflow.core.userparser.BaseUserParser`` 30 | 31 | 32 | LBWF_EVAL_FUNCS 33 | ~~~~~~~~~~~~~~~ 34 | 35 | Default: ``{}`` 36 | 37 | A list of functions that can used in ``Transition.condition``. 38 | 39 | >>> {'get_dept': 'hr.models.get_dept'}. 40 | 41 | ``get_detp`` can used in ``Transition.condition``. 42 | 43 | 44 | LBWF_WF_SEND_MSG_FUNCS 45 | ~~~~~~~~~~~~~~~~~~~~~~ 46 | 47 | Default: ``['lbworkflow.core.sendmsg.wf_print', ]`` 48 | 49 | A list of functions that used to send message when process node changed. 50 | 51 | The function must define as ``def wf_print(users, msg_type, event=None, ext_ctx=None)`` 52 | users: A list of user need send message to. 53 | msg_type: The type of message. Can be ``notify/transfered/new_task``. 54 | 55 | 56 | LBWF_GET_USER_DISPLAY_NAME_FUNC 57 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 58 | 59 | Default: ``lambda user: "%s" % user`` 60 | 61 | A function used to get the display name of a user. 62 | -------------------------------------------------------------------------------- /lbworkflow/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 0, 4, "alpha", 0) 2 | 3 | __version__ = "1.0.4" 4 | 5 | default_app_config = "lbworkflow.apps.LBWorkflowConfig" 6 | -------------------------------------------------------------------------------- /lbworkflow/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import ( 4 | App, 5 | Authorization, 6 | Event, 7 | Node, 8 | Process, 9 | ProcessCategory, 10 | ProcessInstance, 11 | ProcessReportLink, 12 | Task, 13 | Transition, 14 | ) 15 | 16 | 17 | @admin.register(ProcessCategory) 18 | class ProcessCategoryAdmin(admin.ModelAdmin): 19 | search_fields = ("name",) 20 | list_display = ("name", "oid", "is_active") 21 | 22 | 23 | @admin.register(ProcessReportLink) 24 | class ProcessReportLinkAdmin(admin.ModelAdmin): 25 | search_fields = ("category__name", "name", "url") 26 | list_display = ("name", "url", "category", "perm", "oid", "is_active") 27 | list_filter = ("category",) 28 | autocomplete_fields = ("category",) 29 | 30 | 31 | @admin.register(Process) 32 | class ProcessAdmin(admin.ModelAdmin): 33 | search_fields = ("code", "prefix", "name", "category__name") 34 | list_display = ("code", "prefix", "name", "category", "oid", "is_active") 35 | list_filter = ("category",) 36 | autocomplete_fields = ("category",) 37 | 38 | 39 | @admin.register(Node) 40 | class NodeAdmin(admin.ModelAdmin): 41 | search_fields = ( 42 | "process__name", 43 | "process__code", 44 | "name", 45 | "code", 46 | "operators", 47 | "notice_users", 48 | "share_users", 49 | ) 50 | list_display = ( 51 | "process", 52 | "name", 53 | "code", 54 | "step", 55 | "status", 56 | "audit_page_type", 57 | "can_edit", 58 | "can_reject", 59 | "can_give_up", 60 | "operators", 61 | "notice_users", 62 | "share_users", 63 | "is_active", 64 | ) 65 | list_filter = ("process",) 66 | autocomplete_fields = ("process",) 67 | 68 | 69 | @admin.register(Transition) 70 | class TransitionAdmin(admin.ModelAdmin): 71 | search_fields = ( 72 | "process__name", 73 | "process__code", 74 | "input_node__name", 75 | "output_node__name", 76 | "name", 77 | "condition", 78 | ) 79 | list_display = ( 80 | "process", 81 | "name", 82 | "code", 83 | "routing_rule", 84 | "input_node", 85 | "output_node", 86 | "is_agree", 87 | "can_auto_agree", 88 | "app", 89 | "app_param", 90 | "condition", 91 | "oid", 92 | "is_active", 93 | ) 94 | list_filter = ("process",) 95 | raw_id_fields = ( 96 | "input_node", 97 | "output_node", 98 | ) 99 | autocomplete_fields = ("process",) 100 | 101 | 102 | @admin.register(App) 103 | class AppAdmin(admin.ModelAdmin): 104 | list_display = ("name", "app_type", "action") 105 | 106 | 107 | @admin.register(ProcessInstance) 108 | class ProcessInstanceAdmin(admin.ModelAdmin): 109 | search_fields = ( 110 | "process__name", 111 | "process__code", 112 | "created_by__username", 113 | "cur_node__name", 114 | ) 115 | list_display = ( 116 | "process", 117 | "no", 118 | "summary", 119 | "created_by", 120 | "created_on", 121 | "cur_node", 122 | ) 123 | list_filter = ("process",) 124 | raw_id_fields = ( 125 | "content_type", 126 | "created_by", 127 | "attachments", 128 | "can_view_users", 129 | "cur_node", 130 | ) 131 | autocomplete_fields = ("process",) 132 | 133 | 134 | @admin.register(Task) 135 | class TaskAdmin(admin.ModelAdmin): 136 | search_fields = ( 137 | "instance__no", 138 | "node__name", 139 | "user__username", 140 | "agent__username", 141 | ) 142 | list_display = ( 143 | "instance", 144 | "node", 145 | "user", 146 | "agent_user", 147 | "is_hold", 148 | "status", 149 | "created_on", 150 | "receive_on", 151 | ) 152 | list_filter = ("instance__process",) 153 | raw_id_fields = ( 154 | "instance", 155 | "node", 156 | "user", 157 | "agent_user", 158 | "authorization", 159 | ) 160 | 161 | 162 | @admin.register(Event) 163 | class EventAdmin(admin.ModelAdmin): 164 | search_fields = ( 165 | "instance__no", 166 | "user__username", 167 | "old_node__name", 168 | "new_node__name", 169 | ) 170 | list_display = ( 171 | "instance", 172 | "user", 173 | "get_act_name", 174 | "old_node", 175 | "new_node", 176 | "created_on", 177 | ) 178 | raw_id_fields = ( 179 | "instance", 180 | "user", 181 | "task", 182 | "next_operators", 183 | "notice_users", 184 | "attachments", 185 | "old_node", 186 | "new_node", 187 | ) 188 | 189 | 190 | def get_processes(o): 191 | return ", ".join(e.name for e in o.processes.all()) 192 | 193 | 194 | @admin.register(Authorization) 195 | class AuthorizationAdmin(admin.ModelAdmin): 196 | search_fields = ("user__username", "agent_user__username") 197 | list_display = ("user", "agent_user", get_processes, "start_on", "end_on") 198 | raw_id_fields = ("user", "agent_user") 199 | autocomplete_fields = ("processes",) 200 | -------------------------------------------------------------------------------- /lbworkflow/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class LBWorkflowConfig(AppConfig): 6 | name = "lbworkflow" 7 | verbose_name = _("LBWorkflow") 8 | -------------------------------------------------------------------------------- /lbworkflow/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/core/__init__.py -------------------------------------------------------------------------------- /lbworkflow/core/datahelper.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from lbutils import as_callable 3 | 4 | from lbworkflow.models import App, Node, Process, ProcessCategory, Transition 5 | 6 | User = get_user_model() 7 | 8 | 9 | def get_or_create(cls, uid, **kwargs): 10 | uid_field_name = kwargs.pop("uid_field_name", "uuid") 11 | obj = cls.objects.filter(**{uid_field_name: uid}).first() 12 | if obj: 13 | for k, v in kwargs.items(): 14 | setattr(obj, k, v) 15 | obj.save() 16 | return obj 17 | kwargs[uid_field_name] = uid 18 | return cls.objects.create(**kwargs) 19 | 20 | 21 | def create_user(username, **kwargs): 22 | password = kwargs.pop("password", "password") 23 | user = User.objects.filter(username=username).first() 24 | if user: 25 | user.set_password(password) 26 | return user 27 | return User.objects.create_user( 28 | username, "%s@v.cn" % username, password, **kwargs 29 | ) 30 | 31 | 32 | def create_app(uuid, name, **kwargs): 33 | return get_or_create(App, uuid, name=name, **kwargs) 34 | 35 | 36 | def create_category(uuid, name, **kwargs): 37 | return get_or_create(ProcessCategory, uuid, name=name, **kwargs) 38 | 39 | 40 | def create_process(code, name, **kwargs): 41 | return get_or_create( 42 | Process, code, name=name, uid_field_name="code", **kwargs 43 | ) 44 | 45 | 46 | def create_node(uuid, process, name, **kwargs): 47 | return get_or_create(Node, uuid, process=process, name=name, **kwargs) 48 | 49 | 50 | def get_node(process, name): 51 | """ 52 | get node 53 | :param process: 54 | :param name: 'submit' or 'submit,5f31d065-4a87-487b-beea-641f0a6720c3' 55 | :return: node 56 | """ 57 | name_and_uuid = [e.strip() for e in name.split(",") if e.strip()] 58 | qs = Node.objects.filter(process=process) 59 | if len(name_and_uuid) == 1: 60 | qs = qs.filter(name=name_and_uuid[0]) 61 | else: 62 | qs = qs.filter(uuid=name_and_uuid[1]) 63 | return qs[0] 64 | 65 | 66 | def get_app(name): 67 | """ 68 | get node 69 | :param process: 70 | :param name: 'submit' or 'submit,5f31d065-4a87-487b-beea-641f0a6720c3' 71 | :return: node 72 | """ 73 | name_and_uuid = [e.strip() for e in name.split(",") if e.strip()] 74 | qs = App.objects 75 | if len(name_and_uuid) == 1: 76 | qs = qs.filter(name=name_and_uuid[0]) 77 | else: 78 | qs = qs.filter(uuid=name_and_uuid[1]) 79 | return qs[0] 80 | 81 | 82 | def create_transition( 83 | uuid, process, from_node, to_node, app="Simple", **kwargs 84 | ): 85 | from_node = get_node(process, from_node) 86 | to_node = get_node(process, to_node) 87 | app = get_app(app) 88 | return get_or_create( 89 | Transition, 90 | uuid, 91 | process=process, 92 | input_node=from_node, 93 | output_node=to_node, 94 | app=app, 95 | **kwargs 96 | ) 97 | 98 | 99 | def load_wf_data(app, wf_code=""): 100 | if wf_code: 101 | func = "%s.wfdata.load_%s" % (app, wf_code) 102 | else: 103 | func = "%s.wfdata.load_data" % app 104 | as_callable(func)() 105 | -------------------------------------------------------------------------------- /lbworkflow/core/exceptions.py: -------------------------------------------------------------------------------- 1 | class HttpResponseException(Exception): 2 | def __init__(self, http_response): 3 | super().__init__(http_response) 4 | self.http_response = http_response 5 | -------------------------------------------------------------------------------- /lbworkflow/core/helper.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from lbutils import as_callable 3 | 4 | from lbworkflow import settings 5 | 6 | 7 | def safe_eval(source, globals, *args, **kwargs): 8 | globals["Q"] = Q 9 | for s in settings.EVAL_FUNCS: 10 | globals[s[0]] = as_callable(s[1]) 11 | source = source.replace("import", "") 12 | return eval(source, globals, *args, **kwargs) 13 | -------------------------------------------------------------------------------- /lbworkflow/core/sendmsg.py: -------------------------------------------------------------------------------- 1 | from lbutils import as_callable 2 | 3 | from lbworkflow import settings 4 | 5 | # wf_send_sms(users, mail_type, event, ext_ctx) 6 | # wf_send_mail(users, mail_type, event, ext_ctx) 7 | 8 | 9 | def wf_send_msg(users, msg_type, event=None, ext_ctx=None): 10 | if not users: 11 | return 12 | 13 | users = set(users) 14 | if event: # ignore operator 15 | if event.user in users: 16 | users = users.remove(event.user) 17 | 18 | for send_msg in settings.WF_SEND_MSG_FUNCS: 19 | as_callable(send_msg)(users, msg_type, event, ext_ctx) 20 | 21 | 22 | def wf_print(users, msg_type, event=None, ext_ctx=None): 23 | print("wf_print: %s, %s, %s" % (users, msg_type, event)) 24 | -------------------------------------------------------------------------------- /lbworkflow/core/transition.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | 3 | from lbworkflow.models import Event, Task 4 | 5 | from .sendmsg import wf_send_msg 6 | 7 | 8 | def create_event(instance, transition, **kwargs): 9 | act_type = "transition" if transition.pk else transition.code 10 | if transition.is_agree: 11 | act_type = "agree" 12 | event = Event.objects.create( 13 | instance=instance, 14 | act_name=transition.name, 15 | act_type=act_type, 16 | **kwargs 17 | ) 18 | return event 19 | 20 | 21 | class TransitionExecutor(object): 22 | def __init__( 23 | self, 24 | operator, 25 | instance, 26 | task, 27 | transition=None, 28 | comment="", 29 | attachments=[], 30 | ): 31 | self.wf_obj = instance.content_object 32 | self.instance = instance 33 | self.operator = operator 34 | self.task = task 35 | self.transition = transition 36 | 37 | self.comment = comment 38 | self.attachments = attachments 39 | 40 | self.from_node = instance.cur_node 41 | # hold&assign wouldn't change node 42 | self.to_node = transition.output_node 43 | self.all_todo_tasks = instance.get_todo_tasks() 44 | 45 | self.last_event = None 46 | 47 | def execute(self): 48 | # TODO check permission 49 | 50 | all_todo_tasks = self.all_todo_tasks 51 | need_transfer = False 52 | if self.transition.code in ["reject", "back to", "give up"]: 53 | need_transfer = True 54 | elif self.transition.routing_rule == "joint": 55 | if all_todo_tasks.count() == 1: 56 | need_transfer = True 57 | else: 58 | if ( 59 | not all_todo_tasks.exclude(pk=self.task.pk) 60 | .filter(is_joint=True) 61 | .exists() 62 | ): 63 | need_transfer = True 64 | self._complete_task(need_transfer) 65 | if not need_transfer: 66 | return 67 | 68 | self._do_transfer() 69 | 70 | # if is agree should check if need auto agree for next node 71 | if self.transition.is_agree or self.to_node.node_type == "router": 72 | self._auto_agree_next_node() 73 | 74 | def _auto_agree_next_node(self): 75 | instance = self.instance 76 | 77 | agree_transition = instance.get_agree_transition() 78 | all_todo_tasks = instance.get_todo_tasks() 79 | 80 | if not agree_transition: 81 | return 82 | 83 | # if from router, create a task 84 | if self.to_node.node_type == "router": 85 | task = Task( 86 | instance=self.instance, 87 | node=self.instance.cur_node, 88 | user=self.operator, 89 | ) 90 | all_todo_tasks = [task] 91 | 92 | for task in all_todo_tasks: 93 | users = [task.user, task.agent_user] 94 | users = [e for e in users if e] 95 | for user in set(users): 96 | if self.instance.cur_node != task.node: # has processed 97 | return 98 | if instance.is_user_agreed(user): 99 | TransitionExecutor( 100 | self.operator, instance, task, agree_transition 101 | ).execute() 102 | 103 | def _complete_task(self, need_transfer): 104 | """close workite, create event and return it""" 105 | instance = self.instance 106 | task = self.task 107 | transition = self.transition 108 | 109 | task.status = "completed" 110 | task.save() 111 | 112 | to_node = self.to_node if need_transfer else instance.cur_node 113 | self.to_node = to_node 114 | 115 | event = None 116 | pre_last_event = instance.last_event() 117 | if pre_last_event and pre_last_event.new_node.node_type == "router": 118 | event = pre_last_event 119 | event.new_node = to_node 120 | event.save() 121 | 122 | if not event: 123 | event = create_event( 124 | instance, 125 | transition, 126 | comment=self.comment, 127 | user=self.operator, 128 | old_node=task.node, 129 | new_node=to_node, 130 | task=task, 131 | ) 132 | 133 | if self.attachments: 134 | event.attachments.add(*self.attachments) 135 | 136 | self.last_event = event 137 | 138 | return event 139 | 140 | def _do_transfer_for_instance(self): 141 | instance = self.instance 142 | wf_obj = self.wf_obj 143 | 144 | from_node = self.from_node 145 | from_status = from_node.status 146 | 147 | to_node = self.to_node 148 | to_status = self.to_node.status 149 | 150 | # Submit 151 | if not from_node.is_submitted() and to_node.is_submitted(): 152 | instance.submit_time = timezone.now() 153 | wf_obj.on_submit() 154 | 155 | # cancel & give up & reject 156 | if from_node.is_submitted() and not to_node.is_submitted(): 157 | wf_obj.on_fail() 158 | 159 | # complete 160 | if from_status != "completed" and to_status == "completed": 161 | instance.end_on = timezone.now() 162 | self.wf_obj.on_complete() 163 | 164 | # cancel complete 165 | if from_status == "completed" and to_status != "completed": 166 | instance.end_on = None 167 | 168 | instance.cur_node = self.to_node 169 | self.wf_obj.on_do_transition(from_node, to_node) 170 | 171 | instance.save() 172 | 173 | def _send_notification(self): 174 | instance = self.instance 175 | last_event = self.last_event 176 | 177 | notice_users = last_event.notice_users.exclude( 178 | pk__in=[self.operator.pk, instance.created_by.pk] 179 | ).distinct() 180 | wf_send_msg(notice_users, "notify", last_event) 181 | 182 | # send notification to instance.created_by 183 | if instance.created_by != self.operator: 184 | wf_send_msg([instance.created_by], "transfered", last_event) 185 | 186 | def _gen_new_task(self): 187 | last_event = self.last_event 188 | 189 | if not last_event: 190 | return 191 | 192 | next_operators = last_event.next_operators.distinct() 193 | 194 | need_notify_operators = [] 195 | for operator in next_operators: 196 | new_task = Task( 197 | instance=self.instance, node=self.to_node, user=operator 198 | ) 199 | new_task.update_authorization(commit=True) 200 | 201 | # notify next operator(not include current operator and instance.created_by) 202 | if operator not in [self.operator, self.instance.created_by]: 203 | need_notify_operators.append(operator) 204 | 205 | agent_user = new_task.agent_user 206 | if agent_user and agent_user not in [ 207 | self.operator, 208 | self.instance.created_by, 209 | ]: 210 | need_notify_operators.append(agent_user) 211 | 212 | wf_send_msg(need_notify_operators, "new_task", last_event) 213 | 214 | def update_users_on_transfer(self): 215 | instance = self.instance 216 | event = self.last_event 217 | to_node = event.new_node 218 | 219 | next_operators, notice_users, can_view_users = to_node.get_users( 220 | instance.created_by, self.operator, instance 221 | ) 222 | event.next_operators.add(*next_operators) 223 | event.notice_users.add(*notice_users) 224 | instance.can_view_users.add(*can_view_users) 225 | 226 | def _do_transfer(self): 227 | self.update_users_on_transfer() 228 | # auto complete all current work item 229 | self.all_todo_tasks.update(status="completed") 230 | self._do_transfer_for_instance() 231 | self._gen_new_task() 232 | self._send_notification() 233 | -------------------------------------------------------------------------------- /lbworkflow/core/userparser.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import Group 3 | from django.db import models 4 | 5 | from lbworkflow.core.helper import safe_eval 6 | 7 | User = get_user_model() 8 | 9 | 10 | def remove_brackets(s, start_char="[", end_char="]"): 11 | return s.strip(start_char).strip(end_char).strip() 12 | 13 | 14 | class BaseUserParser(object): 15 | def __init__(self, param, pinstance=None, operator=None, owner=None): 16 | self.owner = owner 17 | self.operator = operator 18 | self.param = param 19 | self.pinstance = pinstance 20 | self.wf_obj = None 21 | if pinstance: 22 | self.wf_obj = pinstance.content_object 23 | self.owner = pinstance.created_by 24 | 25 | def _get_eval_val(self, eval_str): 26 | return safe_eval(eval_str, {"o": self.wf_obj}) 27 | 28 | def eval_as_list(self, eval_str): 29 | v = self._get_eval_val(eval_str) 30 | if v.__class__.__name__ == "ManyRelatedManager": 31 | return v.all() 32 | if isinstance(v, models.Model): 33 | return [v] 34 | return v 35 | 36 | def parse(self): 37 | return [] 38 | 39 | 40 | class SimpleUserParser(BaseUserParser): 41 | def process_func(self, func_str): 42 | return None 43 | 44 | def get_object_list( 45 | self, atom_str, obj_class, nature_key, start_char="[", end_char="]" 46 | ): 47 | atom_str = remove_brackets(atom_str, start_char, end_char) 48 | if "." in atom_str: 49 | return self.eval_as_list(atom_str) 50 | if ":" in atom_str: 51 | pk = atom_str.split(":")[0] 52 | return obj_class.objects.filter(pk=pk) 53 | return obj_class.objects.filter(**{nature_key: atom_str}) 54 | 55 | def get_users(self, user_str): 56 | """ 57 | #owner 58 | #operator 59 | [11:vicalloy] 60 | [o.auditor] 61 | [o.auditors] 62 | """ 63 | user_str = remove_brackets(user_str) 64 | if user_str.startswith("#"): 65 | user_str = user_str[1:] 66 | if user_str == "owner": 67 | return [self.owner] 68 | elif user_str == "operator": 69 | return [self.operator] 70 | return self.get_object_list(user_str, User, "username") 71 | 72 | def _get_groups(self, group_str): 73 | """ 74 | g[o.group] 75 | g[o.groups] 76 | g[11:admins] 77 | """ 78 | return self.get_object_list(group_str, Group, "pk", "g[") 79 | 80 | def get_users_by_groups(self, group_str): 81 | groups = self._get_groups(group_str) 82 | return User.objects.filter(group__in=groups) 83 | 84 | def parse_atom_rule(self, atom_rule): 85 | """ 86 | #owner 87 | #operator 88 | user [11:vicalloy] 89 | group g[11:group] 90 | 91 | if syntax error will return None 92 | """ 93 | if not atom_rule: 94 | return [] 95 | users = self.process_func(atom_rule) 96 | if users is not None: # is function 97 | return users 98 | if atom_rule.startswith("#"): 99 | return self.get_users(atom_rule) 100 | elif atom_rule.startswith("g["): # role(group) 101 | return self.get_users_by_groups(atom_rule) 102 | elif atom_rule.startswith("["): # user 103 | return self.get_users(atom_rule) 104 | # log it? 105 | return None 106 | 107 | def to_users(self, rules): 108 | all_users = [] 109 | for rule in rules: 110 | users = self.parse_atom_rule(rule) 111 | if users is not None: 112 | all_users.extend(users) 113 | # TODO ignore quited users 114 | return all_users 115 | 116 | def get_active_rules(self): 117 | """ 118 | :o.leave_days<7 119 | [vicalloy] 120 | :o.leave_days>=7 121 | [tom] 122 | """ 123 | rules = [e.strip() for e in self.param.splitlines() if e.strip()] 124 | str_rules = "" 125 | need_add = True 126 | for rule in rules: 127 | is_condition = rule.startswith(":") 128 | if not is_condition and not need_add: 129 | continue 130 | if is_condition: 131 | need_add = safe_eval(rule[1:], {"o": self.wf_obj}) 132 | continue 133 | str_rules = "%s,%s" % (str_rules, rule) 134 | return [e.strip() for e in str_rules.split(",") if e.strip()] 135 | 136 | def parse(self): 137 | rules = self.get_active_rules() 138 | active_rules = [] 139 | for rule in rules: 140 | active_rules.append(rule) 141 | users = self.to_users(active_rules) 142 | return list(set(users)) 143 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import shutil 4 | import stat 5 | 6 | from jinja2 import Environment, FileSystemLoader 7 | 8 | __all__ = ("FlowAppGenerator", "clean_generated_files") 9 | 10 | 11 | def clean_generated_files(model_class): 12 | folder_path = os.path.dirname(inspect.getfile(model_class)) 13 | for path, dirs, files in os.walk(folder_path): 14 | if not path.endswith(model_class.__name__.lower()): 15 | shutil.rmtree(path) 16 | for file in files: 17 | if file not in ["models.py", "wfdata.py", "__init__.py"]: 18 | try: 19 | os.remove(os.path.join(path, file)) 20 | except: # NOQA 21 | pass 22 | 23 | 24 | def get_fields(model_class): 25 | fields = [] 26 | ignore_fields = ["id", "pinstance", "created_on", "created_by"] 27 | for f in model_class._meta.fields: 28 | if f.name not in ignore_fields: 29 | fields.append(f) 30 | return fields 31 | 32 | 33 | def get_field_names(model_class): 34 | fields = get_fields(model_class) 35 | return ", ".join(["'%s'" % e.name for e in fields]) 36 | 37 | 38 | def group(flat_list): 39 | for i in range(len(flat_list) % 2): 40 | flat_list.append(None) 41 | pass 42 | return list(zip(flat_list[0::2], flat_list[1::2])) 43 | 44 | 45 | class FlowAppGenerator(object): 46 | def __init__(self, app_template_path=None): 47 | if not app_template_path: 48 | app_template_path = os.path.join( 49 | os.path.dirname(os.path.abspath(__file__)), "app_template" 50 | ) 51 | self.app_template_path = app_template_path 52 | super().__init__() 53 | 54 | def init_env(self, template_path): 55 | loader = FileSystemLoader(template_path) 56 | self.env = Environment( 57 | block_start_string="[%", 58 | block_end_string="%]", 59 | variable_start_string="[[", 60 | variable_end_string="]]", 61 | comment_start_string="[#", 62 | comment_end_string="#]", 63 | loader=loader, 64 | ) 65 | 66 | def gen( 67 | self, 68 | model_class, 69 | item_model_class_list=None, 70 | wf_code=None, 71 | replace=False, 72 | ignores=["wfdata.py"], 73 | ): 74 | dest = os.path.dirname(inspect.getfile(model_class)) 75 | app_name = model_class.__module__.split(".")[-2] 76 | if not wf_code: 77 | wf_code = app_name 78 | ctx = { 79 | "app_name": app_name, 80 | "wf_code": wf_code, 81 | "class_name": model_class.__name__, 82 | "wf_name": model_class._meta.verbose_name, 83 | "field_names": get_field_names(model_class), 84 | "fields": get_fields(model_class), 85 | "grouped_fields": group(get_fields(model_class)), 86 | } 87 | if item_model_class_list: 88 | item_list = [] 89 | for item_model_class in item_model_class_list: 90 | item_ctx = { 91 | "class_name": item_model_class.__name__, 92 | "lowercase_class_name": item_model_class.__name__.lower(), 93 | "field_names": get_field_names(item_model_class), 94 | "fields": get_fields(item_model_class), 95 | "grouped__fields": group(get_fields(item_model_class)), 96 | } 97 | item_list.append(item_ctx) 98 | ctx["item_list"] = item_list 99 | self.copy_template(self.app_template_path, dest, ctx, replace, ignores) 100 | 101 | def copy_template(self, src, dest, ctx={}, replace=False, ignores=[]): 102 | self.init_env(src) 103 | for path, dirs, files in os.walk(src): 104 | relative_path = path[len(src) :].lstrip(os.path.sep) 105 | dest_path = os.path.join(dest, relative_path) 106 | dest_path = dest_path.replace( 107 | "app_name", ctx.get("app_name", "app_name") 108 | ) 109 | if not os.path.exists(dest_path): 110 | os.mkdir(dest_path) 111 | for i, subdir in enumerate(dirs): 112 | if subdir.startswith("."): 113 | del dirs[i] 114 | for filename in files: 115 | if filename.endswith(".pyc") or filename.startswith("."): 116 | continue 117 | src_file_path = os.path.join(path, filename) 118 | src_file_path = src_file_path[len(src) :].strip(os.path.sep) 119 | dest_file_path = os.path.join(dest, relative_path, filename) 120 | dest_file_path = dest_file_path.replace( 121 | "app_name", ctx.get("app_name", "app_name") 122 | ) 123 | if dest_file_path.endswith("-tpl"): 124 | dest_file_path = dest_file_path[:-4] 125 | 126 | is_exists = os.path.isfile(dest_file_path) 127 | for ignore in ignores: 128 | if dest_file_path.endswith(ignore): 129 | replace = False 130 | if is_exists and not replace: 131 | continue 132 | self.copy_template_file(src_file_path, dest_file_path, ctx) 133 | 134 | def copy_template_file(self, src, dest, ctx={}): 135 | if os.path.sep != "/": 136 | # https://github.com/pallets/jinja/issues/767 137 | # Jinja template names are not fileystem paths. 138 | # They always use forward slashes so this is working as intended. 139 | src = src.replace(os.path.sep, "/") 140 | template = self.env.get_template(src) 141 | template.stream(ctx).dump(dest, encoding="utf-8") 142 | # Make new file writable. 143 | if os.access(dest, os.W_OK): 144 | st = os.stat(dest) 145 | new_permissions = stat.S_IMODE(st.st_mode) | stat.S_IWUSR 146 | os.chmod(dest, new_permissions) 147 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/flowgen/app_template/__init__.py-tpl -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/admin.py-tpl: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import [[ class_name ]] 4 | [% if item_list %][% for item in item_list %] 5 | from .models import [[ item.class_name ]] 6 | [% endfor %][% endif %] 7 | 8 | class [[ class_name ]]Admin(admin.ModelAdmin): 9 | list_display = ([[ field_names ]]) 10 | 11 | 12 | admin.site.register([[ class_name ]], [[ class_name ]]Admin) 13 | [% if item_list %][% for item in item_list %] 14 | 15 | class [[ item.class_name ]]Admin(admin.ModelAdmin): 16 | list_display = ([[ item.field_names ]]) 17 | 18 | 19 | admin.site.register([[ item.class_name ]], [[ item.class_name ]]Admin) 20 | [% endfor %][% endif %] 21 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/forms.py-tpl: -------------------------------------------------------------------------------- 1 | from django import forms[% if item_list %] 2 | from django.forms.models import inlineformset_factory[% endif %] 3 | from crispy_forms.bootstrap import StrictButton 4 | from crispy_forms.layout import Layout 5 | 6 | from lbutils import BootstrapFormHelperMixin 7 | from lbworkflow.forms import WorkflowFormMixin 8 | from lbworkflow.forms import BSQuickSearchForm 9 | 10 | from .models import [[ class_name ]] 11 | [% if item_list %][% for item in item_list %] 12 | from .models import [[ item.class_name ]] 13 | [% endfor %][% endif %] 14 | 15 | 16 | class SearchForm(BSQuickSearchForm): 17 | def layout(self): 18 | self.helper.layout = Layout( 19 | 'q_quick_search_kw', 20 | StrictButton('Search', type="submit", css_class='btn-sm btn-default'), 21 | StrictButton('Export', type="submit", name="export", css_class='btn-sm btn-default'), 22 | ) 23 | 24 | 25 | class [[ class_name ]]Form(BootstrapFormHelperMixin, WorkflowFormMixin, forms.ModelForm): 26 | 27 | def __init__(self, *args, **kw): 28 | super().__init__(*args, **kw) 29 | self.init_crispy_helper() 30 | self.layout_fields([ 31 | [% for f1, f2 in grouped_fields %] 32 | ['[[ f1.name ]]', '[[ f2.name ]]'], 33 | [% endfor %] 34 | ]) 35 | 36 | class Meta: 37 | model = [[ class_name ]] 38 | fields = [ 39 | [[ field_names ]] 40 | ] 41 | [% if item_list %][% for item in item_list %] 42 | 43 | class [[ item.class_name ]]Form(BootstrapFormHelperMixin, WorkflowFormMixin, forms.ModelForm): 44 | 45 | class Meta: 46 | model = [[ item.class_name ]] 47 | fields = [ 48 | [[ item.field_names ]] 49 | ] 50 | 51 | 52 | def get_[[ item.lowercase_class_name ]]_formset_class(**kwargs): 53 | params = {'extra': 1, 'can_delete': True} 54 | params.update(kwargs) 55 | return inlineformset_factory( 56 | [[ class_name ]], [[ item.class_name ]], 57 | form=[[ item.class_name ]]Form, **params) 58 | [% endfor %][% endif %] 59 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/templates/app_name/detail.html-tpl: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/wf_base_detail.html" %} 2 | 3 | {% block right_side_header_ext_btns %} 4 | Print 5 | | 6 | {% endblock %} 7 | 8 | {% block right_side_tab_base_ctx %} 9 | {% include "[[ app_name ]]/inc_detail_info.html" %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/templates/app_name/form.html-tpl: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/base_form[% if item_list %]set[% endif %].html" %} 2 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/templates/app_name/inc_detail_info.html-tpl: -------------------------------------------------------------------------------- 1 | {% include "lbworkflow/inc_wf_status.html" %} 2 | 3 | [% for f1, f2 in grouped_fields %] 4 | 5 | 6 | 7 | 8 | 9 | 10 | [% endfor %] 11 | {% comment %} 12 | 13 | 14 | 17 | 18 | {% endcomment %} 19 |
[[ f1.verbose_name ]]{{ object.[[ f1.name ]] }}[[ f2.verbose_name ]]{{ object.[[ f2.name ]] }}
Reason 15 | {{ object.reason|linebreaks }} 16 |
20 | [% if item_list %][% for item in item_list %] 21 |
22 | 23 | 24 | [% for f in item.fields %] 25 | [% endfor %] 26 | 27 | {% for o in object.[[ item.lowercase_class_name]]_set.all %} 28 | [% for f in item.fields %] 29 | [% endfor %] 30 | 31 | {% endfor %} 32 |
[[ f.verbose_name ]]
{{ o.[[ f.name ]] }}
33 | [% endfor %][% endif %] 34 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/templates/app_name/list.html-tpl: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load bootstrap_pagination %} 5 | {% load lbworkflow_tags %} 6 | 7 | {% block nav_sel_node %}id-nav-[[ wf_code ]]{% endblock %} 8 | 9 | {% block right_side %} 10 |
11 | {% include "incs/messages.html" %} 12 |
13 |
14 |

15 | {{ process.name }} 16 |

17 |
18 |
19 |
20 | {% if search_form %} 21 |
22 |
23 | {% crispy search_form %} 24 |
25 |
26 | {% endif %} 27 |
28 | 29 | 30 | 31 | 32 | 33 | [% for f in fields %] 34 | 35 | [% endfor %] 36 | 37 | 38 | 39 | 40 | {% for o in object_list %}{% with pi=o.pinstance %} 41 | 42 | 43 | 44 | [% for f in fields %] 45 | 46 | [% endfor %] 47 | 48 | 49 | 54 | 55 | {% endwith %}{% endfor %} 56 | 57 |
NO.Created by[[ f.verbose_name ]]Created onCurrent operatorActivity
{{ pi.no }}{{ pi.created_by }}{{ o.[[ f.name ]] }}{{ pi.created_on|date:"Y-m-d H:i" }}{{ pi.get_operators_display }} 50 | 51 | {{ pi.cur_node.name }} 52 | 53 |
58 |
59 | 62 |
63 |
64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/templates/app_name/print.html-tpl: -------------------------------------------------------------------------------- 1 | {% extends "lbadminlte/mbase_popup.html" %} 2 | 3 | {% block content %} 4 | {% include "[[ app_name ]]/inc_detail_info.html" %} 5 |
6 | {% include "lbworkflow/inc_wf_history.html" %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/views.py-tpl: -------------------------------------------------------------------------------- 1 | from lbworkflow.views.generics import CreateView 2 | from lbworkflow.views.generics import UpdateView 3 | from lbworkflow.views.generics import WFListView[% if item_list %] 4 | from lbworkflow.views.forms import BSFormSetMixin[% endif %] 5 | 6 | from .forms import [[ class_name ]]Form 7 | [% if item_list %][% for item in item_list %] 8 | from .forms import get_[[ item.lowercase_class_name ]]_formset_class 9 | [% endfor %][% endif %] 10 | from .models import [[ class_name ]] 11 | 12 | 13 | class [[ class_name ]]CreateView([% if item_list %]BSFormSetMixin, [% endif %]CreateView): 14 | form_classes = { 15 | 'form': [[ class_name ]]Form, 16 | [% if item_list %][% for item in item_list %] 17 | 'fs_[[ item.lowercase_class_name ]]': get_[[ item.lowercase_class_name ]]_formset_class(), 18 | [% endfor %][% endif %] 19 | } 20 | 21 | 22 | new = [[ class_name ]]CreateView.as_view() 23 | 24 | 25 | class [[ class_name ]]UpdateView([% if item_list %]BSFormSetMixin, [% endif %]UpdateView): 26 | form_classes = { 27 | 'form': [[ class_name ]]Form, 28 | [% if item_list %][% for item in item_list %] 29 | 'fs_[[ item.lowercase_class_name ]]': get_[[ item.lowercase_class_name ]]_formset_class(), 30 | [% endfor %][% endif %] 31 | } 32 | 33 | 34 | edit = [[ class_name ]]UpdateView.as_view() 35 | 36 | 37 | class [[ class_name ]]ListView(WFListView): 38 | wf_code = '[[ wf_code ]]' 39 | model = [[ class_name ]] 40 | excel_file_name = '[[ wf_code ]]' 41 | excel_titles = [ 42 | 'Created on', 'Created by', 43 | [% for f in fields %]'[[ f.verbose_name ]]', [% endfor %] 44 | 'Status', 45 | ] 46 | 47 | def get_excel_data(self, o): 48 | return [ 49 | o.created_by.username, o.created_on, 50 | [% for f in fields %]o.[[ f.name ]], [% endfor %] 51 | o.pinstance.cur_node.name, 52 | ] 53 | 54 | 55 | show_list = [[ class_name ]]ListView.as_view() 56 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/wf_views.py-tpl: -------------------------------------------------------------------------------- 1 | from lbworkflow.views.transition import ExecuteTransitionView 2 | -------------------------------------------------------------------------------- /lbworkflow/flowgen/app_template/wfdata.py-tpl: -------------------------------------------------------------------------------- 1 | from lbworkflow.core.datahelper import create_node 2 | from lbworkflow.core.datahelper import create_category 3 | from lbworkflow.core.datahelper import create_process 4 | from lbworkflow.core.datahelper import create_transition 5 | 6 | 7 | def load_data(): 8 | load_[[ wf_code ]]() 9 | 10 | 11 | def load_[[ wf_code ]](): 12 | category = create_category('', '') 13 | process = create_process('[[ wf_code ]]', '[[ wf_code ]]', category=category) 14 | create_node('', process, 'Draft', status='draft') 15 | create_node('', process, 'Given up', status='given up') 16 | create_node('', process, 'Rejected', status='rejected') 17 | create_node('', process, 'Completed', status='completed') 18 | create_node('', process, 'A1', operators='[owner]') 19 | create_transition('', process, 'Draft,', 'A1') 20 | -------------------------------------------------------------------------------- /lbworkflow/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from django.contrib.auth import get_user_model 4 | from django_select2.forms import ModelSelect2MultipleWidget 5 | from lbattachment.models import LBAttachment 6 | from lbutils import BootstrapFormHelperMixin, JustSelectedSelectMultiple 7 | 8 | from lbworkflow.models import Event, Task 9 | 10 | User = get_user_model() 11 | 12 | try: 13 | from crispy_forms.bootstrap import StrictButton 14 | from crispy_forms.helper import FormHelper 15 | from crispy_forms.layout import Layout 16 | except ImportError: 17 | pass 18 | 19 | 20 | class BSSearchFormMixin(BootstrapFormHelperMixin): 21 | def layout(self): 22 | self.helper.layout = Layout( 23 | "q_quick_search_kw", 24 | StrictButton( 25 | "Search", type="submit", css_class="btn-sm btn-default" 26 | ), 27 | ) 28 | 29 | def init_form_helper(self): 30 | self.add_class2fields("input-sm") 31 | self.helper = helper = FormHelper() 32 | helper.form_class = "form-inline" 33 | helper.form_method = "get" 34 | helper.field_template = "bootstrap3/layout/inline_field.html" 35 | self.layout() 36 | 37 | 38 | class QuickSearchFormMixin(forms.Form): 39 | q_quick_search_kw = forms.CharField(label="Key word", required=False) 40 | 41 | 42 | class BSQuickSearchForm(BSSearchFormMixin, QuickSearchFormMixin, forms.Form): 43 | def __init__(self, *args, **kw): 44 | super().__init__(*args, **kw) 45 | self.init_form_helper() 46 | 47 | 48 | class BSQuickSearchWithExportForm(BSQuickSearchForm): 49 | def layout(self): 50 | self.helper.layout = Layout( 51 | "q_quick_search_kw", 52 | StrictButton( 53 | "Search", type="submit", css_class="btn-sm btn-default" 54 | ), 55 | StrictButton( 56 | "Export", 57 | type="submit", 58 | name="export", 59 | css_class="btn-sm btn-default", 60 | ), 61 | ) 62 | 63 | 64 | class WorkflowFormMixin: 65 | def save_new_process(self, request, wf_code): 66 | submit = request.POST.get("act_submit") 67 | act_name = request.POST.get("act_submit") or "Save" 68 | obj = self.save(commit=False) 69 | obj.created_by = request.user 70 | obj.create_pinstance(wf_code, submit) 71 | self.save_m2m() 72 | # Other action 73 | messages.info( 74 | request, 75 | "Success %s: %s" 76 | % ( 77 | act_name, 78 | obj, 79 | ), 80 | ) 81 | return obj 82 | 83 | def update_process(self, request): 84 | submit = request.POST.get("act_submit") 85 | act_name = request.POST.get("act_submit") or "Save" 86 | obj = self.save() 87 | # add a edit event, change resolution to draft 88 | instance = obj.pinstance 89 | if instance.cur_node.status in ["rejected", "draft", "given up"]: 90 | Task.objects.filter( 91 | instance=instance, status="in progress" 92 | ).delete() 93 | Event.objects.create( 94 | instance=instance, 95 | old_node=instance.cur_node, 96 | new_node=instance.process.get_draft_active(), 97 | act_type="edit", 98 | user=request.user, 99 | ) 100 | instance.cur_node = instance.process.get_draft_active() 101 | instance.save() 102 | can_resubmit = instance.cur_node.status in ["draft"] 103 | # Other action 104 | if submit and can_resubmit: 105 | obj.submit_process(request.user) 106 | messages.info( 107 | request, 108 | "Submitted %s: %s" 109 | % ( 110 | act_name, 111 | obj, 112 | ), 113 | ) 114 | return obj 115 | 116 | 117 | class WorkFlowForm(forms.Form): 118 | attachments = forms.ModelMultipleChoiceField( 119 | label="Attachment", 120 | queryset=LBAttachment.objects.all(), 121 | help_text="", 122 | widget=JustSelectedSelectMultiple(attrs={"class": "nochosen"}), 123 | required=False, 124 | ) 125 | comment = forms.CharField( 126 | label="Comment", required=False, widget=forms.Textarea() 127 | ) 128 | 129 | def __init__(self, *args, **kwargs): 130 | self.instance = kwargs.pop("instance", None) 131 | super().__init__(*args, **kwargs) 132 | 133 | def save(self, *args, **kwargs): 134 | return self.instance 135 | 136 | def save_m2m(self, *args, **kwargs): 137 | return self.instance 138 | 139 | 140 | class BSWorkFlowForm(BootstrapFormHelperMixin, WorkFlowForm): 141 | def __init__(self, *args, **kw): 142 | super().__init__(*args, **kw) 143 | self.init_crispy_helper(label_class="col-md-2", field_class="col-md-8") 144 | self.layout_fields( 145 | [ 146 | [ 147 | "attachments", 148 | ], 149 | [ 150 | "comment", 151 | ], 152 | ] 153 | ) 154 | 155 | 156 | class BatchWorkFlowForm(WorkFlowForm): 157 | pass 158 | 159 | 160 | class BSBatchWorkFlowForm(BootstrapFormHelperMixin, BatchWorkFlowForm): 161 | def __init__(self, *args, **kw): 162 | super().__init__(*args, **kw) 163 | self.init_crispy_helper(label_class="col-md-2", field_class="col-md-8") 164 | 165 | 166 | class BackToNodeForm(WorkFlowForm): 167 | back_to_node = forms.ChoiceField(label="Back to", required=True) 168 | 169 | def __init__(self, process_instance, *args, **kwargs): 170 | super().__init__(*args, **kwargs) 171 | choices = [ 172 | (e.pk, e.name) 173 | for e in process_instance.get_can_back_to_activities() 174 | ] 175 | self.fields["back_to_node"].choices = choices 176 | 177 | 178 | class BSBackToNodeForm(BootstrapFormHelperMixin, BackToNodeForm): 179 | def __init__(self, *args, **kw): 180 | super().__init__(*args, **kw) 181 | self.init_crispy_helper(label_class="col-md-2", field_class="col-md-8") 182 | self.layout_fields( 183 | [ 184 | [ 185 | "back_to_node", 186 | ], 187 | [ 188 | "attachments", 189 | ], 190 | [ 191 | "comment", 192 | ], 193 | ] 194 | ) 195 | 196 | 197 | class UserSelect2MultipleWidget(ModelSelect2MultipleWidget): 198 | search_fields = [ 199 | "username__icontains", 200 | ] 201 | 202 | 203 | class AddAssigneeForm(WorkFlowForm): 204 | assignees = forms.ModelMultipleChoiceField( 205 | label="Assignees", 206 | required=True, 207 | queryset=User.objects, 208 | widget=UserSelect2MultipleWidget, 209 | ) 210 | 211 | def __init__(self, *args, **kwargs): 212 | super(AddAssigneeForm, self).__init__(*args, **kwargs) 213 | self.init_crispy_helper() 214 | 215 | 216 | class BSAddAssigneeForm(BootstrapFormHelperMixin, AddAssigneeForm): 217 | def __init__(self, *args, **kw): 218 | super().__init__(*args, **kw) 219 | self.init_crispy_helper(label_class="col-md-2", field_class="col-md-8") 220 | self.layout_fields( 221 | [ 222 | [ 223 | "assignees", 224 | ], 225 | [ 226 | "attachments", 227 | ], 228 | [ 229 | "comment", 230 | ], 231 | ] 232 | ) 233 | -------------------------------------------------------------------------------- /lbworkflow/migrations/0002_auto_20171019_0549.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2017-10-19 05:49 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("lbworkflow", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name="event", 17 | name="transition", 18 | ), 19 | migrations.AddField( 20 | model_name="event", 21 | name="act_name", 22 | field=models.CharField(blank=True, max_length=255), 23 | ), 24 | migrations.AddField( 25 | model_name="node", 26 | name="node_type", 27 | field=models.CharField( 28 | choices=[("node", "Node"), ("router", "Router")], 29 | default="node", 30 | max_length=16, 31 | verbose_name="Status", 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="event", 36 | name="act_type", 37 | field=models.CharField( 38 | choices=[ 39 | ("transition", "Transition"), 40 | ("agree", "Agree"), 41 | ("edit", "Edit"), 42 | ("give up", "Give up"), 43 | ("reject", "Reject"), 44 | ("back to", "Back to"), 45 | ("rollback", "Rollback"), 46 | ("comment", "Comment"), 47 | ("assign", "Assign"), 48 | ("hold", "Hold"), 49 | ("unhold", "Unhold"), 50 | ], 51 | default="transition", 52 | max_length=255, 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name="node", 57 | name="status", 58 | field=models.CharField( 59 | choices=[ 60 | ("draft", "Draft"), 61 | ("given up", "Given up"), 62 | ("rejected", "Rejected"), 63 | ("in progress", "In Progress"), 64 | ("completed", "Completed"), 65 | ], 66 | default="in progress", 67 | max_length=16, 68 | verbose_name="Status", 69 | ), 70 | ), 71 | ] 72 | -------------------------------------------------------------------------------- /lbworkflow/migrations/0003_auto_20200221_0438.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-02-21 04:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("lbworkflow", "0002_auto_20171019_0549"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="task", 15 | name="is_joint", 16 | field=models.BooleanField(default=False, verbose_name="Is joint"), 17 | ), 18 | migrations.AlterField( 19 | model_name="event", 20 | name="created_on", 21 | field=models.DateTimeField(auto_now_add=True), 22 | ), 23 | migrations.AlterField( 24 | model_name="task", 25 | name="created_on", 26 | field=models.DateTimeField(auto_now_add=True), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /lbworkflow/migrations/0004_processreportlink_uuid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-04-07 02:00 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("lbworkflow", "0003_auto_20200221_0438"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="processreportlink", 16 | name="uuid", 17 | field=models.UUIDField( 18 | default=uuid.uuid4, editable=False, unique=True 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /lbworkflow/migrations/0005_auto_20211217_0304.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2021-12-17 03:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('lbworkflow', '0004_processreportlink_uuid'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='app', 15 | name='id', 16 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 17 | ), 18 | migrations.AlterField( 19 | model_name='authorization', 20 | name='id', 21 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 22 | ), 23 | migrations.AlterField( 24 | model_name='event', 25 | name='id', 26 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 27 | ), 28 | migrations.AlterField( 29 | model_name='node', 30 | name='id', 31 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 32 | ), 33 | migrations.AlterField( 34 | model_name='process', 35 | name='id', 36 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 37 | ), 38 | migrations.AlterField( 39 | model_name='processcategory', 40 | name='id', 41 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 42 | ), 43 | migrations.AlterField( 44 | model_name='processinstance', 45 | name='id', 46 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 47 | ), 48 | migrations.AlterField( 49 | model_name='processreportlink', 50 | name='id', 51 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 52 | ), 53 | migrations.AlterField( 54 | model_name='task', 55 | name='id', 56 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 57 | ), 58 | migrations.AlterField( 59 | model_name='transition', 60 | name='id', 61 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /lbworkflow/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/migrations/__init__.py -------------------------------------------------------------------------------- /lbworkflow/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import * # NOQA 2 | from .runtime import * # NOQA 3 | -------------------------------------------------------------------------------- /lbworkflow/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | from django.utils.module_loading import import_string 3 | 4 | 5 | def perform_import(val): 6 | """ 7 | If the given setting is a string import notation, 8 | then perform the necessary import or imports. 9 | """ 10 | if val is None: 11 | return None 12 | elif isinstance(val, str): 13 | return import_string(val) 14 | elif isinstance(val, (list, tuple)): 15 | return [import_string(item) for item in val] 16 | return val 17 | 18 | 19 | AUTH_USER_MODEL = getattr(django_settings, "AUTH_USER_MODEL", "auth.User") 20 | 21 | USER_PARSER = getattr( 22 | django_settings, 23 | "LBWF_USER_PARSER", 24 | "lbworkflow.core.userparser.SimpleUserParser", 25 | ) 26 | 27 | WF_PAGE_SIZE = getattr(django_settings, "LBWF_PAGE_SIZE", 20) 28 | 29 | EVAL_FUNCS = getattr(django_settings, "LBWF_EVAL_FUNCS", {}) 30 | 31 | WF_SEND_MSG_FUNCS = getattr( 32 | django_settings, 33 | "LBWF_WF_SEND_MSG_FUNCS", 34 | [ 35 | "lbworkflow.core.sendmsg.wf_print", 36 | ], 37 | ) 38 | 39 | DEFAULT_PERMISSION_CLASSES = getattr( 40 | django_settings, "LBWF_DEFAULT_PERMISSION_CLASSES", [] 41 | ) 42 | DEFAULT_NEW_WF_PERMISSION_CLASSES = getattr( 43 | django_settings, "LBWF_DEFAULT_NEW_PERMISSION_CLASSES", [] 44 | ) 45 | DEFAULT_EDIT_WF_PERMISSION_CLASSES = getattr( 46 | django_settings, 47 | "LBWF_DEFAULT_EDIT_PERMISSION_CLASSES", 48 | ["lbworkflow.views.permissions.DefaultEditWorkFlowPermission"], 49 | ) 50 | DEFAULT_DETAIL_WF_PERMISSION_CLASSES = getattr( 51 | django_settings, 52 | "LBWF_DEFAULT_DETAIL_PERMISSION_CLASSES", 53 | ["lbworkflow.views.permissions.DefaultDetailWorkFlowPermission"], 54 | ) 55 | 56 | GET_USER_DISPLAY_NAME_FUNC = getattr( 57 | django_settings, 58 | "LBWF_GET_USER_DISPLAY_NAME_FUNC", 59 | lambda user: "%s" % user, 60 | ) 61 | 62 | DEBUG_WORKFLOW = getattr(django_settings, "LBWF_DEBUG_WORKFLOW", False) 63 | WF_APPS = getattr(django_settings, "LBWF_APPS", {}) 64 | 65 | QUICK_SEARCH_FORM = getattr( 66 | django_settings, 67 | "LBWF_QUICK_SEARCH_FORM", 68 | "lbworkflow.forms.BSQuickSearchForm", 69 | ) 70 | 71 | QUICK_SEARCH_WITH_EXPORT_FORM = getattr( 72 | django_settings, 73 | "LBWF_QUICK_SEARCH_WITH_EXPORT_FORM", 74 | "lbworkflow.forms.BSQuickSearchWithExportForm", 75 | ) 76 | 77 | WORK_FLOW_FORM = getattr( 78 | django_settings, "LBWF_WORK_FLOW_FORM", "lbworkflow.forms.BSWorkFlowForm" 79 | ) 80 | 81 | BATCH_WORK_FLOW_FORM = getattr( 82 | django_settings, 83 | "LBWF_BATCH_WORK_FLOW_FORM", 84 | "lbworkflow.forms.BSBatchWorkFlowForm", 85 | ) 86 | 87 | BACK_TO_ACTIVITY_FORM = getattr( 88 | django_settings, 89 | "LBWF_BACK_TO_ACTIVITY_FORM", 90 | "lbworkflow.forms.BSBackToNodeForm", 91 | ) 92 | 93 | ADD_ASSIGNEE_FORM = getattr( 94 | django_settings, 95 | "LBWF_ADD_ASSIGNEE_FORM", 96 | "lbworkflow.forms.BSAddAssigneeForm", 97 | ) 98 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/simplewf/__init__.py -------------------------------------------------------------------------------- /lbworkflow/simplewf/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import SimpleWorkFlow 4 | 5 | 6 | class SimpleWorkFlowAdmin(admin.ModelAdmin): 7 | list_display = ("summary", "content") 8 | 9 | 10 | admin.site.register(SimpleWorkFlow, SimpleWorkFlowAdmin) 11 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/forms.py: -------------------------------------------------------------------------------- 1 | from crispy_forms.bootstrap import StrictButton 2 | from crispy_forms.layout import Layout 3 | from django import forms 4 | from lbutils import BootstrapFormHelperMixin 5 | 6 | from lbworkflow.forms import BSQuickSearchForm, WorkflowFormMixin 7 | 8 | from .models import SimpleWorkFlow 9 | 10 | 11 | class SearchForm(BSQuickSearchForm): 12 | def layout(self): 13 | self.helper.layout = Layout( 14 | "q_quick_search_kw", 15 | StrictButton( 16 | "Search", type="submit", css_class="btn-sm btn-default" 17 | ), 18 | StrictButton( 19 | "Export", 20 | type="submit", 21 | name="export", 22 | css_class="btn-sm btn-default", 23 | ), 24 | ) 25 | 26 | 27 | class SimpleWorkFlowForm( 28 | BootstrapFormHelperMixin, WorkflowFormMixin, forms.ModelForm 29 | ): 30 | def __init__(self, *args, **kw): 31 | super().__init__(*args, **kw) 32 | self.init_crispy_helper() 33 | self.layout_fields( 34 | [ 35 | [ 36 | "summary", 37 | ], 38 | [ 39 | "content", 40 | ], 41 | ] 42 | ) 43 | 44 | class Meta: 45 | model = SimpleWorkFlow 46 | fields = ["summary", "content"] 47 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-02-21 06:06 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("lbworkflow", "0003_auto_20200221_0438"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="SimpleWorkFlow", 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 | "created_on", 32 | models.DateTimeField( 33 | auto_now_add=True, verbose_name="Created on" 34 | ), 35 | ), 36 | ( 37 | "summary", 38 | models.CharField(max_length=255, verbose_name="Summary"), 39 | ), 40 | ( 41 | "content", 42 | models.TextField(blank=True, verbose_name="Content"), 43 | ), 44 | ( 45 | "created_by", 46 | models.ForeignKey( 47 | null=True, 48 | on_delete=django.db.models.deletion.SET_NULL, 49 | to=settings.AUTH_USER_MODEL, 50 | verbose_name="Created by", 51 | ), 52 | ), 53 | ( 54 | "pinstance", 55 | models.ForeignKey( 56 | blank=True, 57 | null=True, 58 | on_delete=django.db.models.deletion.CASCADE, 59 | related_name="simpleworkflow", 60 | to="lbworkflow.ProcessInstance", 61 | verbose_name="Process instance", 62 | ), 63 | ), 64 | ], 65 | options={ 66 | "abstract": False, 67 | }, 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/migrations/0002_alter_simpleworkflow_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2021-12-17 03:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('simplewf', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='simpleworkflow', 15 | name='id', 16 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/simplewf/migrations/__init__.py -------------------------------------------------------------------------------- /lbworkflow/simplewf/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from lbworkflow.models import BaseWFObj 4 | 5 | 6 | class SimpleWorkFlow(BaseWFObj): 7 | summary = models.CharField("Summary", max_length=255) 8 | # process.ext_data['template'] is the default content 9 | content = models.TextField("Content", blank=True) 10 | 11 | def __str__(self): 12 | return self.summary 13 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/templates/simplewf/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/wf_base_detail.html" %} 2 | 3 | {% block right_side_header_ext_btns %} 4 | Print 5 | | 6 | {% endblock %} 7 | 8 | {% block right_side_tab_base_ctx %} 9 | {% include "simplewf/inc_detail_info.html" %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/templates/simplewf/form.html: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/base_form.html" %} 2 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/templates/simplewf/inc_detail_info.html: -------------------------------------------------------------------------------- 1 | {% include "lbworkflow/inc_wf_status.html" %} 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 14 | 15 |
Summary 6 | {{ object.summary }} 7 |
Content 12 | {{ object.content|linebreaks }} 13 |
16 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/templates/simplewf/list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load bootstrap_pagination %} 5 | {% load lbworkflow_tags %} 6 | 7 | {% block nav_sel_node %}id-nav-simplewf{% endblock %} 8 | 9 | {% block right_side %} 10 |
11 | {% include "incs/messages.html" %} 12 |
13 |
14 |

15 | {{ process.name }} 16 |

17 |
18 |
19 |
20 | {% if search_form %} 21 |
22 |
23 | {% crispy search_form %} 24 |
25 |
26 | {% endif %} 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {% for o in object_list %}{% with pi=o.pinstance %} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 | {% endwith %}{% endfor %} 54 | 55 |
NO.Created bySummaryContentCreated onCurrent operatorActivity
{{ pi.no }}{{ pi.created_by }}{{ o.summary }}{{ o.content }}{{ pi.created_on|date:"Y-m-d H:i" }}{{ pi.get_operators_display }} 48 | 49 | {{ pi.cur_node.name }} 50 | 51 |
56 |
57 | 60 |
61 |
62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/templates/simplewf/print.html: -------------------------------------------------------------------------------- 1 | {% extends "lbadminlte/mbase_popup.html" %} 2 | 3 | {% block content %} 4 | {% include "simplewf/inc_detail_info.html" %} 5 |
6 | {% include "lbworkflow/inc_wf_history.html" %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/views.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.views.generics import CreateView, UpdateView, WFListView 2 | 3 | from .forms import SimpleWorkFlowForm 4 | from .models import SimpleWorkFlow 5 | 6 | 7 | class SimpleWorkFlowCreateView(CreateView): 8 | form_classes = { 9 | "form": SimpleWorkFlowForm, 10 | } 11 | 12 | def get_initial(self, form_class_key): 13 | return {"content": self.process.ext_data.get("template", "")} 14 | 15 | 16 | new = SimpleWorkFlowCreateView.as_view() 17 | 18 | 19 | class SimpleWorkFlowUpdateView(UpdateView): 20 | form_classes = { 21 | "form": SimpleWorkFlowForm, 22 | } 23 | 24 | 25 | edit = SimpleWorkFlowUpdateView.as_view() 26 | 27 | 28 | class SimpleWorkFlowListView(WFListView): 29 | wf_code = "simplewf" 30 | model = SimpleWorkFlow 31 | excel_file_name = "simplewf" 32 | excel_titles = [ 33 | "Created on", 34 | "Created by", 35 | "Summary", 36 | "Content", 37 | "Status", 38 | ] 39 | 40 | def get_excel_data(self, o): 41 | return [ 42 | o.created_by.username, 43 | o.created_on, 44 | o.summary, 45 | o.content, 46 | o.pinstance.cur_node.name, 47 | ] 48 | 49 | 50 | show_list = SimpleWorkFlowListView.as_view() 51 | -------------------------------------------------------------------------------- /lbworkflow/simplewf/wfdata.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.core.datahelper import ( 2 | create_category, 3 | create_node, 4 | create_process, 5 | create_transition, 6 | ) 7 | 8 | 9 | def load_data(): 10 | load_simplewf() 11 | 12 | 13 | def load_simplewf(): 14 | category = create_category("5f31d065-00cc-0020-beea-641f0a670010", "HR") 15 | 16 | ext_data_a = { 17 | "template": """# WorkFlow A 18 | Content A""" 19 | } 20 | process = create_process( 21 | "simplewf__A", 22 | "Simple Workflow: A", 23 | category=category, 24 | ext_data=ext_data_a, 25 | ) 26 | create_node( 27 | "5f31d666-00a0-0020-beea-641f0a670010", 28 | process, 29 | "Draft", 30 | status="draft", 31 | ) 32 | create_node( 33 | "5f31d666-00a0-0020-beea-641f0a670020", 34 | process, 35 | "Given up", 36 | status="given up", 37 | ) 38 | create_node( 39 | "5f31d666-00a0-0020-beea-641f0a670030", 40 | process, 41 | "Rejected", 42 | status="rejected", 43 | ) 44 | create_node( 45 | "5f31d666-00a0-0020-beea-641f0a670040", 46 | process, 47 | "Completed", 48 | status="completed", 49 | ) 50 | create_node( 51 | "5f31d666-00a0-0020-beea-641f0a670050", 52 | process, 53 | "A1", 54 | operators="[owner]", 55 | ) 56 | create_transition( 57 | "5f31d666-00e0-0020-beea-641f0a670010", process, "Draft,", "A1" 58 | ) 59 | create_transition( 60 | "5f31d666-00e0-0020-beea-641f0a670020", process, "A1,", "Completed" 61 | ) 62 | 63 | ext_data_b = { 64 | "template": """# WorkFlow B 65 | Content B""" 66 | } 67 | process = create_process( 68 | "simplewf__B", 69 | "Simple Workflow: B", 70 | category=category, 71 | ext_data=ext_data_b, 72 | ) 73 | create_node( 74 | "5f31d667-00a0-0020-beea-641f0a670010", 75 | process, 76 | "Draft", 77 | status="draft", 78 | ) 79 | create_node( 80 | "5f31d667-00a0-0020-beea-641f0a670020", 81 | process, 82 | "Given up", 83 | status="given up", 84 | ) 85 | create_node( 86 | "5f31d667-00a0-0020-beea-641f0a670030", 87 | process, 88 | "Rejected", 89 | status="rejected", 90 | ) 91 | create_node( 92 | "5f31d667-00a0-0020-beea-641f0a670040", 93 | process, 94 | "Completed", 95 | status="completed", 96 | ) 97 | create_node( 98 | "5f31d667-00a0-0020-beea-641f0a670050", 99 | process, 100 | "A1", 101 | operators="[owner]", 102 | ) 103 | create_transition( 104 | "5f31d667-00e0-0020-beea-641f0a670010", process, "Draft,", "A1" 105 | ) 106 | create_transition( 107 | "5f31d667-00e0-0020-beea-641f0a670020", process, "A1,", "Completed" 108 | ) 109 | -------------------------------------------------------------------------------- /lbworkflow/static/css/lbworkflow.css: -------------------------------------------------------------------------------- 1 | .bottom-btns span { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /lbworkflow/static/js/lbworkflow.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | }); 3 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/base.html: -------------------------------------------------------------------------------- 1 | {% extends "lbadminlte/base.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block head_ext %} 6 | 7 | {% endblock %} 8 | 9 | {% block footer_ext %} 10 | 11 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/base_ext.html: -------------------------------------------------------------------------------- 1 | {% extends "lbadminlte/base_ext.html" %} 2 | 3 | {% block left_side %} 4 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/base_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_form.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block nav_sel_node %}id-nav-mywf{% endblock %} 6 | 7 | {% block right_side_content_top %} 8 | {% if object %} 9 | 34 | {% endif %} 35 | {% endblock %} 36 | 37 | {% block right_side_header %} 38 |
39 | {% if wf_code %} 40 | Flowchart 41 | {% endif %} 42 |
43 |

44 | My workflow 45 | > 46 | {% if object %} 47 | {{ object }} 48 | {% else %} 49 | {{ process.name }} 50 | {% endif %} 51 |

52 | {% endblock %} 53 | 54 | {% block form_act_btns %} 55 | {% if not process_instance.cur_node.is_submitted %} 56 | 57 | {% endif %} 58 | 59 | 60 | {% endblock %} 61 | 62 | {% block footer_ext %} 63 | 64 | {{ block.super }} 65 | 66 | 67 | 68 | 69 | {% endblock %} 70 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/base_formset.html: -------------------------------------------------------------------------------- 1 | {% extends "base_formset.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block nav_sel_node %}id-nav-mywf{% endblock %} 6 | 7 | {% block right_side_content_top %} 8 | {% if object %} 9 | 34 | {% endif %} 35 | {% endblock %} 36 | 37 | {% block right_side_header %} 38 |
39 | {% if wf_code %} 40 | Flowchart 41 | {% endif %} 42 |
43 |

44 | My workflow 45 | > 46 | {% if object %} 47 | {{ object }} 48 | {% else %} 49 | {{ process.name }} 50 | {% endif %} 51 |

52 | {% endblock %} 53 | 54 | 55 | {% block form_act_btns %} 56 | {% if not process_instance.cur_node.is_submitted %} 57 | 58 | {% endif %} 59 | 60 | 61 | {% endblock %} 62 | 63 | {% block footer_ext %} 64 | 65 | {{ block.super }} 66 | 67 | 68 | 69 | 70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/batch_transition_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load lbworkflow_tags %} 4 | {% load crispy_forms_tags %} 5 | {% load static %} 6 | 7 | {% block content_nav_l %} 8 | {{ transition.name }} 9 | {% endblock %} 10 | 11 | {% block head_ext %} 12 | 17 | {% endblock %} 18 | 19 | {% block right_side %} 20 |
21 | {% include "incs/messages.html" %} 22 |
23 |
24 |

25 | Batch 26 | - 27 | {{ transition_name }} 28 |

29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 | 41 |
42 | {% for o in task_list%} 43 | 44 | 45 | {{ o.instance.no }} : {{ o.instance.summary }}
46 | {% endfor %} 47 |
48 |
49 | {% crispy form %} 50 | {% block submit_btns %} 51 |
52 |
53 | 56 | 57 |
58 |
59 | {% endblock %} 60 |
61 |
62 | {% block other_forms %} 63 | {% endblock %} 64 |
65 |
66 | {% endblock %} 67 | 68 | {% block footer_ext %} 69 | {{ block.super }} 70 | 71 | 72 | 73 | 78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/do_transition_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load lbworkflow_tags %} 4 | {% load crispy_forms_tags %} 5 | {% load static %} 6 | 7 | {% block content_nav_l %} 8 | {{ transition.name }} 9 | {% endblock %} 10 | 11 | {% block head_ext %} 12 | {{ form.media.js }} 13 | 18 | {% endblock %} 19 | 20 | {% block right_side %} 21 |
22 | {% include "incs/messages.html" %} 23 |
24 |
25 |

26 | {{ process.name }} 27 | - 28 | {{ object }} 29 | - 30 | {{ transition.name }} 31 |

32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 | 43 | {{ transition.input_node.name }} 44 | 45 |
46 |
47 | {% if backto_node_form %} 48 |
49 | 50 |
51 | {{ backto_node_form.backto_node }} 52 |
53 |
54 | {% endif %} 55 | {% crispy form %} 56 | {% block submit_btns %} 57 |
58 |
59 | 62 | 63 |
64 |
65 | {% endblock %} 66 |
67 |
68 | {% block other_forms %} 69 | {% endblock %} 70 |
71 |
72 | {% endblock %} 73 | 74 | {% block footer_ext %} 75 | {{ block.super }} 76 | 77 | 78 | 79 | 80 | 81 | {{ form.media.js }} 82 | 87 | {% endblock %} 88 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/flowchart.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block body %} 6 |
7 |

{{ process.name }}

8 |
9 | {{ graph_src }} 10 |
11 |
12 | {% endblock %} 13 | 14 | {% block footer_ext %} 15 | 16 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/inc_wf_btns.html: -------------------------------------------------------------------------------- 1 | {% load lbworkflow_tags %} 2 | 3 | {% for t in agree_transitions %} 4 | {{ t.name }} 5 | | 6 | {% endfor %} 7 | {% for t in other_transitions %} 8 | {{ t.name }} 9 | | 10 | {% endfor %} 11 | {% if task %} 12 | Add assignee 13 | | 14 | {% endif %} 15 | {% if can_reject %} 16 | Reject 17 | | 18 | {% endif %} 19 | {% if can_back_to %} 20 | Back to 21 | | 22 | {% endif %} 23 | {# TODO hold and add joint #} 24 | {% comment %} 25 | {% if can_rollback %} 26 | 28 | Rollback 29 | 30 | | 31 | {% endif %} 32 | {% endcomment %} 33 | {% if can_give_up %} 34 | Give up 36 | | 37 | {% endif %} 38 | 39 | {% if not is_btn %} 40 | {% if can_edit %} 41 | Edit 42 | | 43 | {% endif %} 44 | {% if is_wf_admin %} 45 | Delete 46 | | 47 | {% endif %} 48 | {% endif %} 49 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/inc_wf_history.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% for e in wf_history %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 28 | 31 | 32 | {% endfor %} 33 | 34 |
OnUserActionOld nodeNew nodeNoteNotice users
{{ e.created_on|date:"Y-m-d H:i" }}{{ e.user }}{{ e.get_act_name }}{{ e.old_node.name }}{{ e.new_node.name }} 20 | {% if e.comment %} 21 | {{ e.comment|linebreaksbr }} 22 | {% endif %} 23 | {% for a in e.attachments.all %} 24 | {{ a.filename }} 25 |
26 | {% endfor %} 27 |
29 | {{ e.get_next_notice_users_display }} 30 |
35 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/inc_wf_status.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | 31 | 32 |
NO. 5 | {{ process_instance.no }} 6 | Created by{{ process_instance.created_by }}
Created on{{ process_instance.created_on|date:"Y-m-d H:i" }}Current node 15 | {{ process_instance.cur_node.name }} 16 | {% if task.is_hold %} 17 | (hold) 18 | {% endif %} 19 |
Process name{{ process.name }}Current operator 26 | {{ operators_display }} 27 | {% if not process_instance.has_received %} 28 | [unreceived] 29 | {% endif %} 30 |
33 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/list_wf.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load bootstrap_pagination %} 5 | {% load lbworkflow_tags %} 6 | 7 | {% block nav_sel_node %}id-nav-list-wf{% endblock %} 8 | 9 | {% block right_side %} 10 |
11 | {% include "incs/messages.html" %} 12 | 17 |

18 | All flow 19 |

20 |
21 |
22 |
23 | {% if search_form %} 24 |
25 |
26 | {% crispy search_form %} 27 |
28 |
29 | {% endif %} 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {% for pi in object_list %} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 55 | 56 | {% endfor %} 57 | 58 |
NO.Process nameSummaryCreated onCreated byCurrent operatorNode
{{ pi.no }}{{ pi.process.name }}{{ pi.summary }}{{ pi.created_on|date:"Y-m-d H:i" }}{{ pi.created_by }}{{ pi.get_operators_display }} 51 | 52 | {{ pi.cur_node.name }} 53 | 54 |
59 |
60 | 63 |
64 |
65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/my_wf.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load bootstrap_pagination %} 5 | {% load lbworkflow_tags %} 6 | 7 | {% block nav_sel_node %}id-nav-mywf{% endblock %} 8 | 9 | {% block right_side %} 10 |
11 | {% include "incs/messages.html" %} 12 | 15 |

16 | I submitted 17 |

18 |
19 |
20 |
21 | {% if search_form %} 22 |
23 |
24 | {% crispy search_form %} 25 |
26 |
27 | {% endif %} 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {% for pi in object_list %} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 51 | 52 | {% endfor %} 53 | 54 |
NO.Process nameSummaryCreated onCurrent operatorNode
{{ pi.no }}{{ pi.process.name }}{{ pi.summary }}{{ pi.created_on|date:"Y-m-d H:i" }}{{ pi.get_operators_display }} 47 | 48 | {{ pi.cur_node.name }} 49 | 50 |
55 |
56 | 59 |
60 |
61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/report_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load static %} 4 | {% load lbworkflow_tags %} 5 | 6 | {% block head_ext %} 7 | {{ block.super }} 8 | 28 | {% endblock %} 29 | 30 | {% block nav_sel_node %}id-nav-report-list{% endblock %} 31 | 32 | {% block right_side %} 33 |
34 | {% include "incs/messages.html" %} 35 |
36 |
37 |

38 | Report list 39 |

40 |
41 |
42 |
43 | {% for category in categories %} 44 |
45 | {% if category %} 46 |
47 |
48 | {{ category.name }} 49 |
    50 | {% for o in category.get_report_links %} 51 |
  • {{ o.name }}
  • 52 | {% endfor %} 53 | {% for o in category.get_all_process %} 54 |
  • {{ o.name }}
  • 55 | {% endfor %} 56 |
57 |
58 |
59 | {% endif %} 60 |
61 | {% endfor %} 62 |
63 |
64 | {% endblock %} 65 | 66 | {% block footer_ext %} 67 | {{ block.super }} 68 | 69 | 75 | {% endblock %} 76 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/start_wf.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load static %} 4 | {% load lbworkflow_tags %} 5 | 6 | {% block head_ext %} 7 | {{ block.super }} 8 | 28 | {% endblock %} 29 | 30 | {% block nav_sel_node %}id-nav-start-wf{% endblock %} 31 | 32 | {% block right_side %} 33 |
34 | {% include "incs/messages.html" %} 35 |
36 |
37 |

38 | Submit a new workflow 39 |

40 |
41 |
42 |
43 | {% for category in categories %} 44 |
45 | {% if category %} 46 |
47 |
48 | {{ category.name }} 49 |
    50 | {% for o in category|category_have_perm_processes:user %} 51 |
  • {{ o.name }}
  • 52 | {% endfor %} 53 |
54 |
55 |
56 | {% endif %} 57 |
58 | {% endfor %} 59 |
60 |
61 | {% endblock %} 62 | 63 | {% block footer_ext %} 64 | {{ block.super }} 65 | 66 | 72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/todo.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load bootstrap_pagination %} 5 | {% load lbworkflow_tags %} 6 | 7 | {% block nav_sel_node %}id-nav-todo{% endblock %} 8 | 9 | {% block right_side %} 10 |
11 | {% include "incs/messages.html" %} 12 | 17 |

18 | Todo 19 |

20 |
21 |
22 |
23 | {% if search_form %} 24 |
25 |
26 | {% crispy search_form %} 27 |
28 |
29 | {% endif %} 30 |
31 |
32 | {% csrf_token %} 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {% for o in object_list %}{% with pi=o.instance %} 48 | 49 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 63 | 64 | {% endwith %}{% endfor %} 65 | 66 |
37 | 38 | NO.Process nameSummaryCreated onCreated byCurrent operatorNode
50 | 51 | {{ pi.no }}{{ pi.process.name }}{{ pi.summary }}{{ pi.created_on|date:"Y-m-d H:i" }}{{ pi.created_by }}{{ pi.get_operators_display }} 59 | 60 | {{ pi.cur_node.name }} 61 | 62 |
67 |
68 |
69 | 82 |
83 |
84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /lbworkflow/templates/lbworkflow/wf_base_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load lbworkflow_tags %} 4 | 5 | {% block nav_sel_node %}id-nav-mywf{% endblock %} 6 | 7 | {% block right_side %} 8 |
9 | {% include "incs/messages.html" %} 10 | {% block right_side_header %} 11 |
12 | {% include "lbworkflow/inc_wf_btns.html" %} 13 | {% block right_side_header_ext_btns %} 14 | {% endblock %} 15 |
16 | 17 | {% block right_side_header_title %} 18 |

19 | I submitted 20 | > 21 | {{ object }} 22 |

23 | {% endblock %} 24 | {% endblock %} 25 |
26 |
27 | 66 | {% block wf_detail_ext %} 67 | {% with btn_css="1" %} 68 |
69 | {% with is_btn="1" %} 70 | {% include "lbworkflow/inc_wf_btns.html" %} 71 | {% endwith %} 72 |
73 | {% endwith %} 74 | {% endblock %} 75 |
76 | {% endblock %} 77 | -------------------------------------------------------------------------------- /lbworkflow/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/templatetags/__init__.py -------------------------------------------------------------------------------- /lbworkflow/templatetags/lbworkflow_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def app_url(transition, task): 8 | return transition.get_app_url(task) 9 | 10 | 11 | @register.filter 12 | def flow_status_css_class(pinstance): 13 | if not pinstance: 14 | return "default" 15 | if pinstance.cur_node.status in ["rejected"]: 16 | return "danger" 17 | if pinstance.cur_node.status == "in progress": 18 | return "info" 19 | if pinstance.cur_node.status == "finished": 20 | return "success" 21 | return "default" 22 | 23 | 24 | @register.filter 25 | def category_have_perm_processes(category, user): 26 | return category.get_can_apply_processes(user) 27 | 28 | 29 | @register.filter(is_safe=True) 30 | def mermaid_transition_line(transition, event_transitions): 31 | if (transition.input_node, transition.output_node) in event_transitions: 32 | return "-->" 33 | return "-.->" 34 | -------------------------------------------------------------------------------- /lbworkflow/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/tests/__init__.py -------------------------------------------------------------------------------- /lbworkflow/tests/issue/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/tests/issue/__init__.py -------------------------------------------------------------------------------- /lbworkflow/tests/issue/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from lbworkflow.models import BaseWFObj 4 | 5 | 6 | class Issue(BaseWFObj): 7 | title = models.CharField("Title", max_length=255) 8 | summary = models.CharField("Summary", max_length=255) 9 | content = models.TextField("Content", blank=True) 10 | 11 | def __str__(self): 12 | return self.title 13 | -------------------------------------------------------------------------------- /lbworkflow/tests/issue/wfdata.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.core.datahelper import ( 2 | create_category, 3 | create_node, 4 | create_process, 5 | create_transition, 6 | ) 7 | 8 | 9 | def load_data(): 10 | load_issue() 11 | 12 | 13 | def load_issue(): 14 | """load_[wf_code]""" 15 | category = create_category("5f31d065-00cc-0020-beea-641f0a670010", "HR") 16 | process = create_process("issue", "Issue", category=category) 17 | create_node( 18 | "5f31d065-00a0-0020-beea-641f0a670010", 19 | process, 20 | "Draft", 21 | status="draft", 22 | ) 23 | create_node( 24 | "5f31d065-00a0-0020-beea-641f0a670020", 25 | process, 26 | "Given up", 27 | status="given up", 28 | ) 29 | create_node( 30 | "5f31d065-00a0-0020-beea-641f0a670030", 31 | process, 32 | "Rejected", 33 | status="rejected", 34 | ) 35 | create_node( 36 | "5f31d065-00a0-0020-beea-641f0a670040", 37 | process, 38 | "Completed", 39 | status="completed", 40 | ) 41 | create_node( 42 | "5f31d065-00a0-0020-beea-641f0a670050", 43 | process, 44 | "A1", 45 | operators="[owner]", 46 | ) 47 | create_transition( 48 | "5f31d065-00e0-0020-beea-641f0a670010", process, "Draft,", "A1" 49 | ) 50 | create_transition( 51 | "5f31d065-00e0-0020-beea-641f0a670020", process, "A1,", "Completed" 52 | ) 53 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/tests/leave/__init__.py -------------------------------------------------------------------------------- /lbworkflow/tests/leave/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django import forms 3 | from lbutils import BootstrapFormHelperMixin 4 | 5 | from lbworkflow.forms import WorkflowFormMixin 6 | 7 | from .models import Leave 8 | 9 | 10 | class LeaveForm(BootstrapFormHelperMixin, WorkflowFormMixin, forms.ModelForm): 11 | def __init__(self, *args, **kw): 12 | super().__init__(*args, **kw) 13 | self.init_crispy_helper() 14 | self.layout_fields( 15 | [ 16 | ["start_on", "end_on"], 17 | ["leave_days", None], 18 | [ 19 | "reason", 20 | ], 21 | ] 22 | ) 23 | 24 | def save(self, commit=True): 25 | obj = super().save(commit=False) 26 | obj.init_actual_info() 27 | if commit: 28 | self.save_m2m() 29 | obj.save() 30 | return obj 31 | 32 | class Meta: 33 | model = Leave 34 | fields = [ 35 | "start_on", 36 | "end_on", 37 | "leave_days", 38 | "reason", 39 | ] 40 | 41 | 42 | class HRForm(BootstrapFormHelperMixin, WorkflowFormMixin, forms.ModelForm): 43 | comment = forms.CharField( 44 | label="Comment", required=False, widget=forms.Textarea() 45 | ) 46 | 47 | def __init__(self, *args, **kw): 48 | super().__init__(*args, **kw) 49 | self.init_crispy_helper(label_class="col-md-2", field_class="col-md-8") 50 | self.layout_fields( 51 | [ 52 | [ 53 | "actual_start_on", 54 | ], 55 | [ 56 | "actual_end_on", 57 | ], 58 | [ 59 | "actual_leave_days", 60 | ], 61 | [ 62 | "comment", 63 | ], 64 | ] 65 | ) 66 | 67 | class Meta: 68 | model = Leave 69 | fields = [ 70 | "actual_start_on", 71 | "actual_end_on", 72 | "actual_leave_days", 73 | ] 74 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from lbworkflow.models import BaseWFObj 4 | 5 | 6 | class Leave(BaseWFObj): 7 | start_on = models.DateTimeField("Start on") 8 | end_on = models.DateTimeField("End on") 9 | leave_days = models.DecimalField( 10 | "Leave days", max_digits=5, decimal_places=1 11 | ) 12 | 13 | actual_start_on = models.DateTimeField("Actual start on") 14 | actual_end_on = models.DateTimeField("Actual end on") 15 | actual_leave_days = models.DecimalField( 16 | "Actual leave days", max_digits=5, decimal_places=1 17 | ) 18 | 19 | reason = models.TextField("Reason") 20 | 21 | class Meta: 22 | verbose_name = "Leave" 23 | ordering = ["-created_on"] 24 | permissions = () 25 | 26 | def __str__(self): 27 | return "%s %s days" % ( 28 | self.created_by, 29 | self.leave_days, 30 | ) 31 | 32 | def init_actual_info(self): 33 | self.actual_start_on = self.start_on 34 | self.actual_end_on = self.end_on 35 | self.actual_leave_days = self.leave_days 36 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/base.html" %} 2 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/base_ext.html: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/base_ext.html" %} 2 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/base_form.html: -------------------------------------------------------------------------------- 1 | {% extends "lbadminlte/base_form.html" %} 2 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/base_formset.html: -------------------------------------------------------------------------------- 1 | {% extends "lbadminlte/base_formset.html" %} 2 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/leave/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/wf_base_detail.html" %} 2 | 3 | {% block right_side_header_ext_btns %} 4 | Print 5 | | 6 | {% endblock %} 7 | 8 | {% block right_side_tab_base_ctx %} 9 | {% include "leave/inc_detail_info.html" %} 10 | {{ for_test }} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/leave/form.html: -------------------------------------------------------------------------------- 1 | {% extends "lbworkflow/base_form.html" %} 2 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/leave/inc_detail_info.html: -------------------------------------------------------------------------------- 1 | {% include "lbworkflow/inc_wf_status.html" %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 33 |
Start on{{ object.start_on }}End on{{ object.end_on }}
Days{{ object.leave_days }}
Actual start on{{ object.actual_start_on }}Actual end on{{ object.actual_end_on }}
Days{{ object.actual_leave_days }}
Reason 30 | {{ object.reason|linebreaks }} 31 |
34 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/leave/list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_ext.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | {% load bootstrap_pagination %} 5 | {% load lbworkflow_tags %} 6 | 7 | {% block nav_sel_node %}id-nav-leave{% endblock %} 8 | 9 | {% block right_side %} 10 |
11 | {% include "incs/messages.html" %} 12 |
13 |
14 |

15 | Leave 16 |

17 |
18 |
19 |
20 | {% if search_form %} 21 |
22 |
23 | {% crispy search_form %} 24 |
25 |
26 | {% endif %} 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {% for o in object_list %}{% with pi=o.pinstance %} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 | {% endwith %}{% endfor %} 54 | 55 |
NO.Created byStart onEnd onCreated onCurrent operatorActivity
{{ pi.no }}{{ pi.created_by }}{{ o.start_on|date:"Y-m-d H:i" }}{{ o.end_on|date:"Y-m-d H:i" }}{{ pi.created_on|date:"Y-m-d H:i" }}{{ pi.get_operators_display }} 48 | 49 | {{ pi.cur_node.name }} 50 | 51 |
56 |
57 | 60 |
61 |
62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/templates/leave/print.html: -------------------------------------------------------------------------------- 1 | {% extends "lbadminlte/mbase_popup.html" %} 2 | 3 | {% block content %} 4 | {% include "leave/inc_detail_info.html" %} 5 |
6 | {% include "lbworkflow/inc_wf_history.html" %} 7 | {{ for_test }} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/views.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.views.generics import CreateView, UpdateView, WFListView 2 | 3 | from .forms import LeaveForm 4 | from .models import Leave 5 | 6 | 7 | class LeaveCreateView(CreateView): 8 | form_classes = { 9 | "form": LeaveForm, 10 | } 11 | 12 | 13 | new = LeaveCreateView.as_view() 14 | 15 | 16 | class LeaveUpdateView(UpdateView): 17 | form_classes = { 18 | "form": LeaveForm, 19 | } 20 | 21 | 22 | edit = LeaveUpdateView.as_view() 23 | 24 | 25 | class LeaveListView(WFListView): 26 | wf_code = "leave" 27 | model = Leave 28 | excel_file_name = "leave" 29 | excel_titles = [ 30 | "Created on", 31 | "Created by", 32 | "Start on", 33 | "End on", 34 | "Leave days", 35 | "Actual start on", 36 | "Actual start on", 37 | "Actual leave days", 38 | "Status", 39 | ] 40 | 41 | def get_excel_data(self, o): 42 | return [ 43 | o.created_by.username, 44 | o.created_on, 45 | o.start_on, 46 | o.end_on, 47 | o.leave_days, 48 | o.actual_start_on, 49 | o.actual_end_on, 50 | o.actual_leave_days, 51 | o.pinstance.cur_node.name, 52 | ] 53 | 54 | 55 | show_list = LeaveListView.as_view() 56 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/wf_views.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.views.transition import ExecuteTransitionView 2 | 3 | from .forms import HRForm 4 | 5 | 6 | class CustomizedTransitionView(ExecuteTransitionView): 7 | form_classes = {"form": HRForm} 8 | 9 | 10 | c = CustomizedTransitionView.as_view() 11 | -------------------------------------------------------------------------------- /lbworkflow/tests/leave/wfdata.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.core.datahelper import ( 2 | create_category, 3 | create_node, 4 | create_process, 5 | create_transition, 6 | ) 7 | 8 | 9 | def load_data(): 10 | load_leave() 11 | 12 | 13 | def load_leave(): 14 | """load_[wf_code]""" 15 | category = create_category("5f31d065-00cc-0020-beea-641f0a670010", "HR") 16 | process = create_process("leave", "Leave", category=category) 17 | create_node( 18 | "5f31d065-00a0-0010-beea-641f0a670010", 19 | process, 20 | "Draft", 21 | status="draft", 22 | ) 23 | create_node( 24 | "5f31d065-00a0-0010-beea-641f0a670010", 25 | process, 26 | "Draft", 27 | status="draft", 28 | ) # test for update 29 | create_node( 30 | "5f31d065-00a0-0010-beea-641f0a670020", 31 | process, 32 | "Given up", 33 | status="given up", 34 | ) 35 | create_node( 36 | "5f31d065-00a0-0010-beea-641f0a670030", 37 | process, 38 | "Rejected", 39 | status="rejected", 40 | ) 41 | create_node( 42 | "5f31d065-00a0-0010-beea-641f0a670040", 43 | process, 44 | "Completed", 45 | status="completed", 46 | ) 47 | create_node( 48 | "5f31d065-00a0-0010-beea-641f0a670050", 49 | process, 50 | "A1", 51 | operators="[owner]", 52 | ) 53 | create_node( 54 | "5f31d065-00a0-0010-beea-641f0a670060", 55 | process, 56 | "A2", 57 | operators="[tom]", 58 | ) 59 | create_node( 60 | "5f31d065-00a0-0010-beea-641f0a670065", 61 | process, 62 | "A2B1", 63 | operators="[tom],[owner]", 64 | ) 65 | create_node( 66 | "5f31d065-00a0-0010-beea-641f0a670070", 67 | process, 68 | "A3", 69 | operators="[vicalloy]", 70 | ) 71 | create_node( 72 | "5f31d065-00a0-0010-beea-641f0a670080", process, "A4", operators="[hr]" 73 | ) 74 | create_node( 75 | "5f31d065-00a0-0010-beea-641f0a670a10", 76 | process, 77 | "R1", 78 | operators="", 79 | node_type="router", 80 | ) 81 | create_transition( 82 | "5f31d065-00e0-0010-beea-641f0a670010", process, "Draft,", "A1" 83 | ) 84 | create_transition( 85 | "5f31d065-00e0-0010-beea-641f0a670020", process, "A1,", "A2" 86 | ) 87 | create_transition( 88 | "5f31d065-00e0-0010-beea-641f0a670030", 89 | process, 90 | "A2,", 91 | "A3", 92 | condition="o.leave_days<7 # days<7", 93 | ) 94 | create_transition( 95 | "5f31d065-00e0-0010-beea-641f0a670040", 96 | process, 97 | "A2,", 98 | "A2B1", 99 | condition="o.leave_days>=7 # days>=7", 100 | ) 101 | create_transition( 102 | "5f31d065-00e0-0010-beea-641f0a670050", 103 | process, 104 | "A2B1,", 105 | "A3", 106 | routing_rule="joint", 107 | can_auto_agree=False, 108 | ) 109 | create_transition( 110 | "5f31d065-00e0-0010-beea-641f0a670060", process, "A3,", "A4" 111 | ) 112 | create_transition( 113 | "5f31d065-00e0-0010-beea-641f0a670070", 114 | process, 115 | "A4,", 116 | "R1", 117 | app="Customized URL", 118 | app_param="wf_execute_transition {{wf_code}} c", 119 | ) 120 | create_transition( 121 | "5f31d065-00e0-0010-beea-641f0a670080", process, "R1,", "Completed" 122 | ) 123 | -------------------------------------------------------------------------------- /lbworkflow/tests/permissions.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.views.permissions import BasePermission 2 | 3 | 4 | class TestPermission(BasePermission): 5 | def has_permission(self, request, view): 6 | if request.user.username == "hr": 7 | return False 8 | return True 9 | 10 | def has_object_permission(self, request, view, obj): 11 | if request.user.username == "tom": 12 | return False 13 | return True 14 | -------------------------------------------------------------------------------- /lbworkflow/tests/purchase/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/tests/purchase/__init__.py -------------------------------------------------------------------------------- /lbworkflow/tests/purchase/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from lbworkflow.models import BaseWFObj 4 | 5 | 6 | class Purchase(BaseWFObj): 7 | title = models.CharField("Title", max_length=255) 8 | reason = models.CharField("Reason", max_length=255) 9 | 10 | def __str__(self): 11 | return self.reason 12 | 13 | 14 | class Item(models.Model): 15 | purchase = models.ForeignKey( 16 | Purchase, 17 | on_delete=models.CASCADE, 18 | ) 19 | name = models.CharField("Name", max_length=255) 20 | qty = models.IntegerField("Qty") 21 | note = models.CharField("Note", max_length=255) 22 | 23 | class Meta: 24 | verbose_name = "Purchase Item" 25 | 26 | def __str__(self): 27 | return self.name 28 | -------------------------------------------------------------------------------- /lbworkflow/tests/purchase/wfdata.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.core.datahelper import ( 2 | create_category, 3 | create_node, 4 | create_process, 5 | create_transition, 6 | ) 7 | 8 | 9 | def load_data(): 10 | load_issue() 11 | 12 | 13 | def load_issue(): 14 | """load_[wf_code]""" 15 | category = create_category("5f31d065-00cc-0020-beea-641f0a670010", "HR") 16 | process = create_process("purchase", "Purchase", category=category) 17 | create_node( 18 | "5f31d065-00a0-0030-beea-641f0a670010", 19 | process, 20 | "Draft", 21 | status="draft", 22 | ) 23 | create_node( 24 | "5f31d065-00a0-0030-beea-641f0a670020", 25 | process, 26 | "Given up", 27 | status="given up", 28 | ) 29 | create_node( 30 | "5f31d065-00a0-0030-beea-641f0a670030", 31 | process, 32 | "Rejected", 33 | status="rejected", 34 | ) 35 | create_node( 36 | "5f31d065-00a0-0030-beea-641f0a670040", 37 | process, 38 | "Completed", 39 | status="completed", 40 | ) 41 | create_node( 42 | "5f31d065-00a0-0030-beea-641f0a670050", 43 | process, 44 | "A1", 45 | operators="[owner]", 46 | ) 47 | create_transition( 48 | "5f31d065-00e0-0030-beea-641f0a670010", process, "Draft,", "A1" 49 | ) 50 | create_transition( 51 | "5f31d065-00e0-0030-beea-641f0a670020", process, "A1,", "Completed" 52 | ) 53 | -------------------------------------------------------------------------------- /lbworkflow/tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "*lx!g2o%m)b613n$709334=+ulwi^&6e8=o6h3upwn4&3c$o^p" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = [ 33 | "django.contrib.admin", 34 | "django.contrib.auth", 35 | "django.contrib.contenttypes", 36 | "django.contrib.sessions", 37 | "django.contrib.messages", 38 | "django.contrib.staticfiles", 39 | "crispy_forms", 40 | "lbattachment", 41 | "lbadminlte", 42 | "lbutils", 43 | "compressor", 44 | "django_select2", 45 | "bootstrap_pagination", 46 | "lbworkflow", 47 | "lbworkflow.simplewf", 48 | "lbworkflow.tests.leave", 49 | "lbworkflow.tests.purchase", 50 | "lbworkflow.tests.issue", 51 | ] 52 | 53 | MIDDLEWARE = [ 54 | "django.middleware.security.SecurityMiddleware", 55 | "django.contrib.sessions.middleware.SessionMiddleware", 56 | "django.middleware.common.CommonMiddleware", 57 | "django.middleware.csrf.CsrfViewMiddleware", 58 | "django.contrib.auth.middleware.AuthenticationMiddleware", 59 | "django.contrib.messages.middleware.MessageMiddleware", 60 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 61 | ] 62 | 63 | ROOT_URLCONF = "lbworkflow.tests.urls" 64 | 65 | TEMPLATES = [ 66 | { 67 | "BACKEND": "django.template.backends.django.DjangoTemplates", 68 | "DIRS": [], 69 | "APP_DIRS": True, 70 | "OPTIONS": { 71 | "context_processors": [ 72 | "django.template.context_processors.debug", 73 | "django.template.context_processors.request", 74 | "django.contrib.auth.context_processors.auth", 75 | "django.contrib.messages.context_processors.messages", 76 | ], 77 | }, 78 | }, 79 | ] 80 | 81 | WSGI_APPLICATION = "testproject.wsgi.application" 82 | 83 | 84 | # Database 85 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 86 | 87 | DATABASES = { 88 | "default": { 89 | "ENGINE": "django.db.backends.sqlite3", 90 | } 91 | } 92 | 93 | 94 | # Password validation 95 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 96 | 97 | AUTH_PASSWORD_VALIDATORS = [ 98 | { 99 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 100 | }, 101 | { 102 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 103 | }, 104 | { 105 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 106 | }, 107 | { 108 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 109 | }, 110 | ] 111 | 112 | 113 | # Internationalization 114 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 115 | 116 | LANGUAGE_CODE = "en-us" 117 | 118 | TIME_ZONE = "UTC" 119 | 120 | USE_I18N = True 121 | 122 | USE_L10N = True 123 | 124 | USE_TZ = True 125 | 126 | 127 | # Static files (CSS, JavaScript, Images) 128 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 129 | 130 | STATIC_URL = "/static/" 131 | 132 | LBWF_APPS = { 133 | "leave": "lbworkflow.tests.leave", 134 | "purchase": "lbworkflow.tests.purchase", 135 | "simplewf": "lbworkflow.simplewf", 136 | } 137 | 138 | STATIC_ROOT = os.path.join(BASE_DIR, "collectedstatic") 139 | 140 | STATICFILES_FINDERS = ( 141 | "django.contrib.staticfiles.finders.FileSystemFinder", 142 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 143 | ) 144 | 145 | STATICFILES_DIRS = ( 146 | os.path.join(BASE_DIR, '..', 'node_modules'), 147 | ) 148 | 149 | CRISPY_TEMPLATE_PACK = "bootstrap3" 150 | 151 | # django-compressor 152 | STATICFILES_FINDERS += (("compressor.finders.CompressorFinder"),) 153 | COMPRESS_PRECOMPILERS = ( 154 | ("text/coffeescript", "coffee --compile --stdio"), 155 | ("text/less", "lessc {infile} {outfile}"), 156 | ("text/x-sass", "sass {infile} {outfile}"), 157 | ("text/x-scss", "sass --scss {infile} {outfile}"), 158 | ) 159 | 160 | PROJECT_TITLE = "LB-Workflow" 161 | 162 | LBWF_DEFAULT_PERMISSION_CLASSES = ["lbworkflow.views.permissions.AllowAny"] 163 | LBWF_DEFAULT_NEW_PERMISSION_CLASSES = [ 164 | "lbworkflow.tests.permissions.TestPermission" 165 | ] 166 | 167 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 168 | # LBWF_DEFAULT_EDIT_PERMISSION_CLASSES = ['lbworkflow.views.permissions.AllowAny'] 169 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from django.utils import timezone 4 | from lbutils import get_or_none 5 | 6 | from lbworkflow.core.datahelper import load_wf_data 7 | 8 | from .leave.models import Leave 9 | from .wfdata import init_users 10 | 11 | User = get_user_model() 12 | 13 | 14 | class BaseTests(TestCase): 15 | def setUp(self): 16 | self.init_data() 17 | 18 | def init_users(self): 19 | super().setUp() 20 | self.users = init_users() 21 | 22 | # TODO add a function to submit new leave 23 | def create_leave(self, reason, submit=True): 24 | leave = Leave( 25 | start_on=timezone.now(), 26 | end_on=timezone.now(), 27 | leave_days=1, 28 | reason=reason, 29 | created_by=self.users["owner"], 30 | ) 31 | leave.init_actual_info() 32 | leave.save() 33 | leave.create_pinstance("leave", submit) 34 | return leave 35 | 36 | def get_leave(self, reason): 37 | return get_or_none(Leave, reason=reason) 38 | 39 | def init_leave(self): 40 | self.leave = self.create_leave("reason", False) 41 | 42 | def init_data(self): 43 | self.init_users() 44 | load_wf_data("lbworkflow") 45 | load_wf_data("lbworkflow.tests.leave") 46 | self.init_leave() 47 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_flowchart.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from .test_base import BaseTests 4 | 5 | 6 | class ViewTests(BaseTests): 7 | def test_flowchart(self): 8 | resp = self.client.get( 9 | reverse("wf_process_flowchart", args=("leave",)) 10 | ) 11 | self.assertEqual(resp.status_code, 200) 12 | 13 | def test_process_instance_flowchart(self): 14 | leave = self.create_leave("flowchart", True) 15 | resp = self.client.get( 16 | reverse( 17 | "wf_process_instance_flowchart", args=(leave.pinstance.pk,) 18 | ) 19 | ) 20 | self.assertEqual(resp.status_code, 200) 21 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_flowgen.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.flowgen import FlowAppGenerator, clean_generated_files 2 | from lbworkflow.tests.issue.models import Issue 3 | from lbworkflow.tests.purchase.models import Item, Purchase 4 | 5 | from .test_base import BaseTests 6 | 7 | 8 | class ViewTests(BaseTests): 9 | def test_gen_no_item_list(self): 10 | FlowAppGenerator().gen(Issue) 11 | clean_generated_files(Issue) 12 | 13 | def test_gen_with_item_list(self): 14 | FlowAppGenerator().gen(Purchase, [Item]) 15 | clean_generated_files(Purchase) 16 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/tests/test_models.py -------------------------------------------------------------------------------- /lbworkflow/tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.urls import reverse 3 | 4 | from .leave.models import Leave 5 | from .test_base import BaseTests 6 | 7 | User = get_user_model() 8 | 9 | 10 | class PermissionTests(BaseTests): 11 | def setUp(self): 12 | super().setUp() 13 | self.client.login(username="owner", password="password") 14 | 15 | def test_submit(self): 16 | self.client.login(username="hr", password="password") 17 | url = reverse("wf_new", args=("leave",)) 18 | resp = self.client.get(url) 19 | self.assertEqual(resp.status_code, 403) 20 | 21 | self.client.login(username="tom", password="password") 22 | url = reverse("wf_new", args=("leave",)) 23 | resp = self.client.get(url) 24 | self.assertEqual(resp.status_code, 403) 25 | 26 | def test_edit(self): 27 | self.client.login(username="owner", password="password") 28 | 29 | data = { 30 | "start_on": "2017-04-19 09:01", 31 | "end_on": "2017-04-20 09:01", 32 | "leave_days": "1", 33 | "reason": "test save", 34 | } 35 | url = reverse("wf_new", args=("leave",)) 36 | resp = self.client.post(url, data) 37 | leave = Leave.objects.get(reason="test save") 38 | self.assertRedirects(resp, "/wf/%s/" % leave.pinstance.pk) 39 | self.assertEqual("Draft", leave.pinstance.cur_node.name) 40 | 41 | # only poster can edit draft 42 | url = reverse("wf_edit", args=(leave.pinstance.pk,)) 43 | resp = self.client.get(url) 44 | self.assertEqual(resp.status_code, 200) 45 | 46 | # other user can't edit draft 47 | self.client.login(username="hr", password="password") 48 | url = reverse("wf_edit", args=(leave.pinstance.pk,)) 49 | resp = self.client.get(url) 50 | self.assertEqual(resp.status_code, 403) 51 | 52 | self.client.login(username="owner", password="password") 53 | data["act_submit"] = "Submit" 54 | data["reason"] = "test submit" 55 | resp = self.client.post(url, data) 56 | leave = Leave.objects.get(reason="test submit") 57 | self.assertRedirects(resp, "/wf/%s/" % leave.pinstance.pk) 58 | self.assertEqual("A2", leave.pinstance.cur_node.name) 59 | 60 | url = reverse("wf_edit", args=(leave.pinstance.pk,)) 61 | resp = self.client.get(url) 62 | self.assertEqual(resp.status_code, 403) 63 | 64 | def test_detail(self): 65 | self.client.login(username="hr", password="password") 66 | resp = self.client.get(reverse("wf_detail", args=("1",))) 67 | self.assertEqual(resp.status_code, 403) 68 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_process.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.urls import reverse 3 | 4 | from lbworkflow.views.helper import user_wf_info_as_dict 5 | 6 | from .leave.models import Leave 7 | from .test_base import BaseTests 8 | 9 | User = get_user_model() 10 | 11 | 12 | class HelperTests(BaseTests): 13 | def test_user_wf_info_as_dict(self): 14 | leave = self.leave 15 | leave.submit_process() 16 | 17 | info = user_wf_info_as_dict(leave, self.users["tom"]) 18 | self.assertIsNotNone(info["task"]) 19 | self.assertIsNotNone(info["object"]) 20 | self.assertFalse(info["can_give_up"]) 21 | self.assertEqual(info["wf_code"], "leave") 22 | 23 | info = user_wf_info_as_dict(leave, self.users["owner"]) 24 | self.assertIsNone(info["task"]) 25 | self.assertTrue(info["can_give_up"]) 26 | 27 | info = user_wf_info_as_dict(leave, self.users["vicalloy"]) 28 | self.assertIsNone(info["task"]) 29 | 30 | 31 | class ViewTests(BaseTests): 32 | def setUp(self): 33 | super().setUp() 34 | self.client.login(username="owner", password="password") 35 | 36 | def test_start_wf(self): 37 | resp = self.client.get(reverse("wf_start_wf")) 38 | self.assertEqual(resp.status_code, 200) 39 | 40 | def test_wf_list(self): 41 | resp = self.client.get(reverse("wf_list", args=("leave",))) 42 | self.assertEqual(resp.status_code, 200) 43 | 44 | def test_wf_report_list(self): 45 | resp = self.client.get(reverse("wf_report_list")) 46 | self.assertEqual(resp.status_code, 200) 47 | 48 | def test_wf_list_export(self): 49 | resp = self.client.get( 50 | reverse("wf_list", args=("leave",)), {"export": 1} 51 | ) 52 | self.assertEqual(resp.status_code, 200) 53 | 54 | def test_detail(self): 55 | resp = self.client.get(reverse("wf_detail", args=("1",))) 56 | self.assertEqual(resp.status_code, 200) 57 | 58 | def test_submit(self): 59 | self.client.login(username="owner", password="password") 60 | 61 | url = reverse("wf_new", args=("leave",)) 62 | resp = self.client.get(url) 63 | self.assertEqual(resp.status_code, 200) 64 | 65 | data = { 66 | "start_on": "2017-04-19 09:01", 67 | "end_on": "2017-04-20 09:01", 68 | "leave_days": "1", 69 | "reason": "test save", 70 | } 71 | resp = self.client.post(url, data) 72 | leave = Leave.objects.get(reason="test save") 73 | self.assertRedirects(resp, "/wf/%s/" % leave.pinstance.pk) 74 | self.assertEqual("Draft", leave.pinstance.cur_node.name) 75 | 76 | data["act_submit"] = "Submit" 77 | data["reason"] = "test submit" 78 | resp = self.client.post(url, data) 79 | leave = Leave.objects.get(reason="test submit") 80 | self.assertRedirects(resp, "/wf/%s/" % leave.pinstance.pk) 81 | self.assertEqual("A2", leave.pinstance.cur_node.name) 82 | 83 | def test_edit(self): 84 | self.client.login(username="owner", password="password") 85 | 86 | data = { 87 | "start_on": "2017-04-19 09:01", 88 | "end_on": "2017-04-20 09:01", 89 | "leave_days": "1", 90 | "reason": "test save", 91 | } 92 | url = reverse("wf_new", args=("leave",)) 93 | resp = self.client.post(url, data) 94 | leave = Leave.objects.get(reason="test save") 95 | self.assertRedirects(resp, "/wf/%s/" % leave.pinstance.pk) 96 | self.assertEqual("Draft", leave.pinstance.cur_node.name) 97 | 98 | url = reverse("wf_edit", args=(leave.pinstance.pk,)) 99 | resp = self.client.get(url) 100 | self.assertEqual(resp.status_code, 200) 101 | 102 | data["act_submit"] = "Submit" 103 | data["reason"] = "test submit" 104 | resp = self.client.post(url, data) 105 | leave = Leave.objects.get(reason="test submit") 106 | self.assertRedirects(resp, "/wf/%s/" % leave.pinstance.pk) 107 | self.assertEqual("A2", leave.pinstance.cur_node.name) 108 | 109 | def test_delete(self): 110 | self.client.login(username="admin", password="password") 111 | # POST 112 | url = reverse("wf_delete") 113 | leave = self.create_leave("to delete") 114 | data = {"pk": leave.pinstance.pk} 115 | resp = self.client.post(url, data) 116 | self.assertRedirects(resp, "/wf/list/") 117 | self.assertIsNone(self.get_leave("to delete")) 118 | 119 | # GET 120 | leave = self.create_leave("to delete") 121 | data = {"pk": leave.pinstance.pk} 122 | resp = self.client.get(url, data) 123 | self.assertRedirects(resp, "/wf/list/") 124 | self.assertIsNone(self.get_leave("to delete")) 125 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_simplewf.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.urls import reverse 3 | 4 | from lbworkflow.core.datahelper import load_wf_data 5 | from lbworkflow.simplewf.models import SimpleWorkFlow 6 | 7 | from .test_base import BaseTests 8 | 9 | User = get_user_model() 10 | 11 | 12 | class SimpleWFTests(BaseTests): 13 | def create_wf(self, wf_code, summary, submit=True): 14 | wf = SimpleWorkFlow.objects.create( 15 | summary=summary, created_by=self.users["owner"] 16 | ) 17 | wf.create_pinstance(wf_code, submit) 18 | return wf 19 | 20 | def init_data(self): 21 | super().init_data() 22 | load_wf_data("lbworkflow.simplewf") 23 | self.wf_a_1 = self.create_wf("simplewf__A", "wf_a_1") 24 | self.wf_a_2 = self.create_wf("simplewf__A", "wf_a_2") 25 | self.wf_b_1 = self.create_wf("simplewf__B", "wf_b_1") 26 | 27 | def setUp(self): 28 | super().setUp() 29 | self.client.login(username="owner", password="password") 30 | 31 | def test_wf_list(self): 32 | resp = self.client.get(reverse("wf_list", args=("simplewf__A",))) 33 | self.assertEqual(resp.status_code, 200) 34 | self.assertContains(resp, "wf_a_1") 35 | self.assertContains(resp, "wf_a_2") 36 | self.assertNotContains(resp, "wf_b_1") 37 | 38 | resp = self.client.get(reverse("wf_list", args=("simplewf__B",))) 39 | self.assertEqual(resp.status_code, 200) 40 | self.assertNotContains(resp, "wf_a_1") 41 | self.assertNotContains(resp, "wf_a_2") 42 | self.assertContains(resp, "wf_b_1") 43 | 44 | def test_wf_list_export(self): 45 | resp = self.client.get( 46 | reverse("wf_list", args=("simplewf__A",)), {"export": 1} 47 | ) 48 | self.assertEqual(resp.status_code, 200) 49 | 50 | def test_detail(self): 51 | resp = self.client.get( 52 | reverse("wf_detail", args=(self.wf_a_1.pinstance.pk,)) 53 | ) 54 | self.assertEqual(resp.status_code, 200) 55 | 56 | def test_submit(self): 57 | self.client.login(username="owner", password="password") 58 | 59 | url = reverse("wf_new", args=("simplewf__A",)) 60 | resp = self.client.get(url) 61 | self.assertEqual(resp.status_code, 200) 62 | 63 | def test_edit(self): 64 | self.client.login(username="admin", password="password") 65 | 66 | url = reverse("wf_edit", args=(self.wf_a_1.pinstance.pk,)) 67 | resp = self.client.get(url) 68 | self.assertEqual(resp.status_code, 200) 69 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_transition.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.urls import reverse 3 | 4 | from lbworkflow.core.transition import TransitionExecutor 5 | from lbworkflow.views.helper import user_wf_info_as_dict 6 | 7 | from .leave.models import Leave 8 | from .test_base import BaseTests 9 | 10 | User = get_user_model() 11 | 12 | 13 | class TransitionExecutorTests(BaseTests): 14 | def test_submit(self): 15 | leave = self.leave 16 | instance = self.leave.pinstance 17 | leave.submit_process() 18 | 19 | # A1 will auto agree 20 | self.assertEqual(leave.pinstance.cur_node.name, "A2") 21 | self.assertEqual(leave.pinstance.get_operators_display(), "tom") 22 | 23 | # A3 not auto agree 24 | task = instance.get_todo_task() 25 | transition = instance.get_agree_transition() 26 | TransitionExecutor( 27 | self.users["tom"], instance, task, transition 28 | ).execute() 29 | self.assertEqual(leave.pinstance.cur_node.name, "A3") 30 | 31 | 32 | class ViewTests(BaseTests): 33 | def get_transition_url(self, leave, user): 34 | ctx = user_wf_info_as_dict(leave, user) 35 | 36 | transitions = ctx["transitions"] 37 | transition = transitions[0] 38 | transition_url = transition.get_app_url(ctx["task"]) 39 | return transition_url 40 | 41 | def setUp(self): 42 | super(ViewTests, self).setUp() 43 | self.leave.submit_process() 44 | 45 | leave = self.leave 46 | ctx = user_wf_info_as_dict(leave, self.users["tom"]) 47 | 48 | transitions = ctx["transitions"] 49 | transition = transitions[0] 50 | self.transition_url = transition.get_app_url(ctx["task"]) 51 | 52 | self.task = ctx["task"] 53 | 54 | self.client.login(username="tom", password="password") 55 | 56 | def do_agree(self, username, node_name, leave=None, data={}): 57 | if not leave: 58 | leave = self.leave 59 | leave = Leave.objects.get(pk=leave.pk) 60 | 61 | self.client.login(username=username, password="password") 62 | 63 | transition_url = self.get_transition_url(leave, self.users[username]) 64 | resp = self.client.post(transition_url, data=data) 65 | self.assertRedirects(resp, "/wf/todo/") 66 | leave = Leave.objects.get(pk=leave.pk) 67 | self.assertEqual(node_name, leave.pinstance.cur_node.name) 68 | 69 | def test_execute_transition(self): 70 | self.do_agree("tom", "A3") 71 | self.do_agree("vicalloy", "A4") 72 | 73 | def test_execute_transition_customized_url(self): 74 | self.do_agree("tom", "A3") 75 | self.do_agree("vicalloy", "A4") 76 | data = { 77 | "actual_start_on": "2017-04-25 08:00", 78 | "actual_end_on": "2017-04-26 08:00", 79 | "actual_leave_days": "2", 80 | } 81 | self.do_agree("hr", "Completed", data=data) 82 | 83 | def goto_A2B1(self): 84 | leave = self.leave 85 | leave.leave_days = 10 86 | leave.save() 87 | 88 | self.do_agree("tom", "A2B1") 89 | 90 | def test_execute_transition_with_condition(self): 91 | self.goto_A2B1() 92 | 93 | def test_execute_transition_joint(self): 94 | self.goto_A2B1() 95 | self.do_agree("tom", "A2B1") 96 | self.do_agree("owner", "A3") 97 | 98 | def test_execute_transition_no_permission(self): 99 | self.client.login(username="vicalloy", password="password") 100 | resp = self.client.post(self.transition_url) 101 | self.assertEqual(resp.status_code, 403) 102 | 103 | def test_simple_agree(self): 104 | url = reverse("wf_agree") 105 | resp = self.client.get("%s?wi_id=%s" % (url, self.task.pk)) 106 | self.assertEqual(resp.status_code, 200) 107 | 108 | resp = self.client.post("%s?wi_id=%s" % (url, self.task.pk)) 109 | self.assertRedirects(resp, "/wf/todo/") 110 | leave = Leave.objects.get(pk=self.leave.pk) 111 | self.assertEqual("A3", leave.pinstance.cur_node.name) 112 | 113 | def test_reject(self): 114 | url = reverse("wf_reject") 115 | resp = self.client.get("%s?wi_id=%s" % (url, self.task.pk)) 116 | self.assertEqual(resp.status_code, 200) 117 | 118 | resp = self.client.post("%s?wi_id=%s" % (url, self.task.pk)) 119 | self.assertRedirects(resp, "/wf/todo/") 120 | leave = Leave.objects.get(pk=self.leave.pk) 121 | self.assertEqual("Rejected", leave.pinstance.cur_node.name) 122 | 123 | def test_give_up(self): 124 | self.client.login(username="owner", password="password") 125 | url = reverse("wf_give_up") 126 | resp = self.client.get("%s?pk=%s" % (url, self.leave.pinstance.pk)) 127 | self.assertEqual(resp.status_code, 200) 128 | 129 | resp = self.client.post("%s?pk=%s" % (url, self.leave.pinstance.pk)) 130 | self.assertRedirects(resp, "/wf/my/") 131 | leave = Leave.objects.get(pk=self.leave.pk) 132 | self.assertEqual("Given up", leave.pinstance.cur_node.name) 133 | 134 | def test_back_to(self): 135 | self.client.post(self.transition_url) # A2 TO A3 136 | leave = Leave.objects.get(pk=self.leave.pk) 137 | 138 | url = reverse("wf_back_to") 139 | resp = self.client.get("%s?wi_id=%s" % (url, self.task.pk)) 140 | self.assertEqual(resp.status_code, 200) 141 | back_to_node = leave.pinstance.get_can_back_to_activities()[0] 142 | 143 | resp = self.client.post( 144 | "%s?wi_id=%s" % (url, self.task.pk), 145 | {"back_to_node": back_to_node.pk}, 146 | ) 147 | self.assertRedirects(resp, "/wf/todo/") 148 | leave = Leave.objects.get(pk=self.leave.pk) 149 | self.assertEqual("A2", leave.pinstance.cur_node.name) 150 | 151 | def test_batch_agree(self): 152 | url = reverse("wf_batch_agree") 153 | ctx = user_wf_info_as_dict(self.leave, self.users["tom"]) 154 | data = { 155 | "wi": [ctx["task"].pk, 1, 2, 3], 156 | } 157 | resp = self.client.post(url, data) 158 | self.assertEqual(resp.status_code, 200) 159 | 160 | data["do_submit"] = "submit" 161 | resp = self.client.post(url, data) 162 | self.assertRedirects(resp, "/wf/todo/") 163 | leave = Leave.objects.get(pk=self.leave.pk) 164 | self.assertEqual("A3", leave.pinstance.cur_node.name) 165 | 166 | def test_batch_reject(self): 167 | url = reverse("wf_batch_reject") 168 | ctx = user_wf_info_as_dict(self.leave, self.users["tom"]) 169 | data = { 170 | "wi": [ctx["task"].pk, 1, 2, 3], 171 | } 172 | resp = self.client.post(url, data) 173 | self.assertEqual(resp.status_code, 200) 174 | 175 | data["do_submit"] = "submit" 176 | resp = self.client.post(url, data) 177 | self.assertRedirects(resp, "/wf/todo/") 178 | leave = Leave.objects.get(pk=self.leave.pk) 179 | self.assertEqual("Rejected", leave.pinstance.cur_node.name) 180 | 181 | def test_batch_give_up(self): 182 | self.client.login(username="owner", password="password") 183 | url = reverse("wf_batch_give_up") 184 | data = { 185 | "pi": [self.leave.pinstance.pk, 1, 2, 3], 186 | } 187 | resp = self.client.post(url, data) 188 | self.assertEqual(resp.status_code, 200) 189 | 190 | data["do_submit"] = "submit" 191 | resp = self.client.post(url, data) 192 | self.assertRedirects(resp, "/wf/my/") 193 | leave = Leave.objects.get(pk=self.leave.pk) 194 | self.assertEqual("Given up", leave.pinstance.cur_node.name) 195 | 196 | def test_add_assignee(self): 197 | url = reverse("wf_add_assignee") 198 | url = "%s?wi_id=%s" % (url, self.task.pk) 199 | resp = self.client.get(url) 200 | self.assertEqual(resp.status_code, 200) 201 | 202 | data = { 203 | "assignees": ( 204 | self.users["hr"].pk, 205 | self.users["owner"].pk, 206 | ), 207 | "comment": "comments", 208 | } 209 | resp = self.client.post(url, data) 210 | self.assertRedirects(resp, "/wf/todo/") 211 | leave = Leave.objects.get(pk=self.leave.pk) 212 | self.assertEqual("A2", leave.pinstance.cur_node.name) 213 | 214 | self.do_agree("tom", "A2") 215 | self.do_agree("hr", "A2") 216 | self.do_agree("owner", "A3") 217 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_userparser.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | from lbworkflow.core.userparser import SimpleUserParser 4 | 5 | from .test_base import BaseTests 6 | 7 | User = get_user_model() 8 | 9 | 10 | class UserSimpleParserTests(BaseTests): 11 | def test_parser_users(self): 12 | users = SimpleUserParser("[vicalloy]").parse() 13 | self.assertEqual(users[0], self.users["vicalloy"]) 14 | 15 | users = SimpleUserParser( 16 | "[%s:vicalloy]" % self.users["vicalloy"].pk 17 | ).parse() 18 | self.assertEqual(users[0], self.users["vicalloy"]) 19 | 20 | users = SimpleUserParser("#owner", owner=self.users["owner"]).parse() 21 | self.assertEqual(users[0], self.users["owner"]) 22 | 23 | users = SimpleUserParser( 24 | "#operator", operator=self.users["operator"] 25 | ).parse() 26 | self.assertEqual(users[0], self.users["operator"]) 27 | 28 | def test_eval_as_list(self): 29 | # [o.auditors] 30 | users = SimpleUserParser( 31 | "[o.created_by]", self.leave.pinstance 32 | ).parse() 33 | self.assertEqual(users[0], self.users["owner"]) 34 | 35 | def test_condition_rules(self): 36 | rules = """ 37 | :True 38 | [owner] 39 | [operator] 40 | :False 41 | [vicalloy] 42 | """ 43 | users = SimpleUserParser( 44 | rules, 45 | operator=self.users["operator"], 46 | owner=self.users["owner"], 47 | ).parse() 48 | self.assertEqual( 49 | set(users), set([self.users["owner"], self.users["operator"]]) 50 | ) 51 | 52 | def test_parser_groups(self): 53 | # TODO 54 | pass 55 | -------------------------------------------------------------------------------- /lbworkflow/tests/test_views_list.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from .test_base import BaseTests 4 | 5 | 6 | class ViewTests(BaseTests): 7 | def test_my_wf(self): 8 | self.client.login(username="owner", password="password") 9 | resp = self.client.get(reverse("wf_my_wf")) 10 | self.assertEqual(resp.status_code, 200) 11 | 12 | def test_list_wf(self): 13 | self.client.login(username="owner", password="password") 14 | resp = self.client.get(reverse("wf_list_wf")) 15 | self.assertEqual(resp.status_code, 200) 16 | 17 | def test_todo(self): 18 | self.client.login(username="owner", password="password") 19 | resp = self.client.get(reverse("wf_todo")) 20 | self.assertEqual(resp.status_code, 200) 21 | -------------------------------------------------------------------------------- /lbworkflow/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | from django.views.generic import RedirectView 4 | 5 | urlpatterns = [ 6 | path("", RedirectView.as_view(url="/wf/list/"), name="home"), 7 | path("admin/", admin.site.urls), 8 | path("wf/", include("lbworkflow.urls")), 9 | path("attachment/", include("lbattachment.urls")), 10 | path("select2/", include("django_select2.urls")), 11 | ] 12 | -------------------------------------------------------------------------------- /lbworkflow/tests/wfdata.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.core.datahelper import create_user 2 | 3 | 4 | def load_data(): 5 | init_users() 6 | 7 | 8 | def init_users(): 9 | users = { 10 | "owner": create_user("owner"), 11 | "operator": create_user("operator"), 12 | "vicalloy": create_user("vicalloy"), 13 | "tom": create_user("tom"), 14 | "hr": create_user("hr"), 15 | "admin": create_user("admin", is_superuser=True, is_staff=True), 16 | } 17 | return users 18 | -------------------------------------------------------------------------------- /lbworkflow/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import flowchart, processinstance 4 | from .views.list import ListWF, MyWF, Todo 5 | from .views.transition import ( 6 | AddAssigneeView, 7 | BatchExecuteAgreeTransitionView, 8 | BatchExecuteGiveUpTransitionView, 9 | BatchExecuteRejectTransitionView, 10 | ExecuteAgreeTransitionView, 11 | ExecuteBackToTransitionView, 12 | ExecuteGiveUpTransitionView, 13 | ExecuteRejectTransitionView, 14 | ExecuteTransitionView, 15 | execute_transitions, 16 | ) 17 | 18 | urlpatterns = [ 19 | path("t/", ExecuteTransitionView.as_view(), name="wf_execute_transition"), 20 | path("t/agree/", ExecuteAgreeTransitionView.as_view(), name="wf_agree"), 21 | path( 22 | "t/back_to/", ExecuteBackToTransitionView.as_view(), name="wf_back_to" 23 | ), 24 | path("t/reject/", ExecuteRejectTransitionView.as_view(), name="wf_reject"), 25 | path( 26 | "t/give_up/", ExecuteGiveUpTransitionView.as_view(), name="wf_give_up" 27 | ), 28 | path("t/add_assignee/", AddAssigneeView.as_view(), name="wf_add_assignee"), 29 | path( 30 | "t/batch/agree/", 31 | BatchExecuteAgreeTransitionView.as_view(), 32 | name="wf_batch_agree", 33 | ), 34 | path( 35 | "t/batch/reject/", 36 | BatchExecuteRejectTransitionView.as_view(), 37 | name="wf_batch_reject", 38 | ), 39 | path( 40 | "t/batch/give_up/", 41 | BatchExecuteGiveUpTransitionView.as_view(), 42 | name="wf_batch_give_up", 43 | ), 44 | path( 45 | "t/e///", 46 | execute_transitions, 47 | name="wf_execute_transition", 48 | ), 49 | path("start_wf/", processinstance.start_wf, name="wf_start_wf"), 50 | path("report_list/", processinstance.report_list, name="wf_report_list"), 51 | path("new//", processinstance.new, name="wf_new"), 52 | path("delete/", processinstance.delete, name="wf_delete"), 53 | path("list//", processinstance.show_list, name="wf_list"), 54 | path("edit//", processinstance.edit, name="wf_edit"), 55 | path("/", processinstance.detail, name="wf_detail"), 56 | path( 57 | "/print/", 58 | processinstance.detail, 59 | {"ext_ctx": {"is_print": True}}, 60 | name="wf_print_detail", 61 | ), 62 | path("todo/", Todo.as_view(), name="wf_todo"), 63 | path("list/", ListWF.as_view(), name="wf_list_wf"), 64 | path("my/", MyWF.as_view(), name="wf_my_wf"), 65 | path( 66 | "flowchart/process//", 67 | flowchart.process_flowchart, 68 | name="wf_process_flowchart", 69 | ), 70 | path( 71 | "flowchart//", 72 | flowchart.process_instance_flowchart, 73 | name="wf_process_instance_flowchart", 74 | ), 75 | ] 76 | -------------------------------------------------------------------------------- /lbworkflow/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/lbworkflow/views/__init__.py -------------------------------------------------------------------------------- /lbworkflow/views/flowchart.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | 3 | from lbworkflow.models import Process, ProcessInstance 4 | 5 | 6 | def get_event_transitions(process_instance): 7 | from lbworkflow.models import Event 8 | 9 | events = Event.objects.filter(instance=process_instance).order_by( 10 | "-created_on", "-id" 11 | ) 12 | transitions = [] 13 | for event in events: 14 | transition = (event.old_node, event.new_node) 15 | if event.new_node.status in ["rejected", "given up"]: 16 | break 17 | if transition not in transitions: 18 | transitions.append(transition) 19 | return transitions 20 | 21 | 22 | def generate_mermaid_src(process_instance): 23 | file_template = """ 24 | {% load lbworkflow_tags %} 25 | graph TD 26 | {% for node in nodes %} 27 | {% if node.node_type == 'router' %} 28 | {{node.pk}}{ {{node.name}} } 29 | {% else %} 30 | {{node.pk}}( {{node.name}} ) 31 | {% endif %} 32 | {% endfor %} 33 | {% for transition in transitions %} 34 | {{ transition.input_node.pk }} {{ transition|mermaid_transition_line:event_transitions|safe }}{% if transition.get_condition_descn %}|{{transition.get_condition_descn}}|{% endif %} {{ transition.output_node.pk }} 35 | {% endfor %} 36 | """ # NOQA 37 | if isinstance(process_instance, Process): 38 | process = process_instance 39 | process_instance = None 40 | else: 41 | process = process_instance.process 42 | 43 | transitions = process.transition_set.all() 44 | event_transitions = [] 45 | if process_instance: 46 | event_transitions = get_event_transitions(process_instance) 47 | 48 | nodes = process.node_set.all() 49 | ctx = Context( 50 | { 51 | "name": process.name, 52 | "nodes": nodes, 53 | "transitions": transitions, 54 | "event_transitions": event_transitions, 55 | } 56 | ) 57 | t = Template(file_template) 58 | return t.render(ctx) 59 | 60 | 61 | def process_flowchart(request, wf_code): 62 | from django.shortcuts import render 63 | 64 | template_name = "lbworkflow/flowchart.html" 65 | process = Process.objects.get(code=wf_code) 66 | graph_src = generate_mermaid_src(process) 67 | ctx = {"process": process, "graph_src": graph_src} 68 | return render(request, template_name, ctx) 69 | 70 | 71 | def process_instance_flowchart(request, pk): 72 | from django.shortcuts import render 73 | 74 | template_name = "lbworkflow/flowchart.html" 75 | process_instance = ProcessInstance.objects.get(pk=pk) 76 | graph_src = generate_mermaid_src(process_instance) 77 | ctx = {"process": process_instance.process, "graph_src": graph_src} 78 | return render(request, template_name, ctx) 79 | -------------------------------------------------------------------------------- /lbworkflow/views/forms.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.forms import ModelForm 3 | from django.http import HttpResponseRedirect 4 | from django.views.generic.base import ContextMixin, View 5 | 6 | try: 7 | from crispy_forms.helper import FormHelper 8 | except ImportError: 9 | pass 10 | 11 | 12 | __all__ = ( 13 | "FormsMixin", 14 | "ModelFormsMixin", 15 | "FormSetMixin", 16 | "FormsView", 17 | "BSFormSetMixin", 18 | ) 19 | 20 | 21 | class FormsMixin(ContextMixin): 22 | """ 23 | A mixin that provides a way to show and handle any number of form in a request. 24 | """ 25 | 26 | initial = {} 27 | form_classes = None # the main form should named as form 28 | success_url = None 29 | 30 | def get_initial(self, form_class_key): 31 | return self.initial.get(form_class_key, {}).copy() 32 | 33 | def get_form_classes(self): 34 | """ 35 | Returns the form classes to use in this view 36 | """ 37 | if not self.form_classes: 38 | raise ImproperlyConfigured("Provide form_classes.") 39 | return self.form_classes 40 | 41 | def after_create_form(self, form_class_key, form): 42 | return form 43 | 44 | def create_form(self, form_class_key, form_class): 45 | form = form_class(**self.get_form_kwargs(form_class_key, form_class)) 46 | self.after_create_form(form_class_key, form) 47 | return form 48 | 49 | def create_forms(self, **form_classes): 50 | """ 51 | Returns an instance of the forms to be used in this view. 52 | forms can be access by self.forms 53 | """ 54 | forms = {} 55 | self.forms = forms 56 | for form_class_key, form_class in form_classes.items(): 57 | forms[form_class_key] = self.create_form( 58 | form_class_key, form_class 59 | ) 60 | return forms 61 | 62 | def get_form_kwargs(self, form_class_key, form_class): 63 | """ 64 | Returns the keyword arguments for instantiating the form. 65 | """ 66 | kwargs = {"initial": self.get_initial(form_class_key)} 67 | if form_class_key != "form": 68 | kwargs["prefix"] = form_class_key 69 | if self.request.method in ("POST", "PUT"): 70 | kwargs.update( 71 | { 72 | "data": self.request.POST, 73 | "files": self.request.FILES, 74 | } 75 | ) 76 | return kwargs 77 | 78 | def get_success_url(self): 79 | """ 80 | Returns the supplied success URL. 81 | """ 82 | if self.success_url: 83 | # Forcing possible reverse_lazy evaluation 84 | url = self.success_url 85 | else: 86 | raise ImproperlyConfigured( 87 | "No URL to redirect to. Provide a success_url." 88 | ) 89 | return url 90 | 91 | def forms_valid(self, **forms): 92 | """ 93 | If the forms are valid, redirect to the supplied URL. 94 | """ 95 | return HttpResponseRedirect(self.get_success_url()) 96 | 97 | def forms_invalid(self, **forms): 98 | """ 99 | If the forms are invalid, re-render the context data with the 100 | data-filled form and errors. 101 | """ 102 | return self.render_to_response(self.get_context_data(**forms)) 103 | 104 | 105 | class ModelFormsMixin: 106 | def get_form_kwargs(self, form_class_key, form_class): 107 | kwargs = super().get_form_kwargs(form_class_key, form_class) 108 | # not (ModelForm or ModelFormSet) 109 | formset_form_class = getattr(form_class, "form", str) 110 | if not issubclass(form_class, ModelForm) and not issubclass( 111 | formset_form_class, ModelForm 112 | ): 113 | return kwargs 114 | instance = getattr(self, "object", None) 115 | # if have main form, try to get instance from main form 116 | # other form may have ForeignKey to main object 117 | form = self.forms.get("form") 118 | if form and getattr(form, "instance", None): 119 | instance = getattr(form, "instance", None) 120 | kwargs["instance"] = instance 121 | return kwargs 122 | 123 | 124 | def is_formset(form): 125 | # form class 126 | if getattr(form, "__name__", "").endswith("FormSet"): 127 | return True 128 | # form instance 129 | return type(form).__name__.endswith("FormSet") 130 | 131 | 132 | class FormSetMixin: 133 | def get_context_data(self, **kwargs): 134 | kwargs = super().get_context_data(**kwargs) 135 | formset_list = [] 136 | for form in self.forms.values(): 137 | if is_formset(form): 138 | formset_list.append(form) 139 | kwargs["formset_list"] = formset_list 140 | return kwargs 141 | 142 | def after_create_formset(self, form_class_key, formset): 143 | formset.title = "Items" 144 | 145 | def after_create_form(self, form_class_key, form): 146 | super().after_create_form(form_class_key, form) 147 | if is_formset(form): 148 | self.after_create_formset(form_class_key, form) 149 | return form 150 | 151 | def get_formset_kwargs(self, form_class_key, form_class): 152 | return {} 153 | 154 | def get_form_kwargs(self, form_class_key, form_class): 155 | kwargs = super().get_form_kwargs(form_class_key, form_class) 156 | if is_formset(form_class): 157 | return kwargs 158 | ext_kwargs = self.get_formset_kwargs(form_class_key, form_class) 159 | kwargs.update(ext_kwargs) 160 | return kwargs 161 | 162 | 163 | class FormsView(FormSetMixin, ModelFormsMixin, FormsMixin, View): 164 | """ 165 | A mixin that renders any number of forms on GET and processes it on POST. 166 | """ 167 | 168 | def get(self, request, *args, **kwargs): 169 | """ 170 | Handles GET requests and instantiates a blank version of the forms. 171 | """ 172 | form_classes = self.get_form_classes() 173 | forms = self.create_forms(**form_classes) 174 | return self.render_to_response(self.get_context_data(**forms)) 175 | 176 | def post(self, request, *args, **kwargs): 177 | """ 178 | Handles POST requests, instantiating a form instance with the passed 179 | POST variables and then checked for validity. 180 | """ 181 | form_classes = self.get_form_classes() 182 | forms = self.create_forms(**form_classes) 183 | if all([forms[form].is_valid() for form in forms]): 184 | return self.forms_valid(**forms) 185 | else: 186 | return self.forms_invalid(**forms) 187 | 188 | # PUT is a valid HTTP verb for creating (with a known URL) or editing an 189 | # object, note that browsers only support POST for now. 190 | def put(self, *args, **kwargs): 191 | return self.post(*args, **kwargs) 192 | 193 | 194 | class BSFormSetMixin: 195 | """ 196 | Crispy & Bootstrap for formset 197 | """ 198 | 199 | def after_create_formset(self, form_class_key, formset): 200 | super().after_create_formset(form_class_key, formset) 201 | helper = FormHelper() 202 | helper.template = "lbadminlte/bootstrap3/table_inline_formset.html" 203 | formset.helper = helper 204 | return formset 205 | -------------------------------------------------------------------------------- /lbworkflow/views/helper.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | from django.contrib import messages 4 | from django.db.models import Q 5 | from django.utils import timezone 6 | 7 | from lbworkflow import settings 8 | 9 | 10 | def import_wf_views(wf_code, view_module_name="views"): 11 | wf_module = settings.WF_APPS.get(wf_code.split("__")[0]) 12 | return importlib.import_module("%s.%s" % (wf_module, view_module_name)) 13 | 14 | 15 | def add_processed_message(request, process_instance, act_descn="Processed"): 16 | messages.info( 17 | request, 18 | 'Process "%s" has been %s. Current status:"%s" Current user:"%s"' 19 | % ( 20 | process_instance.no, 21 | act_descn, 22 | process_instance.cur_node.name, 23 | process_instance.get_operators_display(), 24 | ), 25 | ) 26 | 27 | 28 | def get_wf_template_names( 29 | wf_code, base_template_name, wf_object=None, model=None 30 | ): 31 | templates = [] 32 | paths = wf_code.split("__") 33 | for i in range(len(paths)): 34 | temp_paths = paths[: len(paths) - i] + [base_template_name] 35 | templates.append("/".join(temp_paths)) 36 | _meta = None 37 | if wf_object: 38 | _meta = wf_object._meta 39 | elif model: 40 | _meta = model._meta 41 | if _meta: 42 | app_label = _meta.app_label 43 | object_name = _meta.object_name.lower() 44 | templates.extend( 45 | [ 46 | "%s/%s/%s" 47 | % ( 48 | app_label, 49 | object_name, 50 | base_template_name, 51 | ), 52 | "%s/%s" 53 | % ( 54 | app_label, 55 | base_template_name, 56 | ), 57 | ] 58 | ) 59 | return templates 60 | 61 | 62 | def user_wf_info_as_dict(wf_obj, user): 63 | ctx = {} 64 | if user.is_anonymous: 65 | return ctx 66 | instance = wf_obj.pinstance 67 | is_wf_admin = instance.is_wf_admin(user) 68 | in_process = instance.cur_node.status == "in progress" 69 | task = instance.get_todo_task(user) 70 | ctx["wf_code"] = instance.process.code 71 | ctx["process"] = instance.process 72 | ctx["process_instance"] = instance 73 | ctx["object"] = wf_obj 74 | ctx["task"] = task 75 | ctx["wf_history"] = instance.event_set.all().order_by("-created_on", "-pk") 76 | ctx["operators_display"] = instance.get_operators_display() 77 | ctx["is_wf_admin"] = is_wf_admin 78 | 79 | ctx["can_edit"] = True # FIXME 80 | 81 | ctx["can_rollback"] = instance.can_rollback(user) 82 | if in_process: 83 | ctx["can_assign"] = task or is_wf_admin or user.is_superuser 84 | ctx["can_remind"] = instance.created_by == user or is_wf_admin 85 | ctx["can_give_up"] = instance.can_give_up(user) 86 | 87 | if task: 88 | instance.get_todo_tasks(user).filter(receive_on=None).update( 89 | receive_on=timezone.now() 90 | ) 91 | transitions = instance.get_transitions() 92 | ctx["can_reject"] = instance.cur_node.can_reject 93 | ctx["can_back_to"] = None 94 | ctx["transitions"] = transitions 95 | ctx["agree_transitions"] = instance.get_merged_agree_transitions() 96 | ctx["other_transitions"] = [e for e in transitions if not e.is_agree] 97 | # TODO add reject,given up to other_transitions? 98 | return ctx 99 | 100 | 101 | def get_base_wf_permit_query_param( 102 | user, process_instance_field_prefix="pinstance__" 103 | ): 104 | def p(param_name, value): 105 | return {process_instance_field_prefix + param_name: value} 106 | 107 | q_param = Q() 108 | # Submit 109 | q_param = q_param | Q(**p("created_by", user)) 110 | # share 111 | q_param = q_param | Q(**p("can_view_users", user)) 112 | # Can process 113 | q_param = q_param | Q(**p("task__user", user)) 114 | q_param = q_param | Q(**p("task__agent_user", user)) 115 | return q_param 116 | -------------------------------------------------------------------------------- /lbworkflow/views/list.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.utils import timezone 3 | 4 | from lbworkflow.models import ProcessInstance, Task 5 | from lbworkflow.views.generics import ListView 6 | 7 | from .helper import get_base_wf_permit_query_param 8 | 9 | 10 | class ListWF(ListView): 11 | model = ProcessInstance 12 | ordering = "-created_on" 13 | template_name = "lbworkflow/list_wf.html" 14 | search_form_class = None # can config search_form_class 15 | quick_query_fields = [ 16 | "no", 17 | "summary", 18 | "created_by__username", 19 | "cur_node__name", 20 | ] 21 | 22 | def permit_filter(self, qs): 23 | # only show have permission 24 | user = self.request.user 25 | if not user.is_superuser: 26 | q_param = get_base_wf_permit_query_param(user, "") 27 | qs = qs.filter(q_param) 28 | return qs 29 | 30 | def get_queryset(self): 31 | qs = super().get_queryset() 32 | qs = qs.exclude(cur_node__status__in=["draft", "given up"]) 33 | qs = self.permit_filter(qs) 34 | qs = qs.select_related("process", "created_by", "cur_node").distinct() 35 | return qs 36 | 37 | 38 | class MyWF(ListView): 39 | model = ProcessInstance 40 | template_name = "lbworkflow/my_wf.html" 41 | search_form_class = None # can config search_form_class 42 | quick_query_fields = [ 43 | "no", 44 | "summary", 45 | "cur_node__name", 46 | ] 47 | 48 | def get_queryset(self): 49 | qs = super().get_queryset() 50 | return qs.filter(created_by=self.request.user) 51 | 52 | 53 | class Todo(ListView): 54 | model = Task 55 | template_name = "lbworkflow/todo.html" 56 | search_form_class = None # can config search_form_class 57 | quick_query_fields = [ 58 | "instance__no", 59 | "instance__summary", 60 | "instance__cur_node__name", 61 | "instance__created_by__username", 62 | ] 63 | 64 | def get_queryset(self): 65 | user = self.request.user 66 | qs = super().get_queryset() 67 | qs = qs.filter(Q(user=user) | Q(agent_user=user), status="in progress") 68 | qs.filter(receive_on=None).update(receive_on=timezone.now()) 69 | qs = qs.select_related( 70 | "instance", "instance__process", "instance__cur_node" 71 | ).distinct() 72 | return qs 73 | -------------------------------------------------------------------------------- /lbworkflow/views/permissions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | from django.db.models import Q 3 | 4 | from lbworkflow import settings 5 | from lbworkflow.models import Task 6 | 7 | from .helper import get_base_wf_permit_query_param 8 | 9 | 10 | class BasePermission: 11 | """ 12 | A base class from which all permission classes should inherit. 13 | """ 14 | 15 | def has_permission(self, request, view): 16 | """ 17 | Return `True` if permission is granted, `False` otherwise. 18 | """ 19 | return True 20 | 21 | def has_object_permission(self, request, view, obj): 22 | """ 23 | Return `True` if permission is granted, `False` otherwise. 24 | """ 25 | return True 26 | 27 | 28 | class AllowAny(BasePermission): 29 | """ 30 | Allow any access. 31 | This isn't strictly required, since you could use an empty 32 | permission_classes list, but it's useful because it makes the intention 33 | more explicit. 34 | """ 35 | 36 | pass 37 | 38 | 39 | class PermissionMixin: 40 | permission_classes = settings.perform_import( 41 | settings.DEFAULT_PERMISSION_CLASSES 42 | ) 43 | 44 | def permission_denied(self, request, message=None): 45 | """ 46 | If request is not permitted, determine what kind of exception to raise. 47 | """ 48 | raise PermissionDenied() 49 | 50 | def get_permissions(self): 51 | """ 52 | Instantiates and returns the list of permissions that this view requires. 53 | """ 54 | return [permission() for permission in self.permission_classes] 55 | 56 | def check_all_permissions(self, request, obj): 57 | """ 58 | call check_permissions && check_object_permissions 59 | """ 60 | self.check_permissions(request) 61 | self.check_object_permissions(request, obj) 62 | 63 | def check_permissions(self, request): 64 | """ 65 | Check if the request should be permitted. 66 | Raises an appropriate exception if the request is not permitted. 67 | """ 68 | for permission in self.get_permissions(): 69 | if not permission.has_permission(request, self): 70 | self.permission_denied( 71 | request, message=getattr(permission, "message", None) 72 | ) 73 | 74 | def check_object_permissions(self, request, obj): 75 | """ 76 | Check if the request should be permitted for a given object. 77 | Raises an appropriate exception if the request is not permitted. 78 | """ 79 | for permission in self.get_permissions(): 80 | if not permission.has_object_permission(request, self, obj): 81 | self.permission_denied( 82 | request, message=getattr(permission, "message", None) 83 | ) 84 | 85 | 86 | class DefaultEditWorkFlowPermission(BasePermission): 87 | def has_object_permission(self, request, view, obj): 88 | instance = obj.pinstance 89 | user = request.user 90 | if instance.is_wf_admin(user): 91 | return True 92 | if ( 93 | instance.cur_node.status in ["draft", "given up", "rejected"] 94 | and instance.created_by == user 95 | ): 96 | return True 97 | task = Task.objects.filter( 98 | Q(user=user) | Q(agent_user=user), 99 | instance=instance, 100 | status="in progress", 101 | ).first() 102 | if instance.cur_node.can_edit and task: 103 | return True 104 | return False 105 | 106 | 107 | class DefaultDetailWorkFlowPermission(BasePermission): 108 | def has_object_permission(self, request, view, obj): 109 | user = request.user 110 | if user.is_superuser: 111 | return True 112 | if not obj: 113 | return False 114 | qs = obj.__class__.objects.all() 115 | q_param = get_base_wf_permit_query_param(user) 116 | qs = qs.filter(q_param) 117 | return qs.filter(pk=obj.pk).exists() 118 | -------------------------------------------------------------------------------- /lbworkflow/views/processinstance.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.shortcuts import get_object_or_404, redirect, render 3 | from django.urls import reverse 4 | 5 | from lbworkflow.models import ProcessCategory, ProcessInstance 6 | 7 | from .generics import DetailView 8 | from .helper import import_wf_views 9 | 10 | 11 | def new(request, wf_code): 12 | views = import_wf_views(wf_code) 13 | return views.new(request, wf_code=wf_code) 14 | 15 | 16 | def show_list(request, wf_code): 17 | views = import_wf_views(wf_code) 18 | return views.show_list(request, wf_code=wf_code) 19 | 20 | 21 | def edit(request, pk): 22 | instance = get_object_or_404(ProcessInstance, pk=pk) 23 | wf_code = instance.process.code 24 | views = import_wf_views(wf_code) 25 | return views.edit(request, instance.content_object) 26 | 27 | 28 | def detail(request, pk, ext_ctx={}): 29 | instance = ProcessInstance.objects.get(pk=pk) 30 | views = import_wf_views(instance.process.code) 31 | is_print = ext_ctx.get("is_print") 32 | func_detail = getattr(views, "detail", DetailView.as_view()) 33 | return func_detail(request, instance.content_object, is_print) 34 | 35 | 36 | def delete(request): 37 | pks = request.POST.getlist("pk") or request.GET.getlist("pk") 38 | instances = ProcessInstance.objects.filter(pk__in=pks) 39 | for instance in instances: 40 | # only workflow admin can delete 41 | if instance.is_wf_admin(request.user): 42 | instance.delete() 43 | messages.info(request, "Deleted") 44 | return redirect(reverse("wf_list_wf")) 45 | 46 | 47 | def start_wf(request): 48 | template_name = "lbworkflow/start_wf.html" 49 | categories = ProcessCategory.objects.filter(is_active=True).order_by("oid") 50 | # only have perm's categories 51 | categories = [ 52 | e for e in categories if e.get_can_apply_processes(request.user) 53 | ] 54 | ctx = { 55 | "categories": categories, 56 | } 57 | return render(request, template_name, ctx) 58 | 59 | 60 | def report_list(request): 61 | template_name = "lbworkflow/report_list.html" 62 | categories = ProcessCategory.objects.filter(is_active=True).order_by("oid") 63 | categories = [e for e in categories] 64 | ctx = { 65 | "categories": categories, 66 | } 67 | return render(request, template_name, ctx) 68 | -------------------------------------------------------------------------------- /lbworkflow/wfdata.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.core.datahelper import create_app 2 | 3 | 4 | def load_data(): 5 | load_base() 6 | 7 | 8 | def load_base(): 9 | create_app( 10 | "5f31d065-00aa-0010-beea-641f0a670010", 11 | "Simple", 12 | action="wf_execute_transition", 13 | ) 14 | create_app("5f31d065-00aa-0010-beea-641f0a670020", "Customized URL") 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "admin-lte": "2.3.11", 4 | "blueimp-file-upload": "9.22.1", 5 | "flatpickr": "2.5.6", 6 | "font-awesome": "4.7.0", 7 | "html5shiv": "^3.7.3", 8 | "ionicons": "2.0.1", 9 | "masonry-layout": "^4.2.2", 10 | "mermaid": "^8.13.6", 11 | "modernizr": "^3.11.8", 12 | "respond": "^0.9.0", 13 | "selectivizr": "^1.0.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.hg 8 | | \.mypy_cache 9 | | \.tox 10 | | \.venv 11 | | \.pytest_cache 12 | | _build 13 | | buck-out 14 | | build 15 | | dist 16 | | coverage_html 17 | )/ 18 | ''' 19 | 20 | [build-system] 21 | requires = ["setuptools", "wheel"] 22 | build-backend = "setuptools.build_meta:__legacy__" 23 | -------------------------------------------------------------------------------- /requirements/requirements-optionals.txt: -------------------------------------------------------------------------------- 1 | # Optional packages which may be used 2 | django_select2>=7.2.0 3 | django-compressor>=2.1.1 4 | django-bower>=5.2.0 5 | django-crispy-forms>=1.6 6 | django-lb-adminlte>=0.9.4 7 | django-bootstrap-pagination>=1.7.0 8 | django-impersonate 9 | django-stronghold 10 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | jsonfield>=1.0.1 2 | xlsxwriter>=0.9.6 3 | jinja2>=2.9.6 4 | django-lbutils>=1.1.0 5 | django-lbattachment>=1.1.0 6 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | def run_test(): 11 | TestRunner = get_runner(settings) 12 | test_runner = TestRunner() 13 | failures = test_runner.run_tests(["lbworkflow"]) 14 | sys.exit(bool(failures)) 15 | 16 | 17 | if __name__ == "__main__": 18 | os.environ["DJANGO_SETTINGS_MODULE"] = "lbworkflow.tests.settings" 19 | django.setup() 20 | from django.core.management import call_command 21 | 22 | if (len(sys.argv)) == 2: 23 | call_command(sys.argv[1]) 24 | sys.exit(0) 25 | run_test() 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = venv/*,.tox/,*tox/*,docs/*,testproject/*,*/migrations/* 3 | ignore = E123,E128,E402,W503,E731,W601,E203 4 | max-line-length = 119 5 | 6 | [isort] 7 | combine_as_imports = true 8 | default_section = THIRDPARTY 9 | include_trailing_comma = true 10 | known_first_party = lbworkflow 11 | multi_line_output = 3 12 | skip=migrations,.mypy_cache/ 13 | force_single_line = false 14 | force_grid_wrap = 0 15 | use_parentheses = True 16 | ensure_newline_before_comments = True 17 | line_length = 79 18 | 19 | [bdist_wheel] 20 | universal=1 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | from lbworkflow import __version__ 4 | 5 | setup( 6 | name="django-lb-workflow", 7 | version=__version__, 8 | url="https://github.com/vicalloy/django-lb-workflow", 9 | author="vicalloy", 10 | author_email="vicalloy@gmail.com", 11 | description="Reusable workflow library for Django", 12 | license="BSD", 13 | packages=find_packages(exclude=["tests"]), 14 | python_requires=">=3.5", 15 | include_package_data=True, 16 | install_requires=[ 17 | "django>=2.2,<4.0", 18 | "jsonfield>=1.0.1", 19 | "xlsxwriter>=0.9.6", 20 | "jinja2>=2.9.6", 21 | "django-lbutils>=1.1.0", 22 | "django-lbattachment>=1.1.0", 23 | ], 24 | tests_require=[ 25 | "coverage", 26 | "flake8==3.7.9", 27 | "isort", 28 | ], 29 | extras_require={ 30 | "options": [ 31 | "django_select2>=7.2.0", 32 | "django-compressor>=2.1.1", 33 | "django-crispy-forms>=1.6", 34 | "django-lb-adminlte>=1.2.1", 35 | "django-impersonate", 36 | "django-stronghold", 37 | "django-bootstrap-pagination>=1.7.0", 38 | ], 39 | }, 40 | classifiers=[ 41 | "Development Status :: 3 - Alpha", 42 | "Environment :: Web Environment", 43 | "Framework :: Django", 44 | "Intended Audience :: Developers", 45 | "License :: OSI Approved :: MIT License", 46 | "Operating System :: OS Independent", 47 | "Programming Language :: Python", 48 | "Programming Language :: Python :: 3", 49 | "Programming Language :: Python :: 3.5", 50 | "Programming Language :: Python :: 3.6", 51 | "Programming Language :: Python :: 3.7", 52 | "Programming Language :: Python :: 3.8", 53 | "Topic :: Software Development :: Libraries :: Python Modules", 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /testproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | sys.path.insert(0, BASE_DIR) 8 | 9 | if __name__ == "__main__": 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError: 14 | # The above import may fail for some other reason. Ensure that the 15 | # issue is really that Django is missing to avoid masking other 16 | # exceptions on Python 2. 17 | try: 18 | import django 19 | except ImportError: 20 | raise ImportError( 21 | "Couldn't import Django. Are you sure it's installed and " 22 | "available on your PYTHONPATH environment variable? Did you " 23 | "forget to activate a virtual environment?" 24 | ) 25 | raise 26 | execute_from_command_line(sys.argv) 27 | -------------------------------------------------------------------------------- /testproject/testproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicalloy/django-lb-workflow/117dedd331032841540d8bc6b9056fa9d05faecf/testproject/testproject/__init__.py -------------------------------------------------------------------------------- /testproject/testproject/settings.py: -------------------------------------------------------------------------------- 1 | from lbworkflow.tests.settings import * # NOQA 2 | 3 | ALLOWED_HOSTS = ["*"] 4 | 5 | INSTALLED_APPS += [ 6 | "testproject", 7 | "stronghold", 8 | "impersonate", 9 | ] 10 | 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 15 | } 16 | } 17 | 18 | STRONGHOLD_PUBLIC_URLS = [ 19 | r"^/admin/", 20 | ] 21 | 22 | MIDDLEWARE += [ 23 | "impersonate.middleware.ImpersonateMiddleware", 24 | "stronghold.middleware.LoginRequiredMiddleware", 25 | ] 26 | 27 | ROOT_URLCONF = "testproject.urls" 28 | 29 | LOGIN_URL = "/admin/login/" 30 | LOGOUT_URL = "/admin/logout/" 31 | IMPERSONATE_REDIRECT_URL = "/" 32 | 33 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 34 | MEDIA_URL_ = "/media/" 35 | MEDIA_URL = MEDIA_URL_ 36 | 37 | LBWF_APPS.update( 38 | { 39 | "issue": "lbworkflow.tests.issue", 40 | } 41 | ) 42 | -------------------------------------------------------------------------------- /testproject/testproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import include 4 | from django.urls import path 5 | 6 | urlpatterns = [ 7 | path("", include("lbworkflow.tests.urls")), 8 | path("impersonate/", include("impersonate.urls")), 9 | ] 10 | 11 | if settings.DEBUG: 12 | urlpatterns += static( 13 | settings.MEDIA_URL_, document_root=settings.MEDIA_ROOT 14 | ) 15 | -------------------------------------------------------------------------------- /testproject/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /testproject/wfgen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import inspect 3 | import os 4 | import uuid 5 | import shutil 6 | import sys 7 | 8 | import django 9 | from django.core.management import call_command 10 | 11 | 12 | def gen(): 13 | from lbworkflow.flowgen import FlowAppGenerator 14 | from lbworkflow.tests.issue.models import Issue as wf_class 15 | 16 | FlowAppGenerator().gen(wf_class, replace=True) 17 | from lbworkflow.tests.purchase.models import Purchase as wf_class 18 | from lbworkflow.tests.purchase.models import Item as wf_item_class 19 | 20 | FlowAppGenerator().gen(wf_class, [wf_item_class], replace=True) 21 | 22 | 23 | def rm_folder(path): 24 | try: 25 | shutil.rmtree(path) 26 | except: 27 | pass 28 | 29 | 30 | def clean(): 31 | from lbworkflow.flowgen import clean_generated_files 32 | from lbworkflow.tests.issue.models import Issue 33 | 34 | clean_generated_files(Issue) 35 | # remove migrations for leave 36 | from lbworkflow.tests.leave.models import Leave 37 | 38 | folder_path = os.path.dirname(inspect.getfile(Leave)) 39 | path = os.path.join(folder_path, "migrations") 40 | rm_folder(path) 41 | # remove migrations for purchase 42 | from lbworkflow.tests.purchase.models import Purchase 43 | 44 | clean_generated_files(Purchase) 45 | folder_path = os.path.dirname(inspect.getfile(Purchase)) 46 | path = os.path.join(folder_path, "migrations") 47 | rm_folder(path) 48 | 49 | 50 | def load_data(): 51 | from lbworkflow.core.datahelper import load_wf_data 52 | 53 | load_wf_data("lbworkflow") 54 | load_wf_data("lbworkflow.tests.issue") 55 | load_wf_data("lbworkflow.tests.leave") 56 | load_wf_data("lbworkflow.tests.purchase") 57 | 58 | 59 | if __name__ == "__main__": 60 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 61 | sys.path.insert(0, BASE_DIR) 62 | os.environ["DJANGO_SETTINGS_MODULE"] = "testproject.settings" 63 | django.setup() 64 | if (len(sys.argv)) == 2: 65 | cmd = sys.argv[1] 66 | if cmd == "load_data": 67 | load_data() 68 | elif cmd == "clean": 69 | clean() 70 | elif cmd == "uuid": 71 | print(str(uuid.uuid4())) 72 | sys.exit(0) 73 | gen() 74 | call_command("makemigrations", "issue") 75 | call_command("makemigrations", "leave") 76 | call_command("makemigrations", "purchase") 77 | call_command("migrate") 78 | load_data() 79 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{35,36,37,38}-django{2x,30,_trunk}, 4 | flake8,isort 5 | 6 | skipsdist = True 7 | 8 | 9 | [testenv] 10 | commands = 11 | python runtests.py bower_install 12 | coverage run {toxinidir}/runtests.py 13 | 14 | deps = 15 | django2x: django>=2.0,<3 16 | django30: Django>=3.0,<3.1 17 | django_trunk: https://github.com/django/django/tarball/master 18 | 19 | coverage 20 | -rrequirements/requirements.txt 21 | -rrequirements/requirements-optionals.txt 22 | 23 | [testenv:flake8] 24 | basepython = python 25 | skip_install=true 26 | deps = flake8==3.7.9 27 | commands= flake8 {toxinidir} 28 | 29 | [testenv:isort] 30 | basepython = python 31 | deps = isort 32 | commands = isort --check-only --recursive lbworkflow 33 | 34 | [testenv:docs] 35 | basepython = python 36 | deps = sphinx 37 | changedir = docs 38 | commands = sphinx-build -b html . _build/html 39 | --------------------------------------------------------------------------------