Skip to content

Commit f6a1cae

Browse files
committed
Implement logging
1 parent faf2953 commit f6a1cae

File tree

11 files changed

+452
-5
lines changed

11 files changed

+452
-5
lines changed

.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/*

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.GenericTransitionLog` (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/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: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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 GenericTransitionLog
13+
from .models import TransitionLogBase
14+
from .signals import post_transition
15+
16+
if typing.TYPE_CHECKING:
17+
from collections.abc import Callable
18+
19+
from . import _Field
20+
21+
22+
__all__ = [
23+
"GenericTransitionLog",
24+
"TransitionLogBase",
25+
"fsm_log_author",
26+
"fsm_log_by",
27+
"fsm_log_description",
28+
"track",
29+
]
30+
31+
32+
@dataclass(frozen=True)
33+
class TrackConfig:
34+
log_model: type[TransitionLogBase] | None
35+
relation_field: str | None
36+
37+
38+
_registry: dict[type[models.Model], TrackConfig] = {}
39+
NOTSET = object()
40+
41+
42+
def track(
43+
*,
44+
log_model: type[TransitionLogBase] | None = None,
45+
relation_field: str | None = None,
46+
) -> Callable[[type[models.Model]], type[models.Model]]:
47+
def decorator(model_cls: type[models.Model]) -> type[models.Model]:
48+
if model_cls._meta.abstract:
49+
raise TypeError("django_fsm.track cannot be used with abstract models")
50+
config = TrackConfig(log_model=log_model, relation_field=relation_field)
51+
_registry[model_cls] = config
52+
_connect_signal(model_cls)
53+
return model_cls
54+
55+
return decorator
56+
57+
58+
class FSMLogDescriptor:
59+
ATTR_PREFIX = "__django_fsm_log_attr_"
60+
61+
def __init__(self, instance: models.Model, attribute: str, value: typing.Any = NOTSET):
62+
self.instance = instance
63+
self.attribute = attribute
64+
if value is not NOTSET:
65+
self.set(value)
66+
67+
def get(self) -> typing.Any:
68+
return getattr(self.instance, self.ATTR_PREFIX + self.attribute)
69+
70+
def set(self, value: typing.Any) -> None:
71+
setattr(self.instance, self.ATTR_PREFIX + self.attribute, value)
72+
73+
def __enter__(self) -> typing.Self:
74+
return self
75+
76+
def __exit__(self, *args: object) -> None:
77+
with contextlib.suppress(AttributeError):
78+
delattr(self.instance, self.ATTR_PREFIX + self.attribute)
79+
80+
81+
def fsm_log_by(func: typing.Callable[..., typing.Any]) -> typing.Callable[..., typing.Any]:
82+
@wraps(func)
83+
def wrapped(instance: models.Model, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
84+
if "author" in kwargs:
85+
author = kwargs.pop("author")
86+
elif "by" in kwargs:
87+
author = kwargs.pop("by")
88+
else:
89+
return func(instance, *args, **kwargs)
90+
91+
with FSMLogDescriptor(instance, "author", author):
92+
return func(instance, *args, **kwargs)
93+
94+
return wrapped
95+
96+
97+
def fsm_log_author(func: typing.Callable[..., typing.Any]) -> typing.Callable[..., typing.Any]:
98+
return fsm_log_by(func)
99+
100+
101+
def fsm_log_description(
102+
func: typing.Callable[..., typing.Any] | None = None,
103+
*,
104+
allow_inline: bool = False,
105+
description: str | None = None,
106+
) -> typing.Callable[..., typing.Any]:
107+
if func is None:
108+
return partial(fsm_log_description, allow_inline=allow_inline, description=description)
109+
110+
@wraps(func)
111+
def wrapped(instance: models.Model, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
112+
with FSMLogDescriptor(instance, "description") as descriptor:
113+
if "description" in kwargs:
114+
descriptor.set(kwargs.pop("description"))
115+
elif allow_inline:
116+
kwargs["description"] = descriptor
117+
else:
118+
descriptor.set(description)
119+
return func(instance, *args, **kwargs)
120+
121+
return wrapped
122+
123+
124+
def _connect_signal(model_cls: type[models.Model]) -> None:
125+
dispatch_uid = f"django_fsm.track.{model_cls._meta.label_lower}"
126+
post_transition.connect(
127+
_log_transition, sender=model_cls, dispatch_uid=dispatch_uid, weak=False
128+
)
129+
130+
131+
def _log_transition(
132+
sender: type[models.Model],
133+
instance: models.Model,
134+
name: str,
135+
source: typing.Any,
136+
target: typing.Any,
137+
field: _Field,
138+
**kwargs: typing.Any,
139+
) -> None:
140+
config = _registry.get(sender)
141+
if not config or instance.pk is None:
142+
return
143+
144+
log_model = config.log_model or GenericTransitionLog
145+
method_kwargs = kwargs.get("method_kwargs") or {}
146+
author = _extract_log_value(instance, method_kwargs, "author", ("author", "by"))
147+
description = _extract_log_value(instance, method_kwargs, "description", ("description",))
148+
log_kwargs: dict[str, typing.Any] = {
149+
"transition": name,
150+
"state_field": field.name,
151+
"source": _coerce_state(source),
152+
"target": _coerce_state(target),
153+
}
154+
if author is not None:
155+
log_kwargs["author"] = author
156+
if description is not None:
157+
log_kwargs["description"] = description
158+
if issubclass(log_model, GenericTransitionLog):
159+
log_kwargs["content_type"] = ContentType.objects.get_for_model(sender)
160+
log_kwargs["object_id"] = str(instance.pk)
161+
else:
162+
relation_field = config.relation_field or _resolve_relation_field(log_model, sender)
163+
log_kwargs[relation_field] = instance
164+
165+
log_model._default_manager.using(instance._state.db).create(**log_kwargs)
166+
167+
168+
def _resolve_relation_field(
169+
log_model: type[TransitionLogBase], model_cls: type[models.Model]
170+
) -> str:
171+
relation_fields = [
172+
field.name
173+
for field in log_model._meta.fields
174+
if isinstance(field, models.ForeignKey)
175+
and _matches_model(field.remote_field.model, model_cls)
176+
]
177+
if len(relation_fields) == 1:
178+
return relation_fields[0]
179+
if not relation_fields:
180+
raise ValueError(
181+
f"{log_model.__name__} does not define a ForeignKey to {model_cls.__name__}"
182+
)
183+
raise ValueError(
184+
f"{log_model.__name__} has multiple ForeignKey fields to {model_cls.__name__}; "
185+
"set relation_field when calling track()"
186+
)
187+
188+
189+
def _coerce_state(value: typing.Any) -> str | None:
190+
if value is None:
191+
return None
192+
if isinstance(value, models.Model):
193+
return str(value.pk)
194+
return str(value)
195+
196+
197+
def _matches_model(remote_model: typing.Any, model_cls: type[models.Model]) -> bool:
198+
if remote_model == model_cls:
199+
return True
200+
if isinstance(remote_model, str):
201+
return remote_model == model_cls.__name__ or remote_model.endswith(f".{model_cls.__name__}")
202+
return False
203+
204+
205+
def _extract_log_value(
206+
instance: models.Model,
207+
method_kwargs: dict[str, typing.Any],
208+
attribute: str,
209+
keys: tuple[str, ...],
210+
) -> typing.Any:
211+
for key in keys:
212+
if key in method_kwargs:
213+
value = method_kwargs[key]
214+
if isinstance(value, FSMLogDescriptor):
215+
try:
216+
return value.get()
217+
except AttributeError:
218+
return None
219+
return value
220+
try:
221+
return FSMLogDescriptor(instance, attribute).get()
222+
except AttributeError:
223+
return None
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Generated by Django 4.2.16 on 2026-01-28 16:49
2+
from __future__ import annotations
3+
4+
import django.db.models.deletion
5+
from django.conf import settings
6+
from django.db import migrations
7+
from django.db import models
8+
9+
10+
class Migration(migrations.Migration):
11+
initial = True
12+
13+
dependencies = [
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
("contenttypes", "0002_remove_content_type_name"),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name="GenericTransitionLog",
21+
fields=[
22+
(
23+
"id",
24+
models.BigAutoField(
25+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
26+
),
27+
),
28+
("created_at", models.DateTimeField(auto_now_add=True)),
29+
("transition", models.CharField(max_length=255)),
30+
("state_field", models.CharField(max_length=255)),
31+
("source", models.TextField(blank=True, null=True)),
32+
("target", models.TextField(blank=True, null=True)),
33+
("description", models.TextField(blank=True, null=True)),
34+
("object_id", models.TextField()),
35+
(
36+
"author",
37+
models.ForeignKey(
38+
blank=True,
39+
null=True,
40+
on_delete=django.db.models.deletion.SET_NULL,
41+
related_name="+",
42+
to=settings.AUTH_USER_MODEL,
43+
),
44+
),
45+
(
46+
"content_type",
47+
models.ForeignKey(
48+
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
49+
),
50+
),
51+
],
52+
options={
53+
"indexes": [
54+
models.Index(
55+
fields=["content_type", "object_id"], name="django_fsm__content_cda773_idx"
56+
)
57+
],
58+
},
59+
),
60+
]

django_fsm/migrations/__init__.py

Whitespace-only changes.

django_fsm/models.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
from django.conf import settings
4+
from django.contrib.contenttypes.fields import GenericForeignKey
5+
from django.contrib.contenttypes.models import ContentType
6+
from django.db import models
7+
8+
9+
class TransitionLogBase(models.Model):
10+
created_at = models.DateTimeField(auto_now_add=True)
11+
transition = models.CharField(max_length=255)
12+
state_field = models.CharField(max_length=255)
13+
source = models.TextField(null=True, blank=True) # noqa: DJ001
14+
target = models.TextField(null=True, blank=True) # noqa: DJ001
15+
author = models.ForeignKey(
16+
settings.AUTH_USER_MODEL,
17+
null=True,
18+
blank=True,
19+
on_delete=models.SET_NULL,
20+
related_name="+",
21+
)
22+
description = models.TextField(null=True, blank=True) # noqa: DJ001
23+
24+
class Meta:
25+
abstract = True
26+
27+
28+
class GenericTransitionLog(TransitionLogBase): # noqa: DJ008
29+
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
30+
object_id = models.TextField()
31+
content_object = GenericForeignKey("content_type", "object_id")
32+
33+
class Meta:
34+
indexes = [
35+
models.Index(fields=["content_type", "object_id"]),
36+
]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ extend-ignore = [
103103
]
104104
fixable = [
105105
"I", # isort
106+
"RUF022",
106107
"RUF100", # Unused `noqa` directive
107108
"E501",
108109
]

0 commit comments

Comments
 (0)