diff --git a/.gitignore b/.gitignore index 972b1a9..7af088b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ /MANIFEST /crm.sqlite3 db*.sqlite3 +dj*.sqlite3 db*.sqlite3-journal node_modules/ /docs/_build/** diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 86751c8..0af6c2f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ Changelog ========= +2.2.8 GIT VERSION +----------------- + +- No exception raiased by Process/Task models in case if flow class deleted, but + still referenced in DB +- Fix jsonstore.DecimalField serialization +- Add missing 'index' view for celery.Task node +- Allow to revive flow.Subprocess and flow.NSubprocess nodes from error state + 2.2.7 2024-08-16 ---------------- @@ -412,7 +421,7 @@ This is the cumulative release with many backward incompatibility changes. * Activation now passed as a request attribute. You need to remove explicit activation parameter from view function signature, and use - request.activation instead.  + request.activation instead. * Built-in class based views are renamed, to be more consistent. Check the documentation to find a new view name. @@ -429,7 +438,7 @@ This is the cumulative release with many backward incompatibility changes. and signal handlers reusable over different flows. * Flow namespace are no longer hard-coded. Flow views now can be - attached to any namespace in a URL config.  + attached to any namespace in a URL config. * flow_start_func, flow_start_signal decorators need to be used for the start nodes handlers. Decorators would establish a proper diff --git a/tox.ini b/tox.ini index 50d6b45..b29d37e 100644 --- a/tox.ini +++ b/tox.ini @@ -68,6 +68,11 @@ deps = tblib==3.0.0 twine==4.0.2 + # typing + django-stubs==5.0.4 + django-filter-stubs==0.1.3 + mypy==1.11.1 + # packaging pyc-wheel==1.2.7 setuptools diff --git a/viewflow/fsm/typing.py b/viewflow/fsm/typing.py index 1a2fd9a..bdc1d8b 100644 --- a/viewflow/fsm/typing.py +++ b/viewflow/fsm/typing.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: UserModel = Any StateValue = Any -Condition = Union[ThisObject, Callable[[object, object], bool]] +Condition = Union[ThisObject, Callable[[object], bool]] Permission = Union[ThisObject, Callable[[object, Any], bool]] StateTransitions = Mapping["TransitionMethod", List["Transition"]] TransitionFunction = Callable[..., Any] diff --git a/viewflow/jsonstore.py b/viewflow/jsonstore.py index 52da443..a7e76cf 100644 --- a/viewflow/jsonstore.py +++ b/viewflow/jsonstore.py @@ -8,6 +8,7 @@ import copy import json from datetime import date, datetime +from decimal import Decimal from functools import partialmethod from django.core.exceptions import FieldError @@ -190,7 +191,13 @@ class DateTimeField(JSONFieldMixin, fields.DateTimeField): class DecimalField(JSONFieldMixin, fields.DecimalField): - pass + def to_json(self, value): + if value is not None: + return str(value) + + def from_json(self, value): + if value is not None: + return Decimal(value) class EmailField(JSONFieldMixin, fields.EmailField): diff --git a/viewflow/urls/base.py b/viewflow/urls/base.py index 7290a93..2662816 100644 --- a/viewflow/urls/base.py +++ b/viewflow/urls/base.py @@ -8,10 +8,9 @@ import copy import types import warnings from collections import namedtuple, OrderedDict -from typing import Optional, Dict, Any, List, Union - +from typing import Optional, Dict, Any, List from django.views.generic import RedirectView -from django.urls import URLPattern, URLResolver, include, path, reverse +from django.urls import ResolverMatch, URLPattern, URLResolver, include, path, reverse from django.urls.resolvers import RoutePattern from viewflow.utils import ( @@ -40,7 +39,7 @@ class _URLResolver(URLResolver): self.extra = kwargs.pop("extra", {}) super(_URLResolver, self).__init__(*args, **kwargs) - def resolve(self, *args, **kwargs) -> str: + def resolve(self, *args, **kwargs) -> ResolverMatch: result = super(_URLResolver, self).resolve(*args, **kwargs) if not isinstance(result.url_name, _UrlName): result.url_name = _UrlName(result.url_name) diff --git a/viewflow/urls/sites.py b/viewflow/urls/sites.py index 3b30e9a..0131ee3 100644 --- a/viewflow/urls/sites.py +++ b/viewflow/urls/sites.py @@ -17,7 +17,7 @@ class AppMenuMixin: """A route that can be listed in an Application menu.""" title = None - icon = Icon("view_carousel") + icon: Icon | str = Icon("view_carousel") def __init__(self, **kwargs): super().__init__(**kwargs) @@ -47,7 +47,7 @@ class AppMenuMixin: class Application(IndexViewMixin, Viewset): title = "" - icon = Icon("view_module") + icon: Icon | str = Icon("view_module") menu_template_name = "viewflow/includes/app_menu.html" base_template_name = "viewflow/base_page.html" permission = None diff --git a/viewflow/utils.py b/viewflow/utils.py index 0407562..56caf61 100644 --- a/viewflow/utils.py +++ b/viewflow/utils.py @@ -230,6 +230,7 @@ def get_object_data(obj: models.Model) -> Iterator[Tuple[models.Field, str, Any] if ( hasattr(obj, "artifact_object_id") + and hasattr(obj, "artifact") and obj.artifact_object_id and obj.artifact is not None ): diff --git a/viewflow/views/base.py b/viewflow/views/base.py index 7d6c388..16b97ab 100644 --- a/viewflow/views/base.py +++ b/viewflow/views/base.py @@ -5,6 +5,7 @@ # LICENSE_EXCEPTION and the Commercial licence defined in file 'COMM_LICENSE', # which is part of this source code package. +from typing import Any, List, Union from django import forms from viewflow.utils import viewprop from viewflow.forms import Span @@ -29,7 +30,7 @@ class FormLayoutMixin(object): Mixin for FormView to infer View.fields definition from form Layout. """ - form_class = None + form_class: Any = None @viewprop def layout(self): @@ -37,7 +38,7 @@ class FormLayoutMixin(object): return self.form_class.layout @viewprop - def fields(self): + def fields(self) -> Any: if self.form_class is None: if self.layout is not None: return _collect_elements(self.layout) diff --git a/viewflow/workflow/fields.py b/viewflow/workflow/fields.py index 2ba8908..b41a572 100644 --- a/viewflow/workflow/fields.py +++ b/viewflow/workflow/fields.py @@ -94,6 +94,8 @@ class FlowReferenceField(models.CharField): return import_flow_by_ref(value) except LookupError: return None + except ImportError: + return None def get_prep_value(self, value): # noqa D1o2 if value and not isinstance(value, str): @@ -118,7 +120,11 @@ class TaskReferenceField(models.CharField): def from_db_value(self, value, expression, connection): if value is None: return value - return import_task_by_ref(value) + + try: + return import_task_by_ref(value) + except ImportError: + return None def get_prep_value(self, value): # noqa D102 if value and not isinstance(value, str): diff --git a/viewflow/workflow/flow/mixins.py b/viewflow/workflow/flow/mixins.py index bbcfa8c..87d4cd2 100644 --- a/viewflow/workflow/flow/mixins.py +++ b/viewflow/workflow/flow/mixins.py @@ -1,3 +1,5 @@ +from typing import Type, Optional +from django.views import View from django.urls import path from viewflow import viewprop from viewflow.urls import ViewsetMeta @@ -7,7 +9,7 @@ from . import utils class NodeDetailMixin(metaclass=ViewsetMeta): """Task detail view.""" - index_view_class = None + index_view_class: Optional[Type[View]] = None @viewprop def index_view(self): @@ -24,7 +26,7 @@ class NodeDetailMixin(metaclass=ViewsetMeta): name="index", ) - detail_view_class = None + detail_view_class: Optional[Type[View]] = None @viewprop def detail_view(self): @@ -49,7 +51,7 @@ class NodeDetailMixin(metaclass=ViewsetMeta): class NodeExecuteMixin(metaclass=ViewsetMeta): """Re-execute a gate manually.""" - execute_view_class = None + execute_view_class: Optional[Type[View]] = None @viewprop def execute_view(self): @@ -70,7 +72,7 @@ class NodeExecuteMixin(metaclass=ViewsetMeta): class NodeUndoMixin(metaclass=ViewsetMeta): """Allow to undo a completed task.""" - undo_view_class = None + undo_view_class: Optional[Type[View]] = None @viewprop def undo_view(self): @@ -94,7 +96,7 @@ class NodeUndoMixin(metaclass=ViewsetMeta): class NodeCancelMixin(metaclass=ViewsetMeta): """Cancel a task action.""" - cancel_view_class = None + cancel_view_class: Optional[Type[View]] = None @viewprop def cancel_view(self): @@ -120,7 +122,7 @@ class NodeCancelMixin(metaclass=ViewsetMeta): class NodeReviveMixin(metaclass=ViewsetMeta): """Review a canceled task""" - revive_view_class = None + revive_view_class: Optional[Type[View]] = None @viewprop def revive_view(self): diff --git a/viewflow/workflow/flow/nodes.py b/viewflow/workflow/flow/nodes.py index 3c73acc..23014fd 100644 --- a/viewflow/workflow/flow/nodes.py +++ b/viewflow/workflow/flow/nodes.py @@ -420,6 +420,7 @@ try: mixins.NodeDetailMixin, mixins.NodeCancelMixin, mixins.NodeUndoMixin, + mixins.NodeReviveMixin, nodes.Subprocess, ): """ @@ -452,11 +453,13 @@ try: detail_view_class = views.DetailTaskView cancel_view_class = views.CancelTaskView undo_view_class = views.UndoTaskView + revive_view_class = views.ReviveTaskView class NSubprocess( mixins.NodeDetailMixin, mixins.NodeCancelMixin, mixins.NodeUndoMixin, + mixins.NodeReviveMixin, nodes.NSubprocess, ): """ @@ -491,6 +494,7 @@ try: detail_view_class = views.DetailTaskView cancel_view_class = views.CancelTaskView undo_view_class = views.UndoTaskView + revive_view_class = views.ReviveTaskView except AttributeError: """Pro-only functionality""" @@ -504,6 +508,22 @@ class Switch( mixins.NodeReviveMixin, nodes.Switch, ): + """ + Gateway that selects one of the outgoing node. + + Activates first node with matched condition. + + Example:: + + select_responsible_person = ( + flow.Switch() + .Case(this.dean_approval, lambda act: a.process.need_dean) + .Case(this.head_approval, lambda act: a.process.need_head) + .Default(this.supervisor_approval) + ) + + """ + index_view_class = views.IndexTaskView detail_view_class = views.DetailTaskView cancel_view_class = views.CancelTaskView diff --git a/viewflow/workflow/models.py b/viewflow/workflow/models.py index b489bf7..591390a 100644 --- a/viewflow/workflow/models.py +++ b/viewflow/workflow/models.py @@ -43,6 +43,9 @@ class AbstractProcess(models.Model): @property def brief(self): """Quick textual process state representation for end user.""" + if self.flow_class is None: + return None + template_content = "" if self.finished: @@ -175,6 +178,9 @@ class AbstractTask(models.Model): @property def title(self): + if self.flow_task is None: + return None + if self.flow_task.task_title: return self.flow_task.task_title return _(str(self.flow_task))