Django FSM-2

repository·main·Indexed 42 days ago

https://github.com/django-commons/django-fsm-2

Declarative finite state machine support for Django models version 4.2.4. It allows developers to centralize lifecycle management, enforce valid transitions, and attach business logic to state changes using FSMField, FSMKeyField, and FSMIntegerField. Features include a drop-in replacement for the original django-fsm, Django Admin integration via FSMAdminMixin, concurrency protection with ConcurrentTransitionMixin, and a graph_transitions management command for visualizing state machines.

Tokens
4K
Snippets
13
Records
14
Agent score
5%

What's inside Django FSM-2

  1. Use dynamic source and target states

    main

    The source and target parameters support advanced logic:

    • Wildcards:
      • source='*': Any state (constant fsm.ANY_STATE).
      • source='+': Any state except the target (constant fsm.ANY_OTHER_STATE).
    • Dynamic Targets:
      • fsm.RETURN_VALUE(value_if_returned, target_state): Uses the method's return value to determine the target.
      • fsm.GET_STATE(callable, states=[...]): Uses a callable to determine the target state from a list of allowed states.
    import django_fsm as fsm
    
    # Using RETURN_VALUE
    @fsm.transition(
        field=state,
        source='*',
        target=fsm.RETURN_VALUE('for_moderators', 'published'),
    )
    def publish(self, is_public=False, **kwargs):
        return 'for_moderators' if is_public else 'published'
    
    # Using GET_STATE
    @fsm.transition(
        field=state,
        source='for_moderators',
        target=fsm.GET_STATE(
            lambda self, allowed: 'published' if allowed else 'rejected',
            states=['published', 'rejected'],
        ),
    )
    def moderate(self, allowed, **kwargs):
        pass
  2. Integrate django-fsm with Django Admin

    main

    To add FSM transition buttons to your Django admin interface, use the FSMAdminMixin.

    Important:

    • FSMAdminMixin must be placed before admin.ModelAdmin in the class inheritance list.
    • If migrating from django-fsm-admin, update your imports from django_fsm_admin.mixins import FSMTransitionMixin to from django_fsm.admin import FSMAdminMixin and ensure the old package is uninstalled.

    Define the fields you want to manage using the fsm_fields attribute.

    from django_fsm.admin import FSMAdminMixin
    
    @admin.register(AdminBlogPost)
    class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
        # Declare the fsm fields you want to manage
        fsm_fields = ['my_fsm_field']
        ...
  3. Customize FSM Admin transition buttons

    main

    You can customize the appearance and behavior of transition buttons in the admin in two ways:

    1. Via the @fsm.transition decorator

    Use the custom dictionary to set label and help_text directly on the transition.

    2. Via FSMAdminMixin methods

    Override get_fsm_label and get_help_text in your ModelAdmin class to dynamically return strings based on the transition name.

    Hiding transitions

    To hide a button from the admin interface:

    • Use custom={"admin": False} in the transition decorator.
    • OR override is_fsm_transition_visible(self, transition) in your ModelAdmin and return False for specific transitions.

    Note on visibility defaults: If you set FSM_ADMIN_FORCE_PERMIT = True in your Django settings, or set fsm_default_disallow_transition = False on your ModelAdmin, all transitions are hidden by default. You must then explicitly enable them using custom={"admin": True}.

    # Option 1: Decorator customization
    @fsm.transition(
        field='state',
        source=['startstate'],
        target='finalstate',
        custom={
            "label": "My awesome transition",
            "help_text": "Rename blog post",
            "admin": False,  # Hides the button
        },
    )
    def do_something(self, **kwargs):
        ...
    
    # Option 2: Admin Mixin customization
    @admin.register(AdminBlogPost)
    class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
        fsm_fields = ['state']
    
        def get_fsm_label(self, transition):
            if transition.name == "do_something":
                return "My awesome transition"
            return super().get_fsm_label(transition)
    
        def is_fsm_transition_visible(self, transition):
            if transition.name == "do_something":
                return False
            return super().is_fsm_transition_visible(transition)
  4. Install django-fsm-2

    main

    Install the package using uv pip. To migrate from the original django-fsm, simply update your dependency to django-fsm-2; it is a drop-in replacement.

    To use features like Admin integration or transition graphing, add 'django_fsm' to your INSTALLED_APPS.

    # Install via uv
    uv pip install django-fsm-2
    
    # Or install from git
    uv pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm
  5. Quick start with Django FSM-2

    main

    To use FSM, inherit from fsm.FSMModelMixin and add an fsm.FSMField. Define transitions using the @fsm.transition decorator. To execute a transition, check if it can proceed using fsm.can_proceed() before calling the method and calling .save() to persist the state change.

    from django.db import models
    import django_fsm as fsm
    
    class BlogPost(fsm.FSMModelMixin, models.Model):
        state = fsm.FSMField(default='new')
    
        @fsm.transition(field=state, source='new', target='published')
        def publish(self, **kwargs):
            pass
    
    # Usage
    post = BlogPost.objects.get(pk=1)
    if fsm.can_proceed(post.publish):
        post.publish()
        post.save()
  6. Protect state fields from direct assignment

    main

    Setting protected=True on an FSMField prevents direct assignment (e.g., model.state = 'new'), ensuring state changes only occur through defined transitions.

    To allow refresh_from_db() to work without triggering errors on protected fields, your model should inherit from fsm.FSMModelMixin.

    import django_fsm as fsm
    
    class BlogPost(fsm.FSMModelMixin, models.Model):
        state = fsm.FSMField(default='new', protected=True)
    
    model = BlogPost()
    # model.state = 'invalid'  # Raises AttributeError
    model.refresh_from_db()    # Works
  7. Restrict transitions with conditions

    main

    Use the conditions argument in @fsm.transition to pass a list of callables. Each callable receives the model instance and must return a truthy or falsey value. Callables should not have side effects.

    If a condition is not met, a django_fsm.TransitionConditionsUnmet exception is raised. You can access the failing callable via the failed_condition attribute on the exception.

    import django_fsm as fsm
    from django_fsm import TransitionConditionsUnmet
    
    def can_publish(instance):
        return datetime.datetime.now().hour <= 17
    
    class XXX(fsm.FSMModelMixin, models.Model):
        @fsm.transition(
            field=state,
            source='new',
            target='published',
            conditions=[can_publish]
        )
        def publish(self, **kwargs):
            pass
    
    # Error handling
    try:
        instance.publish()
    except TransitionConditionsUnmet as e:
        print(e.failed_condition)  # e.g. <function can_publish at 0x...>
  8. Handle concurrent state changes with ConcurrentTransitionMixin

    main

    To prevent race conditions where two processes attempt to transition the same instance simultaneously, inherit from fsm.ConcurrentTransitionMixin.

    If the state in the database has changed since the instance was loaded, django_fsm.ConcurrentTransition is raised during .save().

    Best Practices for Safety:

    1. Ensure transitions have no side effects other than database changes.
    2. Always call .save() within a django.db.transaction.atomic() block to ensure rollbacks occur if a concurrent transition is detected.
    import django_fsm as fsm
    
    class BlogPost(fsm.ConcurrentTransitionMixin, models.Model):
        state = fsm.FSMField(default='new')
  9. Attach permissions to transitions

    main

    You can restrict transitions based on user permissions using the permission argument in @fsm.transition. It accepts:

    • A permission string (e.g., 'myapp.can_remove_post').
    • A callable that receives (instance, user) and returns a boolean.

    To check if a user has permission to perform a transition before calling it, use fsm.has_transition_perm(transition_method, user).

    import django_fsm as fsm
    
    # Using a callable
    @fsm.transition(
        field=state,
        source='*',
        target='published',
        permission=lambda instance, user: not user.has_perm('myapp.can_make_mistakes'),
    )
    def publish(self, **kwargs):
        pass
    
    # Using a string
    @fsm.transition(
        field=state,
        source='*',
        target='removed',
        permission='myapp.can_remove_post',
    )
    def remove(self, **kwargs):
        pass
    
    # Checking permission in a view
    def publish_view(request, post_id):
        post = get_object_or_404(BlogPost, pk=post_id)
        if not fsm.has_transition_perm(post.publish, request.user):
            raise PermissionDenied
    
        post.publish()
        post.save()
  10. Declare transitions with @fsm.transition

    main

    Transitions are declared as methods on your model using the @fsm.transition decorator.

    • field: The name of the state field (string or field instance).
    • source: The required current state(s).
    • target: The state to transition to.

    If the method executes without an exception, the state changes in memory. You must call .save() to persist the change to the database.

    import django_fsm as fsm
    
    @fsm.transition(field=state, source='new', target='published')
    def publish(self, **kwargs):
        """
        This function may contain side effects,
        like updating caches, notifying users, etc.
        The return value will be discarded.
        """
        pass
  11. Use FSMField types: FSMKeyField and FSMIntegerField

    main

    Django FSM-2 provides specialized field types for different storage needs:

    • FSMKeyField: Stores state as a foreign key to another model. This maintains referential integrity. source and target use the Primary Key values of the target model.
    • FSMIntegerField: Stores state as an integer, ideal for using Django IntegerChoices (enums).
    import django_fsm as fsm
    
    # FSMKeyField example
    class DbState(fsm.FSMModelMixin, models.Model):
        id = models.CharField(primary_key=True)
        label = models.CharField()
    
    class BlogPost(fsm.FSMModelMixin, models.Model):
        state = fsm.FSMKeyField(DbState, default='new')
    
    # FSMIntegerField example
    class BlogPostStateChoices(models.IntegerChoices):
        NEW = 10, "New"
        PUBLISHED = 20, "Published"
    
    class BlogPostWithIntegerField(fsm.FSMModelMixin, models.Model):
        state = fsm.FSMIntegerField(default=BlogPostStateChoices.NEW)
  12. Use Custom Forms for FSM transitions

    main

    You can prompt the admin for input before executing a transition by attaching a forms.Form or forms.ModelForm.

    Implementation Methods

    1. On the Model: Add a `