Skip to content

Commit e74e29a

Browse files
committed
Implement logging
1 parent faf2953 commit e74e29a

13 files changed

Lines changed: 523 additions & 5 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,6 @@ test.db
133133

134134
# django fsm command tests
135135
exports/*
136+
137+
# codex
138+
.codex/*

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
UNRELEASED
5+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6+
7+
- Add typing
8+
- Add logging solution
9+
10+
411
django-fsm-2 4.1.0 2025-11-03
512
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
613

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,45 @@ executed transitions, make sure:
418418
Following these recommendations, `ConcurrentTransitionMixin` will cause a
419419
rollback of all changes executed in an inconsistent state.
420420

421+
## Transition tracking
422+
423+
Use `@django_fsm.track()` to write state changes to a log table.
424+
By default, it writes to `django_fsm.StateLog` (single table).
425+
If you prefer one table per model, define your own log model and pass it in.
426+
You can also capture `author` and `description` for each transition.
427+
428+
```python
429+
import django_fsm
430+
from django_fsm.log import fsm_log_by
431+
from django_fsm.log import fsm_log_description
432+
from django.db import models
433+
434+
435+
@django_fsm.track()
436+
class BlogPost(models.Model):
437+
state = django_fsm.FSMField(default="new")
438+
439+
@fsm_log_by
440+
@fsm_log_description
441+
@django_fsm.transition(field=state, source="new", target="published")
442+
def publish(self):
443+
pass
444+
```
445+
446+
```python
447+
import django_fsm
448+
from django.db import models
449+
450+
451+
class BlogPostLog(django_fsm.TransitionLogBase):
452+
post = models.ForeignKey("BlogPost", on_delete=models.CASCADE, related_name="transition_logs")
453+
454+
455+
@django_fsm.track(log_model=BlogPostLog, relation_field="post")
456+
class BlogPost(models.Model):
457+
state = django_fsm.FSMField(default="new")
458+
```
459+
421460
## Drawing transitions
422461

423462
Render a graphical overview of your model transitions.
@@ -460,7 +499,6 @@ INSTALLED_APPS = (
460499
## Extensions
461500

462501
- Admin integration: <https://github.com/coral-li/django-fsm-2-admin>
463-
- Transition logging: <https://github.com/gizmag/django-fsm-log>
464502

465503
## Contributing
466504

django_fsm/admin.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
from warnings import warn
5+
6+
from django.contrib.contenttypes.admin import GenericTabularInline
7+
from django.db.models import F
8+
9+
from .models import StateLog
10+
11+
if typing.TYPE_CHECKING:
12+
from django.db.models import QuerySet
13+
from django.http import HttpRequest
14+
15+
from .models import TransitionLogBase
16+
17+
18+
class FSMTransitionInline(GenericTabularInline):
19+
model: type[TransitionLogBase] = None # type: ignore[assignment]
20+
21+
can_delete = False
22+
23+
def has_add_permission(
24+
self, request: HttpRequest, obj: TransitionLogBase | None = None
25+
) -> bool:
26+
return False
27+
28+
def has_change_permission(
29+
self, request: HttpRequest, obj: TransitionLogBase | None = None
30+
) -> bool:
31+
return True
32+
33+
fields = (
34+
"transition",
35+
"source_state",
36+
"state",
37+
"by",
38+
"description",
39+
"timestamp",
40+
)
41+
42+
def get_readonly_fields(
43+
self, request: HttpRequest, obj: TransitionLogBase | None = None
44+
) -> list[str] | tuple[str, ...] | tuple[()]:
45+
return self.fields
46+
47+
def get_queryset(self, request: HttpRequest) -> QuerySet[TransitionLogBase]:
48+
return super().get_queryset(request).order_by(F("timestamp").desc())
49+
50+
51+
class StateLogInline(FSMTransitionInline):
52+
model = StateLog
53+
54+
def __init__(self, parent_model: typing.Any, admin_site: typing.Any) -> None:
55+
warn(
56+
"StateLogInline has been deprecated by PersistedTransitionInline.",
57+
DeprecationWarning,
58+
stacklevel=2,
59+
)
60+
super().__init__(parent_model, admin_site)

django_fsm/apps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import annotations
2+
3+
from django.apps import AppConfig
4+
5+
6+
class DjangoFSMAppConfig(AppConfig):
7+
name = "django_fsm"
8+
verbose_name = "Django FSM"

django_fsm/log.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import typing
5+
from dataclasses import dataclass
6+
from functools import partial
7+
from functools import wraps
8+
9+
from django.contrib.contenttypes.models import ContentType
10+
from django.db import models
11+
12+
from .models import StateLog
13+
from .models import TransitionLogBase
14+
from .signals import post_transition
15+
16+
if typing.TYPE_CHECKING: # pragma: no cover
17+
from collections.abc import Callable
18+
19+
from . import _Field
20+
21+
22+
__all__ = [
23+
"StateLog",
24+
"TransitionLogBase",
25+
"fsm_log_by",
26+
"fsm_log_description",
27+
"track",
28+
]
29+
30+
31+
@dataclass(frozen=True)
32+
class TrackConfig:
33+
log_model: type[TransitionLogBase] | None
34+
relation_field: str | None
35+
36+
37+
_registry: dict[type[models.Model], TrackConfig] = {}
38+
NOTSET = object()
39+
40+
41+
def track(
42+
*,
43+
log_model: type[TransitionLogBase] | None = None,
44+
relation_field: str | None = None,
45+
) -> Callable[[type[models.Model]], type[models.Model]]:
46+
def decorator(model_cls: type[models.Model]) -> type[models.Model]:
47+
if model_cls._meta.abstract:
48+
raise TypeError("django_fsm.track cannot be used with abstract models")
49+
config = TrackConfig(log_model=log_model, relation_field=relation_field)
50+
_registry[model_cls] = config
51+
52+
post_transition.connect(
53+
_log_transition,
54+
sender=model_cls,
55+
dispatch_uid=f"django_fsm.track.{model_cls._meta.label_lower}",
56+
weak=False,
57+
)
58+
return model_cls
59+
60+
return decorator
61+
62+
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: typing.Callable[..., typing.Any]) -> typing.Callable[..., typing.Any]:
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: typing.Callable[..., typing.Any] | None = None,
102+
*,
103+
description: str | None = None,
104+
) -> typing.Callable[..., typing.Any]:
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+
120+
def _log_transition(
121+
sender: type[models.Model],
122+
instance: models.Model,
123+
name: str,
124+
source: typing.Any,
125+
target: typing.Any,
126+
field: _Field,
127+
**kwargs: typing.Any,
128+
) -> None:
129+
config = _registry.get(sender)
130+
if not config or instance.pk is None:
131+
return
132+
133+
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()"
172+
)
173+
174+
175+
def _coerce_state(value: typing.Any) -> str | None:
176+
if value is None:
177+
return None
178+
if isinstance(value, models.Model):
179+
return str(value.pk)
180+
return str(value)
181+
182+
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+
191+
def _extract_log_value(
192+
instance: models.Model,
193+
attribute: str,
194+
) -> typing.Any:
195+
try:
196+
return FSMLogDescriptor(instance, attribute).get()
197+
except AttributeError:
198+
return None

0 commit comments

Comments
 (0)