Automat skończony (FSM) w Django
Idąc za wikipedią automat skończony to:
abstrakcyjny, matematyczny, iteracyjny model zachowania systemu dynamicznego oparty na tablicy dyskretnych przejść między jego kolejnymi stanami
Na pierwszy rzut oka niewiele nam to mówi jednak w praktyce zastosowanie tego modelu umożliwia łatwą i przejrzystą kontrolę nad zmianami stanów obiektów. Najprostszy przykład? Statusy artykułu. Powiedzmy, że artykuł może mieć status - nieopublikowany, publiczny albo usunięty. Chciałbyś uniemożliwić bezpośrednie przejście z statusu nieopublikowany na usunięty. Jak to zrobić? Można oczywiście wyciągnąć z bazy poprzedni stan jeszcze przed zapisaniem zmian i porównać stan obiektu zmodyfikowanego z oryginalnym, jednak w praktyce takie rozwiązanie jest nieeleganckie i przy bardziej skomplikowanych przejściach istnieje duże prawdopodobieństwo wygenerowania spaghetti code. Dobry programista to leniwy programista dlatego warto skorzystać z automatu skończonego a dokładniej z konkretnej djangowej implementacji - django-fsm.
Do dzieła!
Instalacja
Wystarczy tylko zainstalować pakiet, żadna inna konfiguracja nie jest wymagana.
pip install django-fsm
Implementacja
Na potrzeby artykułu stworzyłem prosty model "Project" z tytułem, statusem oraz dodatkowym polem śmieciowym, którego przeznaczenie poznasz później.
from django.db import models
from django_fsm import FSMIntegerField, transition
class Project(models.Model):
DRAFT = 0
ACCEPTED = 1
PUBLISHED = 2
REMOVED = 3
STATUS_CHOICES = (
(DRAFT, 'Draft'),
(ACCEPTED, 'Accepted'),
(PUBLISHED, 'Published'),
(REMOVED, 'Removed'),
)
title = models.CharField(max_length=255)
status = FSMIntegerField(choices=STATUS_CHOICES, default=DRAFT)
cond = models.IntegerField()
def __str__(self):
return self.title
Teraz powiedzmy, że chcesz uniemożliwić przejście z statusu DRAFT na REMOVED. W takiej sytuacji wystarczy zaimplementować poniższą metodę w modelu:
@transition(field=status, source=[PUBLISHED, ACCEPTED], target=REMOVED)
def removed(self):
first_action()
second_action()
print("Status changed to removed")
Dzięki dekoratorowi transition django-fsm wie, że właśnie ta metoda a nie inna jest odpowiedzialna za zmianę stanów. Argument field określa pole modelu, którego stan ma być śledzony, source określa dozwoloną listę stanów lub pojedynczy stan z którego ma nastąpić przejście, a target definiuje stan docelowy. W metodzie możemy zapisać akcje związane z zmianą stanu na przykład powiadomienie czytelników mailowo o publikacji, wyczyszczenie cache'u czy wysłanie sygnału. W moim przypadku zostanie wyświetlony błąd ponieważ zmiana statusu z DRAFT na REMOVED jest zabroniona. Sprawdźmy:
obj = Project.objects.create(title="Tytuł", status=Project.DRAFT, cond=2)
Wywołanie metody
obj.removed()
wyrzuca następujący błąd:
TransitionNotAllowed: Can't switch from state '0' using method 'removed'
Czyli wszystko działa dokładnie tak jak powinno! Oczywiście można bezpośrednio zmienić wartość pola:
obj.status = 3
obj.save()
obj.__dict__
{'_state': <django.db.models.base.ModelState at 0x110d82198>,
'cond': 2,
'id': 2,
'status': 3,
'title': 'Tytuł'}
Jednak korzystając z django-fsm przyjmujemy konwencję, że programiści nie będą tego praktykować a zamiast tego będą wywoływać odpowiednią metodę w modelu. Jeśli nie ufasz programistom z swojego zespołu to możesz zablokować możliwość bezpośredniej edycji pola modelu ustawiając atrybut protected na true.
status = FSMIntegerField(choices=STATUS_CHOICES, default=DRAFT, protected=True)
Efekt?
obj.status = 2
AttributeError: Direct status modification is not allowed
Na koniec pojawia się pytanie czy można określić dodatkowe warunki zmiany stanów. Oczywiście, że tak :) W tym celu wystarczy dodać argument conditions z listą funkcji/metod w dekoratorze zmiany stanu
@transition(field=status, source=ACCEPTED, target=PUBLISHED, conditions=[f])
def published(self):
print("Status changed to published")
Funkcja f sprawdza wartość pola cond i jeśli jest równa 2 zwraca true
def f(inst):
if inst.cond == 2:
return True
else:
return False
# można krócej i bardziej python way
def f(inst):
return inst.cond == 2
obj.cond = 3
obj.published()
> TransitionNotAllowed: Transition conditions have not been met for method 'published'
obj.cond = 2
obj.published()
# komunikat sukcesu
> Status changed to published
# django-fsm nie zapisuje automatycznie danych do bazy
obj.save()
See ya
ps. cały plik jest dostępny tutaj
Niewiele rozumiem, ale powodzenia! :D