diff --git a/src/wuttjamaican/db/alembic/versions/d686f7abe3e0_add_users_roles.py b/src/wuttjamaican/db/alembic/versions/d686f7abe3e0_add_users_roles.py new file mode 100644 index 0000000..ffacd30 --- /dev/null +++ b/src/wuttjamaican/db/alembic/versions/d686f7abe3e0_add_users_roles.py @@ -0,0 +1,73 @@ +"""add users, roles + +Revision ID: d686f7abe3e0 +Revises: fc3a3bcaa069 +Create Date: 2024-07-14 13:27:22.703093 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd686f7abe3e0' +down_revision: Union[str, None] = 'fc3a3bcaa069' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # role + op.create_table('role', + sa.Column('uuid', sa.String(length=32), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('name', name=op.f('uq_role_name')) + ) + + # user + op.create_table('user', + sa.Column('uuid', sa.String(length=32), nullable=False), + sa.Column('username', sa.String(length=25), nullable=False), + sa.Column('password', sa.String(length=60), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('username', name=op.f('uq_user_username')) + ) + + # permission + op.create_table('permission', + sa.Column('role_uuid', sa.String(length=32), nullable=False), + sa.Column('permission', sa.String(length=254), nullable=False), + sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'], name=op.f('fk_permission_role_uuid_role')), + sa.PrimaryKeyConstraint('role_uuid', 'permission') + ) + + # user_x_role + op.create_table('user_x_role', + sa.Column('uuid', sa.String(length=32), nullable=False), + sa.Column('user_uuid', sa.String(length=32), nullable=False), + sa.Column('role_uuid', sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'], name=op.f('fk_user_x_role_role_uuid_role')), + sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], name=op.f('fk_user_x_role_user_uuid_user')), + sa.PrimaryKeyConstraint('uuid') + ) + + +def downgrade() -> None: + + # user_x_role + op.drop_table('user_x_role') + + # permission + op.drop_table('permission') + + # user + op.drop_table('user') + + # role + op.drop_table('role') diff --git a/src/wuttjamaican/db/model/auth.py b/src/wuttjamaican/db/model/auth.py index 297e891..57360b3 100644 --- a/src/wuttjamaican/db/model/auth.py +++ b/src/wuttjamaican/db/model/auth.py @@ -65,16 +65,10 @@ class Role(Base): See also :attr:`user_refs`. """ __tablename__ = 'role' - __table_args__ = ( - sa.UniqueConstraint('name', - # TODO - # name='role_uq_name', - ), - ) uuid = uuid_column() - name = sa.Column(sa.String(length=100), nullable=False, doc=""" + name = sa.Column(sa.String(length=100), nullable=False, unique=True, doc=""" Name for the role. Each role must have a name, which must be unique. """) @@ -86,6 +80,8 @@ class Role(Base): permission_refs = orm.relationship( 'Permission', back_populates='role', + # TODO + # cascade='save-update, merge, delete, delete-orphan', doc=""" List of :class:`Permission` references for the role. @@ -127,14 +123,8 @@ class Permission(Base): Represents a permission granted to a role. """ __tablename__ = 'permission' - __table_args__ = ( - sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'], - # TODO - # name='permission_fk_role', - ), - ) - role_uuid = uuid_fk_column(primary_key=True, nullable=False) + role_uuid = uuid_fk_column('role.uuid', primary_key=True, nullable=False) role = orm.relationship( Role, back_populates='permission_refs', @@ -157,18 +147,18 @@ class User(Base): This may or may not correspond to a real person, i.e. some users may exist solely for automated tasks. + + .. attribute:: roles + + List of :class:`Role` instances to which the user belongs. + + See also :attr:`role_refs`. """ __tablename__ = 'user' - __table_args__ = ( - sa.UniqueConstraint('username', - # TODO - # name='user_uq_username', - ), - ) uuid = uuid_column() - username = sa.Column(sa.String(length=25), nullable=False, doc=""" + username = sa.Column(sa.String(length=25), nullable=False, unique=True, doc=""" Account username. This is required and must be unique. """) @@ -186,33 +176,35 @@ class User(Base): role_refs = orm.relationship( 'UserRole', back_populates='user', + # TODO + # cascade='all, delete-orphan', doc=""" - List of :class:`UserRole` records. + List of :class:`UserRole` instances belonging to the user. + + See also :attr:`roles`. """) + roles = association_proxy( + 'role_refs', 'role', + creator=lambda r: UserRole(role=r), + # TODO + # getset_factory=getset_factory, + ) + def __str__(self): return self.username or "" class UserRole(Base): """ - Represents the association between a user and a role. + Represents the association between a user and a role; i.e. the + user "belongs" or "is assigned" to the role. """ __tablename__ = 'user_x_role' - __table_args__ = ( - sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], - # TODO - # name='user_x_role_fk_user', - ), - sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'], - # TODO - # name='user_x_role_fk_role', - ), - ) uuid = uuid_column() - user_uuid = uuid_fk_column(nullable=False) + user_uuid = uuid_fk_column('user.uuid', nullable=False) user = orm.relationship( User, back_populates='role_refs', @@ -220,7 +212,7 @@ class UserRole(Base): Reference to the :class:`User` involved. """) - role_uuid = uuid_fk_column(nullable=False) + role_uuid = uuid_fk_column('role.uuid', nullable=False) role = orm.relationship( Role, back_populates='user_refs', diff --git a/src/wuttjamaican/db/model/base.py b/src/wuttjamaican/db/model/base.py index 5de65ca..d596e8f 100644 --- a/src/wuttjamaican/db/model/base.py +++ b/src/wuttjamaican/db/model/base.py @@ -34,7 +34,19 @@ from sqlalchemy import orm from wuttjamaican.util import make_uuid -Base = orm.declarative_base() +# nb. this convention comes from upstream docs +# https://docs.sqlalchemy.org/en/14/core/constraints.html#constraint-naming-conventions +naming_convention = { + 'ix': 'ix_%(column_0_label)s', + 'uq': 'uq_%(table_name)s_%(column_0_name)s', + 'ck': 'ck_%(table_name)s_%(constraint_name)s', + 'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s', + 'pk': 'pk_%(table_name)s', +} + +metadata = sa.MetaData(naming_convention=naming_convention) + +Base = orm.declarative_base(metadata=metadata) def uuid_column(*args, **kwargs): @@ -47,11 +59,14 @@ def uuid_column(*args, **kwargs): return sa.Column(sa.String(length=32), *args, **kwargs) -def uuid_fk_column(*args, **kwargs): +def uuid_fk_column(target_column, *args, **kwargs): """ Returns a UUID column for use as a foreign key to another table. + + :param target_column: Name of the table column on the remote side, + e.g. ``'user.uuid'``. """ - return sa.Column(sa.String(length=32), *args, **kwargs) + return sa.Column(sa.String(length=32), sa.ForeignKey(target_column), *args, **kwargs) class Setting(Base): diff --git a/tests/db/model/test_base.py b/tests/db/model/test_base.py index aa27702..536d2ef 100644 --- a/tests/db/model/test_base.py +++ b/tests/db/model/test_base.py @@ -20,7 +20,7 @@ else: class TestUUIDFKColumn(TestCase): def test_basic(self): - column = model.uuid_column() + column = model.uuid_fk_column('foo.bar') self.assertIsInstance(column, sa.Column) self.assertIsInstance(column.type, sa.String) self.assertEqual(column.type.length, 32)