diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..7eb5e2c
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,4 @@
+# -*- mode: conf; -*-
+
+[MESSAGES CONTROL]
+disable=fixme
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f05132d..3886930 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,54 @@ All notable changes to Wutta-Continuum will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.3.2 (2026-02-01)
+
+### Fix
+
+- add compose indexes to version tables, per upstream changes
+
+## v0.3.1 (2025-12-31)
+
+### Fix
+
+- set transaction user based on session info, when applicable
+
+## v0.3.0 (2025-12-20)
+
+### Feat
+
+- add TransactionMetaPlugin to save comments when applicable
+
+## v0.2.2 (2025-10-29)
+
+### Fix
+
+- exclude user password from continuum versioning
+
+## v0.2.1 (2025-10-29)
+
+### Fix
+
+- add util module, w/ `model_transaction_query()`
+- refactor some more for tests + pylint
+- format all code with black
+
+## v0.2.0 (2024-12-07)
+
+### Feat
+
+- convert all uuid fields from str to proper UUID
+
+### Fix
+
+- add `User.prevent_edit` to schema
+
+## v0.1.1 (2024-08-27)
+
+### Fix
+
+- fix nullable flags for initial version tables
+
## v0.1.0 (2024-08-27)
### Feat
diff --git a/README.md b/README.md
index c7bd996..531c46a 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Wutta-Continuum
-SQLAlchemy-Continuum versioning for WuttJamaican
+SQLAlchemy-Continuum versioning for Wutta Framework
-See docs at https://rattailproject.org/docs/wutta-continuum/
+See docs at https://docs.wuttaproject.org/wutta-continuum/
diff --git a/docs/api/wutta_continuum.testing.rst b/docs/api/wutta_continuum.testing.rst
new file mode 100644
index 0000000..4afca16
--- /dev/null
+++ b/docs/api/wutta_continuum.testing.rst
@@ -0,0 +1,6 @@
+
+``wutta_continuum.testing``
+===========================
+
+.. automodule:: wutta_continuum.testing
+ :members:
diff --git a/docs/api/wutta_continuum.util.rst b/docs/api/wutta_continuum.util.rst
new file mode 100644
index 0000000..c337ddb
--- /dev/null
+++ b/docs/api/wutta_continuum.util.rst
@@ -0,0 +1,6 @@
+
+``wutta_continuum.util``
+========================
+
+.. automodule:: wutta_continuum.util
+ :members:
diff --git a/docs/conf.py b/docs/conf.py
index 654920b..330e71f 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -8,32 +8,36 @@
from importlib.metadata import version as get_version
-project = 'Wutta-Continuum'
-copyright = '2024, Lance Edgar'
-author = 'Lance Edgar'
-release = get_version('Wutta-Continuum')
+project = "Wutta-Continuum"
+copyright = "2024, Lance Edgar"
+author = "Lance Edgar"
+release = get_version("Wutta-Continuum")
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
- 'sphinx.ext.autodoc',
- 'sphinx.ext.intersphinx',
- 'sphinx.ext.viewcode',
- 'sphinx.ext.todo',
+ "sphinx.ext.autodoc",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.viewcode",
+ "sphinx.ext.todo",
]
-templates_path = ['_templates']
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+templates_path = ["_templates"]
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
intersphinx_mapping = {
- 'sqlalchemy-continuum': ('https://sqlalchemy-continuum.readthedocs.io/en/latest/', None),
- 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
+ "sqlalchemy": ("http://docs.sqlalchemy.org/en/latest/", None),
+ "sqlalchemy-continuum": (
+ "https://sqlalchemy-continuum.readthedocs.io/en/latest/",
+ None,
+ ),
+ "wuttjamaican": ("https://docs.wuttaproject.org/wuttjamaican/", None),
}
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
-html_theme = 'furo'
-html_static_path = ['_static']
+html_theme = "furo"
+html_static_path = ["_static"]
diff --git a/docs/index.rst b/docs/index.rst
index e229883..6b003b9 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -5,16 +5,24 @@ Wutta-Continuum
This package adds data versioning/history for `WuttJamaican`_, using
`SQLAlchemy-Continuum`_.
-.. _WuttJamaican: https://rattailproject.org/docs/wuttjamaican/
+.. _WuttJamaican: https://docs.wuttaproject.org/wuttjamaican/
.. _SQLAlchemy-Continuum: https://sqlalchemy-continuum.readthedocs.io/en/latest/
+.. image:: https://img.shields.io/badge/linting-pylint-yellowgreen
+ :target: https://github.com/pylint-dev/pylint
+
+.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
+ :target: https://github.com/psf/black
+
.. toctree::
:maxdepth: 2
:caption: Documentation
+ narr/features
narr/install
+ narr/usage
.. toctree::
:maxdepth: 1
@@ -23,3 +31,5 @@ This package adds data versioning/history for `WuttJamaican`_, using
api/wutta_continuum
api/wutta_continuum.app
api/wutta_continuum.conf
+ api/wutta_continuum.testing
+ api/wutta_continuum.util
diff --git a/docs/narr/features.rst b/docs/narr/features.rst
new file mode 100644
index 0000000..20b08a4
--- /dev/null
+++ b/docs/narr/features.rst
@@ -0,0 +1,26 @@
+
+Features
+========
+
+The general idea is to provide an audit/versioning trail for important
+data tables.
+
+Each table defined in the :term:`app model` can either be versioned,
+or not. Nothing changes for a non-versioned table.
+
+For a "versioned" table, a secondary "versions" table is created,
+schema for which is a superset of the original "versioned" table.
+When records change in the original table, new "version" records are
+added to the versions table.
+
+Therefore you can see how a record has changed over time, by
+inspecting its corresponding versions.
+
+When any record changes (for any versioned table), a new "transaction"
+record is also created. This identifies the user responsible, and
+timestamp etc. Any new version records will tie back to this
+transaction record.
+
+All this is made possible by SQLAlchemy-Continuum; the Wutta-Continuum
+package mostly just adds config glue. See also
+:doc:`sqlalchemy-continuum:index`.
diff --git a/docs/narr/install.rst b/docs/narr/install.rst
index 4959f96..1494d22 100644
--- a/docs/narr/install.rst
+++ b/docs/narr/install.rst
@@ -31,7 +31,7 @@ this package for database migrations. You should already have an
[alembic]
script_location = wuttjamaican.db:alembic
- version_locations = wuttjamaican.db:alembic/versions wutta_continuum.db:alembic/versions
+ version_locations = wutta_continuum.db:alembic/versions poser.db:alembic/versions wuttjamaican.db:alembic/versions
Then (as you would have done previously in
:ref:`wuttjamaican:db-setup`) you can migrate your database to add the
diff --git a/docs/narr/usage.rst b/docs/narr/usage.rst
new file mode 100644
index 0000000..4d42288
--- /dev/null
+++ b/docs/narr/usage.rst
@@ -0,0 +1,41 @@
+
+Usage
+=====
+
+You can check the feature status with
+:meth:`~wutta_continuum.app.WuttaContinuumAppProvider.continuum_is_enabled()`::
+
+ app = config.get_app()
+
+ if not app.continuum_is_enabled():
+ print("Oh no! Continuum is not enabled.")
+
+The rest of this will assume the feature is enabled.
+
+
+Built-In Models
+---------------
+
+The following built-in models are versioned. So, when records are
+added / modified / removed via the ORM, new version records are
+automatically created for each of these:
+
+* :class:`~wuttjamaican:wuttjamaican.db.model.auth.Permission`
+* :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`
+* :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`
+* :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
+* :class:`~wuttjamaican:wuttjamaican.db.model.auth.UserRole`
+
+
+Object Versions
+---------------
+
+A versioned model works normally but also has a ``versions``
+attribute, which reflects the list of version records::
+
+ user = session.query(model.User).first()
+
+ for version in user.versions:
+ print(version)
+
+See also :doc:`sqlalchemy-continuum:version_objects`.
diff --git a/pyproject.toml b/pyproject.toml
index 1e7c109..382e813 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,10 +6,10 @@ build-backend = "hatchling.build"
[project]
name = "Wutta-Continuum"
-version = "0.1.0"
-description = "SQLAlchemy-Continuum versioning for WuttJamaican"
+version = "0.3.2"
+description = "SQLAlchemy-Continuum versioning for Wutta Framework"
readme = "README.md"
-authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
+authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
license = {text = "GNU GPL v3+"}
classifiers = [
"Development Status :: 4 - Beta",
@@ -27,13 +27,13 @@ classifiers = [
requires-python = ">= 3.8"
dependencies = [
"SQLAlchemy-Continuum",
- "WuttJamaican[db]",
+ "WuttJamaican[db]>=0.27.0",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo"]
-tests = ["pytest-cov", "tox"]
+tests = ["pylint", "pytest", "pytest-cov", "tox"]
[project.entry-points."wutta.app.providers"]
@@ -47,6 +47,7 @@ wutta_continuum = "wutta_continuum.conf:WuttaContinuumConfigExtension"
[project.urls]
Homepage = "https://wuttaproject.org/"
Repository = "https://forgejo.wuttaproject.org/wutta/wutta-continuum"
+Issues = "https://forgejo.wuttaproject.org/wutta/wutta-continuum/issues"
Changelog = "https://forgejo.wuttaproject.org/wutta/wutta-continuum/src/branch/master/CHANGELOG.md"
diff --git a/src/wutta_continuum/__init__.py b/src/wutta_continuum/__init__.py
index 63f63ff..60e7ad2 100644
--- a/src/wutta_continuum/__init__.py
+++ b/src/wutta_continuum/__init__.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8; -*-
################################################################################
#
-# Wutta-Continuum -- SQLAlchemy Versioning for WuttJamaican
+# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
@@ -21,7 +21,7 @@
#
################################################################################
"""
-Wutta-Continuum -- SQLAlchemy-Continuum versioning for WuttJamaican
+Wutta-Continuum -- SQLAlchemy-Continuum versioning for Wutta Framework
"""
from ._version import __version__
diff --git a/src/wutta_continuum/_version.py b/src/wutta_continuum/_version.py
index 23de4cf..f243184 100644
--- a/src/wutta_continuum/_version.py
+++ b/src/wutta_continuum/_version.py
@@ -1,6 +1,9 @@
# -*- coding: utf-8; -*-
+"""
+Package Version
+"""
from importlib.metadata import version
-__version__ = version('Wutta-Continuum')
+__version__ = version("Wutta-Continuum")
diff --git a/src/wutta_continuum/app.py b/src/wutta_continuum/app.py
index a6d55c7..30ab321 100644
--- a/src/wutta_continuum/app.py
+++ b/src/wutta_continuum/app.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8; -*-
################################################################################
#
-# Wutta-Continuum -- SQLAlchemy Versioning for WuttJamaican
+# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
@@ -40,5 +40,6 @@ class WuttaContinuumAppProvider(AppProvider):
This checks the config value as described in
:doc:`/narr/install`; default will be ``False``.
"""
- return self.config.get_bool('wutta_continuum.enable_versioning',
- usedb=False, default=False)
+ return self.config.get_bool(
+ "wutta_continuum.enable_versioning", usedb=False, default=False
+ )
diff --git a/src/wutta_continuum/conf.py b/src/wutta_continuum/conf.py
index bdf2d92..6c9abb4 100644
--- a/src/wutta_continuum/conf.py
+++ b/src/wutta_continuum/conf.py
@@ -1,8 +1,8 @@
# -*- coding: utf-8; -*-
################################################################################
#
-# Wutta-Continuum -- SQLAlchemy Versioning for WuttJamaican
-# Copyright © 2024 Lance Edgar
+# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
+# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
@@ -24,12 +24,11 @@
App Configuration
"""
-import datetime
import socket
from sqlalchemy.orm import configure_mappers
from sqlalchemy_continuum import make_versioned
-from sqlalchemy_continuum.plugins import Plugin
+from sqlalchemy_continuum.plugins import Plugin, TransactionMetaPlugin
from wuttjamaican.conf import WuttaConfigExtension
from wuttjamaican.util import load_object
@@ -42,29 +41,74 @@ class WuttaContinuumConfigExtension(WuttaConfigExtension):
This adds a startup hook, which can optionally turn on the
SQLAlchemy-Continuum versioning features for the main app DB.
"""
- key = 'wutta_continuum'
- def startup(self, config):
- """ """
+ key = "wutta_continuum"
+
+ def startup(self, config): # pylint: disable=empty-docstring
+ """
+ Perform final configuration setup for app startup.
+
+ This will do nothing at all, unless config enables the
+ versioning feature. This must be done in config file and not
+ in DB settings table:
+
+ .. code-block:: ini
+
+ [wutta_continuum]
+ enable_versioning = true
+
+ Once enabled, this method will configure the integration, via
+ these steps:
+
+ 1. call :func:`sqlalchemy-continuum:sqlalchemy_continuum.make_versioned()`
+ 2. call :meth:`wuttjamaican:wuttjamaican.app.AppHandler.get_model()`
+ 3. call :func:`sqlalchemy:sqlalchemy.orm.configure_mappers()`
+
+ For more about SQLAlchemy-Continuum see
+ :doc:`sqlalchemy-continuum:intro`.
+
+ Two plugins are provided to ``make_versioned()``:
+
+ The first is ``TransactionMetaPlugin`` for sake of adding
+ comments (see
+ :mod:`~sqlalchemy-continuum:sqlalchemy_continuum.plugins.transaction_meta`).
+
+ The second by default is :class:`WuttaContinuumPlugin` but you
+ can override with config:
+
+ .. code-block:: ini
+
+ [wutta_continuum]
+ wutta_plugin_spec = poser.db.continuum:PoserContinuumPlugin
+
+ See also the SQLAlchemy-Continuum docs for
+ :doc:`sqlalchemy-continuum:plugins`.
+ """
# only do this if config enables it
- if not config.get_bool('wutta_continuum.enable_versioning',
- usedb=False, default=False):
+ if not config.get_bool(
+ "wutta_continuum.enable_versioning", usedb=False, default=False
+ ):
return
# create wutta plugin, to assign user and ip address
- spec = config.get('wutta_continuum.wutta_plugin_spec',
- usedb=False,
- default='wutta_continuum.conf:WuttaContinuumPlugin')
- WuttaPlugin = load_object(spec)
+ spec = config.get(
+ "wutta_continuum.wutta_plugin_spec",
+ usedb=False,
+ default="wutta_continuum.conf:WuttaContinuumPlugin",
+ )
+ plugin = load_object(spec)
- # tell sqlalchemy-continuum to do its thing
- make_versioned(plugins=[WuttaPlugin()])
-
- # nb. must load the model before configuring mappers
app = config.get_app()
- model = app.model
+ if "model" in app.__dict__:
+ raise RuntimeError("something not right, app already has model")
- # tell sqlalchemy to do its thing
+ # let sqlalchemy-continuum do its thing
+ make_versioned(plugins=[TransactionMetaPlugin(), plugin()])
+
+ # must load model *between* prev and next calls
+ app.get_model()
+
+ # let sqlalchemy do its thing
configure_mappers()
@@ -72,47 +116,85 @@ class WuttaContinuumPlugin(Plugin):
"""
SQLAlchemy-Continuum manager plugin for Wutta-Continuum.
- This tries to assign the current user and IP address to the
- transaction.
+ This is the default plugin used within
+ :meth:`~WuttaContinuumConfigExtension.startup()` unless config
+ overrides.
- It will assume the "current machine" IP address, which may be
- suitable for some apps but not all (e.g. web apps, where IP
- address should reflect an arbitrary client machine).
-
- However it does not actually have a way to determine the current
- user. WuttaWeb therefore uses a different plugin, based on this
- one, to get both the user and IP address from current request.
-
- You can override this to use a custom plugin for this purpose; if
- so you must specify in your config file:
-
- .. code-block:: ini
-
- [wutta_continuum]
- wutta_plugin_spec = poser.db.continuum:PoserContinuumPlugin
+ This tries to establish the user and IP address responsible, and
+ comment if applicable, for the current transaction.
See also the SQLAlchemy-Continuum docs for
:doc:`sqlalchemy-continuum:plugins`.
"""
- def get_remote_addr(self, uow, session):
- """ """
+ def get_remote_addr(self, uow, session): # pylint: disable=unused-argument
+ """
+ This should return the effective IP address responsible for
+ the current change(s).
+
+ Default logic will assume the "current machine" e.g. where a
+ CLI command or script is running. In practice that often
+ means this winds up being ``127.0.0.1`` or similar.
+
+ :returns: IP address (v4 or v6) as string
+ """
host = socket.gethostname()
return socket.gethostbyname(host)
- def get_user_id(self, uow, session):
- """ """
+ def get_user_id(self, uow, session): # pylint: disable=unused-argument
+ """
+ This should return the effective ``User.uuid`` indicating who
+ is responsible for the current change(s).
+
+ Default logic does not have a way to determine current user on
+ its own per se. However it can inspect the session, and use a
+ value from there if found.
+
+ Any session can therefore declare the resonsible user::
+
+ myuser = session.query(model.User).first()
+ session.info["continuum_user_id"] = myuser.uuid
+
+ :returns: :attr:`wuttjamaican.db.model.auth.User.uuid` value,
+ or ``None``
+ """
+ if user_id := session.info.get("continuum_user_id"):
+ return user_id
+
+ return None
def transaction_args(self, uow, session):
- """ """
+ """
+ This is a standard hook method for SQLAchemy-Continuum
+ plugins. We use it to (try to) inject these values, which
+ then become set on the current (new) transaction:
+
+ * ``remote_addr`` - effective IP address causing the change
+ * see :meth:`get_remote_addr()`
+ * ``user_id`` - effective ``User.uuid`` for change authorship
+ * see :meth:`get_user_id()`
+ """
kwargs = {}
remote_addr = self.get_remote_addr(uow, session)
if remote_addr:
- kwargs['remote_addr'] = remote_addr
+ kwargs["remote_addr"] = remote_addr
- user_id = self.get_user_id(uow, session)
+ user_id = self.get_user_id(uow, session) # pylint: disable=assignment-from-none
if user_id:
- kwargs['user_id'] = user_id
+ kwargs["user_id"] = user_id
return kwargs
+
+ def before_flush(self, uow, session):
+ """
+ We use this hook to inject the "comment" for current
+ transaction, if applicable.
+
+ This checks the session for the comment; so any session can
+ specify one like so::
+
+ session.info["continuum_comment"] = "hello world"
+ """
+ if comment := session.info.get("continuum_comment"):
+ uow.current_transaction.meta["comment"] = comment
diff --git a/src/wutta_continuum/db/alembic/versions/0a5f8ac0cd06_add_user_prevent_edit.py b/src/wutta_continuum/db/alembic/versions/0a5f8ac0cd06_add_user_prevent_edit.py
new file mode 100644
index 0000000..b2683e5
--- /dev/null
+++ b/src/wutta_continuum/db/alembic/versions/0a5f8ac0cd06_add_user_prevent_edit.py
@@ -0,0 +1,34 @@
+"""add user.prevent_edit
+
+Revision ID: 0a5f8ac0cd06
+Revises: 71406251b8e7
+Create Date: 2024-11-24 17:39:57.415425
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "0a5f8ac0cd06"
+down_revision: Union[str, None] = "71406251b8e7"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # user
+ op.add_column(
+ "user_version",
+ sa.Column("prevent_edit", sa.Boolean(), autoincrement=False, nullable=True),
+ )
+
+
+def downgrade() -> None:
+
+ # user
+ op.drop_column("user_version", "prevent_edit")
diff --git a/src/wutta_continuum/db/alembic/versions/46fb4711411d_add_transaction_meta.py b/src/wutta_continuum/db/alembic/versions/46fb4711411d_add_transaction_meta.py
new file mode 100644
index 0000000..ebaf636
--- /dev/null
+++ b/src/wutta_continuum/db/alembic/versions/46fb4711411d_add_transaction_meta.py
@@ -0,0 +1,40 @@
+"""add transaction_meta
+
+Revision ID: 46fb4711411d
+Revises: 989392cc191d
+Create Date: 2025-12-18 21:22:33.382628
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "46fb4711411d"
+down_revision: Union[str, None] = "989392cc191d"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # transaction_meta
+ op.create_table(
+ "transaction_meta",
+ sa.Column("transaction_id", sa.BigInteger(), nullable=False),
+ sa.Column("key", sa.Unicode(length=255), nullable=False),
+ sa.Column("value", sa.UnicodeText(), nullable=True),
+ sa.PrimaryKeyConstraint(
+ "transaction_id", "key", name=op.f("pk_transaction_meta")
+ ),
+ )
+
+
+def downgrade() -> None:
+
+ # transaction_meta
+ op.drop_table("transaction_meta")
diff --git a/src/wutta_continuum/db/alembic/versions/71406251b8e7_first_versioning_tables.py b/src/wutta_continuum/db/alembic/versions/71406251b8e7_first_versioning_tables.py
index b5e3554..13909cb 100644
--- a/src/wutta_continuum/db/alembic/versions/71406251b8e7_first_versioning_tables.py
+++ b/src/wutta_continuum/db/alembic/versions/71406251b8e7_first_versioning_tables.py
@@ -1,142 +1,296 @@
"""first versioning tables
Revision ID: 71406251b8e7
-Revises:
+Revises:
Create Date: 2024-08-27 18:28:31.488291
"""
+
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
+import wuttjamaican.db.util
# revision identifiers, used by Alembic.
-revision: str = '71406251b8e7'
+revision: str = "71406251b8e7"
down_revision: Union[str, None] = None
-branch_labels: Union[str, Sequence[str], None] = ('wutta_continuum',)
+branch_labels: Union[str, Sequence[str], None] = ("wutta_continuum",)
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# transaction
- op.create_table('transaction',
- sa.Column('issued_at', sa.DateTime(), nullable=True),
- sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
- sa.Column('remote_addr', sa.String(length=50), nullable=True),
- sa.Column('user_id', sa.String(length=32), nullable=True),
- sa.ForeignKeyConstraint(['user_id'], ['user.uuid'], name=op.f('fk_transaction_user_id_user')),
- sa.PrimaryKeyConstraint('id', name=op.f('pk_transaction'))
- )
- op.create_index(op.f('ix_transaction_user_id'), 'transaction', ['user_id'], unique=False)
+ op.create_table(
+ "transaction",
+ sa.Column("issued_at", sa.DateTime(), nullable=True),
+ sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+ sa.Column("remote_addr", sa.String(length=50), nullable=True),
+ sa.Column("user_id", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["user_id"], ["user.uuid"], name=op.f("fk_transaction_user_id_user")
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_transaction")),
+ )
+ op.create_index(
+ op.f("ix_transaction_user_id"), "transaction", ["user_id"], unique=False
+ )
# person
- op.create_table('person_version',
- sa.Column('uuid', sa.String(length=32), autoincrement=False, nullable=False),
- sa.Column('full_name', sa.String(length=100), autoincrement=False, nullable=False),
- sa.Column('first_name', sa.String(length=50), autoincrement=False, nullable=True),
- sa.Column('middle_name', sa.String(length=50), autoincrement=False, nullable=True),
- sa.Column('last_name', sa.String(length=50), autoincrement=False, nullable=True),
- sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
- sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
- sa.Column('operation_type', sa.SmallInteger(), nullable=False),
- sa.PrimaryKeyConstraint('uuid', 'transaction_id', name=op.f('pk_person_version'))
- )
- op.create_index(op.f('ix_person_version_end_transaction_id'), 'person_version', ['end_transaction_id'], unique=False)
- op.create_index(op.f('ix_person_version_operation_type'), 'person_version', ['operation_type'], unique=False)
- op.create_index(op.f('ix_person_version_transaction_id'), 'person_version', ['transaction_id'], unique=False)
+ op.create_table(
+ "person_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "full_name", sa.String(length=100), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "first_name", sa.String(length=50), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "middle_name", sa.String(length=50), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "last_name", sa.String(length=50), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_person_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_person_version_end_transaction_id"),
+ "person_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_person_version_operation_type"),
+ "person_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_person_version_transaction_id"),
+ "person_version",
+ ["transaction_id"],
+ unique=False,
+ )
# user
- op.create_table('user_version',
- sa.Column('uuid', sa.String(length=32), autoincrement=False, nullable=False),
- sa.Column('username', sa.String(length=25), autoincrement=False, nullable=False),
- sa.Column('password', sa.String(length=60), autoincrement=False, nullable=True),
- sa.Column('person_uuid', sa.String(length=32), autoincrement=False, nullable=True),
- sa.Column('active', sa.Boolean(), autoincrement=False, nullable=False),
- sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
- sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
- sa.Column('operation_type', sa.SmallInteger(), nullable=False),
- sa.PrimaryKeyConstraint('uuid', 'transaction_id', name=op.f('pk_user_version'))
- )
- op.create_index(op.f('ix_user_version_end_transaction_id'), 'user_version', ['end_transaction_id'], unique=False)
- op.create_index(op.f('ix_user_version_operation_type'), 'user_version', ['operation_type'], unique=False)
- op.create_index(op.f('ix_user_version_transaction_id'), 'user_version', ['transaction_id'], unique=False)
+ op.create_table(
+ "user_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("username", sa.String(length=25), autoincrement=False, nullable=True),
+ sa.Column("password", sa.String(length=60), autoincrement=False, nullable=True),
+ sa.Column(
+ "person_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint("uuid", "transaction_id", name=op.f("pk_user_version")),
+ )
+ op.create_index(
+ op.f("ix_user_version_end_transaction_id"),
+ "user_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_user_version_operation_type"),
+ "user_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_user_version_transaction_id"),
+ "user_version",
+ ["transaction_id"],
+ unique=False,
+ )
# role
- op.create_table('role_version',
- sa.Column('uuid', sa.String(length=32), autoincrement=False, nullable=False),
- sa.Column('name', sa.String(length=100), autoincrement=False, nullable=False),
- sa.Column('notes', sa.Text(), autoincrement=False, nullable=True),
- sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
- sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
- sa.Column('operation_type', sa.SmallInteger(), nullable=False),
- sa.PrimaryKeyConstraint('uuid', 'transaction_id', name=op.f('pk_role_version'))
- )
- op.create_index(op.f('ix_role_version_end_transaction_id'), 'role_version', ['end_transaction_id'], unique=False)
- op.create_index(op.f('ix_role_version_operation_type'), 'role_version', ['operation_type'], unique=False)
- op.create_index(op.f('ix_role_version_transaction_id'), 'role_version', ['transaction_id'], unique=False)
+ op.create_table(
+ "role_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
+ sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint("uuid", "transaction_id", name=op.f("pk_role_version")),
+ )
+ op.create_index(
+ op.f("ix_role_version_end_transaction_id"),
+ "role_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_role_version_operation_type"),
+ "role_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_role_version_transaction_id"),
+ "role_version",
+ ["transaction_id"],
+ unique=False,
+ )
# user_x_role
- op.create_table('user_x_role_version',
- sa.Column('uuid', sa.String(length=32), autoincrement=False, nullable=False),
- sa.Column('user_uuid', sa.String(length=32), autoincrement=False, nullable=False),
- sa.Column('role_uuid', sa.String(length=32), autoincrement=False, nullable=False),
- sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
- sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
- sa.Column('operation_type', sa.SmallInteger(), nullable=False),
- sa.PrimaryKeyConstraint('uuid', 'transaction_id', name=op.f('pk_user_x_role_version'))
- )
- op.create_index(op.f('ix_user_x_role_version_end_transaction_id'), 'user_x_role_version', ['end_transaction_id'], unique=False)
- op.create_index(op.f('ix_user_x_role_version_operation_type'), 'user_x_role_version', ['operation_type'], unique=False)
- op.create_index(op.f('ix_user_x_role_version_transaction_id'), 'user_x_role_version', ['transaction_id'], unique=False)
+ op.create_table(
+ "user_x_role_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "role_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_user_x_role_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_user_x_role_version_end_transaction_id"),
+ "user_x_role_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_user_x_role_version_operation_type"),
+ "user_x_role_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_user_x_role_version_transaction_id"),
+ "user_x_role_version",
+ ["transaction_id"],
+ unique=False,
+ )
# permission
- op.create_table('permission_version',
- sa.Column('role_uuid', sa.String(length=32), autoincrement=False, nullable=False),
- sa.Column('permission', sa.String(length=254), autoincrement=False, nullable=False),
- sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
- sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
- sa.Column('operation_type', sa.SmallInteger(), nullable=False),
- sa.PrimaryKeyConstraint('role_uuid', 'permission', 'transaction_id', name=op.f('pk_permission_version'))
- )
- op.create_index(op.f('ix_permission_version_end_transaction_id'), 'permission_version', ['end_transaction_id'], unique=False)
- op.create_index(op.f('ix_permission_version_operation_type'), 'permission_version', ['operation_type'], unique=False)
- op.create_index(op.f('ix_permission_version_transaction_id'), 'permission_version', ['transaction_id'], unique=False)
+ op.create_table(
+ "permission_version",
+ sa.Column(
+ "role_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=False,
+ ),
+ sa.Column(
+ "permission", sa.String(length=254), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "role_uuid",
+ "permission",
+ "transaction_id",
+ name=op.f("pk_permission_version"),
+ ),
+ )
+ op.create_index(
+ op.f("ix_permission_version_end_transaction_id"),
+ "permission_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_permission_version_operation_type"),
+ "permission_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_permission_version_transaction_id"),
+ "permission_version",
+ ["transaction_id"],
+ unique=False,
+ )
def downgrade() -> None:
# permission
- op.drop_index(op.f('ix_permission_version_transaction_id'), table_name='permission_version')
- op.drop_index(op.f('ix_permission_version_operation_type'), table_name='permission_version')
- op.drop_index(op.f('ix_permission_version_end_transaction_id'), table_name='permission_version')
- op.drop_table('permission_version')
+ op.drop_index(
+ op.f("ix_permission_version_transaction_id"), table_name="permission_version"
+ )
+ op.drop_index(
+ op.f("ix_permission_version_operation_type"), table_name="permission_version"
+ )
+ op.drop_index(
+ op.f("ix_permission_version_end_transaction_id"),
+ table_name="permission_version",
+ )
+ op.drop_table("permission_version")
# user_x_role
- op.drop_index(op.f('ix_user_x_role_version_transaction_id'), table_name='user_x_role_version')
- op.drop_index(op.f('ix_user_x_role_version_operation_type'), table_name='user_x_role_version')
- op.drop_index(op.f('ix_user_x_role_version_end_transaction_id'), table_name='user_x_role_version')
- op.drop_table('user_x_role_version')
+ op.drop_index(
+ op.f("ix_user_x_role_version_transaction_id"), table_name="user_x_role_version"
+ )
+ op.drop_index(
+ op.f("ix_user_x_role_version_operation_type"), table_name="user_x_role_version"
+ )
+ op.drop_index(
+ op.f("ix_user_x_role_version_end_transaction_id"),
+ table_name="user_x_role_version",
+ )
+ op.drop_table("user_x_role_version")
# role
- op.drop_index(op.f('ix_role_version_transaction_id'), table_name='role_version')
- op.drop_index(op.f('ix_role_version_operation_type'), table_name='role_version')
- op.drop_index(op.f('ix_role_version_end_transaction_id'), table_name='role_version')
- op.drop_table('role_version')
+ op.drop_index(op.f("ix_role_version_transaction_id"), table_name="role_version")
+ op.drop_index(op.f("ix_role_version_operation_type"), table_name="role_version")
+ op.drop_index(op.f("ix_role_version_end_transaction_id"), table_name="role_version")
+ op.drop_table("role_version")
# user
- op.drop_index(op.f('ix_user_version_transaction_id'), table_name='user_version')
- op.drop_index(op.f('ix_user_version_operation_type'), table_name='user_version')
- op.drop_index(op.f('ix_user_version_end_transaction_id'), table_name='user_version')
- op.drop_table('user_version')
+ op.drop_index(op.f("ix_user_version_transaction_id"), table_name="user_version")
+ op.drop_index(op.f("ix_user_version_operation_type"), table_name="user_version")
+ op.drop_index(op.f("ix_user_version_end_transaction_id"), table_name="user_version")
+ op.drop_table("user_version")
# person
- op.drop_index(op.f('ix_person_version_transaction_id'), table_name='person_version')
- op.drop_index(op.f('ix_person_version_operation_type'), table_name='person_version')
- op.drop_index(op.f('ix_person_version_end_transaction_id'), table_name='person_version')
- op.drop_table('person_version')
+ op.drop_index(op.f("ix_person_version_transaction_id"), table_name="person_version")
+ op.drop_index(op.f("ix_person_version_operation_type"), table_name="person_version")
+ op.drop_index(
+ op.f("ix_person_version_end_transaction_id"), table_name="person_version"
+ )
+ op.drop_table("person_version")
# transaction
- op.drop_index(op.f('ix_transaction_user_id'), table_name='transaction')
- op.drop_table('transaction')
+ op.drop_index(op.f("ix_transaction_user_id"), table_name="transaction")
+ op.drop_table("transaction")
diff --git a/src/wutta_continuum/db/alembic/versions/989392cc191d_remove_password.py b/src/wutta_continuum/db/alembic/versions/989392cc191d_remove_password.py
new file mode 100644
index 0000000..545f75a
--- /dev/null
+++ b/src/wutta_continuum/db/alembic/versions/989392cc191d_remove_password.py
@@ -0,0 +1,37 @@
+"""remove password
+
+Revision ID: 989392cc191d
+Revises: 0a5f8ac0cd06
+Create Date: 2025-10-29 19:42:52.985167
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "989392cc191d"
+down_revision: Union[str, None] = "0a5f8ac0cd06"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # user
+ op.drop_column("user_version", "password")
+
+
+def downgrade() -> None:
+
+ # user
+ op.add_column(
+ "user_version",
+ sa.Column(
+ "password", sa.VARCHAR(length=60), autoincrement=False, nullable=True
+ ),
+ )
diff --git a/src/wutta_continuum/db/alembic/versions/f51330d1fa4d_add_composite_indexes_per_upstream_.py b/src/wutta_continuum/db/alembic/versions/f51330d1fa4d_add_composite_indexes_per_upstream_.py
new file mode 100644
index 0000000..5b39ce2
--- /dev/null
+++ b/src/wutta_continuum/db/alembic/versions/f51330d1fa4d_add_composite_indexes_per_upstream_.py
@@ -0,0 +1,106 @@
+"""add composite indexes per upstream changes
+
+Revision ID: f51330d1fa4d
+Revises: 46fb4711411d
+Create Date: 2026-02-01 18:24:25.713961
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "f51330d1fa4d"
+down_revision: Union[str, None] = "46fb4711411d"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # *_version
+ op.create_index(
+ "ix_permission_version_pk_transaction_id",
+ "permission_version",
+ ["role_uuid", "permission", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_permission_version_pk_validity",
+ "permission_version",
+ ["role_uuid", "permission", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_person_version_pk_transaction_id",
+ "person_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_person_version_pk_validity",
+ "person_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_role_version_pk_transaction_id",
+ "role_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_role_version_pk_validity",
+ "role_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_version_pk_transaction_id",
+ "user_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_version_pk_validity",
+ "user_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_x_role_version_pk_transaction_id",
+ "user_x_role_version",
+ ["uuid", sa.literal_column("transaction_id DESC")],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_x_role_version_pk_validity",
+ "user_x_role_version",
+ ["uuid", "transaction_id", "end_transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # *_version
+ op.drop_index(
+ "ix_user_x_role_version_pk_validity", table_name="user_x_role_version"
+ )
+ op.drop_index(
+ "ix_user_x_role_version_pk_transaction_id", table_name="user_x_role_version"
+ )
+ op.drop_index("ix_user_version_pk_validity", table_name="user_version")
+ op.drop_index("ix_user_version_pk_transaction_id", table_name="user_version")
+ op.drop_index("ix_role_version_pk_validity", table_name="role_version")
+ op.drop_index("ix_role_version_pk_transaction_id", table_name="role_version")
+ op.drop_index("ix_person_version_pk_validity", table_name="person_version")
+ op.drop_index("ix_person_version_pk_transaction_id", table_name="person_version")
+ op.drop_index("ix_permission_version_pk_validity", table_name="permission_version")
+ op.drop_index(
+ "ix_permission_version_pk_transaction_id", table_name="permission_version"
+ )
diff --git a/src/wutta_continuum/testing.py b/src/wutta_continuum/testing.py
new file mode 100644
index 0000000..c9229d3
--- /dev/null
+++ b/src/wutta_continuum/testing.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
+# Copyright © 2024-2025 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Testing utilities
+"""
+
+import sys
+
+import sqlalchemy_continuum as continuum
+
+from wuttjamaican.testing import DataTestCase
+
+from wutta_continuum.conf import WuttaContinuumConfigExtension
+
+
+class VersionTestCase(DataTestCase):
+ """
+ Base class for test suites requiring the SQLAlchemy-Continuum
+ versioning feature.
+
+ This inherits from
+ :class:`~wuttjamaican:wuttjamaican.testing.DataTestCase`.
+ """
+
+ def setUp(self):
+ self.setup_versioning()
+
+ def setup_versioning(self):
+ """
+ Do setup tasks relating to this class, as well as its parent(s):
+
+ * call :meth:`wuttjamaican:wuttjamaican.testing.DataTestCase.setup_db()`
+
+ * this will in turn call :meth:`make_config()`
+ """
+ self.setup_db()
+
+ def tearDown(self):
+ self.teardown_versioning()
+
+ def teardown_versioning(self):
+ """
+ Do teardown tasks relating to this class, as well as its parent(s):
+
+ * call :func:`sqlalchemy-continuum:sqlalchemy_continuum.remove_versioning()`
+ * call :meth:`wuttjamaican:wuttjamaican.testing.DataTestCase.teardown_db()`
+ """
+ continuum.remove_versioning()
+ continuum.versioning_manager.transaction_cls = continuum.TransactionFactory()
+ self.teardown_db()
+
+ def make_config(self, **kwargs):
+ """
+ Make and customize the config object.
+
+ We override this to explicitly enable the versioning feature.
+ """
+ config = super().make_config(**kwargs)
+ config.setdefault("wutta_continuum.enable_versioning", "true")
+
+ # nb. must purge model classes from sys.modules, so they will
+ # be reloaded and sqlalchemy-continuum can reconfigure
+ if "wuttjamaican.db.model" in sys.modules:
+ del sys.modules["wuttjamaican.db.model.batch"]
+ del sys.modules["wuttjamaican.db.model.upgrades"]
+ del sys.modules["wuttjamaican.db.model.auth"]
+ del sys.modules["wuttjamaican.db.model.base"]
+ del sys.modules["wuttjamaican.db.model"]
+
+ ext = WuttaContinuumConfigExtension()
+ ext.startup(config)
+ return config
diff --git a/src/wutta_continuum/util.py b/src/wutta_continuum/util.py
new file mode 100644
index 0000000..4ca64ec
--- /dev/null
+++ b/src/wutta_continuum/util.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
+# Copyright © 2024-2025 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+SQLAlchemy-Continuum utilities
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+import sqlalchemy_continuum as continuum
+
+
+OPERATION_TYPES = {
+ continuum.Operation.INSERT: "INSERT",
+ continuum.Operation.UPDATE: "UPDATE",
+ continuum.Operation.DELETE: "DELETE",
+}
+
+
+def render_operation_type(operation_type):
+ """
+ Render a SQLAlchemy-Continuum ``operation_type`` from a version
+ record, for display to user.
+
+ :param operation_type: Value of same name from a version record.
+ Must be one of:
+
+ * :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.INSERT`
+ * :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.UPDATE`
+ * :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.DELETE`
+
+ :returns: Display name for the operation type, as string.
+ """
+ return OPERATION_TYPES[operation_type]
+
+
+def model_transaction_query(instance, session=None, model_class=None):
+ """
+ Make a query capable of finding all SQLAlchemy-Continuum
+ ``transaction`` records associated with the given model instance.
+
+ :param instance: Instance of a versioned :term:`data model`.
+
+ :param session: Optional :term:`db session` to use for the query.
+ If not specified, will be obtained from the ``instance``.
+
+ :param model_class: Optional :term:`data model` class to query.
+ If not specified, will be obtained from the ``instance``.
+
+ :returns: SQLAlchemy query object. Note that it will *not* have an
+ ``ORDER BY`` clause yet.
+ """
+ if not session:
+ session = orm.object_session(instance)
+ if not model_class:
+ model_class = type(instance)
+
+ txncls = continuum.transaction_class(model_class)
+ vercls = continuum.version_class(model_class)
+
+ query = session.query(txncls).join(
+ vercls,
+ sa.and_(vercls.uuid == instance.uuid, vercls.transaction_id == txncls.id),
+ )
+
+ return query
diff --git a/tasks.py b/tasks.py
new file mode 100644
index 0000000..c55d669
--- /dev/null
+++ b/tasks.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8; -*-
+"""
+Tasks for Wutta-Continuum
+"""
+
+import os
+import shutil
+
+from invoke import task
+
+
+@task
+def release(c, skip_tests=False):
+ """
+ Release a new version of Wutta-Continuum
+ """
+ if not skip_tests:
+ c.run("pytest")
+
+ if os.path.exists("dist"):
+ shutil.rmtree("dist")
+
+ c.run("python -m build --sdist")
+ c.run("twine upload dist/*")
diff --git a/tests/test_app.py b/tests/test_app.py
index 8d41b25..4efb525 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -17,5 +17,5 @@ class TestWuttaContinuumAppProvider(DataTestCase):
self.assertFalse(provider.continuum_is_enabled())
# but can be turned on
- self.config.setdefault('wutta_continuum.enable_versioning', 'true')
+ self.config.setdefault("wutta_continuum.enable_versioning", "true")
self.assertTrue(provider.continuum_is_enabled())
diff --git a/tests/test_conf.py b/tests/test_conf.py
index 1f9236b..03485c2 100644
--- a/tests/test_conf.py
+++ b/tests/test_conf.py
@@ -2,35 +2,47 @@
import socket
-from unittest.mock import patch
+from unittest.mock import patch, Mock
-from wuttjamaican.testing import DataTestCase
+from wuttjamaican.testing import ConfigTestCase, DataTestCase
from wutta_continuum import conf as mod
-class TestWuttaContinuumConfigExtension(DataTestCase):
+class TestWuttaContinuumConfigExtension(ConfigTestCase):
def make_extension(self):
return mod.WuttaContinuumConfigExtension()
- def test_startup(self):
+ def test_startup_without_versioning(self):
ext = self.make_extension()
-
- with patch.object(mod, 'make_versioned') as make_versioned:
- with patch.object(mod, 'configure_mappers') as configure_mappers:
-
- # nothing happens by default
+ with patch.object(mod, "make_versioned") as make_versioned:
+ with patch.object(mod, "configure_mappers") as configure_mappers:
ext.startup(self.config)
make_versioned.assert_not_called()
configure_mappers.assert_not_called()
- # but will if we enable it in config
- self.config.setdefault('wutta_continuum.enable_versioning', 'true')
+ def test_startup_with_versioning(self):
+ ext = self.make_extension()
+ with patch.object(mod, "make_versioned") as make_versioned:
+ with patch.object(mod, "configure_mappers") as configure_mappers:
+ self.config.setdefault("wutta_continuum.enable_versioning", "true")
ext.startup(self.config)
make_versioned.assert_called_once()
configure_mappers.assert_called_once_with()
+ def test_startup_with_error(self):
+ ext = self.make_extension()
+ with patch.object(mod, "make_versioned") as make_versioned:
+ with patch.object(mod, "configure_mappers") as configure_mappers:
+ self.config.setdefault("wutta_continuum.enable_versioning", "true")
+ # nb. it is an error for the model to be loaded prior to
+ # calling make_versioned() for sqlalchemy-continuum
+ self.app.get_model()
+ self.assertRaises(RuntimeError, ext.startup, self.config)
+ make_versioned.assert_not_called()
+ configure_mappers.assert_not_called()
+
class TestWuttaContinuumPlugin(DataTestCase):
@@ -39,20 +51,51 @@ class TestWuttaContinuumPlugin(DataTestCase):
def test_remote_addr(self):
plugin = self.make_plugin()
- with patch.object(socket, 'gethostbyname', return_value='127.0.0.1'):
- self.assertEqual(plugin.get_remote_addr(None, self.session), '127.0.0.1')
+ with patch.object(socket, "gethostbyname", return_value="127.0.0.1"):
+ self.assertEqual(plugin.get_remote_addr(None, self.session), "127.0.0.1")
def test_user_id(self):
+ model = self.app.model
plugin = self.make_plugin()
+
+ fred = model.User(username="fred")
+ self.session.add(fred)
+ self.session.commit()
+
+ # empty by default
self.assertIsNone(plugin.get_user_id(None, self.session))
+ # but session can declare one
+ self.session.info["continuum_user_id"] = fred.uuid
+ self.assertEqual(plugin.get_user_id(None, self.session), fred.uuid)
+
def test_transaction_args(self):
plugin = self.make_plugin()
- with patch.object(socket, 'gethostbyname', return_value='127.0.0.1'):
- self.assertEqual(plugin.transaction_args(None, self.session),
- {'remote_addr': '127.0.0.1'})
+ with patch.object(socket, "gethostbyname", return_value="127.0.0.1"):
+ self.assertEqual(
+ plugin.transaction_args(None, self.session),
+ {"remote_addr": "127.0.0.1"},
+ )
- with patch.object(plugin, 'get_user_id', return_value='some-random-uuid'):
- self.assertEqual(plugin.transaction_args(None, self.session),
- {'remote_addr': '127.0.0.1',
- 'user_id': 'some-random-uuid'})
+ with patch.object(plugin, "get_user_id", return_value="some-random-uuid"):
+ self.assertEqual(
+ plugin.transaction_args(None, self.session),
+ {"remote_addr": "127.0.0.1", "user_id": "some-random-uuid"},
+ )
+
+ def test_before_flush(self):
+ plugin = self.make_plugin()
+
+ meta = {}
+ txn = Mock(meta=meta)
+ uow = Mock(current_transaction=txn)
+
+ # no comment in session or transaction
+ plugin.before_flush(uow, self.session)
+ self.assertNotIn("comment", meta)
+
+ # transaction comment matches session
+ self.session.info["continuum_comment"] = "whaddyaknow"
+ plugin.before_flush(uow, self.session)
+ self.assertIn("comment", meta)
+ self.assertEqual(meta["comment"], "whaddyaknow")
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 0000000..944c861
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+
+import sqlalchemy_continuum as continuum
+
+from wutta_continuum import util as mod
+from wutta_continuum.testing import VersionTestCase
+
+
+class TestRenderOperationType(TestCase):
+
+ def test_basic(self):
+ self.assertEqual(
+ mod.render_operation_type(continuum.Operation.INSERT), "INSERT"
+ )
+ self.assertEqual(
+ mod.render_operation_type(continuum.Operation.UPDATE), "UPDATE"
+ )
+ self.assertEqual(
+ mod.render_operation_type(continuum.Operation.DELETE), "DELETE"
+ )
+
+
+class TestModelTransactionQuery(VersionTestCase):
+
+ def test_basic(self):
+ model = self.app.model
+
+ user = model.User(username="fred")
+ self.session.add(user)
+ self.session.commit()
+
+ query = mod.model_transaction_query(user)
+ self.assertEqual(query.count(), 1)
+ txn = query.one()
+
+ UserVersion = continuum.version_class(model.User)
+ version = self.session.query(UserVersion).one()
+ self.assertIs(version.transaction, txn)
diff --git a/tox.ini b/tox.ini
index 3e2d218..460f86b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -6,6 +6,10 @@ envlist = py38, py39, py310, py311
extras = tests
commands = pytest {posargs}
+[testenv:pylint]
+basepython = python3.11
+commands = pylint wutta_continuum
+
[testenv:coverage]
basepython = python3.11
commands = pytest --cov=wutta_continuum --cov-report=html --cov-fail-under=100