Compare commits

...

16 commits

Author SHA1 Message Date
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
22 changed files with 747 additions and 167 deletions

4
.pylintrc Normal file
View file

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

View file

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

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

@ -9,6 +9,12 @@ This package adds data versioning/history for `WuttJamaican`_, using
.. _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
@ -23,3 +29,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

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.2.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.24.1",
]
[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,7 +24,6 @@
App Configuration
"""
import datetime
import socket
from sqlalchemy.orm import configure_mappers
@ -42,29 +41,57 @@ 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`.
"""
# 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=[plugin()])
# must load model *between* prev and next calls
app.get_model()
# let sqlalchemy do its thing
configure_mappers()
@ -95,24 +122,29 @@ class WuttaContinuumPlugin(Plugin):
:doc:`sqlalchemy-continuum:plugins`.
"""
def get_remote_addr(self, uow, session):
def get_remote_addr( # pylint: disable=empty-docstring,unused-argument
self, uow, session
):
""" """
host = socket.gethostname()
return socket.gethostbyname(host)
def get_user_id(self, uow, session):
def get_user_id( # pylint: disable=empty-docstring,unused-argument
self, uow, session
):
""" """
return None
def transaction_args(self, uow, session):
def transaction_args(self, uow, session): # pylint: disable=empty-docstring
""" """
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

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

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

@ -4,33 +4,45 @@ import socket
from unittest.mock import patch
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,8 +51,8 @@ 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):
plugin = self.make_plugin()
@ -48,11 +60,14 @@ class TestWuttaContinuumPlugin(DataTestCase):
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"},
)

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