├── lbworkflow
├── core
│ ├── __init__.py
│ ├── exceptions.py
│ ├── helper.py
│ ├── sendmsg.py
│ ├── datahelper.py
│ ├── userparser.py
│ └── transition.py
├── tests
│ ├── __init__.py
│ ├── test_models.py
│ ├── issue
│ │ ├── __init__.py
│ │ ├── models.py
│ │ └── wfdata.py
│ ├── leave
│ │ ├── __init__.py
│ │ ├── templates
│ │ │ ├── base.html
│ │ │ ├── base_ext.html
│ │ │ ├── base_form.html
│ │ │ ├── leave
│ │ │ │ ├── form.html
│ │ │ │ ├── print.html
│ │ │ │ ├── detail.html
│ │ │ │ ├── inc_detail_info.html
│ │ │ │ └── list.html
│ │ │ └── base_formset.html
│ │ ├── wf_views.py
│ │ ├── models.py
│ │ ├── views.py
│ │ ├── forms.py
│ │ └── wfdata.py
│ ├── purchase
│ │ ├── __init__.py
│ │ ├── models.py
│ │ └── wfdata.py
│ ├── permissions.py
│ ├── urls.py
│ ├── wfdata.py
│ ├── test_flowgen.py
│ ├── test_flowchart.py
│ ├── test_views_list.py
│ ├── test_base.py
│ ├── test_userparser.py
│ ├── test_simplewf.py
│ ├── test_permissions.py
│ ├── test_process.py
│ ├── settings.py
│ └── test_transition.py
├── views
│ ├── __init__.py
│ ├── processinstance.py
│ ├── list.py
│ ├── flowchart.py
│ ├── helper.py
│ ├── permissions.py
│ └── forms.py
├── migrations
│ ├── __init__.py
│ ├── 0004_processreportlink_uuid.py
│ ├── 0003_auto_20200221_0438.py
│ ├── 0002_auto_20171019_0549.py
│ └── 0005_auto_20211217_0304.py
├── simplewf
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ ├── 0002_alter_simpleworkflow_id.py
│ │ └── 0001_initial.py
│ ├── templates
│ │ └── simplewf
│ │ │ ├── form.html
│ │ │ ├── print.html
│ │ │ ├── detail.html
│ │ │ ├── inc_detail_info.html
│ │ │ └── list.html
│ ├── admin.py
│ ├── models.py
│ ├── views.py
│ ├── forms.py
│ └── wfdata.py
├── templatetags
│ ├── __init__.py
│ └── lbworkflow_tags.py
├── flowgen
│ ├── app_template
│ │ ├── __init__.py-tpl
│ │ ├── wf_views.py-tpl
│ │ ├── templates
│ │ │ └── app_name
│ │ │ │ ├── form.html-tpl
│ │ │ │ ├── print.html-tpl
│ │ │ │ ├── detail.html-tpl
│ │ │ │ ├── inc_detail_info.html-tpl
│ │ │ │ └── list.html-tpl
│ │ ├── admin.py-tpl
│ │ ├── wfdata.py-tpl
│ │ ├── views.py-tpl
│ │ └── forms.py-tpl
│ └── __init__.py
├── static
│ ├── js
│ │ └── lbworkflow.js
│ └── css
│ │ └── lbworkflow.css
├── models
│ └── __init__.py
├── __init__.py
├── apps.py
├── wfdata.py
├── templates
│ └── lbworkflow
│ │ ├── base.html
│ │ ├── flowchart.html
│ │ ├── inc_wf_status.html
│ │ ├── inc_wf_history.html
│ │ ├── base_ext.html
│ │ ├── inc_wf_btns.html
│ │ ├── start_wf.html
│ │ ├── my_wf.html
│ │ ├── list_wf.html
│ │ ├── report_list.html
│ │ ├── wf_base_detail.html
│ │ ├── batch_transition_form.html
│ │ ├── base_form.html
│ │ ├── base_formset.html
│ │ ├── do_transition_form.html
│ │ └── todo.html
├── urls.py
├── settings.py
├── admin.py
└── forms.py
├── testproject
├── testproject
│ ├── __init__.py
│ ├── urls.py
│ ├── wsgi.py
│ └── settings.py
├── manage.py
└── wfgen.py
├── docs
├── _static
│ └── demo-flow.png
├── Makefile
├── make.bat
├── index.rst
├── settings.rst
├── install.rst
├── core_concepts.rst
└── conf.py
├── requirements
├── requirements.txt
└── requirements-optionals.txt
├── .coveragerc
├── Pipfile
├── MANIFEST.in
├── package.json
├── pyproject.toml
├── setup.cfg
├── Dockerfile
├── runtests.py
├── .pre-commit-config.yaml
├── tox.ini
├── .travis.yml
├── LICENSE
├── Makefile
├── .gitignore
├── setup.py
└── README.rst
/lbworkflow/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/views/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/simplewf/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/tests/test_models.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/tests/issue/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/tests/leave/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/tests/purchase/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/testproject/testproject/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/simplewf/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/flowgen/app_template/__init__.py-tpl:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lbworkflow/static/js/lbworkflow.js:
--------------------------------------------------------------------------------
1 | $(function(){
2 | });
3 |
--------------------------------------------------------------------------------
/lbworkflow/tests/leave/templates/base.html:
--------------------------------------------------------------------------------
1 | {% extends "lbworkflow/base.html" %}
2 |
--------------------------------------------------------------------------------
/lbworkflow/static/css/lbworkflow.css:
--------------------------------------------------------------------------------
1 | .bottom-btns span {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/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/models/__init__.py:
--------------------------------------------------------------------------------
1 | from .config import * # NOQA
2 | from .runtime import * # NOQA
3 |
--------------------------------------------------------------------------------
/lbworkflow/simplewf/templates/simplewf/form.html:
--------------------------------------------------------------------------------
1 | {% extends "lbworkflow/base_form.html" %}
2 |
--------------------------------------------------------------------------------
/lbworkflow/tests/leave/templates/leave/form.html:
--------------------------------------------------------------------------------
1 | {% extends "lbworkflow/base_form.html" %}
2 |
--------------------------------------------------------------------------------
/lbworkflow/tests/leave/templates/base_formset.html:
--------------------------------------------------------------------------------
1 | {% extends "lbadminlte/base_formset.html" %}
2 |
--------------------------------------------------------------------------------
/lbworkflow/flowgen/app_template/wf_views.py-tpl:
--------------------------------------------------------------------------------
1 | from lbworkflow.views.transition import ExecuteTransitionView
2 |
--------------------------------------------------------------------------------
/docs/_static/demo-flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vicalloy/django-lb-workflow/HEAD/docs/_static/demo-flow.png
--------------------------------------------------------------------------------
/lbworkflow/flowgen/app_template/templates/app_name/form.html-tpl:
--------------------------------------------------------------------------------
1 | {% extends "lbworkflow/base_form[% if item_list %]set[% endif %].html" %}
2 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/simplewf/templates/simplewf/inc_detail_info.html:
--------------------------------------------------------------------------------
1 | {% include "lbworkflow/inc_wf_status.html" %}
2 |
3 |
4 | | Summary |
5 |
6 | {{ object.summary }}
7 | |
8 |
9 |
10 | | Content |
11 |
12 | {{ object.content|linebreaks }}
13 | |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/tests/leave/templates/leave/inc_detail_info.html:
--------------------------------------------------------------------------------
1 | {% include "lbworkflow/inc_wf_status.html" %}
2 |
3 |
4 | | Start on |
5 | {{ object.start_on }} |
6 | End on |
7 | {{ object.end_on }} |
8 |
9 |
10 | | Days |
11 | {{ object.leave_days }} |
12 | |
13 | |
14 |
15 |
16 | | Actual start on |
17 | {{ object.actual_start_on }} |
18 | Actual end on |
19 | {{ object.actual_end_on }} |
20 |
21 |
22 | | Days |
23 | {{ object.actual_leave_days }} |
24 | |
25 | |
26 |
27 |
28 | | Reason |
29 |
30 | {{ object.reason|linebreaks }}
31 | |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/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/templates/lbworkflow/inc_wf_status.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | | NO. |
4 |
5 | {{ process_instance.no }}
6 | |
7 | Created by |
8 | {{ process_instance.created_by }} |
9 |
10 |
11 | | Created on |
12 | {{ process_instance.created_on|date:"Y-m-d H:i" }} |
13 | Current node |
14 |
15 | {{ process_instance.cur_node.name }}
16 | {% if task.is_hold %}
17 | (hold)
18 | {% endif %}
19 | |
20 |
21 |
22 | | Process name |
23 | {{ process.name }} |
24 | Current operator |
25 |
26 | {{ operators_display }}
27 | {% if not process_instance.has_received %}
28 | [unreceived]
29 | {% endif %}
30 | |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | | [[ f1.verbose_name ]] |
6 | {{ object.[[ f1.name ]] }} |
7 | [[ f2.verbose_name ]] |
8 | {{ object.[[ f2.name ]] }} |
9 |
10 | [% endfor %]
11 | {% comment %}
12 |
13 | | Reason |
14 |
15 | {{ object.reason|linebreaks }}
16 | |
17 |
18 | {% endcomment %}
19 |
20 | [% if item_list %][% for item in item_list %]
21 |
22 |
23 |
24 | [% for f in item.fields %]
25 | | [[ f.verbose_name ]] | [% endfor %]
26 |
27 | {% for o in object.[[ item.lowercase_class_name]]_set.all %}
28 | [% for f in item.fields %]
29 | | {{ o.[[ f.name ]] }} | [% endfor %]
30 |
31 | {% endfor %}
32 |
33 | [% endfor %][% endif %]
34 |
--------------------------------------------------------------------------------
/lbworkflow/templates/lbworkflow/inc_wf_history.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | | On |
5 | User |
6 | Action |
7 | Old node |
8 | New node |
9 | Note |
10 | Notice users |
11 |
12 | {% for e in wf_history %}
13 |
14 | | {{ e.created_on|date:"Y-m-d H:i" }} |
15 | {{ e.user }} |
16 | {{ e.get_act_name }} |
17 | {{ e.old_node.name }} |
18 | {{ e.new_node.name }} |
19 |
20 | {% if e.comment %}
21 | {{ e.comment|linebreaksbr }}
22 | {% endif %}
23 | {% for a in e.attachments.all %}
24 | {{ a.filename }}
25 |
26 | {% endfor %}
27 | |
28 |
29 | {{ e.get_next_notice_users_display }}
30 | |
31 |
32 | {% endfor %}
33 |
34 |
35 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lbworkflow/templates/lbworkflow/base_ext.html:
--------------------------------------------------------------------------------
1 | {% extends "lbadminlte/base_ext.html" %}
2 |
3 | {% block left_side %}
4 |
34 | {% endblock %}
35 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
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/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 |
19 |
20 |
21 | {% if search_form %}
22 |
27 | {% endif %}
28 |
29 |
30 |
31 |
32 | | NO. |
33 | Process name |
34 | Summary |
35 | Created on |
36 | Current operator |
37 | Node |
38 |
39 | {% for pi in object_list %}
40 |
41 | | {{ pi.no }} |
42 | {{ pi.process.name }} |
43 | {{ pi.summary }} |
44 | {{ pi.created_on|date:"Y-m-d H:i" }} |
45 | {{ pi.get_operators_display }} |
46 |
47 |
48 | {{ pi.cur_node.name }}
49 |
50 | |
51 |
52 | {% endfor %}
53 |
54 |
55 |
56 |
59 |
60 |
61 | {% endblock %}
62 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
18 |
19 |
20 | {% if search_form %}
21 |
26 | {% endif %}
27 |
28 |
29 |
30 |
31 | | NO. |
32 | Created by |
33 | Summary |
34 | Content |
35 | Created on |
36 | Current operator |
37 | Activity |
38 |
39 | {% for o in object_list %}{% with pi=o.pinstance %}
40 |
41 | | {{ pi.no }} |
42 | {{ pi.created_by }} |
43 | {{ o.summary }} |
44 | {{ o.content }} |
45 | {{ pi.created_on|date:"Y-m-d H:i" }} |
46 | {{ pi.get_operators_display }} |
47 |
48 |
49 | {{ pi.cur_node.name }}
50 |
51 | |
52 |
53 | {% endwith %}{% endfor %}
54 |
55 |
56 |
57 |
60 |
61 |
62 | {% endblock %}
63 |
--------------------------------------------------------------------------------
/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 |
18 |
19 |
20 | {% if search_form %}
21 |
26 | {% endif %}
27 |
28 |
29 |
30 |
31 | | NO. |
32 | Created by |
33 | Start on |
34 | End on |
35 | Created on |
36 | Current operator |
37 | Activity |
38 |
39 | {% for o in object_list %}{% with pi=o.pinstance %}
40 |
41 | | {{ pi.no }} |
42 | {{ pi.created_by }} |
43 | {{ o.start_on|date:"Y-m-d H:i" }} |
44 | {{ o.end_on|date:"Y-m-d H:i" }} |
45 | {{ pi.created_on|date:"Y-m-d H:i" }} |
46 | {{ pi.get_operators_display }} |
47 |
48 |
49 | {{ pi.cur_node.name }}
50 |
51 | |
52 |
53 | {% endwith %}{% endfor %}
54 |
55 |
56 |
57 |
60 |
61 |
62 | {% endblock %}
63 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
21 |
22 |
23 | {% if search_form %}
24 |
29 | {% endif %}
30 |
31 |
32 |
33 |
34 | | NO. |
35 | Process name |
36 | Summary |
37 | Created on |
38 | Created by |
39 | Current operator |
40 | Node |
41 |
42 | {% for pi in object_list %}
43 |
44 | | {{ pi.no }} |
45 | {{ pi.process.name }} |
46 | {{ pi.summary }} |
47 | {{ pi.created_on|date:"Y-m-d H:i" }} |
48 | {{ pi.created_by }} |
49 | {{ pi.get_operators_display }} |
50 |
51 |
52 | {{ pi.cur_node.name }}
53 |
54 | |
55 |
56 | {% endfor %}
57 |
58 |
59 |
60 |
63 |
64 |
65 | {% endblock %}
66 |
--------------------------------------------------------------------------------
/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 |
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/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 |
18 |
19 |
20 | {% if search_form %}
21 |
26 | {% endif %}
27 |
28 |
29 |
30 |
31 | | NO. |
32 | Created by |
33 | [% for f in fields %]
34 | [[ f.verbose_name ]] |
35 | [% endfor %]
36 | Created on |
37 | Current operator |
38 | Activity |
39 |
40 | {% for o in object_list %}{% with pi=o.pinstance %}
41 |
42 | | {{ pi.no }} |
43 | {{ pi.created_by }} |
44 | [% for f in fields %]
45 | {{ o.[[ f.name ]] }} |
46 | [% endfor %]
47 | {{ pi.created_on|date:"Y-m-d H:i" }} |
48 | {{ pi.get_operators_display }} |
49 |
50 |
51 | {{ pi.cur_node.name }}
52 |
53 | |
54 |
55 | {% endwith %}{% endfor %}
56 |
57 |
58 |
59 |
62 |
63 |
64 | {% endblock %}
65 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
26 |
27 |
28 |
29 | -
30 | Information
31 |
32 | {% block right_side_tab_nav_ext %}
33 | {% endblock %}
34 | -
35 | History
36 |
37 |
38 |
39 |
40 | {% block right_side_tab_base_ctx %}
41 | {% include "lbworkflow/inc_wf_status.html" %}
42 |
43 |
44 | | Title 1 |
45 | hello a |
46 | Title 2 |
47 | hello b |
48 |
49 |
50 | | Note |
51 |
52 | hello....
53 | |
54 |
55 |
56 | {% endblock %}
57 |
58 | {% block right_side_tab_ctx_ext %}
59 | {% endblock %}
60 |
61 |
Flow Chart
62 | {% include "lbworkflow/inc_wf_history.html" %}
63 |
64 |
65 |
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/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 |
30 |
66 | {% endblock %}
67 |
68 | {% block footer_ext %}
69 | {{ block.super }}
70 |
71 |
72 |
73 |
78 | {% endblock %}
79 |
--------------------------------------------------------------------------------
/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 |
10 |
20 |
21 |
22 | {% block right_side_tab_base_ctx %}
23 | {% include "lbworkflow/inc_wf_status.html" %}
24 | {% endblock %}
25 |
26 | {% block right_side_tab_ctx_ext %}
27 | {% endblock %}
28 |
29 |
Flow Chart
30 | {% include "lbworkflow/inc_wf_history.html" %}
31 |
32 |
33 |
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 |
10 |
20 |
21 |
22 | {% block right_side_tab_base_ctx %}
23 | {% include "lbworkflow/inc_wf_status.html" %}
24 | {% endblock %}
25 |
26 | {% block right_side_tab_ctx_ext %}
27 | {% endblock %}
28 |
29 |
Flow Chart
30 | {% include "lbworkflow/inc_wf_history.html" %}
31 |
32 |
33 |
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/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/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/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/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/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 |
33 |
72 | {% endblock %}
73 |
74 | {% block footer_ext %}
75 | {{ block.super }}
76 |
77 |
78 |
79 |
80 |
81 | {{ form.media.js }}
82 |
87 | {% endblock %}
88 |
--------------------------------------------------------------------------------
/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/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 |
21 |
22 |
23 | {% if search_form %}
24 |
29 | {% endif %}
30 |
69 |
82 |
83 |
84 | {% endblock %}
85 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------