diff --git a/rattail_demo/commands.py b/rattail_demo/commands.py new file mode 100644 index 0000000..654bdfb --- /dev/null +++ b/rattail_demo/commands.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8; -*- +""" +Rattail Demo Commands +""" + +import sys + +from rattail import commands +from rattail_demo import __version__ + + +def main(*args): + """ + Main entry point for Rattail Demo command system + """ + args = list(args or sys.argv[1:]) + cmd = Command() + cmd.run(*args) + + +class Command(commands.Command): + """ + Main command for Rattail Demo + """ + name = 'rattail-demo' + version = __version__ + description = "Rattail Demo (custom Rattail system)" + long_description = "" + + +class ExportShopfoo(commands.ExportFileSubcommand): + """ + Export data to the Shopfoo system + """ + name = 'export-shopfoo' + description = __doc__.strip() + handler_spec = 'rattail_demo.shopfoo.importing.rattail:FromRattailToShopfoo' + + +class ImportSelf(commands.ImportSubcommand): + """ + Update "cascading" Rattail data based on "core" Rattail data + """ + name = 'import-self' + description = __doc__.strip() + handler_spec = 'rattail_demo.importing.local:FromRattailDemoToSelf' diff --git a/rattail_demo/config.py b/rattail_demo/config.py index 5e2cea8..7815db9 100644 --- a/rattail_demo/config.py +++ b/rattail_demo/config.py @@ -20,4 +20,4 @@ class DemoConfigExtension(ConfigExtension): config.setdefault('tailbone', 'menus', 'rattail_demo.web.menus') # default import handlers - config.setdefault('rattail.importing', 'versions.handler', 'rattail_corepos.importing.versions:FromRattailToRattailVersions') + config.setdefault('rattail.importing', 'versions.handler', 'rattail_demo.importing.versions:FromRattailDemoToRattailDemoVersions') diff --git a/rattail_demo/db/alembic/versions/2108f9efa758_initial_tables.py b/rattail_demo/db/alembic/versions/2108f9efa758_initial_tables.py new file mode 100644 index 0000000..50d434c --- /dev/null +++ b/rattail_demo/db/alembic/versions/2108f9efa758_initial_tables.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""initial tables + +Revision ID: 2108f9efa758 +Revises: efb7cd318947 +Create Date: 2020-08-19 20:02:15.501843 + +""" + +# revision identifiers, used by Alembic. +revision = '2108f9efa758' +down_revision = None +branch_labels = ('rattail_demo',) +depends_on = None + +from alembic import op +import sqlalchemy as sa +import rattail.db.types + + + +def upgrade(): + + # demo_shopfoo_product + op.create_table('demo_shopfoo_product', + sa.Column('uuid', sa.String(length=32), nullable=False), + sa.Column('product_uuid', sa.String(length=32), nullable=True), + sa.Column('upc', sa.String(length=14), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('price', sa.Numeric(precision=13, scale=2), nullable=True), + sa.Column('enabled', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['product_uuid'], ['product.uuid'], name='demo_shopfoo_product_fk_product'), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_table('demo_shopfoo_product_version', + sa.Column('uuid', sa.String(length=32), autoincrement=False, nullable=False), + sa.Column('product_uuid', sa.String(length=32), autoincrement=False, nullable=True), + sa.Column('upc', sa.String(length=14), autoincrement=False, nullable=True), + sa.Column('description', sa.String(length=255), autoincrement=False, nullable=True), + sa.Column('price', sa.Numeric(precision=13, scale=2), autoincrement=False, nullable=True), + sa.Column('enabled', 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') + ) + op.create_index(op.f('ix_demo_shopfoo_product_version_end_transaction_id'), 'demo_shopfoo_product_version', ['end_transaction_id'], unique=False) + op.create_index(op.f('ix_demo_shopfoo_product_version_operation_type'), 'demo_shopfoo_product_version', ['operation_type'], unique=False) + op.create_index(op.f('ix_demo_shopfoo_product_version_transaction_id'), 'demo_shopfoo_product_version', ['transaction_id'], unique=False) + + # demo_shopfoo_product_export + op.create_table('demo_shopfoo_product_export', + sa.Column('uuid', sa.String(length=32), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('created_by_uuid', sa.String(length=32), nullable=False), + sa.Column('record_count', sa.Integer(), nullable=True), + sa.Column('filename', sa.String(length=255), nullable=True), + sa.Column('uploaded', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name='demo_shopfoo_product_export_fk_created_by'), + sa.PrimaryKeyConstraint('uuid') + ) + + +def downgrade(): + + # demo_shopfoo_product_export + op.drop_table('demo_shopfoo_product_export') + + # demo_shopfoo_product + op.drop_index(op.f('ix_demo_shopfoo_product_version_transaction_id'), table_name='demo_shopfoo_product_version') + op.drop_index(op.f('ix_demo_shopfoo_product_version_operation_type'), table_name='demo_shopfoo_product_version') + op.drop_index(op.f('ix_demo_shopfoo_product_version_end_transaction_id'), table_name='demo_shopfoo_product_version') + op.drop_table('demo_shopfoo_product_version') + op.drop_table('demo_shopfoo_product') diff --git a/rattail_demo/db/model.py b/rattail_demo/db/model/__init__.py similarity index 69% rename from rattail_demo/db/model.py rename to rattail_demo/db/model/__init__.py index 947595b..c1974e2 100644 --- a/rattail_demo/db/model.py +++ b/rattail_demo/db/model/__init__.py @@ -8,3 +8,6 @@ from rattail.db.model import * # also bring in CORE integration models from rattail_corepos.db.model import * + +# now bring in Demo-specific models +from .shopfoo import ShopfooProduct, ShopfooProductExport diff --git a/rattail_demo/db/model/shopfoo.py b/rattail_demo/db/model/shopfoo.py new file mode 100644 index 0000000..e44882c --- /dev/null +++ b/rattail_demo/db/model/shopfoo.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8; -*- +""" +Database schema extensions for Shopfoo integration +""" + +import sqlalchemy as sa + +from rattail.db import model +from rattail.db.model.shopfoo import ShopfooProductBase, ShopfooProductExportBase + + +class ShopfooProduct(ShopfooProductBase, model.Base): + """ + Shopfoo-specific product cache table. Each record in this table *should* + match exactly, what is in the actual "Shopfoo" system (even though that's + made-up in this case). + """ + __tablename__ = 'demo_shopfoo_product' + __versioned__ = {} + + upc = sa.Column(sa.String(length=14), nullable=True) + + description = sa.Column(sa.String(length=255), nullable=True) + + price = sa.Column(sa.Numeric(precision=13, scale=2), nullable=True) + + enabled = sa.Column(sa.Boolean(), nullable=True) + + def __str__(self): + return self.description or self.upc or "" + + +class ShopfooProductExport(ShopfooProductExportBase, model.Base): + """ + Shopfoo product exports + """ + __tablename__ = 'demo_shopfoo_product_export' diff --git a/rattail_demo/importing/__init__.py b/rattail_demo/importing/__init__.py new file mode 100644 index 0000000..a368e7c --- /dev/null +++ b/rattail_demo/importing/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +""" +Importing into Rattail Demo +""" + +from . import model diff --git a/rattail_demo/importing/local.py b/rattail_demo/importing/local.py new file mode 100644 index 0000000..662c491 --- /dev/null +++ b/rattail_demo/importing/local.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8; -*- +""" +Rattail Demo -> Rattail Demo "self" data import +""" + +from rattail.importing.local import FromRattailLocalToRattail, FromRattailLocal +from rattail.importing.shopfoo import ShopfooProductImporterMixin +from rattail.util import OrderedDict +from rattail_demo import importing as rattail_demo_importing + + +class FromRattailDemoToSelf(FromRattailLocalToRattail): + """ + Handler for Rattail Demo -> Rattail Demo ("self") imports + """ + + def get_importers(self): + importers = OrderedDict() + importers['ShopfooProduct'] = ShopfooProductImporter + return importers + + +class ShopfooProductImporter(ShopfooProductImporterMixin, FromRattailLocal, rattail_demo_importing.model.ShopfooProductImporter): + """ + Product -> ShopfooProduct + """ + supported_fields = [ + 'uuid', + 'product_uuid', + 'upc', + 'description', + 'price', + 'enabled', + ] + + def normalize_base_product_data(self, product): + + price = None + if product.regular_price: + price = product.regular_price.price + + return { + 'product_uuid': product.uuid, + 'upc': str(product.upc or '') or None, + 'description': product.full_description, + 'price': price, + 'enabled': True, # will maybe unset this in mark_unwanted() + } + + def product_is_unwanted(self, product, data): + if super(ShopfooProductImporter, self).product_is_unwanted(product, data): + return True + if not data['price']: # let's say this is a required field for Shopfoo + return True + return False + + def mark_unwanted(self, product, data): + data = super(ShopfooProductImporter, self).mark_unwanted(product, data) + data['enabled'] = False + return data diff --git a/rattail_demo/importing/model.py b/rattail_demo/importing/model.py new file mode 100644 index 0000000..0c219da --- /dev/null +++ b/rattail_demo/importing/model.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8; -*- +""" +Rattail Demo model importers +""" + +from rattail.importing.model import ToRattail +from rattail_demo.db import model + + +############################## +# custom models +############################## + +class ShopfooProductImporter(ToRattail): + """ + Importer for ShopfooProduct data + """ + model_class = model.ShopfooProduct diff --git a/rattail_demo/importing/versions.py b/rattail_demo/importing/versions.py new file mode 100644 index 0000000..07d4067 --- /dev/null +++ b/rattail_demo/importing/versions.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8; -*- +""" +Rattail Demo -> Rattail Demo "versions" data import +""" + +from rattail_demo.db import model +from rattail.importing import versions as base +from rattail_corepos.importing.versions import CoreposVersionMixin + + +class FromRattailDemoToRattailDemoVersions(base.FromRattailToRattailVersions, + CoreposVersionMixin): + """ + Handler for Rattail Demo -> Rattail Demo "versions" data import + """ + + def get_importers(self): + importers = super(FromRattailDemoToRattailDemoVersions, self).get_importers() + importers = self.add_corepos_importers(importers) + importers['ShopfooProduct'] = ShopfooProductImporter + return importers + + +class ShopfooProductImporter(base.VersionImporter): + host_model_class = model.ShopfooProduct diff --git a/rattail_demo/shopfoo/__init__.py b/rattail_demo/shopfoo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rattail_demo/shopfoo/importing/__init__.py b/rattail_demo/shopfoo/importing/__init__.py new file mode 100644 index 0000000..7678b5b --- /dev/null +++ b/rattail_demo/shopfoo/importing/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +""" +Importing into Shopfoo +""" + +from . import model diff --git a/rattail_demo/shopfoo/importing/model.py b/rattail_demo/shopfoo/importing/model.py new file mode 100644 index 0000000..e7a0180 --- /dev/null +++ b/rattail_demo/shopfoo/importing/model.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8; -*- +""" +Shopfoo model importers +""" + +from rattail_demo.db import model +from rattail.importing.exporters import ToCSV +from rattail.shopfoo.importing.model import ProductImporterMixin + + +class ToShopfoo(ToCSV): + pass + + +class ProductImporter(ProductImporterMixin, ToShopfoo): + """ + Shopfoo product data importer + """ + key = 'uuid' + simple_fields = [ + 'uuid', + 'product_uuid', + 'upc', + 'description', + 'price', + 'enabled', + ] + export_model_class = model.ShopfooProductExport diff --git a/rattail_demo/shopfoo/importing/rattail.py b/rattail_demo/shopfoo/importing/rattail.py new file mode 100644 index 0000000..4617dbe --- /dev/null +++ b/rattail_demo/shopfoo/importing/rattail.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8; -*- +""" +Rattail -> Shopfoo importing +""" + +from rattail import importing +from rattail.util import OrderedDict + +from rattail_demo.db import model +from rattail_demo.shopfoo import importing as shopfoo_importing +from rattail.shopfoo.importing.rattail import ProductImporterMixin + + +class FromRattailToShopfoo(importing.FromRattailHandler): + """ + Rattail -> Shopfoo import handler + """ + host_title = "Rattail" + local_title = "Shopfoo" + direction = 'export' + + def get_importers(self): + importers = OrderedDict() + importers['Product'] = ProductImporter + return importers + + +class FromRattail(importing.FromSQLAlchemy): + """ + Base class for Shopfoo -> Rattail importers + """ + + +class ProductImporter(ProductImporterMixin, FromRattail, shopfoo_importing.model.ProductImporter): + """ + Product data importer + """ + host_model_class = model.ShopfooProduct + supported_fields = [ + 'uuid', + 'product_uuid', + 'upc', + 'description', + 'price', + 'enabled', + ] + + def query(self): + return self.host_session.query(model.ShopfooProduct)\ + .order_by(model.ShopfooProduct.upc) + + def normalize_host_object(self, product): + + # copy all values "as-is" from our cache record + data = dict([(field, getattr(product, field)) + for field in self.fields]) + + # TODO: is it ever a good idea to set this flag? doing so will mean + # the record is *not* included in CSV output file + # data['_deleted_'] = product.deleted_from_shopfoo + + return data diff --git a/rattail_demo/web/menus.py b/rattail_demo/web/menus.py index 9e73781..e9cbac6 100644 --- a/rattail_demo/web/menus.py +++ b/rattail_demo/web/menus.py @@ -126,6 +126,22 @@ def simple_menus(request): }, ], }, + { + 'title': "Shopfoo", + 'type': 'menu', + 'items': [ + { + 'title': "Products", + 'url': url('shopfoo.products'), + 'perm': 'shopfoo.products.list', + }, + { + 'title': "Product Exports", + 'url': url('shopfoo.product_exports'), + 'perm': 'shopfoo.product_exports.list', + }, + ], + }, { 'title': "Batches", 'type': 'menu', diff --git a/rattail_demo/web/views/__init__.py b/rattail_demo/web/views/__init__.py index 951d5df..d74e2e0 100644 --- a/rattail_demo/web/views/__init__.py +++ b/rattail_demo/web/views/__init__.py @@ -37,6 +37,9 @@ def includeme(config): # core-pos views config.include('tailbone_corepos.views.corepos') + # shopfoo views + config.include('rattail_demo.web.views.shopfoo') + # batch views config.include('tailbone.views.handheld') config.include('tailbone.views.batch.inventory') diff --git a/rattail_demo/web/views/shopfoo/__init__.py b/rattail_demo/web/views/shopfoo/__init__.py new file mode 100644 index 0000000..c61916e --- /dev/null +++ b/rattail_demo/web/views/shopfoo/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8; -*- +""" +Shopfoo views +""" + + +def includeme(config): + config.include('rattail_demo.web.views.shopfoo.products') + config.include('rattail_demo.web.views.shopfoo.exports') diff --git a/rattail_demo/web/views/shopfoo/exports.py b/rattail_demo/web/views/shopfoo/exports.py new file mode 100644 index 0000000..52bb72e --- /dev/null +++ b/rattail_demo/web/views/shopfoo/exports.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" +Views for Shopfoo product exports +""" + +from rattail_demo.db import model + +from tailbone.views.exports import ExportMasterView + + +class ShopfooProductExportView(ExportMasterView): + """ + Master view for Shopfoo product exports. + """ + model_class = model.ShopfooProductExport + route_prefix = 'shopfoo.product_exports' + url_prefix = '/shopfoo/exports/product' + downloadable = True + editable = True + delete_export_files = True + + grid_columns = [ + 'id', + 'created', + 'created_by', + 'filename', + 'record_count', + 'uploaded', + ] + + form_fields = [ + 'id', + 'created', + 'created_by', + 'record_count', + 'filename', + 'uploaded', + ] + + +def includeme(config): + ShopfooProductExportView.defaults(config) diff --git a/rattail_demo/web/views/shopfoo/products.py b/rattail_demo/web/views/shopfoo/products.py new file mode 100644 index 0000000..acdfe25 --- /dev/null +++ b/rattail_demo/web/views/shopfoo/products.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8; -*- +""" +Shopfoo product views +""" + +from rattail_demo.db import model + +from tailbone.views import MasterView + + +class ShopfooProductView(MasterView): + """ + Shopfoo Product views + """ + model_class = model.ShopfooProduct + url_prefix = '/shopfoo/products' + route_prefix = 'shopfoo.products' + creatable = False + editable = False + bulk_deletable = True + has_versions = True + + labels = { + 'upc': "UPC", + } + + grid_columns = [ + 'upc', + 'description', + 'price', + 'enabled', + ] + + form_fields = [ + 'product', + 'upc', + 'description', + 'price', + 'enabled', + ] + + def configure_grid(self, g): + super(ShopfooProductView, self).configure_grid(g) + + g.filters['upc'].default_active = True + g.filters['upc'].default_verb = 'equal' + + g.filters['description'].default_active = True + g.filters['description'].default_verb = 'contains' + + g.set_sort_defaults('upc') + + g.set_type('price', 'currency') + + g.set_link('upc') + g.set_link('description') + + def grid_extra_class(self, product, i): + if not product.enabled: + return 'warning' + + def configure_form(self, f): + super(ShopfooProductView, self).configure_form(f) + + f.set_renderer('product', self.render_product) + f.set_type('price', 'currency') + + +def includeme(config): + ShopfooProductView.defaults(config) diff --git a/setup.py b/setup.py index 3f9a2bd..e51a661 100644 --- a/setup.py +++ b/setup.py @@ -89,5 +89,13 @@ setup( 'rattail.config.extensions': [ 'rattail-demo = rattail_demo.config:DemoConfigExtension', ], + 'console_scripts': [ + 'rattail-demo = rattail_demo.commands:main', + ], + + 'rattail_demo.commands': [ + 'export-shopfoo = rattail_demo.commands:ExportShopfoo', + 'import-self = rattail_demo.commands:ImportSelf', + ], }, )