From f5cfac33b4b0f6701587da660d87e130d2552caa Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 16 Jul 2026 16:57:21 -0400 Subject: [PATCH] printedparts stage 2: models, real 0001 baseline, tables live PrintedItem (catalog: code, name, image, cached quantityonhand, per-item threshold, bin) and PrintedItemTransaction (the ledger: signed quantity change attributed to a badge-resolved employee). Both registered in PLUGIN_TABLE_OWNERS; 0001 is a post-cutover real baseline. The migration-guard test learns the new expected head. Routes are a placeholder ping until the next stage - the scaffold's list route imported the deleted scaffold model, which surfaces as an empty 'Migration error' because the alembic env imports the models package. --- plugins/printedparts/api/routes.py | 49 +++------- plugins/printedparts/migrations/env.py | 14 +++ .../printedparts/migrations/script.py.mako | 24 +++++ .../versions/0001_printedparts_baseline.py | 71 ++++++++++++++ plugins/printedparts/models/__init__.py | 4 +- plugins/printedparts/models/printeditem.py | 95 +++++++++++++++++++ plugins/printedparts/models/printedparts.py | 32 ------- plugins/printedparts/plugin.py | 4 +- shopdb/plugins/alembic_template.py | 1 + tests/test_plugin_migrations.py | 2 + 10 files changed, 222 insertions(+), 74 deletions(-) create mode 100644 plugins/printedparts/migrations/env.py create mode 100644 plugins/printedparts/migrations/script.py.mako create mode 100644 plugins/printedparts/migrations/versions/0001_printedparts_baseline.py create mode 100644 plugins/printedparts/models/printeditem.py delete mode 100644 plugins/printedparts/models/printedparts.py diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index d847cad..73d11ff 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -1,45 +1,18 @@ -"""Printedparts plugin API routes.""" +"""Printedparts plugin API routes. -from flask import Blueprint, request -from flask_jwt_extended import jwt_required +Stage 2 placeholder: the blueprint must import cleanly for plugin discovery +and migrations (the alembic env imports the models package, which pulls in +plugin.py and this module). Real endpoints land in the next stage. +""" -from shopdb.api import ( - success_response, - error_response, - paginated_response, - ErrorCodes, - get_pagination_params, - paginate_query, -) - -from ..models import Printedparts +from flask import Blueprint +from shopdb.api import success_response printedparts_bp = Blueprint('printedparts', __name__) -@printedparts_bp.route('', methods=['GET']) -@jwt_required(optional=True) -def list_printedparts(): - """List printedparts assets, paginated.""" - page, per_page = get_pagination_params(request) - - query = Printedparts.query - items, total = paginate_query(query, page, per_page) - data = [item.to_dict() for item in items] - - return paginated_response(data, page, per_page, total) - - -@printedparts_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_printedparts(assetid: int): - """Get a single printedparts by assetid.""" - item = Printedparts.query.get(assetid) - if not item: - return error_response( - ErrorCodes.NOT_FOUND, - f'Printedparts with assetid {assetid} not found', - http_code=404, - ) - return success_response(item.to_dict()) +@printedparts_bp.route('/ping', methods=['GET']) +def ping(): + """Liveness probe for the lab: proves the blueprint is registered.""" + return success_response({'plugin': 'printedparts', 'status': 'ok'}) diff --git a/plugins/printedparts/migrations/env.py b/plugins/printedparts/migrations/env.py new file mode 100644 index 0000000..1490d7a --- /dev/null +++ b/plugins/printedparts/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the printedparts plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which +filters the metadata to this plugin's tables and drives Alembic against the +per-plugin version table alembic_version_printedparts (ADR-008). This plugin +is NEW (post-cutover): its 0001 baseline really CREATES its tables. +""" +import os + +os.environ['PLUGIN_NAME'] = 'printedparts' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/printedparts/migrations/script.py.mako b/plugins/printedparts/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/plugins/printedparts/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py new file mode 100644 index 0000000..f0bb348 --- /dev/null +++ b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py @@ -0,0 +1,71 @@ +"""printedparts plugin baseline (real create). + +Post-ADR-008 plugin: this per-plugin chain is the sole authoritative creator +of printeditems and printeditemtransactions - the core chain never knew them. +Runs from `flask plugin install printedparts` (and `flask plugin upgrade-all`) +after `flask db upgrade` builds the core schema. + +Both tables are self-contained (the only FK is transactions -> items inside +the plugin), so the shared create_plugin_tables helper would work here; the +ops are written out explicitly anyway to match the measuringtools exemplar +and keep the baseline reviewable. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'printedparts0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'printeditems', + sa.Column('printeditemid', sa.Integer(), nullable=False), + sa.Column('itemcode', sa.String(length=20), nullable=True), + sa.Column('itemname', sa.String(length=120), nullable=False), + sa.Column('itemdescription', sa.String(length=500), nullable=True), + sa.Column('imageurl', sa.String(length=255), nullable=True), + sa.Column('quantityonhand', sa.Integer(), nullable=False), + sa.Column('lowstockthreshold', sa.Integer(), nullable=False), + sa.Column('binlocation', sa.String(length=100), nullable=True), + sa.Column('printnotes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('printeditemid'), + sa.UniqueConstraint('itemcode'), + ) + op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode']) + + op.create_table( + 'printeditemtransactions', + sa.Column('transactionid', sa.Integer(), nullable=False), + sa.Column('printeditemid', sa.Integer(), nullable=False), + sa.Column('transactiontype', sa.String(length=10), nullable=False), + sa.Column('quantitychange', sa.Integer(), nullable=False), + sa.Column('employeesso', sa.String(length=20), nullable=False), + sa.Column('employeename', sa.String(length=120), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=True), + sa.Column('transactiondate', sa.DateTime(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['printeditemid'], ['printeditems.printeditemid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('transactionid'), + ) + op.create_index('ix_printeditemtransactions_printeditemid', + 'printeditemtransactions', ['printeditemid']) + op.create_index('ix_printeditemtransactions_employeesso', + 'printeditemtransactions', ['employeesso']) + op.create_index('ix_printeditemtransactions_transactiondate', + 'printeditemtransactions', ['transactiondate']) + + +def downgrade(): + op.drop_table('printeditemtransactions') + op.drop_table('printeditems') diff --git a/plugins/printedparts/models/__init__.py b/plugins/printedparts/models/__init__.py index 05d90e9..bc0397c 100644 --- a/plugins/printedparts/models/__init__.py +++ b/plugins/printedparts/models/__init__.py @@ -1,5 +1,5 @@ """Printedparts plugin models.""" -from .printedparts import Printedparts +from .printeditem import PrintedItem, PrintedItemTransaction, TRANSACTION_TYPES -__all__ = ['Printedparts'] +__all__ = ['PrintedItem', 'PrintedItemTransaction', 'TRANSACTION_TYPES'] diff --git a/plugins/printedparts/models/printeditem.py b/plugins/printedparts/models/printeditem.py new file mode 100644 index 0000000..b45ee49 --- /dev/null +++ b/plugins/printedparts/models/printeditem.py @@ -0,0 +1,95 @@ +"""Printedparts models. + +PrintedItem is a KIND of 3D-printed part with a quantity on hand - a +consumable, not an ADR-001 asset (which is one row per physical thing). +PrintedItemTransaction is the ledger: every take, restock, and adjust as a +signed quantity change attributed to a badge-resolved employee. The ledger is +the source of truth; quantityonhand is a cache moved in the same commit as +each ledger write, and the stock report reconciles the two. +""" + +from datetime import datetime, timezone + +from shopdb.api import db, BaseModel + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +TRANSACTION_TYPES = ('take', 'restock', 'adjust') + + +class PrintedItem(BaseModel): + """A printable part the engineers stock in bins.""" + + __tablename__ = 'printeditems' + + printeditemid = db.Column(db.Integer, primary_key=True) + itemcode = db.Column(db.String(20), unique=True, index=True, + comment='Generated bin-label code, e.g. 3DP-0042') + itemname = db.Column(db.String(120), nullable=False) + itemdescription = db.Column(db.String(500)) + imageurl = db.Column(db.String(255)) + quantityonhand = db.Column(db.Integer, nullable=False, default=0) + lowstockthreshold = db.Column(db.Integer, nullable=False, default=5) + binlocation = db.Column(db.String(100)) + printnotes = db.Column(db.Text, comment='Material, print time, slicer file') + + transactions = db.relationship( + 'PrintedItemTransaction', backref='printeditem', + cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic') + + @property + def islowstock(self): + return self.quantityonhand <= self.lowstockthreshold + + def to_dict(self): + return { + 'printeditemid': self.printeditemid, + 'itemcode': self.itemcode, + 'itemname': self.itemname, + 'itemdescription': self.itemdescription, + 'imageurl': self.imageurl, + 'quantityonhand': self.quantityonhand, + 'lowstockthreshold': self.lowstockthreshold, + 'islowstock': self.islowstock, + 'binlocation': self.binlocation, + 'printnotes': self.printnotes, + 'isactive': self.isactive, + 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, + } + + +class PrintedItemTransaction(BaseModel): + """One signed stock movement, always attributed to an employee.""" + + __tablename__ = 'printeditemtransactions' + + transactionid = db.Column(db.Integer, primary_key=True) + printeditemid = db.Column( + db.Integer, + db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'), + nullable=False, index=True) + transactiontype = db.Column(db.String(10), nullable=False, + comment='take, restock, or adjust') + quantitychange = db.Column(db.Integer, nullable=False, + comment='Negative for take, signed for adjust') + employeesso = db.Column(db.String(20), nullable=False, index=True) + employeename = db.Column(db.String(120)) + reason = db.Column(db.String(255)) + transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow, + index=True) + + def to_dict(self): + return { + 'transactionid': self.transactionid, + 'printeditemid': self.printeditemid, + 'transactiontype': self.transactiontype, + 'quantitychange': self.quantitychange, + 'employeesso': self.employeesso, + 'employeename': self.employeename, + 'reason': self.reason, + 'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None, + } diff --git a/plugins/printedparts/models/printedparts.py b/plugins/printedparts/models/printedparts.py deleted file mode 100644 index e070a7f..0000000 --- a/plugins/printedparts/models/printedparts.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Printedparts model. - -This is an Asset extension table keyed by assetid. The Asset row holds -the platform fields (assetnumber, name, vendorid, locationid, etc.); -this table holds the printedparts-specific fields. Replace the example fields -below with your domain model. -""" - -from shopdb.api import db, BaseModel - - -class Printedparts(BaseModel): - """Printedparts domain entity, extending Asset by assetid.""" - - __tablename__ = 'printedparts' - - assetid = db.Column( - db.Integer, - db.ForeignKey('assets.assetid', ondelete='CASCADE'), - primary_key=True, - ) - - # TODO: replace these example fields with your domain fields. - examplefield = db.Column(db.String(255), nullable=True) - - asset = db.relationship('Asset', backref=db.backref('printedparts', uselist=False)) - - def to_dict(self): - return { - 'assetid': self.assetid, - 'examplefield': self.examplefield, - } diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index fec4782..89cf221 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -16,7 +16,7 @@ from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.api import db, Setting -from .models import Printedparts +from .models import PrintedItem, PrintedItemTransaction from .api import printedparts_bp logger = logging.getLogger(__name__) @@ -46,7 +46,7 @@ class PrintedpartsPlugin(BasePlugin): return printedparts_bp def get_models(self) -> List[Type]: - return [Printedparts] + return [PrintedItem, PrintedItemTransaction] def init_app(self, app: Flask, db_instance) -> None: logger.info(f'Printedparts plugin initialized (v{self.meta.version})') diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index ff7dd64..5a8f25c 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -53,6 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'measuringtools': ('measuringtooltypes', 'measuringtools'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'notifications': ('notificationtypes', 'notifications'), + 'printedparts': ('printeditems', 'printeditemtransactions'), 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'), 'slides': ('tvslides',), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 16e0269..a17806b 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -54,6 +54,8 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo' # usb drops the dead usbcheckouts.machineid column on top of its anchor. EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' +# printedparts is post-cutover: its 0001 really creates its tables. +EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline' # notifications indexes businessunitid on top of its anchor. EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'