django-phase 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-phase
3
+ Version: 0.1.0
4
+ Summary: Race-safe state machines for Django models: a protected PhaseField and transitions as one conditional UPDATE
5
+ Project-URL: Homepage, https://github.com/beyonder-deutschland/django-phase/
6
+ Project-URL: Documentation, https://django-phase.readthedocs.io/en/latest/index.html
7
+ Project-URL: Maintained by, https://beyonder.de/
8
+ Project-URL: Bugtracker, https://github.com/beyonder-deutschland/django-phase/issues
9
+ Project-URL: Changelog, https://django-phase.readthedocs.io/en/latest/features/changelog.html
10
+ Author-email: Beyonder Deutschland <hello@beyonder.de>
11
+ License: MIT License
12
+
13
+ Copyright (c) 2026 Beyonder Deutschland GmbH
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE.md
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Environment :: Web Environment
35
+ Classifier: Framework :: Django
36
+ Classifier: Framework :: Django :: 5.2
37
+ Classifier: Framework :: Django :: 6.0
38
+ Classifier: Framework :: Django :: 6.1
39
+ Classifier: Intended Audience :: Developers
40
+ Classifier: License :: OSI Approved :: MIT License
41
+ Classifier: Natural Language :: English
42
+ Classifier: Operating System :: OS Independent
43
+ Classifier: Programming Language :: Python
44
+ Classifier: Programming Language :: Python :: 3
45
+ Classifier: Programming Language :: Python :: 3.11
46
+ Classifier: Programming Language :: Python :: 3.12
47
+ Classifier: Programming Language :: Python :: 3.13
48
+ Classifier: Programming Language :: Python :: 3.14
49
+ Classifier: Programming Language :: Python :: 3.15
50
+ Classifier: Topic :: Utilities
51
+ Requires-Python: >=3.11
52
+ Requires-Dist: django>=5.2
53
+ Provides-Extra: dev
54
+ Requires-Dist: ambient-package-update; extra == 'dev'
55
+ Requires-Dist: keyring~=25.7; extra == 'dev'
56
+ Requires-Dist: pre-commit~=4.7; extra == 'dev'
57
+ Requires-Dist: pytest-cov~=7.1; extra == 'dev'
58
+ Requires-Dist: pytest-django~=4.14; extra == 'dev'
59
+ Requires-Dist: pytest-mock~=3.17; extra == 'dev'
60
+ Requires-Dist: sphinx-mdinclude~=0.6; extra == 'dev'
61
+ Requires-Dist: sphinx-rtd-theme~=3.1; extra == 'dev'
62
+ Requires-Dist: sphinx~=8.2; extra == 'dev'
63
+ Requires-Dist: typer~=0.27; extra == 'dev'
64
+ Requires-Dist: uv~=0.13; extra == 'dev'
65
+ Description-Content-Type: text/markdown
66
+
67
+ [![PyPI release](https://img.shields.io/pypi/v/django-phase.svg)](https://pypi.org/project/django-phase/)
68
+ [![Downloads](https://static.pepy.tech/badge/django-phase)](https://pepy.tech/project/django-phase)
69
+ [![Coverage](https://img.shields.io/badge/Coverage-100.0%25-success)](https://github.com/beyonder-deutschland/django-phase/actions?workflow=CI)
70
+ [![Linting](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
71
+ [![Coding Style](https://img.shields.io/badge/code%20style-Ruff-000000.svg)](https://github.com/astral-sh/ruff)
72
+ [![Documentation Status](https://readthedocs.org/projects/django-phase/badge/?version=latest)](https://django-phase.readthedocs.io/en/latest/?badge=latest)
73
+
74
+ Race-safe state machines for Django models: a protected `PhaseField`, transitions as one conditional `UPDATE`, and
75
+ guards that tell humans and machines why an action was refused.
76
+
77
+ [PyPI](https://pypi.org/project/django-phase/) • [GitHub](https://github.com/beyonder-deutschland/django-phase) • [Full documentation](https://django-phase.readthedocs.io/en/latest/index.html)
78
+
79
+ Creator & Maintainer: [Beyonder Deutschland](https://beyonder.de/)
80
+
81
+ ## Features
82
+
83
+ * Declare the allowed transitions once, as a table on the field
84
+ * Assigning the phase directly raises; only `move()` changes it
85
+ * `move()` writes phase and further fields in **one** conditional `UPDATE`, so of two concurrent transitions from the
86
+ same phase exactly one succeeds
87
+ * Every `save()` is guarded against a stale phase, so it can't silently undo a concurrent transition
88
+ * Migrations see a plain `CharField` and never import `phase`
89
+ * System checks catch a missing mixin, a second phase field or one inherited from a concrete parent model
90
+ * `PhaseGuardError` gives refused actions a stable `code` for machines and a `detail` for humans
91
+ * Overrides no private Django method
92
+
93
+ ```python
94
+ from django.db import models
95
+ from django.utils import timezone
96
+
97
+ from phase.fields import PhaseField
98
+ from phase.mixins.phase_guard import PhaseGuardMixin
99
+ from phase.transitions import move
100
+
101
+
102
+ class Order(PhaseGuardMixin, models.Model):
103
+ class Status(models.TextChoices):
104
+ OPEN = "open"
105
+ PAID = "paid"
106
+ SHIPPED = "shipped"
107
+ CANCELLED = "cancelled"
108
+
109
+ status = PhaseField(
110
+ max_length=10,
111
+ choices=Status.choices,
112
+ default=Status.OPEN,
113
+ transitions={
114
+ Status.OPEN: (Status.PAID, Status.CANCELLED),
115
+ Status.PAID: (Status.SHIPPED,),
116
+ },
117
+ )
118
+ paid_at = models.DateTimeField(null=True, blank=True)
119
+
120
+
121
+ order = Order.objects.create()
122
+ move(obj=order, to=Order.Status.PAID, paid_at=timezone.now())
123
+
124
+ order.status = Order.Status.OPEN # raises ProtectedPhaseFieldError
125
+ move(obj=order, to=Order.Status.OPEN) # raises IllegalPhaseTransitionError
126
+ ```
127
+
128
+ ## Installation
129
+
130
+ - Install the package via pip:
131
+
132
+ `pip install django_phase`
133
+
134
+ or via uv:
135
+
136
+ `uv add django_phase`
137
+
138
+ - Add module to `INSTALLED_APPS` within the main django `settings.py`:
139
+
140
+ ```python
141
+ INSTALLED_APPS = (
142
+ # ...
143
+ "phase",
144
+ )
145
+ ```
146
+
147
+ ## Releasing a new version
148
+
149
+ Releases are fully automated. Push a version tag and the pipeline will build, sign with
150
+ [Sigstore](https://www.sigstore.dev/), publish to PyPI via
151
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/), and create a GitHub Release —
152
+ no API tokens needed.
153
+
154
+ ```bash
155
+ git tag v<version> # e.g. git tag v1.2.3
156
+ git push origin v<version>
157
+ ```
158
+
159
+ Tags **must** start with `v`. Tags without the prefix won't trigger the pipeline.
160
+
161
+ ### First-time setup
162
+
163
+ Before the pipeline can run for the first time, an admin must:
164
+
165
+ 1. **Create GitHub Environment `pypi`**
166
+ - Go to *Settings → Environments → New environment*, name it exactly `pypi`
167
+ - Under *Deployment branches and tags*, add a tag rule with pattern `v*`
168
+ - Optionally add required reviewers for a manual approval gate
169
+
170
+ 2. **Configure PyPI Trusted Publisher**
171
+ - Go to *PyPI → Project settings → Publishing → Add a new publisher*
172
+ - Fill in: Owner `beyonder-deutschland`, Repository `django-phase`,
173
+ Workflow `release.yml`, Environment `pypi`
174
+
175
+ ### Publish to ReadTheDocs.io
176
+
177
+ - Fetch the latest changes in GitHub mirror and push them
178
+ - Trigger new build at ReadTheDocs.io (follow instructions in admin panel at RTD) if the GitHub webhook is not yet set
179
+ up.
180
+
181
+ ### Maintenance
182
+
183
+ Please note that this package supports the [ambient-package-update](https://pypi.org/project/ambient-package-update/).
184
+ So you don't have to worry about the maintenance of this package. This updater is rendering all important
185
+ configuration and setup files. It works similar to well-known updaters like `pyupgrade` or `django-upgrade`.
186
+
187
+ To run an update, refer to the [documentation page](https://pypi.org/project/ambient-package-update/)
188
+ of the "ambient-package-update".
@@ -0,0 +1,12 @@
1
+ phase/__init__.py,sha256=JnIHCWjHzrgr1bkK6AvjLOxNeE1aYICkL57oSOLJmiI,138
2
+ phase/apps.py,sha256=xLVNKm3jHfvxOgqJKnJYBeCzToaly7DgKUFG_1Y43AQ,258
3
+ phase/checks.py,sha256=njuvHSJEXGDddSwmM2-gFap7E7eWMXUPylzk2wXwgMo,2438
4
+ phase/exceptions.py,sha256=M0Gd9tJD5ar4rqn62wAc4i0KNXRxn5dqy7KXuAuBItc,4074
5
+ phase/fields.py,sha256=k6BJN7ideFY0pB5G_O_q-_e9Iy8daW00XeByAe7vFhM,8224
6
+ phase/transitions.py,sha256=XvzMEsRLCZV1lNBJuiA11vmSPAYvr76Sg8bet62eDOY,572
7
+ phase/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ phase/mixins/phase_guard.py,sha256=hcygbhETdglz06Y7mo-vJmCntLur8O7IaYU7nENE4pY,2965
9
+ django_phase-0.1.0.dist-info/METADATA,sha256=v7VKCSRwfpegDSKK7eeBiiETyx-hBl0CCiNRRZgdBsM,8197
10
+ django_phase-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ django_phase-0.1.0.dist-info/licenses/LICENSE.md,sha256=b7UhMFD7Rhj2PPUk0Pqg815sqtLOvZFUcM8nTsiBf7k,1082
12
+ django_phase-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Beyonder Deutschland GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
phase/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Race-safe state machines for Django models: a protected PhaseField and transitions as one conditional UPDATE"""
2
+
3
+ __version__ = "0.1.0"
phase/apps.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class PhaseConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "phase"
7
+
8
+ def ready(self) -> None:
9
+ from phase import checks # noqa: F401, PLC0415 - registers the system checks
phase/checks.py ADDED
@@ -0,0 +1,57 @@
1
+ from itertools import chain
2
+
3
+ from django.apps import AppConfig, apps
4
+ from django.core import checks
5
+
6
+ from phase.fields import get_phase_fields
7
+ from phase.mixins.phase_guard import PhaseGuardMixin
8
+
9
+
10
+ @checks.register(checks.Tags.models)
11
+ def check_phase_fields(app_configs: list[AppConfig] | None = None, **kwargs: object) -> list[checks.Error]:
12
+ """
13
+ Every model with a PhaseField inherits PhaseGuardMixin, has exactly one PhaseField and stores it in its own table.
14
+
15
+ Without the mixin the model can't be reloaded, because the field refuses the second assignment. With two phase
16
+ fields `phase.transitions.move()` couldn't tell which one to move. A phase field in the table of a concrete parent
17
+ makes Django split the UPDATE of `move()` into a SELECT of the primary keys and an UPDATE without the condition on
18
+ the phase, so two concurrent transitions could both succeed. Proxy models share their concrete model's fields and
19
+ are reported there.
20
+ """
21
+ if app_configs is None:
22
+ models = apps.get_models()
23
+ else:
24
+ models = chain.from_iterable(app_config.get_models() for app_config in app_configs)
25
+
26
+ errors = []
27
+ for model in models:
28
+ if model._meta.proxy:
29
+ continue
30
+ phase_fields = get_phase_fields(model=model)
31
+ if phase_fields and not issubclass(model, PhaseGuardMixin):
32
+ errors.append(
33
+ checks.Error(
34
+ f"{model._meta.label} has a PhaseField but doesn't inherit from PhaseGuardMixin.",
35
+ obj=model,
36
+ id="phase.E001",
37
+ )
38
+ )
39
+ if len(phase_fields) > 1:
40
+ errors.append(
41
+ checks.Error(
42
+ f"{model._meta.label} has more than one PhaseField.",
43
+ hint="A model has one lifecycle; a second one usually belongs on its own model.",
44
+ obj=model,
45
+ id="phase.E002",
46
+ )
47
+ )
48
+ if any(field.model is not model for field in phase_fields):
49
+ errors.append(
50
+ checks.Error(
51
+ f"{model._meta.label} inherits its PhaseField from a concrete parent model.",
52
+ hint="Make the parent abstract, or give the lifecycle its own model without concrete children.",
53
+ obj=model,
54
+ id="phase.E003",
55
+ )
56
+ )
57
+ return errors
phase/exceptions.py ADDED
@@ -0,0 +1,96 @@
1
+ class IllegalPhaseTransitionError(Exception):
2
+ """
3
+ The transition isn't in the table, or the row changed or vanished since `obj` was loaded.
4
+
5
+ `source` is the phase in the database when the row changed in the meantime, and `None` when the row doesn't exist.
6
+ """
7
+
8
+ def __init__(self, *, model_name: str, pk: int | None, source: str | None, target: str):
9
+ self.model_name = model_name
10
+ self.pk = pk
11
+ self.source = source
12
+ self.target = target
13
+ if source is None:
14
+ message = f"{model_name} ID #{pk} doesn't exist in the database and cannot change to '{target}'."
15
+ else:
16
+ message = f"{model_name} ID #{pk} cannot change from '{source}' to '{target}'."
17
+ super().__init__(message)
18
+
19
+ def __reduce__(self) -> tuple:
20
+ # The parallel test runner sends failures between processes; the default reduce calls __init__ positionally.
21
+ return _rebuild_illegal_phase_transition_error, (self.model_name, self.pk, self.source, self.target)
22
+
23
+
24
+ def _rebuild_illegal_phase_transition_error(
25
+ model_name: str, pk: int | None, source: str | None, target: str
26
+ ) -> IllegalPhaseTransitionError:
27
+ return IllegalPhaseTransitionError(model_name=model_name, pk=pk, source=source, target=target)
28
+
29
+
30
+ class StalePhaseError(Exception):
31
+ def __init__(self, *, model_name: str, pk: int, phases: dict[str, str]):
32
+ self.model_name = model_name
33
+ self.pk = pk
34
+ self.phases = phases
35
+ loaded = ", ".join(f"{field}='{phase}'" for field, phase in phases.items())
36
+ super().__init__(f"{model_name} ID #{pk} no longer has {loaded} in the database; reload it before saving.")
37
+
38
+ def __reduce__(self) -> tuple:
39
+ return _rebuild_stale_phase_error, (self.model_name, self.pk, self.phases)
40
+
41
+
42
+ def _rebuild_stale_phase_error(model_name: str, pk: int, phases: dict[str, str]) -> StalePhaseError:
43
+ return StalePhaseError(model_name=model_name, pk=pk, phases=phases)
44
+
45
+
46
+ class RebuiltWithoutInitMixin:
47
+ """
48
+ Pickle an exception whose constructor takes keyword arguments.
49
+
50
+ The default reduce calls `__init__` positionally with the message, which such a constructor refuses. The copy is
51
+ rebuilt from message and attributes instead, so it works for any constructor signature a subclass defines.
52
+ """
53
+
54
+ def __reduce__(self) -> tuple:
55
+ return _rebuild_without_init, (type(self), self.args, dict(self.__dict__))
56
+
57
+
58
+ def _rebuild_without_init(error_class: type[BaseException], args: tuple, attributes: dict) -> BaseException:
59
+ error = error_class.__new__(error_class)
60
+ error.args = args
61
+ error.__dict__.update(attributes)
62
+ return error
63
+
64
+
65
+ class PhaseGuardError(RebuiltWithoutInitMixin, Exception):
66
+ """
67
+ An action refuses to run because a condition on the current phase beyond the transition table isn't met.
68
+
69
+ Every guard is its own subclass with a stable `code` for machines and a `detail` for humans, which tells the user
70
+ why the action was refused and what to do next. `phase` doesn't translate; the subclass passes a translated text.
71
+ The message given to the constructor is meant for logs and may contain IDs. A subclass is free in its constructor
72
+ signature and stays picklable.
73
+ """
74
+
75
+ code: str
76
+ detail: str
77
+
78
+
79
+ class ProtectedPhaseFieldError(RebuiltWithoutInitMixin, AttributeError):
80
+ def __init__(self, *, field_name: str):
81
+ super().__init__(f"'{field_name}' is a phase field and can only change through a transition.")
82
+
83
+
84
+ class EditablePhaseFieldError(TypeError):
85
+ def __init__(self, *args):
86
+ super().__init__("A PhaseField can't be editable; its value only changes through a transition.")
87
+
88
+
89
+ class InvalidTransitionTableError(RebuiltWithoutInitMixin, TypeError):
90
+ def __init__(self, *, source: str):
91
+ super().__init__(f"Targets of phase '{source}' must be a tuple, e.g. ('declined',) for a single target.")
92
+
93
+
94
+ class MissingPhaseFieldError(RebuiltWithoutInitMixin, TypeError):
95
+ def __init__(self, *, model_name: str):
96
+ super().__init__(f"{model_name} has no PhaseField.")
phase/fields.py ADDED
@@ -0,0 +1,174 @@
1
+ from collections.abc import Mapping
2
+
3
+ from django.db import models, router
4
+ from django.db.models import signals
5
+ from django.db.models.query_utils import DeferredAttribute
6
+
7
+ from phase.exceptions import (
8
+ EditablePhaseFieldError,
9
+ IllegalPhaseTransitionError,
10
+ InvalidTransitionTableError,
11
+ MissingPhaseFieldError,
12
+ ProtectedPhaseFieldError,
13
+ )
14
+
15
+ # Set on the instance while `PhaseGuardMixin.refresh_from_db()` runs, so the reload may overwrite the phase.
16
+ REFRESHING_FLAG = "_phase_field_refreshing"
17
+
18
+
19
+ class ProtectedPhaseDescriptor(DeferredAttribute):
20
+ """
21
+ Allow a phase value to be set once per instance, refuse every later assignment.
22
+
23
+ Django sets field values with `setattr` when it builds an instance (constructor, loading from the database) and
24
+ when it fetches a deferred field; in all these cases the instance holds no value yet. A reload through
25
+ `PhaseGuardMixin.refresh_from_db()` may overwrite it, because it writes what the database holds. So may Django
26
+ after an INSERT, when it replaces an expression such as a `db_default` with the value the database returned.
27
+
28
+ Assigning the value the instance already holds changes nothing and passes. Django does that in
29
+ `Model.clean_fields()` and when it writes back the columns an INSERT returned.
30
+ """
31
+
32
+ def __set__(self, instance: models.Model, value: str) -> None:
33
+ if (
34
+ self.field.attname in instance.__dict__
35
+ and not instance.__dict__.get(REFRESHING_FLAG)
36
+ and not is_expression(instance.__dict__[self.field.attname])
37
+ and instance.__dict__[self.field.attname] != value
38
+ ):
39
+ raise ProtectedPhaseFieldError(field_name=self.field.attname)
40
+ instance.__dict__[self.field.attname] = value
41
+
42
+
43
+ def is_expression(value: object) -> bool:
44
+ """A placeholder the database resolves, e.g. `DatabaseDefault` for a field with `db_default` before the INSERT."""
45
+ return hasattr(value, "resolve_expression")
46
+
47
+
48
+ class PhaseField(models.CharField):
49
+ """
50
+ A CharField whose value only changes along `transitions`, via `move()`.
51
+
52
+ A phase missing from `transitions` is final. `move()` writes with a conditional UPDATE on the source phase, so two
53
+ concurrent transitions from the same phase cannot both succeed: the second one updates no row and is rejected like
54
+ any other illegal transition.
55
+
56
+ The field is never editable: admin and model forms leave it out, because saving a form would assign it directly.
57
+ """
58
+
59
+ descriptor_class = ProtectedPhaseDescriptor
60
+
61
+ def __init__(self, *args: object, transitions: Mapping[str, tuple[str, ...]], **kwargs: object) -> None:
62
+ # `(DECLINED)` without a trailing comma is a plain string, and `in` on a string matches substrings.
63
+ for source, targets in transitions.items():
64
+ if not isinstance(targets, tuple):
65
+ raise InvalidTransitionTableError(source=source)
66
+ if kwargs.get("editable", False):
67
+ raise EditablePhaseFieldError
68
+ kwargs["editable"] = False
69
+ self.transitions = transitions
70
+ super().__init__(*args, **kwargs)
71
+
72
+ def deconstruct(self) -> tuple:
73
+ # Migrations see a plain CharField: the table and the editability are behaviour, not schema, and must not pin
74
+ # migrations to phase.
75
+ name, _, args, kwargs = super().deconstruct()
76
+ kwargs.pop("editable", None)
77
+ return name, "django.db.models.CharField", args, kwargs
78
+
79
+ def clone(self) -> "PhaseField":
80
+ # Field.clone() rebuilds from deconstruct(), which drops the table on purpose; the clone still needs it.
81
+ _, _, args, kwargs = self.deconstruct()
82
+ return self.__class__(*args, transitions=self.transitions, **kwargs)
83
+
84
+ def targets(self, *, obj: models.Model) -> tuple[str, ...]:
85
+ return self.transitions.get(getattr(obj, self.attname), ())
86
+
87
+ def can_transition(self, *, obj: models.Model, to: str) -> bool:
88
+ return to in self.targets(obj=obj)
89
+
90
+ def move(self, *, obj: models.Model, to: str, **fields: object) -> None:
91
+ """
92
+ Move `obj` to phase `to` and write `fields` and the model's `auto_now` fields in the same UPDATE.
93
+
94
+ Towards signals this behaves like `obj.save(update_fields=[<phase>, *fields, *<auto_now fields>])`: `pre_save`
95
+ sees the new values on `obj` and may change those fields, `post_save` follows a successful UPDATE.
96
+
97
+ On success `obj` is refreshed, so field values given as expressions are read back as stored values. On failure
98
+ `obj` stays as it was: its phase no longer matches the database, so a later `save()` is refused as stale
99
+ instead of overwriting what the winner of a race wrote.
100
+ """
101
+ model = type(obj)
102
+ using = router.db_for_write(model, instance=obj)
103
+ rows = model._base_manager.using(using).filter(pk=obj.pk)
104
+
105
+ source = getattr(obj, self.attname)
106
+ if is_expression(source):
107
+ # On Django 5.2 a backend without INSERT … RETURNING leaves the `db_default` placeholder on the instance.
108
+ source = rows.values_list(self.attname, flat=True).first()
109
+ if source is not None:
110
+ obj.__dict__[self.attname] = source
111
+ if to not in self.transitions.get(source, ()):
112
+ raise IllegalPhaseTransitionError(model_name=obj._meta.label, pk=obj.pk, source=source, target=to)
113
+
114
+ written_fields = self._written_fields(obj=obj, fields=fields)
115
+ update_fields = frozenset(field.name for field in written_fields)
116
+
117
+ loaded_state = (dict(obj.__dict__), dict(obj._state.fields_cache))
118
+ try:
119
+ obj.__dict__[self.attname] = to
120
+ for name, value in fields.items():
121
+ setattr(obj, name, value)
122
+ signals.pre_save.send(sender=model, instance=obj, raw=False, using=using, update_fields=update_fields)
123
+ # Like `save()`: values are read after `pre_save`, and `Field.pre_save()` stamps the `auto_now` fields.
124
+ values = {field.attname: field.pre_save(obj, add=False) for field in written_fields}
125
+ updated_rows = rows.filter(**{self.attname: source}).update(**values)
126
+ except BaseException:
127
+ self._restore(obj=obj, loaded_state=loaded_state)
128
+ raise
129
+ if not updated_rows:
130
+ self._restore(obj=obj, loaded_state=loaded_state)
131
+ raise IllegalPhaseTransitionError(
132
+ model_name=obj._meta.label,
133
+ pk=obj.pk,
134
+ source=rows.values_list(self.attname, flat=True).first(),
135
+ target=to,
136
+ )
137
+
138
+ obj.refresh_from_db(using=using, fields=[field.attname for field in written_fields])
139
+ signals.post_save.send(
140
+ sender=model, instance=obj, created=False, update_fields=update_fields, raw=False, using=using
141
+ )
142
+
143
+ def _written_fields(self, *, obj: models.Model, fields: Mapping[str, object]) -> list[models.Field]:
144
+ """The phase, `fields` and the `auto_now` fields not among them, as `save(update_fields=…)` would write them."""
145
+ written_fields = [self, *(obj._meta.get_field(name) for name in fields)]
146
+ written_fields += [
147
+ field
148
+ for field in obj._meta.concrete_fields
149
+ if getattr(field, "auto_now", False) and field not in written_fields
150
+ ]
151
+ return written_fields
152
+
153
+ @staticmethod
154
+ def _restore(*, obj: models.Model, loaded_state: tuple[dict, dict]) -> None:
155
+ instance_dict, fields_cache = loaded_state
156
+ obj.__dict__.clear()
157
+ obj.__dict__.update(instance_dict)
158
+ obj._state.fields_cache = fields_cache
159
+
160
+
161
+ def get_phase_fields(*, model: type[models.Model]) -> tuple[PhaseField, ...]:
162
+ return tuple(field for field in model._meta.concrete_fields if isinstance(field, PhaseField))
163
+
164
+
165
+ def get_phase_field(*, model: type[models.Model]) -> PhaseField:
166
+ """
167
+ Return the one `PhaseField` of `model`.
168
+
169
+ A model has at most one phase field (system check `phase.E002`), so callers never name the field.
170
+ """
171
+ phase_fields = get_phase_fields(model=model)
172
+ if not phase_fields:
173
+ raise MissingPhaseFieldError(model_name=model._meta.label)
174
+ return phase_fields[0]
File without changes
@@ -0,0 +1,64 @@
1
+ from django.db import models, router, transaction
2
+
3
+ from phase.exceptions import StalePhaseError
4
+ from phase.fields import REFRESHING_FLAG, PhaseField, get_phase_fields, is_expression
5
+
6
+ # The arguments of `Model.save()` in positional order.
7
+ SAVE_ARGUMENTS = ("force_insert", "force_update", "using", "update_fields")
8
+
9
+
10
+ class PhaseGuardMixin(models.Model):
11
+ """
12
+ Companion of `PhaseField`: lets reloads past the field's write protection and guards every save against a stale
13
+ phase.
14
+
15
+ A save of an instance whose phase no longer matches the database would silently undo a concurrent transition, so
16
+ it is refused. Because a `PhaseField` only changes through `move()`, the in-memory value always is the value last
17
+ read from or written to the database, and comparing it with a locked row is enough.
18
+
19
+ This also covers a new instance with an explicit primary key: Django tries an UPDATE first, which would otherwise
20
+ overwrite the phase of an existing row without a transition.
21
+
22
+ System check `phase.E001` makes sure every model with a `PhaseField` inherits this mixin.
23
+ """
24
+
25
+ class Meta:
26
+ abstract = True
27
+
28
+ def save(self, *args: object, **kwargs: object) -> None:
29
+ loaded_phases = {
30
+ field.attname: self.__dict__[field.attname]
31
+ for field in self.phase_fields()
32
+ if field.attname in self.__dict__ and not is_expression(self.__dict__[field.attname])
33
+ }
34
+ # Django 5.2 accepts the arguments of `save()` positionally as well.
35
+ options = dict(zip(SAVE_ARGUMENTS, args, strict=False)) | kwargs
36
+ update_fields = options.get("update_fields")
37
+ is_skipped_by_django = update_fields is not None and not update_fields
38
+ # Nothing to compare, an INSERT anyway, or a save Django skips (empty `update_fields`).
39
+ if not loaded_phases or self.pk is None or options.get("force_insert") or is_skipped_by_django:
40
+ super().save(*args, **kwargs)
41
+ return
42
+
43
+ using = options.get("using") or router.db_for_write(type(self), instance=self)
44
+ with transaction.atomic(using=using):
45
+ stored_phases = (
46
+ type(self)._base_manager.using(using).select_for_update().filter(pk=self.pk).values(*loaded_phases)
47
+ ).first()
48
+ if stored_phases is None and self._state.adding:
49
+ super().save(*args, **kwargs)
50
+ return
51
+ if stored_phases != loaded_phases:
52
+ raise StalePhaseError(model_name=self._meta.label, pk=self.pk, phases=loaded_phases)
53
+ super().save(*args, **kwargs)
54
+
55
+ @classmethod
56
+ def phase_fields(cls) -> tuple[PhaseField, ...]:
57
+ return get_phase_fields(model=cls)
58
+
59
+ def refresh_from_db(self, *args: object, **kwargs: object) -> None:
60
+ self.__dict__[REFRESHING_FLAG] = True
61
+ try:
62
+ super().refresh_from_db(*args, **kwargs)
63
+ finally:
64
+ del self.__dict__[REFRESHING_FLAG]
phase/transitions.py ADDED
@@ -0,0 +1,16 @@
1
+ from django.db import models
2
+
3
+ from phase.fields import get_phase_field
4
+
5
+
6
+ def move(*, obj: models.Model, to: str, **fields: object) -> None:
7
+ """Move `obj` to phase `to` and write `fields` in the same conditional UPDATE, see `PhaseField.move()`."""
8
+ get_phase_field(model=type(obj)).move(obj=obj, to=to, **fields)
9
+
10
+
11
+ def can_transition(*, obj: models.Model, to: str) -> bool:
12
+ return get_phase_field(model=type(obj)).can_transition(obj=obj, to=to)
13
+
14
+
15
+ def targets(*, obj: models.Model) -> tuple[str, ...]:
16
+ return get_phase_field(model=type(obj)).targets(obj=obj)