printedparts stage 15: print-file revision history + role-based alerts
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s

printeditemfiles lands as the plugin's first incremental migration
(0002 on the plugin chain - the ADR-008 payoff). Revisions are
append-only per item: upload assigns the next number, records the
uploader from the JWT, enforces an extension allowlist and a 100 MB
cap; download serves the original filename; a permission-gated delete
covers wrong-file mistakes. The detail page gains the revision table
with a current badge. Unique storedfilename is sized 191 so the index
fits MySQL's 767-byte prefix - the per-plugin chain does not apply the
core env's ROW_FORMAT hook.

Alert recipients gain roles: Role joins the 0.13.0 surface, a role
picker on the settings page, and every active member of the selected
roles is folded into the deduped recipient list.
This commit is contained in:
cproudlock
2026-07-17 08:47:41 -04:00
parent 26b6b6b32f
commit aa4bfcd41c
14 changed files with 479 additions and 11 deletions

View File

@@ -268,7 +268,7 @@ def _alert_recipients():
"""Merge selected shopdb users' account emails with the free-text list.
Empty result means fall back to the site-wide alert_recipients."""
from shopdb.api import User
from shopdb.api import User, Role
recipients = []
userids = (Setting.get('printedparts_alert_userids') or '').strip()
for rawid in userids.split(','):
@@ -278,6 +278,15 @@ def _alert_recipients():
user = db.session.get(User, int(rawid))
if user and user.isactive and user.email:
recipients.append(user.email)
roleids = (Setting.get('printedparts_alert_roleids') or '').strip()
for rawid in roleids.split(','):
rawid = rawid.strip()
if not rawid.isdigit():
continue
role = db.session.get(Role, int(rawid))
if role:
recipients.extend(member.email for member in role.users
if member.isactive and member.email)
extra = (Setting.get('printedparts_alert_email') or '').strip()
recipients.extend(address.strip() for address in extra.split(',')
if address.strip())
@@ -525,3 +534,126 @@ def report_by_person():
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-by-person.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})
# --- print files: append-only revisions per item ------------------------------
from flask_jwt_extended import get_jwt_identity
from ..models import PrintedItemFile
FILE_EXTENSIONS = {'.stl', '.3mf', '.gcode', '.gco', '.bgcode', '.step',
'.stp', '.obj', '.amf'}
MAX_FILE_BYTES = 100 * 1024 * 1024
def _filedir():
return os.path.join(current_app.instance_path, 'printedpartsfiles')
def _uploader_name():
from shopdb.api import User
identity = get_jwt_identity()
try:
user = db.session.get(User, int(identity))
if user:
return user.username
except (TypeError, ValueError):
pass
return str(identity)
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
@jwt_required(optional=True)
def list_item_files(item_id: int):
"""Revision history, newest first."""
files = (PrintedItemFile.query.filter_by(printeditemid=item_id)
.order_by(PrintedItemFile.revision.desc()).all())
return success_response([f.to_dict() for f in files])
@printedparts_bp.route('/items/<int:item_id>/files', methods=['POST'])
@jwt_required()
@require_permission('printedparts.edit')
def upload_item_file(item_id: int):
"""Upload the next revision of the item's print file.
multipart/form-data: file=<stl/3mf/gcode/...>, note=<what changed>.
Revisions are append-only; nothing is replaced.
"""
item = db.session.get(PrintedItem, item_id)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in FILE_EXTENSIONS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Unsupported file type {ext}; allowed: '
+ ', '.join(sorted(FILE_EXTENSIONS)))
upload.stream.seek(0, os.SEEK_END)
filesize = upload.stream.tell()
upload.stream.seek(0)
if filesize > MAX_FILE_BYTES:
return error_response(ErrorCodes.VALIDATION_ERROR,
'File exceeds the 100 MB limit')
latest = (db.session.query(db.func.max(PrintedItemFile.revision))
.filter_by(printeditemid=item_id).scalar()) or 0
revision = latest + 1
filedir = _filedir()
os.makedirs(filedir, exist_ok=True)
storedfilename = secure_filename(
f'printeditem-{item_id}-rev{revision}{ext}')
upload.save(os.path.join(filedir, storedfilename))
record = PrintedItemFile(
printeditemid=item_id,
revision=revision,
filename=secure_filename(upload.filename),
storedfilename=storedfilename,
filesize=filesize,
uploadnote=(request.form.get('note') or '').strip() or None,
uploadedby=_uploader_name(),
)
db.session.add(record)
db.session.commit()
return success_response(record.to_dict(),
message=f'Revision {revision} uploaded',
http_code=201)
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
@jwt_required(optional=True)
def download_item_file(file_id: int):
"""Download a revision under its original filename."""
from flask import send_from_directory
record = db.session.get(PrintedItemFile, file_id)
if not record:
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
http_code=404)
return send_from_directory(_filedir(), record.storedfilename,
as_attachment=True,
download_name=record.filename)
@printedparts_bp.route('/files/<int:file_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printedparts.delete')
def delete_item_file(file_id: int):
"""Remove a bad revision (wrong file uploaded). History otherwise stays."""
record = db.session.get(PrintedItemFile, file_id)
if not record:
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
http_code=404)
path = os.path.join(_filedir(), record.storedfilename)
if os.path.exists(path):
os.remove(path)
db.session.delete(record)
db.session.commit()
return success_response(message='Revision removed')

View File

@@ -0,0 +1,43 @@
"""Add printeditemfiles: append-only print-file revisions per item.
The plugin's first incremental migration on top of its 0001 baseline -
the ADR-008 payoff: the plugin evolves its own schema without touching
the core chain. Applied by `flask plugin upgrade-all`.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'printedparts0002files'
down_revision = 'printedparts0001baseline'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'printeditemfiles',
sa.Column('fileid', sa.Integer(), nullable=False),
sa.Column('printeditemid', sa.Integer(), nullable=False),
sa.Column('revision', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('storedfilename', sa.String(length=191), nullable=False),
sa.Column('filesize', sa.Integer(), nullable=False),
sa.Column('uploadnote', sa.String(length=255), nullable=True),
sa.Column('uploadedby', sa.String(length=80), 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('fileid'),
sa.UniqueConstraint('storedfilename'),
)
op.create_index('ix_printeditemfiles_printeditemid',
'printeditemfiles', ['printeditemid'])
def downgrade():
op.drop_table('printeditemfiles')

View File

@@ -1,5 +1,11 @@
"""Printedparts plugin models."""
from .printeditem import PrintedItem, PrintedItemTransaction, TRANSACTION_TYPES
from .printeditem import (
PrintedItem,
PrintedItemTransaction,
PrintedItemFile,
TRANSACTION_TYPES,
)
__all__ = ['PrintedItem', 'PrintedItemTransaction', 'TRANSACTION_TYPES']
__all__ = ['PrintedItem', 'PrintedItemTransaction', 'PrintedItemFile',
'TRANSACTION_TYPES']

View File

@@ -93,3 +93,43 @@ class PrintedItemTransaction(BaseModel):
'reason': self.reason,
'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None,
}
class PrintedItemFile(BaseModel):
"""One uploaded revision of an item's print file (STL/3MF/gcode/...).
Revisions are append-only per item: uploading assigns the next revision
number and never replaces earlier files, so the history of what was
actually printed stays reconstructible. The current file is simply the
highest revision.
"""
__tablename__ = 'printeditemfiles'
fileid = db.Column(db.Integer, primary_key=True)
printeditemid = db.Column(
db.Integer,
db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
nullable=False, index=True)
revision = db.Column(db.Integer, nullable=False)
filename = db.Column(db.String(255), nullable=False,
comment='Original upload name, used for download')
storedfilename = db.Column(db.String(191), nullable=False, unique=True,
comment='191: unique index fits the 767-byte MySQL prefix')
filesize = db.Column(db.Integer, nullable=False)
uploadnote = db.Column(db.String(255),
comment='What changed in this revision')
uploadedby = db.Column(db.String(80), nullable=False,
comment='Username of the uploader')
def to_dict(self):
return {
'fileid': self.fileid,
'printeditemid': self.printeditemid,
'revision': self.revision,
'filename': self.filename,
'filesize': self.filesize,
'uploadnote': self.uploadnote,
'uploadedby': self.uploadedby,
'uploadeddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
}

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 PrintedItem, PrintedItemTransaction
from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
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 [PrintedItem, PrintedItemTransaction]
return [PrintedItem, PrintedItemTransaction, PrintedItemFile]
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
@@ -136,6 +136,9 @@ class PrintedpartsPlugin(BasePlugin):
('printedparts_alert_userids', '', 'string',
'Comma-separated shopdb user ids whose account emails receive '
'low-stock alerts'),
('printedparts_alert_roleids', '', 'string',
'Comma-separated role ids; every active member of these roles '
'receives low-stock alerts'),
]
for key, value, valuetype, description in defaults:
if Setting.get(key) is None: