django-fsm-dynamic 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_fsm_dynamic/__init__.py +30 -0
- django_fsm_dynamic/__version__.py +3 -0
- django_fsm_dynamic/core.py +614 -0
- django_fsm_dynamic-1.0.0.dist-info/METADATA +237 -0
- django_fsm_dynamic-1.0.0.dist-info/RECORD +8 -0
- django_fsm_dynamic-1.0.0.dist-info/WHEEL +5 -0
- django_fsm_dynamic-1.0.0.dist-info/licenses/LICENSE +21 -0
- django_fsm_dynamic-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
"""
|
2
|
+
Django FSM Dynamic Workflows
|
3
|
+
|
4
|
+
Dynamic workflow extensions for django-fsm-2 that allow optional Django apps
|
5
|
+
to modify FSM state machines without creating database migrations.
|
6
|
+
|
7
|
+
Features:
|
8
|
+
- Dynamic state enums that can be extended at runtime
|
9
|
+
- Callable choices that prevent Django migrations
|
10
|
+
- Workflow extension framework for app-based extensions
|
11
|
+
- Transition builder utilities for programmatic transition creation
|
12
|
+
"""
|
13
|
+
|
14
|
+
from __future__ import annotations
|
15
|
+
|
16
|
+
from .__version__ import __version__
|
17
|
+
from .core import DynamicStateEnum
|
18
|
+
from .core import TransitionBuilder
|
19
|
+
from .core import TransitionModifier
|
20
|
+
from .core import WorkflowExtension
|
21
|
+
from .core import create_simple_workflow_extension
|
22
|
+
|
23
|
+
__all__ = [
|
24
|
+
"DynamicStateEnum",
|
25
|
+
"TransitionBuilder",
|
26
|
+
"TransitionModifier",
|
27
|
+
"WorkflowExtension",
|
28
|
+
"__version__",
|
29
|
+
"create_simple_workflow_extension",
|
30
|
+
]
|
@@ -0,0 +1,614 @@
|
|
1
|
+
"""
|
2
|
+
Dynamic workflow utilities for django-fsm-2.
|
3
|
+
|
4
|
+
This module provides utilities for creating extensible FSM workflows that can be
|
5
|
+
modified by optional Django apps without requiring database migrations.
|
6
|
+
|
7
|
+
Key features:
|
8
|
+
- Dynamic state enums that can be extended at runtime
|
9
|
+
- Callable choices that prevent Django migrations
|
10
|
+
- Workflow extension framework for app-based extensions
|
11
|
+
- Transition builder utilities for programmatic transition creation
|
12
|
+
|
13
|
+
Based on the implementation patterns from examples/dynamic_workflow/.
|
14
|
+
"""
|
15
|
+
|
16
|
+
from __future__ import annotations
|
17
|
+
|
18
|
+
import importlib
|
19
|
+
import logging
|
20
|
+
from typing import TYPE_CHECKING
|
21
|
+
from typing import Any
|
22
|
+
from typing import Callable
|
23
|
+
|
24
|
+
from django.apps import apps
|
25
|
+
from django.db.models.signals import class_prepared
|
26
|
+
from django_fsm import transition
|
27
|
+
|
28
|
+
if TYPE_CHECKING:
|
29
|
+
from django.apps import AppConfig
|
30
|
+
from django.db import models
|
31
|
+
|
32
|
+
logger = logging.getLogger(__name__)
|
33
|
+
|
34
|
+
|
35
|
+
class DynamicStateEnum:
|
36
|
+
"""
|
37
|
+
Base class for extensible state enums.
|
38
|
+
|
39
|
+
This class provides the core functionality for creating state enums that can
|
40
|
+
be extended at runtime by other apps via monkeypatching, without requiring
|
41
|
+
database migrations.
|
42
|
+
|
43
|
+
Example:
|
44
|
+
class MyStateEnum(DynamicStateEnum):
|
45
|
+
NEW = 10
|
46
|
+
PUBLISHED = 20
|
47
|
+
HIDDEN = 30
|
48
|
+
|
49
|
+
# Other apps can extend:
|
50
|
+
MyStateEnum.IN_REVIEW = 15
|
51
|
+
"""
|
52
|
+
|
53
|
+
@classmethod
|
54
|
+
def get_choices(cls) -> list[tuple[int, str]]:
|
55
|
+
"""
|
56
|
+
Returns all states as Django choices, including dynamically added ones.
|
57
|
+
|
58
|
+
This method scans the class for uppercase integer attributes and converts
|
59
|
+
them to Django choices format. State names are automatically converted
|
60
|
+
from UPPER_CASE to "Title Case" for display.
|
61
|
+
|
62
|
+
Returns:
|
63
|
+
List of (value, display_name) tuples sorted by value
|
64
|
+
"""
|
65
|
+
choices = []
|
66
|
+
for attr_name in dir(cls):
|
67
|
+
if attr_name.isupper() and not attr_name.startswith("_"):
|
68
|
+
value = getattr(cls, attr_name)
|
69
|
+
if isinstance(value, int):
|
70
|
+
# Convert STATE_NAME to "State Name" for display
|
71
|
+
display = attr_name.replace("_", " ").title()
|
72
|
+
choices.append((value, display))
|
73
|
+
return sorted(choices, key=lambda x: x[0])
|
74
|
+
|
75
|
+
@classmethod
|
76
|
+
def get_values(cls) -> list[int]:
|
77
|
+
"""
|
78
|
+
Returns list of state values only.
|
79
|
+
|
80
|
+
Useful for database constraints and validation.
|
81
|
+
|
82
|
+
Returns:
|
83
|
+
List of integer state values
|
84
|
+
"""
|
85
|
+
return [value for value, _ in cls.get_choices()]
|
86
|
+
|
87
|
+
@classmethod
|
88
|
+
def get_state_map(cls) -> dict[int, str]:
|
89
|
+
"""
|
90
|
+
Returns a mapping of state values to display names.
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
Dictionary mapping state values to display names
|
94
|
+
"""
|
95
|
+
return dict(cls.get_choices())
|
96
|
+
|
97
|
+
@classmethod
|
98
|
+
def has_state(cls, value: int) -> bool:
|
99
|
+
"""
|
100
|
+
Check if a state value exists in this enum.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
value: State value to check
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
True if state exists, False otherwise
|
107
|
+
"""
|
108
|
+
return value in cls.get_values()
|
109
|
+
|
110
|
+
@classmethod
|
111
|
+
def add_state(cls, name: str, value: int) -> None:
|
112
|
+
"""
|
113
|
+
Add a new state to this enum.
|
114
|
+
|
115
|
+
This is a convenience method for monkeypatching that includes validation.
|
116
|
+
|
117
|
+
Args:
|
118
|
+
name: State name (should be UPPER_CASE)
|
119
|
+
value: State value (integer)
|
120
|
+
|
121
|
+
Raises:
|
122
|
+
ValueError: If name is not uppercase or value is not integer or already exists
|
123
|
+
"""
|
124
|
+
if not name.isupper():
|
125
|
+
raise ValueError(f"State name '{name}' must be uppercase")
|
126
|
+
|
127
|
+
if not isinstance(value, int):
|
128
|
+
raise TypeError(f"State value must be integer, got {type(value)}")
|
129
|
+
|
130
|
+
if hasattr(cls, name):
|
131
|
+
existing_value = getattr(cls, name)
|
132
|
+
if existing_value != value:
|
133
|
+
raise ValueError(f"State '{name}' already exists with value {existing_value}")
|
134
|
+
return # Same state, no change needed
|
135
|
+
|
136
|
+
if value in cls.get_values():
|
137
|
+
existing_name = next(
|
138
|
+
attr_name for attr_name in dir(cls) if attr_name.isupper() and getattr(cls, attr_name) == value
|
139
|
+
)
|
140
|
+
raise ValueError(f"State value {value} already used by '{existing_name}'")
|
141
|
+
|
142
|
+
setattr(cls, name, value)
|
143
|
+
logger.debug("Added state %s=%s to %s", name, value, cls.__name__)
|
144
|
+
|
145
|
+
|
146
|
+
class TransitionBuilder:
|
147
|
+
"""
|
148
|
+
Utility for building and attaching FSM transitions programmatically.
|
149
|
+
|
150
|
+
This class simplifies the process of creating transition methods with proper
|
151
|
+
FSM metadata and attaching them to model classes.
|
152
|
+
"""
|
153
|
+
|
154
|
+
def __init__(self, model_class: type[models.Model], field_name: str = "state"):
|
155
|
+
"""
|
156
|
+
Initialize the transition builder.
|
157
|
+
|
158
|
+
Args:
|
159
|
+
model_class: The Django model class to add transitions to
|
160
|
+
field_name: Name of the FSM field (default: 'state')
|
161
|
+
"""
|
162
|
+
self.model_class = model_class
|
163
|
+
self.field_name = field_name
|
164
|
+
self.field = model_class._meta.get_field(field_name)
|
165
|
+
self._transitions = []
|
166
|
+
|
167
|
+
def add_transition(
|
168
|
+
self,
|
169
|
+
method_name: str,
|
170
|
+
source: int | list[int] | str,
|
171
|
+
target: int,
|
172
|
+
method_impl: Callable | None = None,
|
173
|
+
conditions: list[Callable] | None = None,
|
174
|
+
permission: str | Callable | None = None,
|
175
|
+
on_error: int | None = None,
|
176
|
+
custom: dict[str, Any] | None = None,
|
177
|
+
) -> TransitionBuilder:
|
178
|
+
"""
|
179
|
+
Add a transition method to be built.
|
180
|
+
|
181
|
+
Args:
|
182
|
+
method_name: Name of the transition method
|
183
|
+
source: Source state(s) for the transition
|
184
|
+
target: Target state for the transition
|
185
|
+
method_impl: Optional custom implementation (defaults to no-op)
|
186
|
+
conditions: List of condition functions
|
187
|
+
permission: Permission string or callable
|
188
|
+
on_error: Target state if method raises exception
|
189
|
+
custom: Custom transition properties
|
190
|
+
|
191
|
+
Returns:
|
192
|
+
Self for method chaining
|
193
|
+
"""
|
194
|
+
if method_impl is None:
|
195
|
+
|
196
|
+
def default_impl(instance):
|
197
|
+
logger.debug("Executing transition %s on %s", method_name, instance)
|
198
|
+
|
199
|
+
method_impl = default_impl
|
200
|
+
|
201
|
+
self._transitions.append(
|
202
|
+
{
|
203
|
+
"method_name": method_name,
|
204
|
+
"source": source,
|
205
|
+
"target": target,
|
206
|
+
"method_impl": method_impl,
|
207
|
+
"conditions": conditions or [],
|
208
|
+
"permission": permission,
|
209
|
+
"on_error": on_error,
|
210
|
+
"custom": custom or {},
|
211
|
+
}
|
212
|
+
)
|
213
|
+
|
214
|
+
return self
|
215
|
+
|
216
|
+
def build_and_attach(self) -> None:
|
217
|
+
"""
|
218
|
+
Build all transitions and attach them to the model class.
|
219
|
+
|
220
|
+
This method creates the transition methods with proper FSM metadata
|
221
|
+
and attaches them to the model class.
|
222
|
+
"""
|
223
|
+
for transition_config in self._transitions:
|
224
|
+
self._build_single_transition(transition_config)
|
225
|
+
|
226
|
+
# Re-collect transitions to update django-fsm registry
|
227
|
+
self.field._collect_transitions(sender=self.model_class)
|
228
|
+
|
229
|
+
logger.debug("Built and attached %d transitions to %s", len(self._transitions), self.model_class.__name__)
|
230
|
+
|
231
|
+
def _build_single_transition(self, config: dict[str, Any]) -> None:
|
232
|
+
"""Build a single transition and attach it to the model."""
|
233
|
+
method_name = config["method_name"]
|
234
|
+
method_impl = config["method_impl"]
|
235
|
+
|
236
|
+
# Create the decorated transition method
|
237
|
+
@transition(
|
238
|
+
field=self.field,
|
239
|
+
source=config["source"],
|
240
|
+
target=config["target"],
|
241
|
+
conditions=config["conditions"],
|
242
|
+
permission=config["permission"],
|
243
|
+
on_error=config["on_error"],
|
244
|
+
custom=config["custom"],
|
245
|
+
)
|
246
|
+
def transition_method(instance):
|
247
|
+
return method_impl(instance)
|
248
|
+
|
249
|
+
# Set proper method name and documentation
|
250
|
+
transition_method.__name__ = method_name
|
251
|
+
transition_method.__qualname__ = f"{self.model_class.__name__}.{method_name}"
|
252
|
+
|
253
|
+
if not method_impl.__doc__:
|
254
|
+
transition_method.__doc__ = f"FSM transition: {config['source']} -> {config['target']}"
|
255
|
+
else:
|
256
|
+
transition_method.__doc__ = method_impl.__doc__
|
257
|
+
|
258
|
+
# Attach to model class
|
259
|
+
setattr(self.model_class, method_name, transition_method)
|
260
|
+
|
261
|
+
|
262
|
+
class WorkflowExtension:
|
263
|
+
"""
|
264
|
+
Base class for creating workflow extensions in Django apps.
|
265
|
+
|
266
|
+
This class provides a structured way to extend existing FSM workflows
|
267
|
+
from other Django apps, handling the boilerplate of signal connections
|
268
|
+
and error handling.
|
269
|
+
|
270
|
+
Example:
|
271
|
+
class ReviewWorkflowExtension(WorkflowExtension):
|
272
|
+
target_model = 'blog.BlogPost'
|
273
|
+
target_enum = 'blog.models.BlogPostStateEnum'
|
274
|
+
|
275
|
+
def extend_states(self, enum_class):
|
276
|
+
enum_class.add_state('IN_REVIEW', 15)
|
277
|
+
enum_class.add_state('APPROVED', 17)
|
278
|
+
|
279
|
+
def extend_transitions(self, model_class, enum_class):
|
280
|
+
builder = TransitionBuilder(model_class)
|
281
|
+
builder.add_transition('send_to_review', enum_class.NEW, enum_class.IN_REVIEW)
|
282
|
+
builder.add_transition('approve', enum_class.IN_REVIEW, enum_class.APPROVED)
|
283
|
+
builder.build_and_attach()
|
284
|
+
"""
|
285
|
+
|
286
|
+
# Subclasses should override these
|
287
|
+
target_model: str | None = None # 'app_label.ModelName'
|
288
|
+
target_enum: str | None = None # 'module.path.EnumClass'
|
289
|
+
|
290
|
+
def __init__(self, app_config: AppConfig):
|
291
|
+
"""
|
292
|
+
Initialize the workflow extension.
|
293
|
+
|
294
|
+
Args:
|
295
|
+
app_config: The Django AppConfig instance
|
296
|
+
"""
|
297
|
+
self.app_config = app_config
|
298
|
+
self.logger = logging.getLogger(f"{app_config.name}.workflow_extension")
|
299
|
+
|
300
|
+
def apply(self) -> bool:
|
301
|
+
"""
|
302
|
+
Apply the workflow extension.
|
303
|
+
|
304
|
+
This method handles the complete extension process, including error handling
|
305
|
+
and signal connection.
|
306
|
+
|
307
|
+
Returns:
|
308
|
+
True if extension was applied successfully, False otherwise
|
309
|
+
"""
|
310
|
+
try:
|
311
|
+
# Import target classes
|
312
|
+
model_class, enum_class = self._import_targets()
|
313
|
+
if not model_class or not enum_class:
|
314
|
+
return False
|
315
|
+
|
316
|
+
# Extend states first
|
317
|
+
self.extend_states(enum_class)
|
318
|
+
|
319
|
+
# Connect to class_prepared signal for transition modifications
|
320
|
+
def enhance_workflow(sender, **kwargs):
|
321
|
+
if sender == model_class:
|
322
|
+
self.logger.debug("Enhancing workflow for %s", sender.__name__)
|
323
|
+
try:
|
324
|
+
self.extend_transitions(sender, enum_class)
|
325
|
+
self.modify_existing_transitions(sender, enum_class)
|
326
|
+
except Exception:
|
327
|
+
self.logger.exception("Error extending transitions")
|
328
|
+
|
329
|
+
class_prepared.connect(enhance_workflow)
|
330
|
+
except Exception:
|
331
|
+
self.logger.exception("Failed to apply workflow extension")
|
332
|
+
return False
|
333
|
+
else:
|
334
|
+
self.logger.info("Applied workflow extension for %s", model_class.__name__)
|
335
|
+
return True
|
336
|
+
|
337
|
+
def _import_targets(self) -> tuple[type[models.Model] | None, type | None]:
|
338
|
+
"""Import the target model and enum classes."""
|
339
|
+
if not self.target_model or not self.target_enum:
|
340
|
+
self.logger.error("target_model and target_enum must be specified")
|
341
|
+
return None, None
|
342
|
+
|
343
|
+
try:
|
344
|
+
# Import model class
|
345
|
+
app_label, model_name = self.target_model.split(".")
|
346
|
+
model_class = apps.get_model(app_label, model_name)
|
347
|
+
|
348
|
+
# Import enum class
|
349
|
+
module_path, class_name = self.target_enum.rsplit(".", 1)
|
350
|
+
module = importlib.import_module(module_path)
|
351
|
+
enum_class = getattr(module, class_name)
|
352
|
+
|
353
|
+
except ImportError as e:
|
354
|
+
self.logger.warning("Could not import target classes: %s", e)
|
355
|
+
return None, None
|
356
|
+
except Exception:
|
357
|
+
self.logger.exception("Error importing target classes")
|
358
|
+
return None, None
|
359
|
+
else:
|
360
|
+
return model_class, enum_class
|
361
|
+
|
362
|
+
def extend_states(self, enum_class: type[DynamicStateEnum]) -> None:
|
363
|
+
"""
|
364
|
+
Extend the enum with new states.
|
365
|
+
|
366
|
+
Subclasses should override this method to add their states.
|
367
|
+
|
368
|
+
Args:
|
369
|
+
enum_class: The target enum class to extend
|
370
|
+
"""
|
371
|
+
...
|
372
|
+
|
373
|
+
def extend_transitions(self, model_class: type[models.Model], enum_class: type[DynamicStateEnum]) -> None:
|
374
|
+
"""
|
375
|
+
Add new transitions to the model.
|
376
|
+
|
377
|
+
Subclasses should override this method to add new transition methods.
|
378
|
+
|
379
|
+
Args:
|
380
|
+
model_class: The target model class
|
381
|
+
enum_class: The enum class with states
|
382
|
+
"""
|
383
|
+
...
|
384
|
+
|
385
|
+
def modify_existing_transitions(self, model_class: type[models.Model], enum_class: type[DynamicStateEnum]) -> None:
|
386
|
+
"""
|
387
|
+
Modify existing transitions.
|
388
|
+
|
389
|
+
Subclasses should override this method to modify existing transition methods.
|
390
|
+
|
391
|
+
Args:
|
392
|
+
model_class: The target model class
|
393
|
+
enum_class: The enum class with states
|
394
|
+
"""
|
395
|
+
...
|
396
|
+
|
397
|
+
|
398
|
+
class TransitionModifier:
|
399
|
+
"""
|
400
|
+
Utility for modifying existing FSM transitions.
|
401
|
+
|
402
|
+
This class provides methods to safely modify existing transition metadata,
|
403
|
+
such as changing source/target states or adding/removing conditions.
|
404
|
+
"""
|
405
|
+
|
406
|
+
def __init__(self, model_class: type[models.Model], method_name: str):
|
407
|
+
"""
|
408
|
+
Initialize the transition modifier.
|
409
|
+
|
410
|
+
Args:
|
411
|
+
model_class: The Django model class
|
412
|
+
method_name: Name of the existing transition method
|
413
|
+
|
414
|
+
Raises:
|
415
|
+
ValueError: If method doesn't exist or doesn't have FSM metadata
|
416
|
+
"""
|
417
|
+
self.model_class = model_class
|
418
|
+
self.method_name = method_name
|
419
|
+
|
420
|
+
if not hasattr(model_class, method_name):
|
421
|
+
raise ValueError(f"Method '{method_name}' not found on {model_class.__name__}")
|
422
|
+
|
423
|
+
self.method = getattr(model_class, method_name)
|
424
|
+
|
425
|
+
if not hasattr(self.method, "_django_fsm"):
|
426
|
+
raise ValueError(f"Method '{method_name}' has no FSM metadata")
|
427
|
+
|
428
|
+
self.fsm_meta = self.method._django_fsm
|
429
|
+
|
430
|
+
def clear_transitions(self) -> TransitionModifier:
|
431
|
+
"""
|
432
|
+
Clear all existing transitions for this method.
|
433
|
+
|
434
|
+
Returns:
|
435
|
+
Self for method chaining
|
436
|
+
"""
|
437
|
+
self.fsm_meta.transitions.clear()
|
438
|
+
logger.debug("Cleared transitions for %s.%s", self.model_class.__name__, self.method_name)
|
439
|
+
return self
|
440
|
+
|
441
|
+
def add_transition(
|
442
|
+
self,
|
443
|
+
source: int | list[int] | str,
|
444
|
+
target: int,
|
445
|
+
conditions: list[Callable] | None = None,
|
446
|
+
permission: str | Callable | None = None,
|
447
|
+
on_error: int | None = None,
|
448
|
+
custom: dict[str, Any] | None = None,
|
449
|
+
) -> TransitionModifier:
|
450
|
+
"""
|
451
|
+
Add a new transition to this method.
|
452
|
+
|
453
|
+
Args:
|
454
|
+
source: Source state(s) for the transition
|
455
|
+
target: Target state for the transition
|
456
|
+
conditions: List of condition functions
|
457
|
+
permission: Permission string or callable
|
458
|
+
on_error: Target state if method raises exception
|
459
|
+
custom: Custom transition properties
|
460
|
+
|
461
|
+
Returns:
|
462
|
+
Self for method chaining
|
463
|
+
"""
|
464
|
+
self.fsm_meta.add_transition(
|
465
|
+
method=self.method,
|
466
|
+
source=source,
|
467
|
+
target=target,
|
468
|
+
on_error=on_error,
|
469
|
+
conditions=conditions or [],
|
470
|
+
permission=permission,
|
471
|
+
custom=custom or {},
|
472
|
+
)
|
473
|
+
|
474
|
+
logger.debug("Added transition %s -> %s to %s.%s", source, target, self.model_class.__name__, self.method_name)
|
475
|
+
return self
|
476
|
+
|
477
|
+
def apply(self) -> None:
|
478
|
+
"""
|
479
|
+
Apply the modifications by re-collecting transitions.
|
480
|
+
|
481
|
+
This method must be called after making all modifications to update
|
482
|
+
the django-fsm registry.
|
483
|
+
"""
|
484
|
+
field = self.fsm_meta.field
|
485
|
+
field._collect_transitions(sender=self.model_class)
|
486
|
+
logger.debug("Applied transition modifications for %s.%s", self.model_class.__name__, self.method_name)
|
487
|
+
|
488
|
+
|
489
|
+
# Convenience functions for common patterns
|
490
|
+
|
491
|
+
|
492
|
+
def _resolve_state_value(state_ref: str | int, enum_class: type[DynamicStateEnum]) -> int:
|
493
|
+
"""Resolve a string state reference to its integer value."""
|
494
|
+
return getattr(enum_class, state_ref) if isinstance(state_ref, str) else state_ref
|
495
|
+
|
496
|
+
|
497
|
+
def _build_transitions(
|
498
|
+
builder: TransitionBuilder, transitions_config: list[dict[str, Any]], enum_class: type[DynamicStateEnum]
|
499
|
+
) -> None:
|
500
|
+
"""Build transitions from configuration."""
|
501
|
+
for config in transitions_config:
|
502
|
+
source = _resolve_state_value(config["source"], enum_class)
|
503
|
+
target = _resolve_state_value(config["target"], enum_class)
|
504
|
+
|
505
|
+
builder.add_transition(
|
506
|
+
method_name=config["method_name"],
|
507
|
+
source=source,
|
508
|
+
target=target,
|
509
|
+
method_impl=config.get("method_impl"),
|
510
|
+
conditions=config.get("conditions"),
|
511
|
+
permission=config.get("permission"),
|
512
|
+
on_error=config.get("on_error"),
|
513
|
+
custom=config.get("custom"),
|
514
|
+
)
|
515
|
+
|
516
|
+
|
517
|
+
def _modify_transitions(
|
518
|
+
modifier: TransitionModifier, transition_configs: list[dict[str, Any]], enum_class: type[DynamicStateEnum]
|
519
|
+
) -> None:
|
520
|
+
"""Modify transitions from configuration."""
|
521
|
+
for trans_config in transition_configs:
|
522
|
+
source = _resolve_state_value(trans_config["source"], enum_class)
|
523
|
+
target = _resolve_state_value(trans_config["target"], enum_class)
|
524
|
+
|
525
|
+
modifier.add_transition(
|
526
|
+
source=source,
|
527
|
+
target=target,
|
528
|
+
conditions=trans_config.get("conditions"),
|
529
|
+
permission=trans_config.get("permission"),
|
530
|
+
on_error=trans_config.get("on_error"),
|
531
|
+
custom=trans_config.get("custom"),
|
532
|
+
)
|
533
|
+
|
534
|
+
|
535
|
+
def create_simple_workflow_extension(
|
536
|
+
app_config: AppConfig,
|
537
|
+
target_model: str,
|
538
|
+
target_enum: str,
|
539
|
+
states_to_add: dict[str, int],
|
540
|
+
transitions_to_add: list[dict[str, Any]],
|
541
|
+
transitions_to_modify: list[dict[str, Any]] | None = None,
|
542
|
+
) -> bool:
|
543
|
+
"""
|
544
|
+
Create a simple workflow extension without subclassing.
|
545
|
+
|
546
|
+
This function provides a quick way to create workflow extensions for simple cases
|
547
|
+
without having to subclass WorkflowExtension.
|
548
|
+
|
549
|
+
Args:
|
550
|
+
app_config: The Django AppConfig instance
|
551
|
+
target_model: Target model as 'app_label.ModelName'
|
552
|
+
target_enum: Target enum as 'module.path.EnumClass'
|
553
|
+
states_to_add: Dictionary of state_name -> state_value to add
|
554
|
+
transitions_to_add: List of transition configurations
|
555
|
+
transitions_to_modify: List of existing transition modifications
|
556
|
+
|
557
|
+
Returns:
|
558
|
+
True if extension was applied successfully, False otherwise
|
559
|
+
|
560
|
+
Example:
|
561
|
+
create_simple_workflow_extension(
|
562
|
+
app_config=self,
|
563
|
+
target_model='blog.BlogPost',
|
564
|
+
target_enum='blog.models.BlogPostStateEnum',
|
565
|
+
states_to_add={'IN_REVIEW': 15, 'APPROVED': 17},
|
566
|
+
transitions_to_add=[
|
567
|
+
{
|
568
|
+
'method_name': 'send_to_review',
|
569
|
+
'source': 'NEW', # Will be resolved from enum
|
570
|
+
'target': 'IN_REVIEW'
|
571
|
+
}
|
572
|
+
],
|
573
|
+
transitions_to_modify=[
|
574
|
+
{
|
575
|
+
'method_name': 'publish',
|
576
|
+
'clear_existing': True,
|
577
|
+
'transitions': [{'source': 'APPROVED', 'target': 'PUBLISHED'}]
|
578
|
+
}
|
579
|
+
]
|
580
|
+
)
|
581
|
+
"""
|
582
|
+
|
583
|
+
class SimpleWorkflowExtension(WorkflowExtension):
|
584
|
+
def extend_states(self, enum_class):
|
585
|
+
for name, value in states_to_add.items():
|
586
|
+
enum_class.add_state(name, value)
|
587
|
+
|
588
|
+
def extend_transitions(self, model_class, enum_class):
|
589
|
+
if transitions_to_add:
|
590
|
+
builder = TransitionBuilder(model_class)
|
591
|
+
_build_transitions(builder, transitions_to_add, enum_class)
|
592
|
+
builder.build_and_attach()
|
593
|
+
|
594
|
+
def modify_existing_transitions(self, model_class, enum_class):
|
595
|
+
if not transitions_to_modify:
|
596
|
+
return
|
597
|
+
|
598
|
+
for config in transitions_to_modify:
|
599
|
+
modifier = TransitionModifier(model_class, config["method_name"])
|
600
|
+
|
601
|
+
if config.get("clear_existing", False):
|
602
|
+
modifier.clear_transitions()
|
603
|
+
|
604
|
+
transition_configs = config.get("transitions", [])
|
605
|
+
if transition_configs:
|
606
|
+
_modify_transitions(modifier, transition_configs, enum_class)
|
607
|
+
|
608
|
+
modifier.apply()
|
609
|
+
|
610
|
+
extension = SimpleWorkflowExtension(app_config)
|
611
|
+
extension.target_model = target_model
|
612
|
+
extension.target_enum = target_enum
|
613
|
+
|
614
|
+
return extension.apply()
|
@@ -0,0 +1,237 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: django-fsm-dynamic
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: Dynamic workflow extensions for django-fsm-2
|
5
|
+
Author-email: LevIT <info@levit.be>
|
6
|
+
License-Expression: MIT
|
7
|
+
Project-URL: Homepage, https://gitlab.levitnet.be/levit/django-fsm-dynamic
|
8
|
+
Project-URL: Repository, https://gitlab.levitnet.be/levit/django-fsm-dynamic.git
|
9
|
+
Keywords: django,fsm,workflow,state-machine,dynamic
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
11
|
+
Classifier: Framework :: Django
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
13
|
+
Classifier: Framework :: Django :: 5.0
|
14
|
+
Classifier: Framework :: Django :: 5.1
|
15
|
+
Classifier: Framework :: Django :: 5.2
|
16
|
+
Classifier: Intended Audience :: Developers
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
23
|
+
Requires-Python: >=3.8
|
24
|
+
Description-Content-Type: text/markdown
|
25
|
+
License-File: LICENSE
|
26
|
+
Requires-Dist: django>=4.2
|
27
|
+
Requires-Dist: django-fsm-2>=4.0.0
|
28
|
+
Provides-Extra: dev
|
29
|
+
Requires-Dist: build>=1.2.2.post1; extra == "dev"
|
30
|
+
Requires-Dist: coverage; extra == "dev"
|
31
|
+
Requires-Dist: ruff; extra == "dev"
|
32
|
+
Requires-Dist: setuptools>=75.3.2; extra == "dev"
|
33
|
+
Requires-Dist: twine>=6.1.0; extra == "dev"
|
34
|
+
Dynamic: license-file
|
35
|
+
|
36
|
+
# Django FSM Dynamic
|
37
|
+
|
38
|
+
Dynamic workflow extensions for [django-fsm-2](https://github.com/django-commons/django-fsm-2) that allow optional Django apps to modify FSM state machines without creating database migrations.
|
39
|
+
|
40
|
+
[](https://badge.fury.io/py/django-fsm-dynamic)
|
41
|
+
[](https://pypi.org/project/django-fsm-dynamic/)
|
42
|
+
[](https://docs.djangoproject.com/en/stable/releases/)
|
43
|
+
|
44
|
+
## Features
|
45
|
+
|
46
|
+
- **Dynamic State Enums**: Extend state enums at runtime without migrations
|
47
|
+
- **Callable Choices**: Prevent Django from generating migrations when choices change
|
48
|
+
- **Transition Builder**: Programmatically create FSM transitions
|
49
|
+
- **Workflow Extensions**: Structured app-based workflow modifications
|
50
|
+
- **Migration-Free**: All extensions work without requiring database migrations
|
51
|
+
|
52
|
+
## Installation
|
53
|
+
|
54
|
+
```bash
|
55
|
+
pip install django-fsm-dynamic
|
56
|
+
```
|
57
|
+
|
58
|
+
**Requirements:**
|
59
|
+
- Python 3.8+
|
60
|
+
- Django 4.2+
|
61
|
+
- django-fsm-2 4.0+
|
62
|
+
|
63
|
+
## Quick Start
|
64
|
+
|
65
|
+
### 1. Create a Dynamic State Enum
|
66
|
+
|
67
|
+
```python
|
68
|
+
from django_fsm_dynamic import DynamicStateEnum
|
69
|
+
from django_fsm import FSMIntegerField
|
70
|
+
from django.db import models
|
71
|
+
|
72
|
+
class BlogPostStateEnum(DynamicStateEnum):
|
73
|
+
NEW = 10
|
74
|
+
PUBLISHED = 20
|
75
|
+
HIDDEN = 30
|
76
|
+
|
77
|
+
class BlogPost(models.Model):
|
78
|
+
title = models.CharField(max_length=200)
|
79
|
+
state = FSMIntegerField(
|
80
|
+
default=BlogPostStateEnum.NEW,
|
81
|
+
choices=BlogPostStateEnum.get_choices # Prevents migrations!
|
82
|
+
)
|
83
|
+
```
|
84
|
+
|
85
|
+
### 2. Extend from Another App
|
86
|
+
|
87
|
+
```python
|
88
|
+
# In your review app's apps.py
|
89
|
+
from django.apps import AppConfig
|
90
|
+
from django_fsm_dynamic import WorkflowExtension, TransitionBuilder
|
91
|
+
|
92
|
+
class ReviewWorkflowExtension(WorkflowExtension):
|
93
|
+
target_model = 'blog.BlogPost'
|
94
|
+
target_enum = 'blog.models.BlogPostStateEnum'
|
95
|
+
|
96
|
+
def extend_states(self, enum_class):
|
97
|
+
enum_class.add_state('IN_REVIEW', 15)
|
98
|
+
enum_class.add_state('APPROVED', 17)
|
99
|
+
|
100
|
+
def extend_transitions(self, model_class, enum_class):
|
101
|
+
builder = TransitionBuilder(model_class)
|
102
|
+
builder.add_transition('send_to_review', enum_class.NEW, enum_class.IN_REVIEW)
|
103
|
+
builder.add_transition('approve', enum_class.IN_REVIEW, enum_class.APPROVED)
|
104
|
+
builder.build_and_attach()
|
105
|
+
|
106
|
+
class ReviewConfig(AppConfig):
|
107
|
+
name = 'review'
|
108
|
+
|
109
|
+
def ready(self):
|
110
|
+
ReviewWorkflowExtension(self).apply()
|
111
|
+
```
|
112
|
+
|
113
|
+
### 3. Use the Extended Workflow
|
114
|
+
|
115
|
+
```python
|
116
|
+
# Create a blog post
|
117
|
+
post = BlogPost.objects.create(title="My Post", state=BlogPostStateEnum.NEW)
|
118
|
+
|
119
|
+
# Use dynamically added transitions
|
120
|
+
post.send_to_review() # NEW -> IN_REVIEW
|
121
|
+
post.approve() # IN_REVIEW -> APPROVED
|
122
|
+
```
|
123
|
+
|
124
|
+
## Core Components
|
125
|
+
|
126
|
+
### DynamicStateEnum
|
127
|
+
|
128
|
+
Base class for extensible state enums:
|
129
|
+
|
130
|
+
```python
|
131
|
+
from django_fsm_dynamic import DynamicStateEnum
|
132
|
+
|
133
|
+
class MyStateEnum(DynamicStateEnum):
|
134
|
+
NEW = 10
|
135
|
+
PUBLISHED = 20
|
136
|
+
|
137
|
+
# Other apps can extend:
|
138
|
+
MyStateEnum.add_state('IN_REVIEW', 15)
|
139
|
+
|
140
|
+
# Get all choices including dynamic ones:
|
141
|
+
choices = MyStateEnum.get_choices() # [(10, 'New'), (15, 'In Review'), (20, 'Published')]
|
142
|
+
```
|
143
|
+
|
144
|
+
### Dynamic Choices
|
145
|
+
|
146
|
+
Use the `get_choices` method directly to prevent Django migrations:
|
147
|
+
|
148
|
+
```python
|
149
|
+
class MyModel(models.Model):
|
150
|
+
state = FSMIntegerField(
|
151
|
+
default=MyStateEnum.NEW,
|
152
|
+
choices=MyStateEnum.get_choices # No migrations when enum changes!
|
153
|
+
)
|
154
|
+
```
|
155
|
+
|
156
|
+
### TransitionBuilder
|
157
|
+
|
158
|
+
Programmatically create FSM transitions:
|
159
|
+
|
160
|
+
```python
|
161
|
+
from django_fsm_dynamic import TransitionBuilder
|
162
|
+
|
163
|
+
builder = TransitionBuilder(MyModel)
|
164
|
+
builder.add_transition(
|
165
|
+
'approve',
|
166
|
+
source=MyStateEnum.IN_REVIEW,
|
167
|
+
target=MyStateEnum.APPROVED,
|
168
|
+
conditions=[lambda instance: instance.is_valid()],
|
169
|
+
permission='myapp.can_approve'
|
170
|
+
).build_and_attach()
|
171
|
+
```
|
172
|
+
|
173
|
+
### WorkflowExtension
|
174
|
+
|
175
|
+
Structured approach to extending workflows:
|
176
|
+
|
177
|
+
```python
|
178
|
+
from django_fsm_dynamic import WorkflowExtension
|
179
|
+
|
180
|
+
class MyExtension(WorkflowExtension):
|
181
|
+
target_model = 'app.Model'
|
182
|
+
target_enum = 'app.models.StateEnum'
|
183
|
+
|
184
|
+
def extend_states(self, enum_class):
|
185
|
+
enum_class.add_state('NEW_STATE', 99)
|
186
|
+
|
187
|
+
def extend_transitions(self, model_class, enum_class):
|
188
|
+
# Add new transitions
|
189
|
+
pass
|
190
|
+
|
191
|
+
def modify_existing_transitions(self, model_class, enum_class):
|
192
|
+
# Modify existing transitions
|
193
|
+
pass
|
194
|
+
```
|
195
|
+
|
196
|
+
## Migration from django-fsm-2
|
197
|
+
|
198
|
+
If you were using the dynamic utilities from django-fsm-2, simply update your imports:
|
199
|
+
|
200
|
+
```python
|
201
|
+
# Old (django-fsm-2 < 4.1.0)
|
202
|
+
from django_fsm.dynamic import DynamicStateEnum, TransitionBuilder
|
203
|
+
|
204
|
+
# New (with django-fsm-dynamic)
|
205
|
+
from django_fsm_dynamic import DynamicStateEnum, TransitionBuilder
|
206
|
+
```
|
207
|
+
|
208
|
+
**Note**: If you were using `make_callable_choices` from django-fsm-2, simply use `MyStateEnum.get_choices` directly instead - Django 5.0+ accepts callables for the choices parameter.
|
209
|
+
|
210
|
+
All functionality remains the same, just in a separate package.
|
211
|
+
|
212
|
+
## Documentation
|
213
|
+
|
214
|
+
- [Complete Documentation](docs/dynamic_workflows.md)
|
215
|
+
- [API Reference](docs/api.md)
|
216
|
+
- [Examples](examples/)
|
217
|
+
|
218
|
+
## Why Separate Package?
|
219
|
+
|
220
|
+
Dynamic workflows are a powerful but specialized feature. By extracting them into a separate package:
|
221
|
+
|
222
|
+
1. **Focused Development**: Each package has a clear, focused scope
|
223
|
+
2. **Optional Dependency**: Only install if you need dynamic workflows
|
224
|
+
3. **Independent Versioning**: Features can evolve independently
|
225
|
+
4. **Cleaner Core**: django-fsm-2 stays focused on core FSM functionality
|
226
|
+
|
227
|
+
## Contributing
|
228
|
+
|
229
|
+
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
230
|
+
|
231
|
+
## License
|
232
|
+
|
233
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
234
|
+
|
235
|
+
## Changelog
|
236
|
+
|
237
|
+
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
@@ -0,0 +1,8 @@
|
|
1
|
+
django_fsm_dynamic/__init__.py,sha256=GsI-n4gl2JxoFr_HtBODcdNIn0Ei9JeWD5GoUtGcVhs,864
|
2
|
+
django_fsm_dynamic/__version__.py,sha256=LHyzn7KDxWBSsydCVLd1g3gAApom3Y5gMAFi7DNuP-k,58
|
3
|
+
django_fsm_dynamic/core.py,sha256=SQLZNlzxfyCWbTApJhnMaKlWkd0_XB-vl27j7IEe5Ls,21237
|
4
|
+
django_fsm_dynamic-1.0.0.dist-info/licenses/LICENSE,sha256=6i7nwD2wlVtTs1_MJErpdM_OLZUy34-5vdoWBiqELzM,1061
|
5
|
+
django_fsm_dynamic-1.0.0.dist-info/METADATA,sha256=LTkDMVqH-OgSioNI8tnAhMXgoHgjzxJKX81zhssDGIw,7141
|
6
|
+
django_fsm_dynamic-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
7
|
+
django_fsm_dynamic-1.0.0.dist-info/top_level.txt,sha256=w-rTK-Kghzxx3wTIvqKOz6EK5kyJ0bcsrEwSdwkKa2Y,19
|
8
|
+
django_fsm_dynamic-1.0.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 LevIT
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1 @@
|
|
1
|
+
django_fsm_dynamic
|