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