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.
@@ -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,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "1.0.0"
@@ -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
+ [![PyPI version](https://badge.fury.io/py/django-fsm-dynamic.svg)](https://badge.fury.io/py/django-fsm-dynamic)
41
+ [![Python Support](https://img.shields.io/pypi/pyversions/django-fsm-dynamic.svg)](https://pypi.org/project/django-fsm-dynamic/)
42
+ [![Django Support](https://img.shields.io/badge/django-4.2%2B-blue)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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