Skip to content

Commit ab48e4a

Browse files
committed
Remove fsm_log_by and fsm_log_description
1 parent 1f86169 commit ab48e4a

10 files changed

Lines changed: 131 additions & 177 deletions

File tree

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -605,17 +605,13 @@ You can also capture `author` and `description` for each transition.
605605

606606
```python
607607
import django_fsm
608-
from django_fsm.log import fsm_log_by
609-
from django_fsm.log import fsm_log_description
610608
from django.db import models
611609

612610

613611
@django_fsm.track()
614612
class BlogPost(models.Model):
615613
state = django_fsm.FSMField(default="new")
616614

617-
@fsm_log_by
618-
@fsm_log_description
619615
@django_fsm.transition(field=state, source="new", target="published")
620616
def publish(self):
621617
pass

django_fsm/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import contextlib
78
import inspect
89
import typing
910
from functools import partialmethod
@@ -648,6 +649,24 @@ def save(self, *args: typing.Any, **kwargs: typing.Any) -> None:
648649
self._update_initial_state()
649650

650651

652+
class FSMLogDescriptor:
653+
ATTR_PREFIX = "__django_fsm_log_attr_"
654+
655+
def __init__(self, instance: models.Model, attrs: dict[str, typing.Any]) -> None:
656+
self.instance = instance
657+
self.attrs = attrs
658+
659+
def __enter__(self) -> typing.Self:
660+
for name, value in self.attrs.items():
661+
setattr(self.instance, self.ATTR_PREFIX + name, value)
662+
return self
663+
664+
def __exit__(self, *args: object) -> None:
665+
for name in self.attrs:
666+
with contextlib.suppress(AttributeError):
667+
delattr(self.instance, self.ATTR_PREFIX + name)
668+
669+
651670
def transition(
652671
field: FSMFieldMixin | str,
653672
source: _StateValue | typing.Sequence[_StateValue] = ANY_STATE,
@@ -686,6 +705,14 @@ def _change_state(
686705
instance: _FSMModel, *args: typing.Any, **kwargs: typing.Any
687706
) -> typing.Any:
688707
assert isinstance(fsm_meta.field, FSMFieldMixin)
708+
log_attrs: dict[str, typing.Any] = {}
709+
if "by" in kwargs:
710+
log_attrs["by"] = kwargs.pop("by")
711+
if "description" in kwargs:
712+
log_attrs["description"] = kwargs.pop("description")
713+
if log_attrs:
714+
with FSMLogDescriptor(instance, log_attrs):
715+
return fsm_meta.field.change_state(instance, func, *args, **kwargs)
689716
return fsm_meta.field.change_state(instance, func, *args, **kwargs)
690717

691718
if not wrapper_installed:

django_fsm/admin.py

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import logging
44
import typing
55
from dataclasses import dataclass
6-
from warnings import warn
76

87
try:
98
from typing import override
@@ -17,7 +16,6 @@
1716
from django.contrib import messages
1817
from django.contrib.admin import TabularInline
1918
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
20-
from django.contrib.contenttypes.admin import GenericTabularInline
2119
from django.core.exceptions import ImproperlyConfigured
2220
from django.db import models
2321
from django.forms import Form
@@ -32,19 +30,21 @@
3230

3331
import django_fsm as fsm
3432

35-
from .models import StateLog
36-
from .models import TransitionLogBase
37-
3833
logger = logging.getLogger(__name__)
3934

4035
if typing.TYPE_CHECKING: # pragma: no cover
36+
from django.http import HttpRequest
37+
38+
from .models import TransitionLogBase
39+
4140
_ModelAdmin: typing.TypeAlias = admin.ModelAdmin[fsm._FSMModel]
4241
_FormType: typing.TypeAlias = type[Form | ModelForm[fsm._FSMModel]]
43-
44-
from django.http import HttpRequest
42+
_FSMTransitionInlineBase: typing.TypeAlias = TabularInline[TransitionLogBase, models.Model]
4543
else:
4644
_ModelAdmin = admin.ModelAdmin
4745
_FormType = type[Form | ModelForm]
46+
TransitionLogBase = models.Model
47+
_FSMTransitionInlineBase = TabularInline
4848

4949

5050
@dataclass
@@ -405,7 +405,7 @@ def fsm_transition_view(
405405
)
406406

407407

408-
class FSMTransitionInlineMixin(TabularInline[TransitionLogBase, models.Model]):
408+
class FSMTransitionInlineMixin(_FSMTransitionInlineBase):
409409
can_delete = False
410410

411411
def has_add_permission(self, request: HttpRequest, obj: models.Model | None = None) -> bool:
@@ -430,15 +430,3 @@ def get_readonly_fields(
430430

431431
def get_queryset(self, request: HttpRequest) -> models.QuerySet[TransitionLogBase]:
432432
return super().get_queryset(request).order_by(models.F("timestamp").desc())
433-
434-
435-
class StateLogInline(FSMTransitionInlineMixin, GenericTabularInline):
436-
model = StateLog
437-
438-
def __init__(self, parent_model: typing.Any, admin_site: typing.Any) -> None:
439-
warn(
440-
"StateLogInline has been deprecated by FSMTransitionInlineMixin.",
441-
DeprecationWarning,
442-
stacklevel=2,
443-
)
444-
super().__init__(parent_model, admin_site)

django_fsm/log.py

Lines changed: 20 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
from __future__ import annotations
22

3-
import contextlib
43
import typing
54
from dataclasses import dataclass
6-
from functools import partial
7-
from functools import wraps
85

9-
from django.contrib.contenttypes.models import ContentType
106
from django.db import models
117

8+
from . import FSMLogDescriptor
129
from .models import StateLog
1310
from .models import TransitionLogBase
1411
from .signals import post_transition
@@ -22,19 +19,18 @@
2219
__all__ = [
2320
"StateLog",
2421
"TransitionLogBase",
25-
"fsm_log_by",
26-
"fsm_log_description",
2722
"track",
2823
]
2924

3025

3126
@dataclass(frozen=True)
3227
class TrackConfig:
33-
log_model: type[TransitionLogBase] | None
34-
relation_field: str | None
28+
log_model: type[TransitionLogBase]
29+
relation_field: str
3530

3631

3732
_registry: dict[type[models.Model], TrackConfig] = {}
33+
3834
NOTSET = object()
3935

4036

@@ -47,7 +43,10 @@ def decorator(model_cls: type[models.Model]) -> type[models.Model]:
4743
if model_cls._meta.abstract:
4844
raise TypeError("django_fsm.track cannot be used with abstract models")
4945

50-
_registry[model_cls] = TrackConfig(log_model=log_model, relation_field=relation_field)
46+
_registry[model_cls] = TrackConfig(
47+
log_model=log_model or StateLog,
48+
relation_field=relation_field or "content_object",
49+
)
5150

5251
post_transition.connect(
5352
_log_transition,
@@ -60,63 +59,6 @@ def decorator(model_cls: type[models.Model]) -> type[models.Model]:
6059
return decorator
6160

6261

63-
class FSMLogDescriptor:
64-
ATTR_PREFIX = "__django_fsm_log_attr_"
65-
66-
def __init__(self, instance: models.Model, attribute: str, value: typing.Any = NOTSET):
67-
self.instance = instance
68-
self.attribute = attribute
69-
if value is not NOTSET:
70-
self.set(value)
71-
72-
def get(self) -> typing.Any:
73-
return getattr(self.instance, self.ATTR_PREFIX + self.attribute)
74-
75-
def set(self, value: typing.Any) -> None:
76-
setattr(self.instance, self.ATTR_PREFIX + self.attribute, value)
77-
78-
def __enter__(self) -> typing.Self:
79-
return self
80-
81-
def __exit__(self, *args: object) -> None:
82-
with contextlib.suppress(AttributeError):
83-
delattr(self.instance, self.ATTR_PREFIX + self.attribute)
84-
85-
86-
def fsm_log_by(func: _TransitionFunc) -> _TransitionFunc:
87-
@wraps(func)
88-
def wrapped(instance: models.Model, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
89-
if "by" in kwargs:
90-
author = kwargs.pop("by")
91-
else:
92-
return func(instance, *args, **kwargs)
93-
94-
with FSMLogDescriptor(instance, "by", author):
95-
return func(instance, *args, **kwargs)
96-
97-
return wrapped
98-
99-
100-
def fsm_log_description(
101-
func: _TransitionFunc | None = None,
102-
*,
103-
description: str | None = None,
104-
) -> _TransitionFunc:
105-
if func is None:
106-
return partial(fsm_log_description, description=description)
107-
108-
@wraps(func)
109-
def wrapped(instance: models.Model, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
110-
with FSMLogDescriptor(instance, "description") as descriptor:
111-
if "description" in kwargs:
112-
descriptor.set(kwargs.pop("description"))
113-
else:
114-
descriptor.set(description)
115-
return func(instance, *args, **kwargs)
116-
117-
return wrapped
118-
119-
12062
def _log_transition(
12163
sender: type[models.Model],
12264
instance: models.Model,
@@ -131,44 +73,17 @@ def _log_transition(
13173
return
13274

13375
log_model = config.log_model or StateLog
134-
log_kwargs: dict[str, typing.Any] = {
135-
"transition": name,
136-
"state_field": field.name,
137-
"source_state": _coerce_state(source),
138-
"state": _coerce_state(target),
139-
"by": _extract_log_value(instance, "by"),
140-
"description": _extract_log_value(instance, "description"),
141-
}
142-
143-
if issubclass(log_model, StateLog):
144-
log_kwargs["content_type"] = ContentType.objects.get_for_model(sender)
145-
log_kwargs["object_id"] = str(instance.pk)
146-
else:
147-
relation_field = config.relation_field or _resolve_relation_field(log_model, sender)
148-
log_kwargs[relation_field] = instance
149-
150-
log_model._default_manager.using(instance._state.db).create(**log_kwargs)
151-
152-
153-
def _resolve_relation_field(
154-
log_model: type[TransitionLogBase], model_cls: type[models.Model]
155-
) -> str:
156-
relation_fields = [
157-
field.name
158-
for field in log_model._meta.fields
159-
if isinstance(field, models.ForeignKey)
160-
and _matches_model(field.remote_field.model, model_cls)
161-
]
162-
if len(relation_fields) == 1:
163-
return relation_fields[0]
164-
165-
if not relation_fields:
166-
raise ValueError(
167-
f"{log_model.__name__} does not define a ForeignKey to {model_cls.__name__}"
168-
)
169-
raise ValueError(
170-
f"{log_model.__name__} has multiple ForeignKey fields to {model_cls.__name__}; "
171-
"set relation_field when calling track()"
76+
77+
log_model._default_manager.using(instance._state.db).create(
78+
**{
79+
"transition": name,
80+
"state_field": field.name,
81+
"source_state": _coerce_state(source),
82+
"state": _coerce_state(target),
83+
"by": _extract_log_value(instance, "by"),
84+
"description": _extract_log_value(instance, "description"),
85+
config.relation_field: instance,
86+
}
17287
)
17388

17489

@@ -180,16 +95,5 @@ def _coerce_state(value: _StateValue | None) -> _StateValue | None:
18095
return value
18196

18297

183-
def _matches_model(remote_model: typing.Any, model_cls: type[models.Model]) -> bool:
184-
if remote_model == model_cls:
185-
return True
186-
if isinstance(remote_model, str):
187-
return remote_model == model_cls.__name__ or remote_model.endswith(f".{model_cls.__name__}")
188-
return False
189-
190-
19198
def _extract_log_value(instance: models.Model, attribute: str) -> typing.Any:
192-
try:
193-
return FSMLogDescriptor(instance, attribute).get()
194-
except AttributeError:
195-
return None
99+
return getattr(instance, f"{FSMLogDescriptor.ATTR_PREFIX}{attribute}", None)

django_fsm/migrations/0001_initial.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.2.16 on 2026-01-29 08:32
1+
# Generated by Django 6.0.2 on 2026-03-18 12:31
22
from __future__ import annotations
33

44
import django.db.models.deletion
@@ -53,12 +53,13 @@ class Migration(migrations.Migration):
5353
),
5454
],
5555
options={
56+
"db_table": "django_fsm_log_statelog",
5657
"indexes": [
5758
models.Index(
58-
fields=["source_state", "state"], name="django_fsm__source__bb71ee_idx"
59+
fields=["source_state", "state"], name="django_fsm__source__b87a8b_idx"
5960
),
6061
models.Index(
61-
fields=["content_type", "object_id"], name="django_fsm__content_9593e3_idx"
62+
fields=["content_type", "object_id"], name="django_fsm__content_c774a6_idx"
6263
),
6364
],
6465
},

django_fsm/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class StateLog(TransitionLogBase): # noqa: DJ008
4646
objects = StateLogQuerySet.as_manager()
4747

4848
class Meta:
49+
db_table = "django_fsm_log_statelog"
4950
indexes = [
5051
models.Index(fields=["source_state", "state"]),
5152
models.Index(fields=["content_type", "object_id"]),

django_fsm_log/admin.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from __future__ import annotations
22

33
import typing
4+
from warnings import warn
45

6+
from django.contrib.contenttypes.admin import GenericTabularInline
57
from django.core import checks
68

7-
from django_fsm.admin import StateLogInline
9+
from django_fsm.admin import FSMTransitionInlineMixin
10+
11+
from .models import StateLog
812

913
__all__ = ["StateLogInline"]
1014

@@ -24,3 +28,15 @@ def check_deprecated_mixin_import(
2428
id="django_fsm.log.W002",
2529
)
2630
]
31+
32+
33+
class StateLogInline(FSMTransitionInlineMixin, GenericTabularInline):
34+
model = StateLog
35+
36+
def __init__(self, parent_model: typing.Any, admin_site: typing.Any) -> None:
37+
warn(
38+
"StateLogInline has been deprecated by FSMTransitionInlineMixin.",
39+
DeprecationWarning,
40+
stacklevel=2,
41+
)
42+
super().__init__(parent_model, admin_site)

0 commit comments

Comments
 (0)