printedparts stage 2: models, real 0001 baseline, tables live
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

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.
This commit is contained in:
cproudlock
2026-07-16 16:57:21 -04:00
parent 8dd1fadeca
commit f5cfac33b4
10 changed files with 222 additions and 74 deletions

View File

@@ -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('/<int:assetid>', 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'})

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -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']

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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})')

View File

@@ -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'),

View File

@@ -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'