diff --git a/docs/api/wuttjamaican/db.model.upgrades.rst b/docs/api/wuttjamaican/db.model.upgrades.rst
new file mode 100644
index 0000000..f89fcf2
--- /dev/null
+++ b/docs/api/wuttjamaican/db.model.upgrades.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.db.model.upgrades``
+==================================
+
+.. automodule:: wuttjamaican.db.model.upgrades
+ :members:
diff --git a/docs/api/wuttjamaican/enum.rst b/docs/api/wuttjamaican/enum.rst
new file mode 100644
index 0000000..12b0081
--- /dev/null
+++ b/docs/api/wuttjamaican/enum.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.enum``
+=====================
+
+.. automodule:: wuttjamaican.enum
+ :members:
diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst
index 43b1642..70de342 100644
--- a/docs/api/wuttjamaican/index.rst
+++ b/docs/api/wuttjamaican/index.rst
@@ -15,7 +15,9 @@
db.model
db.model.auth
db.model.base
+ db.model.upgrades
db.sess
+ enum
exc
people
testing
diff --git a/docs/conf.py b/docs/conf.py
index baf9505..23fc2cf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -22,6 +22,7 @@ extensions = [
'sphinxcontrib.programoutput',
'sphinx.ext.viewcode',
'sphinx.ext.todo',
+ 'enum_tools.autoenum',
]
templates_path = ['_templates']
diff --git a/docs/glossary.rst b/docs/glossary.rst
index c9b2f94..fa581df 100644
--- a/docs/glossary.rst
+++ b/docs/glossary.rst
@@ -25,6 +25,11 @@ Glossary
Usually this is named ``app`` and is located at the root of the
virtual environment.
+ app enum
+ Python module whose namespace contains all the "enum" values
+ used by the :term:`app`. Available on the :term:`app handler`
+ as :attr:`~wuttjamaican.app.AppHandler.enum`.
+
app handler
Python object representing the core :term:`handler` for the
:term:`app`. There is normally just one "global" app handler;
diff --git a/pyproject.toml b/pyproject.toml
index dab36b5..01fa468 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,8 +32,8 @@ dependencies = [
[project.optional-dependencies]
-db = ["SQLAlchemy<2", "alembic", "passlib"]
-docs = ["Sphinx", "sphinxcontrib-programoutput", "furo"]
+db = ["SQLAlchemy<2", "alembic", "alembic-postgresql-enum", "passlib"]
+docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"]
tests = ["pytest-cov", "tox"]
diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py
index 0d1eb77..35cb332 100644
--- a/src/wuttjamaican/app.py
+++ b/src/wuttjamaican/app.py
@@ -59,6 +59,16 @@ class AppHandler:
need to call :meth:`get_model()` yourself - that part will
happen automatically.
+ .. attribute:: enum
+
+ Reference to the :term:`app enum` module.
+
+ Note that :meth:`get_enum()` is responsible for determining
+ which module this will point to. However you can always get
+ the model using this attribute (e.g. ``app.enum``) and do not
+ need to call :meth:`get_enum()` yourself - that part will
+ happen automatically.
+
.. attribute:: providers
Dictionary of :class:`AppProvider` instances, as returned by
@@ -66,6 +76,7 @@ class AppHandler:
"""
default_app_title = "WuttJamaican"
default_model_spec = 'wuttjamaican.db.model'
+ default_enum_spec = 'wuttjamaican.enum'
default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler'
default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
@@ -103,6 +114,9 @@ class AppHandler:
if name == 'model':
return self.get_model()
+ if name == 'enum':
+ return self.get_enum()
+
if name == 'providers':
self.providers = self.get_all_providers()
return self.providers
@@ -298,6 +312,30 @@ class AppHandler:
self.model = importlib.import_module(spec)
return self.model
+ def get_enum(self):
+ """
+ Returns the :term:`app enum` module.
+
+ Note that you don't actually need to call this method; you can
+ get the module by simply accessing :attr:`enum`
+ (e.g. ``app.enum``) instead.
+
+ By default this will return :mod:`wuttjamaican.enum` unless
+ the config class or some :term:`config extension` has provided
+ another default.
+
+ A custom app can override the default like so (within a config
+ extension)::
+
+ config.setdefault('wutta.enum_spec', 'poser.enum')
+ """
+ if 'enum' not in self.__dict__:
+ spec = self.config.get(f'{self.appname}.enum_spec',
+ usedb=False,
+ default=self.default_enum_spec)
+ self.enum = importlib.import_module(spec)
+ return self.enum
+
def load_object(self, spec):
"""
Import and/or load and return the object designated by the
diff --git a/src/wuttjamaican/db/alembic/env.py b/src/wuttjamaican/db/alembic/env.py
index 2bc674c..4a20bd5 100644
--- a/src/wuttjamaican/db/alembic/env.py
+++ b/src/wuttjamaican/db/alembic/env.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8; -*-
+import alembic_postgresql_enum
from alembic import context
from wuttjamaican.conf import make_config
diff --git a/src/wuttjamaican/db/alembic/versions/ebd75b9feaa7_add_upgrades.py b/src/wuttjamaican/db/alembic/versions/ebd75b9feaa7_add_upgrades.py
new file mode 100644
index 0000000..1ccbd66
--- /dev/null
+++ b/src/wuttjamaican/db/alembic/versions/ebd75b9feaa7_add_upgrades.py
@@ -0,0 +1,46 @@
+"""add upgrades
+
+Revision ID: ebd75b9feaa7
+Revises: 3abcc44f7f91
+Create Date: 2024-08-24 09:42:21.199679
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = 'ebd75b9feaa7'
+down_revision: Union[str, None] = '3abcc44f7f91'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # upgrade
+ sa.Enum('PENDING', 'EXECUTING', 'SUCCESS', 'FAILURE', name='upgradestatus').create(op.get_bind())
+ op.create_table('upgrade',
+ sa.Column('uuid', sa.String(length=32), nullable=False),
+ sa.Column('created', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('created_by_uuid', sa.String(length=32), nullable=False),
+ sa.Column('description', sa.String(length=255), nullable=False),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('executing', sa.Boolean(), nullable=False),
+ sa.Column('status', postgresql.ENUM('PENDING', 'EXECUTING', 'SUCCESS', 'FAILURE', name='upgradestatus', create_type=False), nullable=False),
+ sa.Column('executed', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('executed_by_uuid', sa.String(length=32), nullable=True),
+ sa.Column('exit_code', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_upgrade_created_by_uuid_user')),
+ sa.ForeignKeyConstraint(['executed_by_uuid'], ['user.uuid'], name=op.f('fk_upgrade_executed_by_uuid_user')),
+ sa.PrimaryKeyConstraint('uuid', name=op.f('pk_upgrade'))
+ )
+
+
+def downgrade() -> None:
+
+ # upgrade
+ op.drop_table('upgrade')
+ sa.Enum('PENDING', 'EXECUTING', 'SUCCESS', 'FAILURE', name='upgradestatus').drop(op.get_bind())
diff --git a/src/wuttjamaican/db/model/__init__.py b/src/wuttjamaican/db/model/__init__.py
index 760e3a6..267738c 100644
--- a/src/wuttjamaican/db/model/__init__.py
+++ b/src/wuttjamaican/db/model/__init__.py
@@ -36,7 +36,9 @@ The ``wuttjamaican.db.model`` namespace contains the following:
* :class:`~wuttjamaican.db.model.auth.Permission`
* :class:`~wuttjamaican.db.model.auth.User`
* :class:`~wuttjamaican.db.model.auth.UserRole`
+* :class:`~wuttjamaican.db.model.upgrades.Upgrade`
"""
from .base import uuid_column, uuid_fk_column, Base, Setting, Person
from .auth import Role, Permission, User, UserRole
+from .upgrades import Upgrade
diff --git a/src/wuttjamaican/db/model/upgrades.py b/src/wuttjamaican/db/model/upgrades.py
new file mode 100644
index 0000000..c8f3666
--- /dev/null
+++ b/src/wuttjamaican/db/model/upgrades.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttJamaican -- Base package for Wutta Framework
+# Copyright © 2023-2024 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 .
+#
+################################################################################
+"""
+Upgrade Model
+"""
+
+import datetime
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from .base import Base, uuid_column, uuid_fk_column
+from wuttjamaican.enum import UpgradeStatus
+
+
+class Upgrade(Base):
+ """
+ Represents an app upgrade.
+ """
+ __tablename__ = 'upgrade'
+
+ uuid = uuid_column()
+
+ created = sa.Column(sa.DateTime(timezone=True), nullable=False,
+ default=datetime.datetime.now, doc="""
+ When the upgrade record was created.
+ """)
+
+ created_by_uuid = uuid_fk_column('user.uuid', nullable=False)
+ created_by = orm.relationship(
+ 'User',
+ foreign_keys=[created_by_uuid],
+ doc="""
+ :class:`~wuttjamaican.db.model.auth.User` who created the
+ upgrade record.
+ """)
+
+ description = sa.Column(sa.String(length=255), nullable=False, doc="""
+ Basic (identifying) description for the upgrade.
+ """)
+
+ notes = sa.Column(sa.Text(), nullable=True, doc="""
+ Notes for the upgrade.
+ """)
+
+ executing = sa.Column(sa.Boolean(), nullable=False, default=False, doc="""
+ Whether or not the upgrade is currently being performed.
+ """)
+
+ status = sa.Column(sa.Enum(UpgradeStatus), nullable=False, doc="""
+ Current status for the upgrade. This field uses an enum,
+ :class:`~wuttjamaican.enum.UpgradeStatus`.
+ """)
+
+ executed = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
+ When the upgrade was executed.
+ """)
+
+ executed_by_uuid = uuid_fk_column('user.uuid', nullable=True)
+ executed_by = orm.relationship(
+ 'User',
+ foreign_keys=[executed_by_uuid],
+ doc="""
+ :class:`~wuttjamaican.db.model.auth.User` who executed the
+ upgrade.
+ """)
+
+ exit_code = sa.Column(sa.Integer(), nullable=True, doc="""
+ Exit code for the upgrade execution process, if applicable.
+ """)
+
+ def __str__(self):
+ return str(self.description or "")
diff --git a/src/wuttjamaican/enum.py b/src/wuttjamaican/enum.py
new file mode 100644
index 0000000..3265745
--- /dev/null
+++ b/src/wuttjamaican/enum.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttJamaican -- Base package for Wutta Framework
+# Copyright © 2023-2024 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 .
+#
+################################################################################
+"""
+Enum Values
+"""
+
+from enum import Enum
+
+
+class UpgradeStatus(Enum):
+ """
+ Enum values for
+ :attr:`wuttjamaican.db.model.upgrades.Upgrade.status`.
+ """
+ PENDING = 'pending'
+ EXECUTING = 'executing'
+ SUCCESS = 'success'
+ FAILURE = 'failure'
diff --git a/tests/db/model/test_upgrades.py b/tests/db/model/test_upgrades.py
new file mode 100644
index 0000000..c40a193
--- /dev/null
+++ b/tests/db/model/test_upgrades.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+
+try:
+ from wuttjamaican.db.model import upgrades as mod
+except ImportError:
+ pass
+else:
+
+ class TestUpgrade(TestCase):
+
+ def test_str(self):
+ upgrade = mod.Upgrade(description="upgrade foo")
+ self.assertEqual(str(upgrade), "upgrade foo")
diff --git a/tests/test_app.py b/tests/test_app.py
index 35ec466..2d9d716 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
import pytest
+import wuttjamaican.enum
from wuttjamaican import app
from wuttjamaican.conf import WuttaConfig
from wuttjamaican.util import UNSPECIFIED
@@ -27,6 +28,9 @@ class TestAppHandler(TestCase):
self.assertEqual(self.app.handlers, {})
self.assertEqual(self.app.appname, 'wuttatest')
+ def test_get_enum(self):
+ self.assertIs(self.app.get_enum(), wuttjamaican.enum)
+
def test_load_object(self):
# just confirm the method works on a basic level; the
@@ -403,6 +407,12 @@ class TestAppProvider(TestCase):
def test_getattr(self):
+ # enum
+ self.assertNotIn('enum', self.app.__dict__)
+ self.assertIs(self.app.enum, wuttjamaican.enum)
+
+ # now we test that providers are loaded...
+
class FakeProvider(app.AppProvider):
def fake_foo(self):
return 42
@@ -417,6 +427,16 @@ class TestAppProvider(TestCase):
self.assertIs(self.app.providers, fake_providers)
get_all_providers.assert_called_once_with()
+ def test_getattr_model(self):
+ try:
+ import wuttjamaican.db.model
+ except ImportError:
+ pytest.skip("test not relevant without sqlalchemy")
+
+ # model
+ self.assertNotIn('model', self.app.__dict__)
+ self.assertIs(self.app.model, wuttjamaican.db.model)
+
def test_getattr_providers(self):
# collection of providers is loaded on demand