Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Authors
- Anton Kulikov (`bigtimecriminal <https://github.com/bigtimecriminal>`_)
- Ben Lawson (`blawson <https://github.com/blawson>`_)
- Benjamin Mampaey (`bmampaey <https://github.com/bmampaey>`_)
- Benjamin Skov Kaas-Hansen (`epiben <https://github.com/epiben>`_)
- Berke Agababaoglu (`bagababaoglu <https://github.com/bagababaoglu>`_)
- Bheesham Persaud (`bheesham <https://github.com/bheesham>`_)
- `bradford281 <https://github.com/bradford281>`_
Expand All @@ -43,10 +44,10 @@ Authors
- David Smith
- `ddabble <https://github.com/ddabble>`_
- Dmytro Shyshov (`xahgmah <https://github.com/xahgmah>`_)
- Edouard Richard (`vied12 <https://github.com/vied12>` _)
- Edouard Richard (`vied12 <https://github.com/vied12>`_)
- Eduardo Cuducos
- Erik van Widenfelt (`erikvw <https://github.com/erikvw>`_)
- Fábio Capuano (`fabiocapsouza <https://github.com/fabiocapsouza`_)
- Fábio Capuano (`fabiocapsouza <https://github.com/fabiocapsouza>`_)
- Filipe Pina (@fopina)
- Florian Eßer
- François Martin (`martinfrancois <https://github.com/martinfrancois>`_)
Expand Down
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Unreleased

- Added support for Python 3.14
- Added support for Django 6.0
- Added UUIDv7 support for ``history_id`` fields (gh-1532)

3.10.1 (2025-06-20)
-------------------
Expand Down
4 changes: 3 additions & 1 deletion docs/historical_model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ The example below uses a ``UUIDField`` instead of an ``AutoField``:

Since using a ``UUIDField`` for the ``history_id`` is a common use case, there is a
``SIMPLE_HISTORY_HISTORY_ID_USE_UUID`` setting that will set all instances of ``history_id`` to UUIDs.
By default, this will use UUIDv4, but you can choose to use UUIDv7 by setting
``SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION`` to ``7``.
Set this with the following line in your ``settings.py`` file:


.. code-block:: python

SIMPLE_HISTORY_HISTORY_ID_USE_UUID = True

SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION = 7 # Optional


This setting can still be overridden using the ``history_id_field`` parameter on a per model basis.
Expand Down
13 changes: 12 additions & 1 deletion simple_history/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,19 @@ def _get_history_id_field(self):
history_id_field.primary_key = True
history_id_field.editable = False
elif getattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID", False):
uuid_version = getattr(
settings, "SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION", 4
)
if uuid_version == 4:
uuid_default = uuid.uuid4
elif uuid_version == 7:
uuid_default = utils.uuid7
else:
raise ImproperlyConfigured(
"SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION must be either 4 or 7"
)
history_id_field = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False
primary_key=True, default=uuid_default, editable=False
)
else:
history_id_field = models.AutoField(primary_key=True)
Expand Down
12 changes: 12 additions & 0 deletions simple_history/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,18 @@ class UUIDDefaultModel(models.Model):
history = HistoricalRecords()


# Set the SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION
setattr(settings, "SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION", 7)


class UUIDv7Model(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
history = HistoricalRecords()


# Clear the SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION
delattr(settings, "SIMPLE_HISTORY_HISTORY_ID_UUID_VERSION")

# Clear the SIMPLE_HISTORY_HISTORY_ID_USE_UUID
delattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID")

Expand Down
17 changes: 17 additions & 0 deletions simple_history/tests/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dataclasses
import time
import unittest
import uuid
import warnings
Expand Down Expand Up @@ -122,6 +123,7 @@
UserTextFieldChangeReasonModel,
UUIDDefaultModel,
UUIDModel,
UUIDv7Model,
WaterLevel,
)
from .utils import (
Expand Down Expand Up @@ -637,6 +639,21 @@ def test_uuid_default_history_id(self):
history = entry.history.all()[0]
self.assertTrue(isinstance(history.history_id, uuid.UUID))

def test_uuidv7_history_id(self):
"""
Tests that UUIDv7 is being used for history_id when defined in the settings,
and that consequent PKs are ordered based on creation time.
"""
entries = []
for _ in range(10):
entries.append(UUIDv7Model.objects.create())
time.sleep(0.01) # Ensure increasing time part of UUIDv7 PKs

history = [entry.history.all()[0] for entry in entries]
self.assertTrue(all(isinstance(h.history_id, uuid.UUID) for h in history))
for h1, h2 in zip(history[:-1], history[1:]):
self.assertLess(h1.history_id, h2.history_id)

def test_default_history_change_reason(self):
entry = CharFieldChangeReasonModel.objects.create(greeting="what's up?")
history = entry.history.get()
Expand Down
26 changes: 26 additions & 0 deletions simple_history/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import os
import time
import uuid

from django.db import transaction
from django.db.models import Case, ForeignKey, ManyToManyField, Q, When
from django.forms.models import model_to_dict
Expand Down Expand Up @@ -241,3 +245,25 @@ def get_change_reason_from_object(obj):
return getattr(obj, "_change_reason")

return None


def uuid7():
"""
Custom function to generate a UUIDv7 (time-based UUID) as per
https://www.rfc-editor.org/rfc/rfc9562.html#name-uuid-version-7.
NB! Although uuid.UUID() has a version parameter, we cannot set it to 7
as of 2025-06-07 because UUID7 isn't part of the module--it it were,
this function would be redundant.
"""

# Initialise random bytearray
res = bytearray(os.urandom(16))

# Replace first 6 bytes (= 48 bits) with timestamp values
res[0:6] = (time.time_ns() // 1_000_000).to_bytes(6, "big")

# Then, set version and variant bits
res[6] = (res[6] & 0x0F) | 0x70
res[8] = (res[8] & 0x3F) | 0x80

return uuid.UUID(bytes=bytes(res))