Compare commits

...

24 commits

Author SHA1 Message Date
d95a230848 bump: version 0.3.1 → 0.3.2 2026-02-01 18:28:03 -06:00
665a9684af fix: add compose indexes to version tables, per upstream changes
as of SQLAlchemy-Continuum v1.6.0

https://github.com/sqlalchemy-continuum/sqlalchemy-continuum/releases/tag/1.6.0

but these changes should be safe to add for older sqlalchemy-continuum
as well
2026-02-01 18:25:32 -06:00
e7d6a10751 docs: update docs link in readme 2026-01-05 13:02:38 -06:00
552c03af3e bump: version 0.3.0 → 0.3.1 2025-12-31 19:11:18 -06:00
d22a9963bf fix: set transaction user based on session info, when applicable
combined with `--runas` CLI param, this gives "any" command a way to
assign authorship for versioning
2025-12-29 10:41:09 -06:00
1fc280eff1 bump: version 0.2.2 → 0.3.0 2025-12-20 20:09:59 -06:00
b19f565aa1 feat: add TransactionMetaPlugin to save comments when applicable 2025-12-18 23:02:08 -06:00
09e4ef6a1e docs: add basic feature, usage docs 2025-12-18 20:57:46 -06:00
b858ba1793 bump: version 0.2.1 → 0.2.2 2025-10-29 19:47:34 -05:00
2c57d8ba50 fix: exclude user password from continuum versioning 2025-10-29 19:47:13 -05:00
53b35f1f55 bump: version 0.2.0 → 0.2.1 2025-10-29 17:11:12 -05:00
4db3fa5962 fix: add util module, w/ model_transaction_query()
just a basic implementation, will have to improve later
2025-10-29 14:02:06 -05:00
0e25cca0ba fix: refactor some more for tests + pylint 2025-08-31 18:39:43 -05:00
b89684cfc0 docs: add badge for black code style 2025-08-31 13:29:33 -05:00
1b01df79e2 fix: format all code with black
and from now on should not deviate from that...
2025-08-31 12:45:58 -05:00
e20ef31ff7 docs: update intersphinx doc links per server migration 2025-02-18 12:13:48 -06:00
39dc66df40 bump: version 0.1.1 → 0.2.0 2024-12-07 23:48:31 -06:00
6baca8c6b1 feat: convert all uuid fields from str to proper UUID
ugh had to rewrite alembic migrations instead of just adding a new
one..  will be good to be past this hiccup
2024-12-07 23:48:16 -06:00
bec16f4de2 docs: update author, url info 2024-12-05 08:38:09 -06:00
77d1b58160 docs: fix project description 2024-12-05 08:37:55 -06:00
Lance Edgar
94a554ebc9 fix: add User.prevent_edit to schema 2024-11-24 17:40:42 -06:00
Lance Edgar
2a6dbfacd3 bump: version 0.1.0 → 0.1.1 2024-08-27 21:23:18 -05:00
Lance Edgar
7dd4eb587b fix: fix nullable flags for initial version tables
hm gonna have to get to the bottom of this
2024-08-27 21:22:54 -05:00
Lance Edgar
8e78cd3253 build: add release task 2024-08-27 19:25:58 -05:00
27 changed files with 1082 additions and 191 deletions

4
.pylintrc Normal file
View file

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

View file

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

View file

@ -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/

View file

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

View file

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

View file

@ -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"]

View file

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

26
docs/narr/features.rst Normal file
View file

@ -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`.

View file

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

41
docs/narr/usage.rst Normal file
View file

@ -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`.

View file

@ -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"

View file

@ -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__

View file

@ -1,6 +1,9 @@
# -*- 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 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
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

24
tasks.py Normal file
View file

@ -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/*")

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,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")

40
tests/test_util.py Normal file
View file

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

View file

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