mirror of
https://github.com/viewflow/viewflow.git
synced 2026-03-13 10:32:34 +08:00
Changes sync
This commit is contained in:
@@ -5,8 +5,8 @@ Changelog
|
||||
GIT VERSION
|
||||
-----------
|
||||
|
||||
Introduce SplitFirst Node
|
||||
|
||||
- Introduce SplitFirst Node
|
||||
- celery.Timer Node
|
||||
|
||||
2.0.0.b3 2023-04-25
|
||||
-------------------
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
__all__ = ("celery_app",)
|
||||
|
||||
|
||||
def print_urls():
|
||||
"""For debug purpose"""
|
||||
|
||||
def list_urls(urls, parent_pattern=""):
|
||||
for entry in urls:
|
||||
full_pattern = parent_pattern + entry.pattern.regex.pattern
|
||||
if hasattr(entry, "url_patterns"):
|
||||
list_urls(entry.url_patterns, full_pattern)
|
||||
else:
|
||||
print(f"{entry.name or '<unnamed>'}: {full_pattern}")
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
urlconf = __import__(settings.ROOT_URLCONF, {}, {}, [""])
|
||||
list_urls(urlconf.urlpatterns)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from viewflow.workflow.models import Task
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
"DATABASE_URL" in os.environ, "Celery test requires specific database config"
|
||||
)
|
||||
class CeleryTestCase(TransactionTestCase):
|
||||
def setUp(self):
|
||||
"""Start celery worker connection the the test database"""
|
||||
env = os.environ.copy()
|
||||
database_url = env["DATABASE_URL"]
|
||||
env["DATABASE_URL"] = "{}/{}".format(
|
||||
database_url[: database_url.rfind("/")], connection.settings_dict["NAME"]
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"celery",
|
||||
"--app",
|
||||
"cookbook.workflow101.config",
|
||||
"worker",
|
||||
"--loglevel",
|
||||
"DEBUG",
|
||||
"-E",
|
||||
"-c",
|
||||
"1",
|
||||
]
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env
|
||||
)
|
||||
self.assertIsNone(self.process.poll())
|
||||
|
||||
def tearDown(self):
|
||||
self.process.send_signal(signal.SIGTERM)
|
||||
if "-v3" in sys.argv:
|
||||
stdout, stderr = self.process.communicate()
|
||||
print(" CELERY STDOUT ".center(80, "="))
|
||||
print(stdout.decode())
|
||||
|
||||
print(" CELERY STDERR ".center(80, "="), file=sys.stderr)
|
||||
print(stderr.decode(), file=sys.stderr)
|
||||
|
||||
def wait_for_task(self, process, flow_task, status=None):
|
||||
for _ in range(100):
|
||||
try:
|
||||
return process.task_set.filter(flow_task=flow_task, status=status).get()
|
||||
except Task.DoesNotExist:
|
||||
time.sleep(0.05)
|
||||
assert False, "Task {} not found".format(flow_task)
|
||||
|
||||
@@ -5,46 +5,56 @@ from viewflow.urls import AppMenuMixin, Application, Site, Viewset, route
|
||||
|
||||
|
||||
class NestedViewset(Viewset):
|
||||
app_name = 'nested'
|
||||
page_path = path('page/', TemplateView.as_view(template_name='viewflow/base.html'), name="page")
|
||||
app_name = "nested"
|
||||
page_path = path(
|
||||
"page/", TemplateView.as_view(template_name="viewflow/base.html"), name="page"
|
||||
)
|
||||
|
||||
|
||||
class CityViewset(AppMenuMixin, Viewset):
|
||||
index_path = path('', TemplateView.as_view(template_name='viewflow/base.html'), name="index")
|
||||
nested_path = route('nested', NestedViewset())
|
||||
index_path = path(
|
||||
"", TemplateView.as_view(template_name="viewflow/base.html"), name="index"
|
||||
)
|
||||
nested_path = route("nested/", NestedViewset())
|
||||
|
||||
|
||||
site = Site(title="Test site", viewsets=[
|
||||
Application(app_name="test", viewsets=[
|
||||
CityViewset(),
|
||||
])
|
||||
])
|
||||
site = Site(
|
||||
title="Test site",
|
||||
viewsets=[
|
||||
Application(
|
||||
app_name="test",
|
||||
viewsets=[
|
||||
CityViewset(),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('', site.urls)
|
||||
]
|
||||
urlpatterns = [path("", site.urls)]
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF=__name__)
|
||||
class Test(TestCase):
|
||||
def test_context_injected(self):
|
||||
response = self.client.get('/test/city/')
|
||||
response = self.client.get("/test/city/")
|
||||
match = response.wsgi_request.resolver_match
|
||||
|
||||
self.assertTrue(hasattr(match, 'site'))
|
||||
self.assertTrue(hasattr(match, "site"))
|
||||
self.assertEqual(match.site, site)
|
||||
|
||||
self.assertTrue(hasattr(match, 'app'))
|
||||
self.assertTrue(hasattr(match, "app"))
|
||||
self.assertEqual(match.app, site._children[0])
|
||||
|
||||
# App -> Site -> CityViewset
|
||||
self.assertTrue(hasattr(match, 'viewset'))
|
||||
self.assertTrue(hasattr(match, "viewset"))
|
||||
self.assertEqual(match.viewset, site._children[0]._children[0])
|
||||
|
||||
def test_nested_viewset_injected(self):
|
||||
response = self.client.get('/test/city/nested/page/')
|
||||
response = self.client.get("/test/city/nested/page/")
|
||||
match = response.wsgi_request.resolver_match
|
||||
|
||||
# App -> Site -> CityViewset -> Nested
|
||||
self.assertTrue(hasattr(match, 'viewset'))
|
||||
self.assertEqual(match.viewset, site._children[0]._children[0].nested_path.viewset)
|
||||
self.assertTrue(hasattr(match, "viewset"))
|
||||
self.assertEqual(
|
||||
match.viewset, site._children[0]._children[0].nested_path.viewset
|
||||
)
|
||||
|
||||
@@ -5,52 +5,56 @@ from viewflow.urls import route, IndexViewMixin, Viewset
|
||||
|
||||
|
||||
class NestedViewset(IndexViewMixin, Viewset):
|
||||
app_name = 'nested'
|
||||
app_name = "nested"
|
||||
|
||||
page_path = path('page/', TemplateView.as_view(template_name='viewflow/base.html'), name="page")
|
||||
route_path = route('test', Viewset())
|
||||
page_path = path(
|
||||
"page/", TemplateView.as_view(template_name="viewflow/base.html"), name="page"
|
||||
)
|
||||
route_path = route("test/", Viewset())
|
||||
|
||||
|
||||
class InheritedViewset(NestedViewset):
|
||||
app_name = 'nested'
|
||||
app_name = "nested"
|
||||
|
||||
page_path = path('page2/', TemplateView.as_view(template_name='viewflow/base.html'), name="page")
|
||||
page_path = path(
|
||||
"page2/", TemplateView.as_view(template_name="viewflow/base.html"), name="page"
|
||||
)
|
||||
|
||||
|
||||
class RootViewset(Viewset):
|
||||
app_name = 'root'
|
||||
app_name = "root"
|
||||
|
||||
index_path = path('', TemplateView.as_view(template_name='viewflow/base.html'), name="index")
|
||||
nested_path = route('test', NestedViewset())
|
||||
nested2_path = route('nested2', NestedViewset(app_name='nested2'))
|
||||
index_path = path(
|
||||
"", TemplateView.as_view(template_name="viewflow/base.html"), name="index"
|
||||
)
|
||||
nested_path = route("test/", NestedViewset())
|
||||
nested2_path = route("nested2/", NestedViewset(app_name="nested2"))
|
||||
|
||||
# check here that route_url mounted second time successfully
|
||||
nested3_path = route('inherited', InheritedViewset(app_name='nested2'))
|
||||
nested3_path = route("inherited/", InheritedViewset(app_name="nested2"))
|
||||
|
||||
|
||||
urlconfig = RootViewset()
|
||||
|
||||
urlpatterns = [
|
||||
path('', urlconfig.urls)
|
||||
]
|
||||
urlpatterns = [path("", urlconfig.urls)]
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF=__name__)
|
||||
class Test(TestCase): # noqa: D101
|
||||
def test_created_urls(self):
|
||||
self.assertEqual('/', reverse('root:index'))
|
||||
self.assertEqual("/", reverse("root:index"))
|
||||
|
||||
self.assertEqual('/test/', reverse('root:nested:index'))
|
||||
self.assertEqual('/test/page/', reverse('root:nested:page'))
|
||||
self.assertEqual("/test/", reverse("root:nested:index"))
|
||||
self.assertEqual("/test/page/", reverse("root:nested:page"))
|
||||
|
||||
self.assertEqual('/nested2/', reverse('root:nested2:index'))
|
||||
self.assertEqual('/nested2/page/', reverse('root:nested2:page'))
|
||||
self.assertEqual("/nested2/", reverse("root:nested2:index"))
|
||||
self.assertEqual("/nested2/page/", reverse("root:nested2:page"))
|
||||
|
||||
def test_urlconf_resolve(self):
|
||||
self.assertEqual('/', urlconfig.reverse('index'))
|
||||
self.assertEqual('/test/', urlconfig.nested_path.viewset.reverse('index'))
|
||||
self.assertEqual('/test/page/', urlconfig.nested_path.viewset.reverse('page'))
|
||||
self.assertEqual("/", urlconfig.reverse("index"))
|
||||
self.assertEqual("/test/", urlconfig.nested_path.viewset.reverse("index"))
|
||||
self.assertEqual("/test/page/", urlconfig.nested_path.viewset.reverse("page"))
|
||||
|
||||
def test_auto_redirect(self):
|
||||
response = self.client.get(reverse('root:nested:index'))
|
||||
self.assertRedirects(response, '/test/page/')
|
||||
response = self.client.get(reverse("root:nested:index"))
|
||||
self.assertRedirects(response, "/test/page/")
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<section class="vf-card__header">
|
||||
<h1 class="vf-card__title">{{ view.model|verbose_name_plural|title }}</h1>
|
||||
<h2 class="vf-card__breadcrumbs">
|
||||
<a href="{% reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a href="{% current_viewset_reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a>{% trans 'Export' %}</a>
|
||||
</h2>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% if app.title %}<h3 class="vf-page__menu-subheader mdc-list-group__subheader">{{ app.title }}</h3>{% endif %}
|
||||
{% block viewset_links %}
|
||||
{% if app.parent and app.parent != site %}
|
||||
{% reverse app.parent 'index' as parent_index %}{% if parent_index %}
|
||||
{% current_viewset_reverse app.parent 'index' as parent_index %}{% if parent_index %}
|
||||
<a class="mdc-list-item mdc-list-item--with-one-line mdc-list-item--with-leading-icon vf-page__menu-list-item" href="{{ parent_index }}">
|
||||
<span class="mdc-list-item__start"><i class="material-icons">arrow_back</i></span>
|
||||
<span class="mdc-list-item__content">{% trans 'Back' %}</span>
|
||||
@@ -12,7 +12,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% for viewset in app.menu_items %}{% if viewset|has_perm:request.user %}
|
||||
{% reverse viewset 'index' as index_url %}{% if index_url %}
|
||||
{% current_viewset_reverse viewset 'index' as index_url %}{% if index_url %}
|
||||
<a class="mdc-list-item mdc-list-item--with-one-line mdc-list-item--with-leading-icon vf-page__menu-list-item" href="{{ index_url }}" {% if app.turbo_disabled or viewset.turbo_disabled %} data-turbo="false"{% endif %}>
|
||||
<span class="mdc-list-item__start">{% if viewset.icon %}{{ viewset.icon }}{% else %}<i class="material-icons">view_carousel</i>{% endif %}</span>
|
||||
<span class="mdc-list-item__content">{% trans viewset.title %}</span>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<ul class="mdc-list" role="listbox">
|
||||
{% for action in bulk_actions %}
|
||||
{% if action.viewname %}
|
||||
<li class="mdc-list-item mdc-list-item--with-one-line" data-value="{% reverse viewset action.viewname %}?{{ view.filterset.data.urlencode }}" role="option">
|
||||
<li class="mdc-list-item mdc-list-item--with-one-line" data-value="{% current_viewset_reverse viewset action.viewname %}?{{ view.filterset.data.urlencode }}" role="option">
|
||||
{% else %}
|
||||
<li class="mdc-list-item mdc-list-item--with-one-line" data-value="{{ action.url }}?{{ view.filterset.data.urlencode }}" role="option">
|
||||
{% endif %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<section class="vf-card__header">
|
||||
<h1 class="vf-card__title">{{ view.object }}</h1>
|
||||
<h2 class="vf-card__breadcrumbs">
|
||||
<a href="{% reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a href="{% current_viewset_reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a>{{ view.object }}</a>
|
||||
</h2>
|
||||
<div class="vf-card__menu">
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<span class="mdc-list-item__start">{{ app.icon }}</span>
|
||||
<span class="mdc-list-item__content">{% trans app.title %}</span>
|
||||
</a>
|
||||
{% block app_links_extra %}{% endblock %}
|
||||
{% endif %}{% empty %}
|
||||
<div class="mdc-list-item" style="height:auto; color: red">
|
||||
<small>No applications found.</small>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<h1 class="vf-card__title">{{ view.object }}</h1>
|
||||
{% block confirm-header-subtitle %}
|
||||
<h2 class="vf-card__breadcrumbs">
|
||||
<a href="{% reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a href="{% current_viewset_reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
{% if view.object.pk %}
|
||||
{% reverse viewset 'detail' view.object.pk as detail_url %}
|
||||
{% current_viewset_reverse viewset 'detail' view.object.pk as detail_url %}
|
||||
{% if detail_url %}
|
||||
<a href="{{ detail_url }}">{{ view.object }}</a>
|
||||
{% else %}
|
||||
<a href="{% reverse viewset 'change' view.object.pk %}">{{ view.object }}</a>
|
||||
<a href="{% current_viewset_reverse viewset 'change' view.object.pk %}">{{ view.object }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a>{% trans 'Delete' %}</a>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="vf-card__header">
|
||||
<h1 class="vf-card__title">{{ view.model|verbose_name_plural|title }}</h1>
|
||||
<h2 class="vf-card__breadcrumbs">
|
||||
<a href="{% reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a href="{% current_viewset_reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a>{% trans 'Delete' %}</a>
|
||||
</h2>
|
||||
</section>
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
{% block form-header-subtitle %}
|
||||
<h2 class="vf-card__breadcrumbs">
|
||||
{% block breadcrumbs_items %}
|
||||
<a href="{% reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a href="{% current_viewset_reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
{% if view.object.pk %}
|
||||
{% reverse viewset 'detail' view.object.pk as detail_url %}
|
||||
{% current_viewset_reverse viewset 'detail' view.object.pk as detail_url %}
|
||||
{% if detail_url %}
|
||||
<a href="{{ detail_url }}">{{ view.object }}</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
{% load i18n viewflow %}
|
||||
|
||||
{% block breadcrumbs_items %}
|
||||
<a href="{% reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
<a href="{% current_viewset_reverse viewset 'list' %}">{{ view.model|verbose_name_plural|title }}</a>
|
||||
{% if view.object.pk %}
|
||||
{% reverse viewset 'detail' view.object.pk as detail_url %}
|
||||
{% current_viewset_reverse viewset 'detail' view.object.pk as detail_url %}
|
||||
{% if detail_url %}
|
||||
<a href="{{ detail_url }}">{{ view.object }}</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -15,8 +15,7 @@ from django.utils.html import conditional_escape
|
||||
|
||||
from viewflow.contrib import auth
|
||||
from viewflow.forms import FormLayout
|
||||
from viewflow.urls import Site, Viewset
|
||||
|
||||
from viewflow.urls import Site, Viewset, current_viewset_reverse
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -53,13 +52,9 @@ def _resolve_args(context, args, kwargs):
|
||||
return args, kwargs
|
||||
|
||||
|
||||
@register.tag("reverse")
|
||||
class ViewsetURLNode(template.Node):
|
||||
class BaseViewsetURLNode(template.Node):
|
||||
"""
|
||||
Reverse a url to a view within viewset
|
||||
|
||||
Example::
|
||||
{% reverse viewset viewname args kwargs %}
|
||||
Base class for reversing a url to a view within a viewset
|
||||
"""
|
||||
|
||||
def __init__(self, parser, token):
|
||||
@@ -97,8 +92,8 @@ class ViewsetURLNode(template.Node):
|
||||
|
||||
url = ""
|
||||
try:
|
||||
url = viewset.reverse(
|
||||
view_name, args=args, kwargs=kwargs, current_app=current_app
|
||||
url = self._reverse_url(
|
||||
viewset, view_name, args, kwargs, current_app, context
|
||||
)
|
||||
except NoReverseMatch:
|
||||
if self.variable_name is None:
|
||||
@@ -113,6 +108,36 @@ class ViewsetURLNode(template.Node):
|
||||
return url
|
||||
|
||||
|
||||
@register.tag("reverse")
|
||||
class ViewsetURLNode(BaseViewsetURLNode):
|
||||
"""
|
||||
Reverse a url to a view within viewset
|
||||
|
||||
Example::
|
||||
{% reverse viewset viewname args kwargs %}
|
||||
"""
|
||||
|
||||
def _reverse_url(self, viewset, view_name, args, kwargs, current_app, context):
|
||||
return viewset.reverse(
|
||||
view_name, args=args, kwargs=kwargs, current_app=current_app
|
||||
)
|
||||
|
||||
|
||||
@register.tag("current_viewset_reverse")
|
||||
class CurrentViewsetURLNode(BaseViewsetURLNode):
|
||||
"""
|
||||
Reverse a url to a view within viewset
|
||||
|
||||
Example::
|
||||
{% current_viewset_reverse viewset viewname args kwargs %}
|
||||
"""
|
||||
|
||||
def _reverse_url(self, viewset, view_name, args, kwargs, current_app, context):
|
||||
return current_viewset_reverse(
|
||||
context.request, viewset, view_name, args=args, kwargs=kwargs
|
||||
)
|
||||
|
||||
|
||||
@register.tag("render")
|
||||
class FormNode(template.Node):
|
||||
"""
|
||||
|
||||
@@ -1,18 +1,62 @@
|
||||
"""Class based CRUD."""
|
||||
|
||||
from .base import (
|
||||
BaseViewset, IndexViewMixin, Viewset, ViewsetMeta, route
|
||||
)
|
||||
from .base import BaseViewset, IndexViewMixin, Viewset, ViewsetMeta, route
|
||||
from .sites import AppMenuMixin, Application, Site
|
||||
from .model import (
|
||||
BaseModelViewset, DeleteViewMixin, DetailViewMixin, CreateViewMixin,
|
||||
UpdateViewMixin, ListBulkActionsMixin, ModelViewset, ReadonlyModelViewset,
|
||||
BaseModelViewset,
|
||||
DeleteViewMixin,
|
||||
DetailViewMixin,
|
||||
CreateViewMixin,
|
||||
UpdateViewMixin,
|
||||
ListBulkActionsMixin,
|
||||
ModelViewset,
|
||||
ReadonlyModelViewset,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BaseViewset', 'IndexViewMixin', 'Viewset', 'ViewsetMeta', 'route',
|
||||
'AppMenuMixin', 'Application', 'Site',
|
||||
'BaseModelViewset', 'DeleteViewMixin', 'DetailViewMixin', 'CreateViewMixin',
|
||||
'UpdateViewMixin', 'ListBulkActionsMixin', 'ModelViewset', 'ReadonlyModelViewset',
|
||||
"BaseViewset",
|
||||
"IndexViewMixin",
|
||||
"Viewset",
|
||||
"ViewsetMeta",
|
||||
"route",
|
||||
"AppMenuMixin",
|
||||
"Application",
|
||||
"Site",
|
||||
"BaseModelViewset",
|
||||
"DeleteViewMixin",
|
||||
"DetailViewMixin",
|
||||
"CreateViewMixin",
|
||||
"UpdateViewMixin",
|
||||
"ListBulkActionsMixin",
|
||||
"ModelViewset",
|
||||
"ReadonlyModelViewset",
|
||||
)
|
||||
|
||||
|
||||
def current_viewset_reverse(request, viewset, view_name, args=None, kwargs=None):
|
||||
"""
|
||||
Reverse a URL within the current viewset. This function is specifically
|
||||
designed for use in templates that belong to a view within a viewset.
|
||||
"""
|
||||
current_app = getattr(request, "current_app", None)
|
||||
if current_app is None:
|
||||
current_app = getattr(request.resolver_match, "namespace", None)
|
||||
|
||||
if args:
|
||||
current_viewset = viewset
|
||||
while current_viewset is not None:
|
||||
for kwarg in current_viewset.extra_kwargs or []:
|
||||
args = [request.resolver_match.kwargs[kwarg]] + args
|
||||
current_viewset = current_viewset.parent
|
||||
else:
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
|
||||
current_viewset = viewset
|
||||
while current_viewset is not None:
|
||||
for kwarg in current_viewset.extra_kwargs or []:
|
||||
kwargs.setdefault(kwarg, request.resolver_match.kwargs[kwarg])
|
||||
current_viewset = current_viewset.parent
|
||||
|
||||
return viewset.reverse(view_name, args=args, kwargs=kwargs, current_app=current_app)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# This work is dual-licensed under AGPL defined in file 'LICENSE' with
|
||||
# LICENSE_EXCEPTION and the Commercial license defined in file 'COMM_LICENSE',
|
||||
# which is part of this source code package.
|
||||
|
||||
import copy
|
||||
import types
|
||||
import warnings
|
||||
@@ -14,7 +13,12 @@ from django.views.generic import RedirectView
|
||||
from django.urls import URLPattern, URLResolver, include, path, reverse
|
||||
from django.urls.resolvers import RoutePattern
|
||||
|
||||
from viewflow.utils import camel_case_to_underscore, strip_suffixes, DEFAULT
|
||||
from viewflow.utils import (
|
||||
camel_case_to_underscore,
|
||||
strip_suffixes,
|
||||
list_path_components,
|
||||
DEFAULT,
|
||||
)
|
||||
|
||||
|
||||
class _UrlName(str):
|
||||
@@ -69,6 +73,7 @@ class BaseViewset(object):
|
||||
app_name = None
|
||||
namespace = None
|
||||
parent_namespace = None
|
||||
extra_kwargs = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -207,6 +212,7 @@ class Viewset(BaseViewset, metaclass=ViewsetMeta):
|
||||
attr_value = getattr(self.__class__, attr_name)
|
||||
if isinstance(attr_value, Route):
|
||||
viewset = copy.copy(attr_value.viewset)
|
||||
viewset.extra_kwargs = list_path_components(attr_value.prefix)
|
||||
setattr(self, attr_name, Route(attr_value.prefix, viewset))
|
||||
self._children.append(viewset)
|
||||
|
||||
@@ -217,7 +223,7 @@ class Viewset(BaseViewset, metaclass=ViewsetMeta):
|
||||
value.viewset.parent = self
|
||||
patterns, app_name, namespace = value.viewset.urls
|
||||
pattern = path(
|
||||
"{}/".format(value.prefix) if value.prefix else "",
|
||||
value.prefix if value.prefix else "",
|
||||
include((patterns, app_name), namespace=namespace),
|
||||
)
|
||||
return pattern
|
||||
@@ -256,7 +262,7 @@ class Viewset(BaseViewset, metaclass=ViewsetMeta):
|
||||
viewset
|
||||
)
|
||||
urlpatterns.append(
|
||||
self._create_url_pattern(route(viewset.app_name, viewset))
|
||||
self._create_url_pattern(route(f"{viewset.app_name}/", viewset))
|
||||
)
|
||||
|
||||
return urlpatterns
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
# which is part of this source code package.
|
||||
|
||||
import re
|
||||
from functools import update_wrapper
|
||||
from typing import Any, Callable, Iterator, List, Optional, Tuple, Type, TypeVar
|
||||
|
||||
from django.apps import apps
|
||||
from django.core import mail
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.core import mail
|
||||
from django.db import models
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -19,6 +19,10 @@ from django.utils.safestring import mark_safe
|
||||
__all__ = ("has_object_perm", "viewprop", "DEFAULT", "first_not_default")
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
|
||||
|
||||
|
||||
class MARKER(object):
|
||||
def __init__(self, marker: str):
|
||||
self.marker = marker
|
||||
@@ -117,52 +121,66 @@ def get_containing_app_data(module):
|
||||
return app_config.label, app_config.module.__name__
|
||||
|
||||
|
||||
def is_owner(owner, user):
|
||||
def is_owner(owner: models.Model, user: models.Model) -> bool:
|
||||
"""
|
||||
Checks whether the specified user instance or subclass is equal to the
|
||||
specified owner instance or subclass.
|
||||
"""
|
||||
return isinstance(user, owner.__class__) and owner.pk == user.pk
|
||||
return isinstance(user, type(owner)) and owner.pk == user.pk
|
||||
|
||||
|
||||
class viewprop(object):
|
||||
class viewprop:
|
||||
"""
|
||||
A property that can be overridden.
|
||||
|
||||
The viewprop class is a descriptor that works similarly to the built-in
|
||||
`property` decorator but allows its value to be overridden on instances
|
||||
of the class it is used in.
|
||||
"""
|
||||
|
||||
def __init__(self, func):
|
||||
def __init__(self, func: Any):
|
||||
self.__doc__ = getattr(func, "__doc__")
|
||||
self.fget = func
|
||||
|
||||
def __get__(self, obj, objtype=None):
|
||||
def __get__(self, obj: Optional[Any], objtype: Optional[Type[Any]] = None) -> Any:
|
||||
if obj is None:
|
||||
return self
|
||||
if self.fget.__name__ not in obj.__dict__:
|
||||
obj.__dict__[self.fget.__name__] = self.fget(obj)
|
||||
return obj.__dict__[self.fget.__name__]
|
||||
|
||||
def __set__(self, obj, value):
|
||||
def __set__(self, obj: Any, value: Any) -> None:
|
||||
obj.__dict__[self.fget.__name__] = value
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return "<view_property func={}>".format(self.fget)
|
||||
|
||||
|
||||
class LazySingletonDescriptor(object):
|
||||
class LazySingletonDescriptor:
|
||||
"""
|
||||
Descriptor class that creates a lazy singleton instance.
|
||||
|
||||
This descriptor can be used as a class attribute, and the first time the
|
||||
attribute is accessed, it creates an instance of the class. Subsequent
|
||||
accesses return the same instance, effectively making the class a singleton.
|
||||
"""
|
||||
|
||||
def __init__(self): # noqa D102
|
||||
self.instance = None
|
||||
def __init__(self) -> None: # noqa D102
|
||||
self.instance: Optional[T] = None
|
||||
|
||||
def __get__(self, instance=None, owner=None):
|
||||
def __get__(
|
||||
self,
|
||||
instance: Optional[T] = None,
|
||||
owner: Optional[Type[T]] = None,
|
||||
) -> T:
|
||||
if self.instance is None:
|
||||
if owner is None:
|
||||
raise ValueError("Owner class not provided")
|
||||
self.instance = owner()
|
||||
return self.instance
|
||||
|
||||
|
||||
class Icon(object):
|
||||
class Icon:
|
||||
"""
|
||||
Class representing an HTML icon element.
|
||||
|
||||
@@ -174,11 +192,11 @@ class Icon(object):
|
||||
The CSS class to apply to the icon element.
|
||||
"""
|
||||
|
||||
def __init__(self, icon_name, class_=None):
|
||||
def __init__(self, icon_name: str, class_: Optional[str] = None):
|
||||
self.icon_name = icon_name
|
||||
self.class_ = class_ or ""
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
icon_name = conditional_escape(self.icon_name)
|
||||
class_name = conditional_escape(self.class_)
|
||||
return mark_safe(
|
||||
@@ -186,7 +204,7 @@ class Icon(object):
|
||||
)
|
||||
|
||||
|
||||
def get_object_data(obj):
|
||||
def get_object_data(obj: models.Model) -> Iterator[Tuple[models.Field, str, Any]]:
|
||||
"""
|
||||
List of object fields to display. Choice fields values are expanded to
|
||||
readable choice label.
|
||||
@@ -212,3 +230,23 @@ def get_object_data(obj):
|
||||
|
||||
if hasattr(obj, "artifact_object_id") and obj.artifact_object_id:
|
||||
yield from get_object_data(obj.artifact)
|
||||
|
||||
|
||||
PATH_PARAMETER_COMPONENT_RE = re.compile(
|
||||
r"<(?:(?P<converter>[^>:]+):)?(?P<parameter>[^>]+)>"
|
||||
)
|
||||
|
||||
|
||||
def list_path_components(route: str) -> List[str]:
|
||||
"""
|
||||
Extract keyword arguments from a Django path expression, which are used as
|
||||
input parameters for a view function.
|
||||
|
||||
Example Usage:
|
||||
|
||||
>>> list_path_components('/prefix/<str:pk>')
|
||||
['pk']
|
||||
>>> list_path_components('<str:pk>/<int:id>')
|
||||
['pk', 'id']
|
||||
"""
|
||||
return [match["parameter"] for match in PATH_PARAMETER_COMPONENT_RE.finditer(route)]
|
||||
|
||||
@@ -15,20 +15,19 @@ from django.views import generic
|
||||
from viewflow.utils import has_object_perm, get_object_data
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
class DetailModelView(generic.DetailView):
|
||||
viewset = None
|
||||
page_actions = None
|
||||
object_actions = None
|
||||
|
||||
def has_view_permission(self, user, obj=None):
|
||||
if self.viewset is not None and hasattr(self.viewset, 'has_view_permission'):
|
||||
if self.viewset is not None and hasattr(self.viewset, "has_view_permission"):
|
||||
return self.viewset.has_view_permission(user, obj=obj)
|
||||
else:
|
||||
return (
|
||||
has_object_perm(user, 'view', self.model, obj=obj)
|
||||
or has_object_perm(user, 'change', self.model, obj=obj)
|
||||
)
|
||||
return has_object_perm(
|
||||
user, "view", self.model, obj=obj
|
||||
) or has_object_perm(user, "change", self.model, obj=obj)
|
||||
|
||||
def get_object_data(self):
|
||||
"""List of object fields to display.
|
||||
@@ -38,22 +37,32 @@ class DetailModelView(generic.DetailView):
|
||||
|
||||
def get_page_actions(self, *actions):
|
||||
if self.viewset:
|
||||
actions = self.viewset.get_detail_page_actions(self.request, self.object) + actions
|
||||
actions = (
|
||||
self.viewset.get_detail_page_actions(self.request, self.object)
|
||||
+ actions
|
||||
)
|
||||
if self.page_actions:
|
||||
actions = self.page_actions + actions
|
||||
return actions
|
||||
|
||||
def get_object_actions(self, *actions):
|
||||
if self.viewset:
|
||||
actions = self.viewset.get_detail_page_object_actions(self.request, self.object) + actions
|
||||
actions = (
|
||||
self.viewset.get_detail_page_object_actions(self.request, self.object)
|
||||
+ actions
|
||||
)
|
||||
if self.object_actions:
|
||||
actions = self.object_actions + actions
|
||||
return actions
|
||||
|
||||
def get_object_change_link(self):
|
||||
if self.viewset and hasattr(self.viewset, 'has_change_permission'):
|
||||
from viewflow.urls import current_viewset_reverse
|
||||
|
||||
if self.viewset and hasattr(self.viewset, "has_change_permission"):
|
||||
if self.viewset.has_change_permission(self.request.user, self.object):
|
||||
return self.viewset.reverse('change', args=[self.object.pk])
|
||||
return current_viewset_reverse(
|
||||
self.request, self.viewset, "change", kwargs={"pk": self.object.pk}
|
||||
)
|
||||
|
||||
def get_object(self):
|
||||
pk = self.kwargs.get(self.pk_url_kwarg)
|
||||
@@ -80,7 +89,9 @@ class DetailModelView(generic.DetailView):
|
||||
if self.template_name is None:
|
||||
opts = self.model._meta
|
||||
return [
|
||||
'{}/{}{}.html'.format(opts.app_label, opts.model_name, self.template_name_suffix),
|
||||
'viewflow/views/detail.html',
|
||||
"{}/{}{}.html".format(
|
||||
opts.app_label, opts.model_name, self.template_name_suffix
|
||||
),
|
||||
"viewflow/views/detail.html",
|
||||
]
|
||||
return [self.template_name]
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from ..activation import Activation
|
||||
from ..base import Node
|
||||
from ..exceptions import FlowRuntimeError
|
||||
from ..fields import get_flow_ref, import_flow_by_ref
|
||||
from ..fields import get_flow_ref
|
||||
from ..status import STATUS
|
||||
from . import mixins
|
||||
|
||||
@@ -20,7 +17,7 @@ class AbstractJobActivation(mixins.NextNodeActivationMixin, Activation):
|
||||
process=prev_activation.process,
|
||||
flow_task=flow_task,
|
||||
token=token,
|
||||
external_task_id=str(uuid.uuid4())
|
||||
external_task_id=str(uuid.uuid4()),
|
||||
)
|
||||
task.save()
|
||||
task.previous.add(prev_activation.task)
|
||||
@@ -41,20 +38,17 @@ class AbstractJobActivation(mixins.NextNodeActivationMixin, Activation):
|
||||
self.activate_next()
|
||||
|
||||
def ref(self):
|
||||
return f'{get_flow_ref(self.flow_class)}/{self.process.pk}/{self.task.pk}'
|
||||
return f"{get_flow_ref(self.flow_class)}/{self.process.pk}/{self.task.pk}"
|
||||
|
||||
|
||||
class AbstractJob(
|
||||
mixins.NextNodeMixin,
|
||||
Node
|
||||
):
|
||||
task_type = 'JOB'
|
||||
class AbstractJob(mixins.NextNodeMixin, Node):
|
||||
task_type = "JOB"
|
||||
|
||||
shape = {
|
||||
'width': 150,
|
||||
'height': 100,
|
||||
'text-align': 'middle',
|
||||
'svg': """
|
||||
"width": 150,
|
||||
"height": 100,
|
||||
"text-align": "middle",
|
||||
"svg": """
|
||||
<rect class="task" width="150" height="100" rx="5" ry="5"/>
|
||||
<path class="task-label"
|
||||
d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65
|
||||
@@ -68,46 +62,7 @@ class AbstractJob(
|
||||
c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46
|
||||
c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5
|
||||
s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/>
|
||||
"""
|
||||
""",
|
||||
}
|
||||
|
||||
bpmn_element = 'scriptTask'
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def activate(activation_ref):
|
||||
try:
|
||||
flow_ref, process_pk_ref, task_pk_ref = activation_ref.rsplit('/', 2)
|
||||
process_pk, task_pk = int(process_pk_ref), int(task_pk_ref)
|
||||
except ValueError as exc:
|
||||
raise FlowRuntimeError(f'Invalid activation reference - "{activation_ref}"') from exc
|
||||
|
||||
flow_class = import_flow_by_ref(flow_ref)
|
||||
|
||||
# start
|
||||
with transaction.atomic(), flow_class.lock(process_pk):
|
||||
try:
|
||||
task = flow_class.task_class.objects.get(pk=task_pk, process_id=process_pk)
|
||||
if task.status == STATUS.CANCELED:
|
||||
return
|
||||
except flow_class.task_class.DoesNotExist:
|
||||
# There was rollback on job task created transaction,
|
||||
# we don't need to do the job
|
||||
return
|
||||
else:
|
||||
activation = task.flow_task.activation_class(task)
|
||||
activation.start()
|
||||
|
||||
try:
|
||||
yield activation # long-running job without lock
|
||||
except Exception as exc:
|
||||
# error
|
||||
with transaction.atomic(), flow_class.lock(process_pk):
|
||||
activation = task.flow_task.activation_class(task)
|
||||
activation.error(exc)
|
||||
raise exc
|
||||
else:
|
||||
# success
|
||||
with transaction.atomic(), flow_class.lock(process_pk):
|
||||
activation = task.flow_task.activation_class(task)
|
||||
activation.execute()
|
||||
bpmn_element = "scriptTask"
|
||||
|
||||
Reference in New Issue
Block a user