Compare commits

..

No commits in common. "master" and "v0.1.1" have entirely different histories.

26 changed files with 196 additions and 945 deletions

View file

@ -1,4 +0,0 @@
# -*- mode: conf; -*-
[MESSAGES CONTROL]
disable=fixme

View file

@ -5,42 +5,6 @@ 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.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

View file

@ -1,6 +1,6 @@
# Wutta-Continuum
SQLAlchemy-Continuum versioning for Wutta Framework
SQLAlchemy-Continuum versioning for WuttJamaican
See docs at https://docs.wuttaproject.org/wutta-continuum/
See docs at https://rattailproject.org/docs/wutta-continuum/

View file

@ -1,6 +0,0 @@
``wutta_continuum.testing``
===========================
.. automodule:: wutta_continuum.testing
:members:

View file

@ -1,6 +0,0 @@
``wutta_continuum.util``
========================
.. automodule:: wutta_continuum.util
:members:

View file

@ -8,36 +8,32 @@
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": ("http://docs.sqlalchemy.org/en/latest/", None),
"sqlalchemy-continuum": (
"https://sqlalchemy-continuum.readthedocs.io/en/latest/",
None,
),
"wuttjamaican": ("https://docs.wuttaproject.org/wuttjamaican/", None),
'sqlalchemy-continuum': ('https://sqlalchemy-continuum.readthedocs.io/en/latest/', None),
'wuttjamaican': ('https://rattailproject.org/docs/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']

View file

@ -5,24 +5,16 @@ Wutta-Continuum
This package adds data versioning/history for `WuttJamaican`_, using
`SQLAlchemy-Continuum`_.
.. _WuttJamaican: https://docs.wuttaproject.org/wuttjamaican/
.. _WuttJamaican: https://rattailproject.org/docs/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
@ -31,5 +23,3 @@ 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

View file

@ -1,26 +0,0 @@
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`.

View file

@ -31,7 +31,7 @@ this package for database migrations. You should already have an
[alembic]
script_location = wuttjamaican.db:alembic
version_locations = wutta_continuum.db:alembic/versions poser.db:alembic/versions wuttjamaican.db:alembic/versions
version_locations = wuttjamaican.db:alembic/versions wutta_continuum.db:alembic/versions
Then (as you would have done previously in
:ref:`wuttjamaican:db-setup`) you can migrate your database to add the

View file

@ -1,41 +0,0 @@
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`.

View file

@ -6,10 +6,10 @@ build-backend = "hatchling.build"
[project]
name = "Wutta-Continuum"
version = "0.3.1"
description = "SQLAlchemy-Continuum versioning for Wutta Framework"
version = "0.1.1"
description = "SQLAlchemy-Continuum versioning for WuttJamaican"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
authors = [{name = "Lance Edgar", email = "lance@edbob.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]>=0.27.0",
"WuttJamaican[db]",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo"]
tests = ["pylint", "pytest", "pytest-cov", "tox"]
tests = ["pytest-cov", "tox"]
[project.entry-points."wutta.app.providers"]
@ -47,7 +47,6 @@ 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"

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
# Wutta-Continuum -- SQLAlchemy Versioning for WuttJamaican
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
@ -21,7 +21,7 @@
#
################################################################################
"""
Wutta-Continuum -- SQLAlchemy-Continuum versioning for Wutta Framework
Wutta-Continuum -- SQLAlchemy-Continuum versioning for WuttJamaican
"""
from ._version import __version__

View file

@ -1,9 +1,6 @@
# -*- coding: utf-8; -*-
"""
Package Version
"""
from importlib.metadata import version
__version__ = version("Wutta-Continuum")
__version__ = version('Wutta-Continuum')

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
# Wutta-Continuum -- SQLAlchemy Versioning for WuttJamaican
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
@ -40,6 +40,5 @@ 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)

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
# Copyright © 2024-2025 Lance Edgar
# Wutta-Continuum -- SQLAlchemy Versioning for WuttJamaican
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
@ -24,11 +24,12 @@
App Configuration
"""
import datetime
import socket
from sqlalchemy.orm import configure_mappers
from sqlalchemy_continuum import make_versioned
from sqlalchemy_continuum.plugins import Plugin, TransactionMetaPlugin
from sqlalchemy_continuum.plugins import Plugin
from wuttjamaican.conf import WuttaConfigExtension
from wuttjamaican.util import load_object
@ -41,74 +42,29 @@ 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'
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`.
"""
def startup(self, config):
""" """
# 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",
)
plugin = load_object(spec)
spec = config.get('wutta_continuum.wutta_plugin_spec',
usedb=False,
default='wutta_continuum.conf:WuttaContinuumPlugin')
WuttaPlugin = 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()
if "model" in app.__dict__:
raise RuntimeError("something not right, app already has model")
model = app.model
# 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
# tell sqlalchemy to do its thing
configure_mappers()
@ -116,85 +72,47 @@ class WuttaContinuumPlugin(Plugin):
"""
SQLAlchemy-Continuum manager plugin for Wutta-Continuum.
This is the default plugin used within
:meth:`~WuttaContinuumConfigExtension.startup()` unless config
overrides.
This tries to assign the current user and IP address to the
transaction.
This tries to establish the user and IP address responsible, and
comment if applicable, for the current transaction.
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
See also the SQLAlchemy-Continuum docs for
:doc:`sqlalchemy-continuum:plugins`.
"""
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
"""
def get_remote_addr(self, uow, session):
""" """
host = socket.gethostname()
return socket.gethostbyname(host)
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 get_user_id(self, uow, session):
""" """
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) # pylint: disable=assignment-from-none
user_id = self.get_user_id(uow, session)
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

View file

@ -1,34 +0,0 @@
"""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")

View file

@ -1,40 +0,0 @@
"""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")

View file

@ -1,296 +1,142 @@
"""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", 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
)
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)
# person
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,
)
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=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", 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,
)
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=True),
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=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", 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,
)
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=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", 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,
)
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=True),
sa.Column('role_uuid', sa.String(length=32), 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",
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,
)
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)
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')

View file

@ -1,37 +0,0 @@
"""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
),
)

View file

@ -1,92 +0,0 @@
# -*- 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -1,85 +0,0 @@
# -*- 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -15,10 +15,10 @@ def release(c, skip_tests=False):
Release a new version of Wutta-Continuum
"""
if not skip_tests:
c.run("pytest")
c.run('pytest')
if os.path.exists("dist"):
shutil.rmtree("dist")
if os.path.exists('dist'):
shutil.rmtree('dist')
c.run("python -m build --sdist")
c.run("twine upload dist/*")
c.run('python -m build --sdist')
c.run('twine upload dist/*')

View file

@ -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())

View file

@ -2,47 +2,35 @@
import socket
from unittest.mock import patch, Mock
from unittest.mock import patch
from wuttjamaican.testing import ConfigTestCase, DataTestCase
from wuttjamaican.testing import DataTestCase
from wutta_continuum import conf as mod
class TestWuttaContinuumConfigExtension(ConfigTestCase):
class TestWuttaContinuumConfigExtension(DataTestCase):
def make_extension(self):
return mod.WuttaContinuumConfigExtension()
def test_startup_without_versioning(self):
def test_startup(self):
ext = self.make_extension()
with patch.object(mod, "make_versioned") as make_versioned:
with patch.object(mod, "configure_mappers") as configure_mappers:
with patch.object(mod, 'make_versioned') as make_versioned:
with patch.object(mod, 'configure_mappers') as configure_mappers:
# nothing happens by default
ext.startup(self.config)
make_versioned.assert_not_called()
configure_mappers.assert_not_called()
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")
# but will if we enable it in config
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):
@ -51,51 +39,20 @@ 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"},
)
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")
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'})

View file

@ -1,40 +0,0 @@
# -*- 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)

View file

@ -6,10 +6,6 @@ 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