diff --git a/migrations/versions/938218f911e8_.py b/migrations/versions/938218f911e8_.py new file mode 100644 index 0000000..c741f9a --- /dev/null +++ b/migrations/versions/938218f911e8_.py @@ -0,0 +1,108 @@ +"""empty message + +Revision ID: 938218f911e8 +Revises: +Create Date: 2022-07-12 06:31:18.835124 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '938218f911e8' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('access_keys', + sa.Column('key', sa.String(length=64), nullable=False), + sa.Column('clientId', sa.String(length=64), nullable=False), + sa.Column('clientIP', sa.String(length=46), nullable=False), + sa.Column('userAgent', sa.String(length=255), nullable=False), + sa.Column('suspended', sa.Boolean(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.Column('createdAt', sa.DateTime(), nullable=True), + sa.Column('suspendedAt', sa.DateTime(), nullable=True), + sa.Column('revokedAt', sa.DateTime(), nullable=True), + sa.Column('restoredAt', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('key') + ) + op.create_table('access_tokens', + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('clientIP', sa.String(length=46), nullable=False), + sa.Column('userAgent', sa.String(length=255), nullable=False), + sa.Column('expired', sa.Boolean(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.Column('issuedAt', sa.DateTime(), nullable=True), + sa.Column('expiresAt', sa.DateTime(), nullable=True), + sa.Column('revokedAt', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('token') + ) + op.create_table('lists', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('accessKey', sa.String(length=64), nullable=True), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('createdAt', sa.DateTime(), nullable=True), + sa.Column('modifiedAt', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('products', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('accessKey', sa.String(length=64), nullable=True), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('category', sa.String(length=128), nullable=False), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('createdAt', sa.DateTime(), nullable=True), + sa.Column('modifiedAt', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_products_category'), 'products', ['category'], unique=False) + op.create_table('sigl_config', + sa.Column('key', sa.String(), nullable=False), + sa.Column('value', sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint('key') + ) + op.create_table('list_entries', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('list_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.String(length=128), nullable=True), + sa.Column('crossedOff', sa.Boolean(), nullable=True), + sa.Column('deleted', sa.Boolean(), nullable=True), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('createdAt', sa.DateTime(), nullable=True), + sa.Column('modifiedAt', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('product_locations', + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('store', sa.String(length=128), nullable=False), + sa.Column('aisle', sa.String(length=64), nullable=False), + sa.Column('bin', sa.String(length=64), nullable=True), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('createdAt', sa.DateTime(), nullable=True), + sa.Column('modifiedAt', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.PrimaryKeyConstraint('product_id', 'store') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('product_locations') + op.drop_table('list_entries') + op.drop_table('sigl_config') + op.drop_index(op.f('ix_products_category'), table_name='products') + op.drop_table('products') + op.drop_table('lists') + op.drop_table('access_tokens') + op.drop_table('access_keys') + # ### end Alembic commands ### diff --git a/migrations/versions/b3639db87e06_.py b/migrations/versions/b3639db87e06_.py deleted file mode 100644 index 9b99bdd..0000000 --- a/migrations/versions/b3639db87e06_.py +++ /dev/null @@ -1,56 +0,0 @@ -"""empty message - -Revision ID: b3639db87e06 -Revises: -Create Date: 2022-07-11 17:09:20.502213 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'b3639db87e06' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('products', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=128), nullable=False), - sa.Column('category', sa.String(length=128), nullable=False), - sa.Column('notes', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('modified_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_products_category'), 'products', ['category'], unique=False) - op.create_table('sigl_config', - sa.Column('key', sa.String(), nullable=False), - sa.Column('value', sa.String(length=128), nullable=True), - sa.PrimaryKeyConstraint('key') - ) - op.create_table('product_locations', - sa.Column('product_id', sa.Integer(), nullable=False), - sa.Column('store', sa.String(length=128), nullable=False), - sa.Column('aisle', sa.String(length=64), nullable=False), - sa.Column('bin', sa.String(length=64), nullable=True), - sa.Column('notes', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('modified_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), - sa.PrimaryKeyConstraint('product_id', 'store') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('product_locations') - op.drop_table('sigl_config') - op.drop_index(op.f('ix_products_category'), table_name='products') - op.drop_table('products') - # ### end Alembic commands ### diff --git a/sigl/database/tables.py b/sigl/database/tables.py index e0fdf0d..a5493c2 100644 --- a/sigl/database/tables.py +++ b/sigl/database/tables.py @@ -15,6 +15,91 @@ sigl_config = db.Table( db.Column('value', db.String(128)), ) +#: Access Key Table +access_keys = db.Table( + 'access_keys', + + # Primary Key + db.Column('key', db.String(64), primary_key=True), + + # Client Attributes + db.Column('clientId', db.String(64), nullable=False), + db.Column('clientIP', db.String(46), nullable=False), + db.Column('userAgent', db.String(255), nullable=False), + + # Key Validity + db.Column('suspended', db.Boolean, nullable=False, default=False), + db.Column('revoked', db.Boolean, nullable=False, default=False), + + # Timestamps + db.Column('createdAt', db.DateTime(), default=None), + db.Column('suspendedAt', db.DateTime(), default=None), + db.Column('revokedAt', db.DateTime(), default=None), + db.Column('restoredAt', db.DateTime(), default=None), +) + +#: Access Token Table +access_tokens = db.Table( + 'access_tokens', + + # Primary Key + db.Column('token', db.String(64), primary_key=True), + + # Client Attributes + db.Column('clientIP', db.String(46), nullable=False), + db.Column('userAgent', db.String(255), nullable=False), + + # Key Validity + db.Column('expired', db.Boolean, nullable=False, default=False), + db.Column('revoked', db.Boolean, nullable=False, default=False), + + # Timestamps + db.Column('issuedAt', db.DateTime(), default=None), + db.Column('expiresAt', db.DateTime(), default=None), + db.Column('revokedAt', db.DateTime(), default=None), +) + +#: Shopping List Table +lists = db.Table( + 'lists', + + # Primary Key + db.Column('id', db.Integer, primary_key=True), + + # Access Key + db.Column('accessKey', db.String(64), default=None), + + # List Attributes + db.Column('name', db.String(128), nullable=False), + + # Mixin Columns + db.Column('notes', db.String(), default=None), + db.Column('createdAt', db.DateTime(), default=None), + db.Column('modifiedAt', db.DateTime(), default=None), +) + +#: List Entry Table +list_entries = db.Table( + 'list_entries', + + # Primary Key + db.Column('id', db.Integer, primary_key=True), + + # Shopping List and Product Link + db.Column('list_id', db.ForeignKey('lists.id'), nullable=False), + db.Column('product_id', db.ForeignKey('products.id'), nullable=False), + + # Entry Attributes + db.Column('quantity', db.String(128), default=None), + db.Column('crossedOff', db.Boolean, default=False), + db.Column('deleted', db.Boolean, default=False), + + # Mixin Columns + db.Column('notes', db.String(), default=None), + db.Column('createdAt', db.DateTime(), default=None), + db.Column('modifiedAt', db.DateTime(), default=None), +) + #: Product Table products = db.Table( 'products', @@ -22,14 +107,17 @@ products = db.Table( # Primary Key db.Column('id', db.Integer, primary_key=True), + # Access Key + db.Column('accessKey', db.String(64), default=None), + # Product Attributes db.Column('name', db.String(128), nullable=False), db.Column('category', db.String(128), nullable=False, index=True), # Mixin Columns db.Column('notes', db.String(), default=None), - db.Column('created_at', db.DateTime(), default=None), - db.Column('modified_at', db.DateTime(), default=None), + db.Column('createdAt', db.DateTime(), default=None), + db.Column('modifiedAt', db.DateTime(), default=None), ) #: Product Location Table @@ -51,6 +139,6 @@ product_locations = db.Table( # Mixin Columns db.Column('notes', db.String(), default=None), - db.Column('created_at', db.DateTime(), default=None), - db.Column('modified_at', db.DateTime(), default=None), + db.Column('createdAt', db.DateTime(), default=None), + db.Column('modifiedAt', db.DateTime(), default=None), ) diff --git a/sigl/domain/models/accessKey.py b/sigl/domain/models/accessKey.py new file mode 100644 index 0000000..144c3e5 --- /dev/null +++ b/sigl/domain/models/accessKey.py @@ -0,0 +1,67 @@ +"""Sigl Access Key Model. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from dataclasses import dataclass +from datetime import datetime + +__all__ = ('AccessKey', 'AccessToken') + + +@dataclass +class AccessKey: + """Sigl Access Key Class. + + The Access Key represents a client or group of clients authorized to + interact with their specific Sigl shopping lists and products. Access Keys + are generally not revoked or deleted and remain for the lifetime of the + server. + + When an Access Key is created, client details including the IP address and + User Agent string are logged for future auditing purposes. The IP address + may also be used for throttling. + """ + key: str = None + clientId: str = None + clientIP: str = None + userAgent: str = None + + # Key Suspension/Revocation + suspended: bool = False + revoked: bool = False + + # Timestamps + createdAt: datetime = datetime.utcnow() + suspendedAt: datetime = None + revokedAt: datetime = None + restoredAt: datetime = None + + +@dataclass +class AccessToken: + """Sigl Access Token Class. + + The Access Token represents authorization for a client (identified by an + Access Key) to interact with the Sigl server. The token string, issue time, + and expiry time must match the client-provided JWT for access to be granted. + Access Tokens are short-lived tokens expected to expire or be revoked, and + new tokens requested by the client. + + When an Access Key is created, client details including the IP address and + User Agent string are logged for future auditing purposes. + """ + token: str = None + accessKey: str = None + clientIP: str = None + userAgent: str = None + + # Key Suspension/Revocation + expired: bool = False + revoked: bool = False + + # Timestamps + issuedAt: datetime = datetime.utcnow() + expiresAt: datetime = None + revokedAt: datetime = None diff --git a/sigl/domain/models/list.py b/sigl/domain/models/list.py new file mode 100644 index 0000000..0b52e55 --- /dev/null +++ b/sigl/domain/models/list.py @@ -0,0 +1,44 @@ +"""Sigl Shopping List Domain Model. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from dataclasses import dataclass +from typing import List + +from .mixins import NotesMixin, TimestampMixin +from .product import Product + +__all__ = ('ShoppingList', 'ListEntry') + + +@dataclass +class ListEntry(NotesMixin, TimestampMixin): + """Information about a Product on a Shopping List. + + This class contains information about a Product that is on a shopping + list, including the quantity to be purchased, notes about the entry, and + whether the Product has been crossed off the list or deleted. + """ + quantity: str = None + crossedOff: bool = False + deleted: bool = False + + # Relationship Fields + shoppingList: 'ShoppingList' = None + product: Product = None + + +@dataclass +class ShoppingList(NotesMixin, TimestampMixin): + """Top-Level Shopping List. + + Contains a collection of `ListEntry` items which are intended to be + purchased. + """ + accessKey: str = None + name: str = None + + # Populated by ORM + # entries: List[ListEntry] = None diff --git a/sigl/domain/models/mixins.py b/sigl/domain/models/mixins.py index 89d54e5..ce55715 100644 --- a/sigl/domain/models/mixins.py +++ b/sigl/domain/models/mixins.py @@ -34,8 +34,8 @@ class TimestampMixin: Adds a ``modified_at`` column to the database object with type :py:class:`datetime.datetime` ''' - created_at: datetime = datetime.utcnow() - modified_at: datetime = None + createdAt: datetime = datetime.utcnow() + modifiedAt: datetime = None def set_modified_at(self): - self.modified_at = datetime.utcnow() + self.modifiedAt = datetime.utcnow() diff --git a/sigl/domain/models/product.py b/sigl/domain/models/product.py index 78e0643..8b716cf 100644 --- a/sigl/domain/models/product.py +++ b/sigl/domain/models/product.py @@ -23,6 +23,9 @@ class Product(NotesMixin, TimestampMixin): name: str = None category: str = None + # Access Control + accessKey: str = None + # Populated by ORM # locations: List['ProductLocation'] = []