diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b76d380..a5c1d41 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches: - - master + - main jobs: coverage: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0b71929..da4187d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,13 @@ Changelog ========= +Unreleased +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Add support for Django 5.2 +- Add support for python 3.13 + + django-fsm-2 4.0.0 2024-09-02 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/README.md b/README.md index c2639a6..e5f1f01 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ class DbState(models.Model): id = models.CharField(primary_key=True, max_length=50) label = models.CharField(max_length=255) - def __unicode__(self): + def __str__(self): return self.label diff --git a/django_fsm/__init__.py b/django_fsm/__init__.py index aa0779e..77c3234 100644 --- a/django_fsm/__init__.py +++ b/django_fsm/__init__.py @@ -18,22 +18,22 @@ from django_fsm.signals import pre_transition __all__ = [ - "TransitionNotAllowed", + "GET_STATE", + "RETURN_VALUE", "ConcurrentTransition", - "FSMFieldMixin", + "ConcurrentTransitionMixin", "FSMField", + "FSMFieldMixin", "FSMIntegerField", "FSMKeyField", - "ConcurrentTransitionMixin", - "transition", + "TransitionNotAllowed", "can_proceed", "has_transition_perm", - "GET_STATE", - "RETURN_VALUE", + "transition", ] -class TransitionNotAllowed(Exception): +class TransitionNotAllowed(Exception): # noqa: N818 """Raised when a transition is not allowed""" def __init__(self, *args, **kwargs): @@ -42,11 +42,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class InvalidResultState(Exception): +class InvalidResultState(Exception): # noqa: N818 """Raised when we got invalid result state""" -class ConcurrentTransition(Exception): +class ConcurrentTransition(Exception): # noqa: N818 """ Raised when the transition cannot be executed because the object has become stale (state has been changed since it @@ -91,7 +91,7 @@ def __eq__(self, other): return False -def get_available_FIELD_transitions(instance, field): +def get_available_FIELD_transitions(instance, field): # noqa: N802 """ List of transitions available in current model state with all conditions met @@ -105,14 +105,14 @@ def get_available_FIELD_transitions(instance, field): yield meta.get_transition(curr_state) -def get_all_FIELD_transitions(instance, field): +def get_all_FIELD_transitions(instance, field): # noqa: N802 """ List of all transitions available in current model state """ return field.get_all_transitions(instance.__class__) -def get_available_user_FIELD_transitions(instance, user, field): +def get_available_user_FIELD_transitions(instance, user, field): # noqa: N802 """ List of transitions available in current model state with all conditions met and user have rights on it @@ -211,7 +211,7 @@ class FSMFieldDescriptor: def __init__(self, field): self.field = field - def __get__(self, instance, type=None): + def __get__(self, instance, instance_type=None): if instance is None: return self return self.field.get_state(instance) @@ -234,7 +234,7 @@ def __init__(self, *args, **kwargs): self.state_proxy = {} # state -> ProxyClsRef state_choices = kwargs.pop("state_choices", None) - choices = kwargs.get("choices", None) + choices = kwargs.get("choices") if state_choices is not None and choices is not None: raise ValueError("Use one of choices or state_choices value") @@ -344,8 +344,7 @@ def get_all_transitions(self, instance_cls): for transition in transitions.values(): meta = transition._django_fsm - for transition in meta.transitions.values(): - yield transition + yield from meta.transitions.values() def contribute_to_class(self, cls, name, **kwargs): self.base_cls = cls @@ -406,8 +405,6 @@ class FSMIntegerField(FSMFieldMixin, models.IntegerField): Same as FSMField, but stores the state value in an IntegerField. """ - pass - class FSMKeyField(FSMFieldMixin, models.ForeignKey): """ @@ -557,7 +554,7 @@ def _change_state(instance, *args, **kwargs): return inner_transition -def can_proceed(bound_method, check_conditions=True): +def can_proceed(bound_method, check_conditions=True): # noqa: FBT002 """ Returns True if model in state allows to call bound_method @@ -597,25 +594,23 @@ def get_state(self, model, transition, result, args=[], kwargs={}): raise NotImplementedError -class RETURN_VALUE(State): +class RETURN_VALUE(State): # noqa: N801 def __init__(self, *allowed_states): self.allowed_states = allowed_states if allowed_states else None def get_state(self, model, transition, result, args=[], kwargs={}): - if self.allowed_states is not None: - if result not in self.allowed_states: - raise InvalidResultState(f"{result} is not in list of allowed states\n{self.allowed_states}") + if self.allowed_states is not None and result not in self.allowed_states: + raise InvalidResultState(f"{result} is not in list of allowed states\n{self.allowed_states}") return result -class GET_STATE(State): +class GET_STATE(State): # noqa: N801 def __init__(self, func, states=None): self.func = func self.allowed_states = states def get_state(self, model, transition, result, args=[], kwargs={}): result_state = self.func(model, *args, **kwargs) - if self.allowed_states is not None: - if result_state not in self.allowed_states: - raise InvalidResultState(f"{result_state} is not in list of allowed states\n{self.allowed_states}") + if self.allowed_states is not None and result_state not in self.allowed_states: + raise InvalidResultState(f"{result_state} is not in list of allowed states\n{self.allowed_states}") return result_state diff --git a/django_fsm/management/commands/graph_transitions.py b/django_fsm/management/commands/graph_transitions.py index b5dedfe..21a191d 100644 --- a/django_fsm/management/commands/graph_transitions.py +++ b/django_fsm/management/commands/graph_transitions.py @@ -27,7 +27,7 @@ def node_label(field, state): return state -def generate_dot(fields_data): # noqa: C901 +def generate_dot(fields_data): # noqa: C901, PLR0912 result = graphviz.Digraph() for field, model in fields_data: @@ -88,11 +88,11 @@ def generate_dot(fields_data): # noqa: C901 subgraph.node(name, label=label, shape="doublecircle") for name, label in (sources | targets) - final_states: subgraph.node(name, label=label, shape="circle") - if field.default: # Adding initial state notation - if label == field.default: - initial_name = node_name(field, "_initial") - subgraph.node(name=initial_name, label="", shape="point") - subgraph.edge(initial_name, name) + # Adding initial state notation + if field.default and label == field.default: + initial_name = node_name(field, "_initial") + subgraph.node(name=initial_name, label="", shape="point") + subgraph.edge(initial_name, name) for source_name, target_name, attrs in edges: subgraph.edge(source_name, target_name, **dict(attrs)) @@ -111,10 +111,10 @@ def add_transition(transition_source, transition_target, transition_name, source def get_graphviz_layouts(): try: import graphviz - - return graphviz.backend.ENGINES - except Exception: + except ModuleNotFoundError: return {"sfdp", "circo", "twopi", "dot", "neato", "fdp", "osage", "patchwork"} + else: + return graphviz.backend.ENGINES class Command(BaseCommand): @@ -139,10 +139,10 @@ def add_arguments(self, parser): parser.add_argument("args", nargs="*", help=("[appname[.model[.field]]]")) def render_output(self, graph, **options): - filename, format = options["outputfile"].rsplit(".", 1) + filename, graph_format = options["outputfile"].rsplit(".", 1) graph.engine = options["layout"] - graph.format = format + graph.format = graph_format graph.render(filename) def handle(self, *args, **options): @@ -156,10 +156,10 @@ def handle(self, *args, **options): models = apps.get_models(app) for model in models: fields_data += all_fsm_fields_data(model) - elif len(field_spec) == 2: + if len(field_spec) == 2: # noqa: PLR2004 model = apps.get_model(field_spec[0], field_spec[1]) fields_data += all_fsm_fields_data(model) - elif len(field_spec) == 3: + if len(field_spec) == 3: # noqa: PLR2004 model = apps.get_model(field_spec[0], field_spec[1]) fields_data += all_fsm_fields_data(model) else: @@ -170,4 +170,4 @@ def handle(self, *args, **options): if options["outputfile"]: self.render_output(dotdata, **options) else: - print(dotdata) + print(dotdata) # noqa: T201 diff --git a/django_fsm/tests/test_key_field.py b/django_fsm/tests/test_key_field.py index d8ba939..5706c84 100644 --- a/django_fsm/tests/test_key_field.py +++ b/django_fsm/tests/test_key_field.py @@ -23,16 +23,19 @@ class DBState(models.Model): label = models.CharField(max_length=255) - def __unicode__(self): - return self.label - class Meta: app_label = "django_fsm" + def __str__(self): + return self.label + class FKBlogPost(models.Model): state = FSMKeyField(DBState, default="new", protected=True, on_delete=models.CASCADE) + class Meta: + app_label = "django_fsm" + @transition(field=state, source="new", target="published") def publish(self): pass @@ -57,9 +60,6 @@ def steal(self): def moderate(self): pass - class Meta: - app_label = "django_fsm" - class FSMKeyFieldTest(TestCase): def setUp(self): @@ -119,7 +119,7 @@ def test_star_shortcut_succeed(self): """ -TODO FIX it +# TODO: FIX it class BlogPostStatus(models.Model): name = models.CharField(max_length=10, unique=True) objects = models.Manager() diff --git a/django_fsm/tests/test_protected_field.py b/django_fsm/tests/test_protected_field.py index b148982..0ef8684 100644 --- a/django_fsm/tests/test_protected_field.py +++ b/django_fsm/tests/test_protected_field.py @@ -10,13 +10,13 @@ class ProtectedAccessModel(models.Model): status = FSMField(default="new", protected=True) + class Meta: + app_label = "django_fsm" + @transition(field=status, source="new", target="published") def publish(self): pass - class Meta: - app_label = "django_fsm" - class MultiProtectedAccessModel(models.Model): status1 = FSMField(default="new", protected=True) diff --git a/django_fsm/tests/test_protected_fields.py b/django_fsm/tests/test_protected_fields.py index 325ce84..c905c5c 100644 --- a/django_fsm/tests/test_protected_fields.py +++ b/django_fsm/tests/test_protected_fields.py @@ -11,13 +11,13 @@ class RefreshableProtectedAccessModel(models.Model): status = FSMField(default="new", protected=True) + class Meta: + app_label = "django_fsm" + @transition(field=status, source="new", target="published") def publish(self): pass - class Meta: - app_label = "django_fsm" - class RefreshableModel(FSMModelMixin, RefreshableProtectedAccessModel): pass diff --git a/django_fsm/tests/test_proxy_inheritance.py b/django_fsm/tests/test_proxy_inheritance.py index 8140d05..cef7d13 100644 --- a/django_fsm/tests/test_proxy_inheritance.py +++ b/django_fsm/tests/test_proxy_inheritance.py @@ -17,13 +17,13 @@ def publish(self): class InheritedModel(BaseModel): + class Meta: + proxy = True + @transition(field="state", source="published", target="sticked") def stick(self): pass - class Meta: - proxy = True - class TestinheritedModel(TestCase): def setUp(self): diff --git a/pyproject.toml b/pyproject.toml index e2a68f3..6c983ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,19 +58,31 @@ target-version = "py38" fix = true [tool.ruff.lint] -# select = ["ALL"] -extend-select = [ - "F", # Pyflakes - "E", # pycodestyle - "W", # pycodestyle - "UP", # pyupgrade +select = ["ALL"] +extend-ignore = [ + "COM812", # This rule may cause conflicts when used with the formatter + "D", # pydocstyle + "DOC", # pydoclint + "B", + "PTH", + "ANN", # Missing type annotation + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` + "PT", # Use a regular `assert` instead of unittest-style + "DJ008", # Model does not define `__str__` method + "ARG001", # Unused function argument + "ARG002", # Unused method argument + "TRY002", # Create your own exception + "TRY003", # Avoid specifying long messages outside the exception class + "EM101", # Exception must not use a string literal, assign to variable first + "EM102", # Exception must not use an f-string literal, assign to variable first + "SLF001", # Private member accessed + "SIM103", # Return the condition directly + "PLR0913", # Too many arguments in function definition +] +fixable = [ "I", # isort - "PERF", - "RET", - "C", - # "B", + "RUF100", # Unused `noqa` directive ] -fixable = ["I"] [tool.ruff.lint.isort] diff --git a/tests/settings.py b/tests/settings.py index 3fdae8c..0daad4e 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -20,7 +20,7 @@ ) DATABASE_ENGINE = "sqlite3" -SECRET_KEY = "nokey" +SECRET_KEY = "nokey" # noqa: S105 MIDDLEWARE_CLASSES = () DATABASES = { "default": { diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 844a4f4..29302f8 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -82,7 +82,7 @@ class DbState(models.Model): label = models.CharField(max_length=255) - def __unicode__(self): + def __str__(self): return self.label @@ -93,6 +93,12 @@ class BlogPost(models.Model): state = FSMField(default="new", protected=True) + class Meta: + permissions = [ + ("can_publish_post", "Can publish post"), + ("can_remove_post", "Can remove post"), + ] + def can_restore(self, user): return user.is_superuser or user.is_staff @@ -118,7 +124,7 @@ def hide(self): source="new", target="removed", on_error="failed", - permission=lambda self, u: u.has_perm("testapp.can_remove_post"), + permission=lambda _, u: u.has_perm("testapp.can_remove_post"), ) def remove(self): raise Exception(f"No rights to delete {self}") @@ -134,9 +140,3 @@ def steal(self): @transition(field=state, source="*", target="moderated") def moderate(self): pass - - class Meta: - permissions = [ - ("can_publish_post", "Can publish post"), - ("can_remove_post", "Can remove post"), - ] diff --git a/tests/testapp/tests/test_access_deferred_fsm_field.py b/tests/testapp/tests/test_access_deferred_fsm_field.py index 5e1b68c..bfcfa7b 100644 --- a/tests/testapp/tests/test_access_deferred_fsm_field.py +++ b/tests/testapp/tests/test_access_deferred_fsm_field.py @@ -11,6 +11,9 @@ class DeferrableModel(models.Model): state = FSMField(default="new") + class Meta: + app_label = "testapp" + @transition(field=state, source="new", target="published") def publish(self): pass @@ -19,9 +22,6 @@ def publish(self): def remove(self): pass - class Meta: - app_label = "testapp" - class Test(TestCase): def setUp(self): diff --git a/tests/testapp/tests/test_custom_data.py b/tests/testapp/tests/test_custom_data.py index 8db857a..66e6232 100644 --- a/tests/testapp/tests/test_custom_data.py +++ b/tests/testapp/tests/test_custom_data.py @@ -10,6 +10,9 @@ class BlogPostWithCustomData(models.Model): state = FSMField(default="new") + class Meta: + app_label = "testapp" + @transition(field=state, source="new", target="published", conditions=[], custom={"label": "Publish", "type": "*"}) def publish(self): pass @@ -22,9 +25,6 @@ def destroy(self): def review(self): pass - class Meta: - app_label = "testapp" - class CustomTransitionDataTest(TestCase): def setUp(self): diff --git a/tests/testapp/tests/test_exception_transitions.py b/tests/testapp/tests/test_exception_transitions.py index 790f3e1..4ec54e4 100644 --- a/tests/testapp/tests/test_exception_transitions.py +++ b/tests/testapp/tests/test_exception_transitions.py @@ -12,6 +12,9 @@ class ExceptionalBlogPost(models.Model): state = FSMField(default="new") + class Meta: + app_label = "testapp" + @transition(field=state, source="new", target="published", on_error="crashed") def publish(self): raise Exception("Upss") @@ -20,9 +23,6 @@ def publish(self): def delete(self): raise Exception("Upss") - class Meta: - app_label = "testapp" - class FSMFieldExceptionTest(TestCase): def setUp(self): diff --git a/tests/testapp/tests/test_lock_mixin.py b/tests/testapp/tests/test_lock_mixin.py index 27ede7b..6eedcfb 100644 --- a/tests/testapp/tests/test_lock_mixin.py +++ b/tests/testapp/tests/test_lock_mixin.py @@ -13,6 +13,9 @@ class LockedBlogPost(ConcurrentTransitionMixin, models.Model): state = FSMField(default="new") text = models.CharField(max_length=50) + class Meta: + app_label = "testapp" + @transition(field=state, source="new", target="published") def publish(self): pass @@ -21,21 +24,18 @@ def publish(self): def remove(self): pass - class Meta: - app_label = "testapp" - class ExtendedBlogPost(LockedBlogPost): review_state = FSMField(default="waiting", protected=True) notes = models.CharField(max_length=50) + class Meta: + app_label = "testapp" + @transition(field=review_state, source="waiting", target="rejected") def reject(self): pass - class Meta: - app_label = "testapp" - class TestLockMixin(TestCase): def test_create_succeed(self): diff --git a/tests/testapp/tests/test_mixin_support.py b/tests/testapp/tests/test_mixin_support.py index c8f3036..23e8be5 100644 --- a/tests/testapp/tests/test_mixin_support.py +++ b/tests/testapp/tests/test_mixin_support.py @@ -8,6 +8,9 @@ class WorkflowMixin: + class Meta: + app_label = "testapp" + @transition(field="state", source="*", target="draft") def draft(self): pass @@ -16,9 +19,6 @@ def draft(self): def publish(self): pass - class Meta: - app_label = "testapp" - class MixinSupportTestModel(WorkflowMixin, models.Model): state = FSMField(default="new") diff --git a/tests/testapp/tests/test_model_create_with_generic.py b/tests/testapp/tests/test_model_create_with_generic.py index 60a979a..c1f0128 100644 --- a/tests/testapp/tests/test_model_create_with_generic.py +++ b/tests/testapp/tests/test_model_create_with_generic.py @@ -18,23 +18,24 @@ class Meta: app_label = "testapp" -class Task(models.Model): - class STATE: - NEW = "new" - DONE = "done" +class TaskState(models.TextChoices): + NEW = "new", "New" + DONE = "done", "Done" + +class Task(models.Model): content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() causality = GenericForeignKey("content_type", "object_id") - state = FSMField(default=STATE.NEW) - - @transition(field=state, source=STATE.NEW, target=STATE.DONE) - def do(self): - pass + state = FSMField(default=TaskState.NEW) class Meta: app_label = "testapp" + @transition(field=state, source=TaskState.NEW, target=TaskState.DONE) + def do(self): + pass + class Test(TestCase): def setUp(self): diff --git a/tests/testapp/tests/test_multi_resultstate.py b/tests/testapp/tests/test_multi_resultstate.py index 20db8d3..88c6cde 100644 --- a/tests/testapp/tests/test_multi_resultstate.py +++ b/tests/testapp/tests/test_multi_resultstate.py @@ -14,21 +14,21 @@ class MultiResultTest(models.Model): state = FSMField(default="new") + class Meta: + app_label = "testapp" + @transition(field=state, source="new", target=RETURN_VALUE("for_moderators", "published")) - def publish(self, is_public=False): + def publish(self, *, is_public=False): return "published" if is_public else "for_moderators" @transition( field=state, source="for_moderators", - target=GET_STATE(lambda self, allowed: "published" if allowed else "rejected", states=["published", "rejected"]), + target=GET_STATE(lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"]), ) def moderate(self, allowed): pass - class Meta: - app_label = "testapp" - class Test(TestCase): def test_return_state_succeed(self): diff --git a/tests/testapp/tests/test_multidecorators.py b/tests/testapp/tests/test_multidecorators.py index eea9617..e1c669d 100644 --- a/tests/testapp/tests/test_multidecorators.py +++ b/tests/testapp/tests/test_multidecorators.py @@ -13,15 +13,15 @@ class TestModel(models.Model): signal_counter = models.IntegerField(default=0) state = FSMField(default="SUBMITTED_BY_USER") + class Meta: + app_label = "testapp" + @transition(field=state, source="SUBMITTED_BY_USER", target="REVIEW_USER") @transition(field=state, source="SUBMITTED_BY_ADMIN", target="REVIEW_ADMIN") @transition(field=state, source="SUBMITTED_BY_ANONYMOUS", target="REVIEW_ANONYMOUS") def review(self): self.counter += 1 - class Meta: - app_label = "testapp" - def count_calls(sender, instance, name, source, target, **kwargs): instance.signal_counter += 1 diff --git a/tests/testapp/tests/test_object_permissions.py b/tests/testapp/tests/test_object_permissions.py index 68263df..29bf2e7 100644 --- a/tests/testapp/tests/test_object_permissions.py +++ b/tests/testapp/tests/test_object_permissions.py @@ -14,6 +14,13 @@ class ObjectPermissionTestModel(models.Model): state = FSMField(default="new") + class Meta: + app_label = "testapp" + + permissions = [ + ("can_publish_objectpermissiontestmodel", "Can publish ObjectPermissionTestModel"), + ] + @transition( field=state, source="new", @@ -24,13 +31,6 @@ class ObjectPermissionTestModel(models.Model): def publish(self): pass - class Meta: - app_label = "testapp" - - permissions = [ - ("can_publish_objectpermissiontestmodel", "Can publish ObjectPermissionTestModel"), - ] - @override_settings( AUTHENTICATION_BACKENDS=("django.contrib.auth.backends.ModelBackend", "guardian.backends.ObjectPermissionBackend") diff --git a/tests/testapp/tests/test_state_transitions.py b/tests/testapp/tests/test_state_transitions.py index 532394e..dd39e2d 100644 --- a/tests/testapp/tests/test_state_transitions.py +++ b/tests/testapp/tests/test_state_transitions.py @@ -16,6 +16,9 @@ class STATE: state = FSMField(default=STATE.CATERPILLAR, state_choices=STATE_CHOICES) + class Meta: + app_label = "testapp" + @transition(field=state, source=STATE.CATERPILLAR, target=STATE.BUTTERFLY) def cocoon(self): pass @@ -26,31 +29,28 @@ def fly(self): def crawl(self): raise NotImplementedError + +class Caterpillar(Insect): class Meta: app_label = "testapp" + proxy = True - -class Caterpillar(Insect): def crawl(self): """ Do crawl """ + +class Butterfly(Insect): class Meta: app_label = "testapp" proxy = True - -class Butterfly(Insect): def fly(self): """ Do fly """ - class Meta: - app_label = "testapp" - proxy = True - class TestStateProxy(TestCase): def test_initial_proxy_set_succeed(self): diff --git a/tests/testapp/tests/test_string_field_parameter.py b/tests/testapp/tests/test_string_field_parameter.py index f34210f..38e9102 100644 --- a/tests/testapp/tests/test_string_field_parameter.py +++ b/tests/testapp/tests/test_string_field_parameter.py @@ -10,6 +10,9 @@ class BlogPostWithStringField(models.Model): state = FSMField(default="new") + class Meta: + app_label = "testapp" + @transition(field="state", source="new", target="published", conditions=[]) def publish(self): pass @@ -22,9 +25,6 @@ def destroy(self): def review(self): pass - class Meta: - app_label = "testapp" - class StringFieldTestCase(TestCase): def setUp(self): diff --git a/tests/testapp/tests/test_transition_all_except_target.py b/tests/testapp/tests/test_transition_all_except_target.py index a7765bf..f5d1b7e 100644 --- a/tests/testapp/tests/test_transition_all_except_target.py +++ b/tests/testapp/tests/test_transition_all_except_target.py @@ -11,6 +11,9 @@ class TestExceptTargetTransitionShortcut(models.Model): state = FSMField(default="new") + class Meta: + app_label = "testapp" + @transition(field=state, source="new", target="published") def publish(self): pass @@ -19,9 +22,6 @@ def publish(self): def remove(self): pass - class Meta: - app_label = "testapp" - class Test(TestCase): def setUp(self):