diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 05f47d8..ad3dc13 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -479,8 +479,8 @@ What `shopdb.api` exposes: - Import mode: `apply_import_timestamps`, `import_mode_active`, `parse_import_datetime` - Legacy employee directory: `employee_connection` -- `User` (0.13.0) - the account model, e.g. resolving alert recipients' - emails from selected user ids +- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving + alert recipients' emails from selected user ids or role membership - Mailer (0.12.0): `send_email(to, subject, html, text=None)` and `send_alert(subject, html, text=None)` - settings-first, no-op safe when email is unconfigured; send_alert targets the site's alert_recipients diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index ebf576d..be58d98 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -347,6 +347,30 @@ the site wanted `WJRP0042`, not `WJRP-0042`. 2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their codes - itemcode is an immutable label once printed on a bin. +## Stage 15 (extension) - print-file revisions + role-based alerts + +Two more field requests, and the plugin's FIRST incremental migration: +1. `printeditemfiles` (append-only revisions of the STL/3MF/gcode per item) + arrives as `0002_printeditemfiles` on top of the 0001 baseline - the + ADR-008 payoff: the plugin evolves its own schema, `flask plugin + upgrade-all` applies it, the core chain never hears about it. Update + PLUGIN_TABLE_OWNERS and the guard test's expected head. + Gotchas hit live: (a) MySQL 5.6 dev box - a VARCHAR(255) UNIQUE on + utf8mb4 dies with error 1071 because the per-plugin chain does not apply + the core env's ROW_FORMAT=DYNAMIC hook; size unique columns to 191 or + less (191*4 = 764 bytes fits the 767 prefix). (b) The dev container's + innodb_large_prefix globals reset on restart (documented dev caveat). +2. Upload endpoint assigns revision = max+1, stores + `printeditem--rev` in `instance/printedpartsfiles/` + (extension allowlist, 100 MB cap), records uploader from the JWT. + Download serves the ORIGINAL filename; delete (permission-gated) exists + for wrong-file mistakes, otherwise history is append-only. Detail page + gains the revision table with a "current" badge on the newest. +3. Role-based alert recipients: `Role` joins the 0.13.0 surface beside User; + Setting `printedparts_alert_roleids`; `_alert_recipients` folds in every + ACTIVE member of each selected role (role.users backref), deduped with + the user picks and free-text; settings page gains a role picker. + --- ## Where each pattern lives (cheat sheet) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 87b4529..ac1fcee 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1168,5 +1168,19 @@ export const printedpartsApi = { }, kioskTake(data) { return api.post('/printedparts/kiosk/take', data) + }, + listFiles(printeditemid) { + return api.get(`/printedparts/items/${printeditemid}/files`) + }, + uploadFile(printeditemid, file, note) { + const formData = new FormData() + formData.append('file', file) + if (note) formData.append('note', note) + return api.post(`/printedparts/items/${printeditemid}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + removeFile(fileid) { + return api.delete(`/printedparts/files/${fileid}`) } } diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue index ceef6bf..335895b 100644 --- a/frontend/src/views/printedparts/PrintedItemDetail.vue +++ b/frontend/src/views/printedparts/PrintedItemDetail.vue @@ -68,6 +68,57 @@
+
+

Print files

+
+ + + +
+
{{ fileError }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
RevFileSizeByNote
{{ revision.revision }} + + {{ revision.filename }} + + current + {{ formatSize(revision.filesize) }}{{ revision.uploadedby }}{{ revision.uploadnote || '-' }} + +
No print file uploaded yet
+
+
+

Recent transactions

@@ -151,6 +202,7 @@ onMounted(async () => { try { const response = await printedpartsApi.get(route.params.id) item.value = response.data.data + await loadFiles() } catch (loadError) { console.error('Error loading printed item:', loadError) } finally { @@ -158,6 +210,58 @@ onMounted(async () => { } }) +const files = ref([]) +const fileInput = ref(null) +const fileNote = ref('') +const fileUploading = ref(false) +const fileError = ref('') + +async function loadFiles() { + try { + const response = await printedpartsApi.listFiles(route.params.id) + files.value = response.data.data || [] + } catch (filesError) { + console.error('Error loading files:', filesError) + } +} + +async function uploadRevision() { + const file = fileInput.value?.files?.[0] + if (!file) { fileError.value = 'Choose a file first'; return } + fileUploading.value = true + fileError.value = '' + try { + await printedpartsApi.uploadFile(route.params.id, file, fileNote.value) + fileNote.value = '' + fileInput.value.value = '' + await loadFiles() + } catch (uploadError) { + fileError.value = + uploadError.response?.data?.data?.error?.message || 'Upload failed' + } finally { + fileUploading.value = false + } +} + +async function removeRevision(revision) { + if (!window.confirm( + `Delete revision ${revision.revision} (${revision.filename})?`)) return + try { + await printedpartsApi.removeFile(revision.fileid) + await loadFiles() + } catch (removeError) { + fileError.value = 'Delete failed' + console.error(removeError) + } +} + +function formatSize(bytes) { + if (!bytes && bytes !== 0) return '-' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1048576).toFixed(1)} MB` +} + const ledgerOpen = ref(false) const ledgerMode = ref('restock') const ledgerQuantity = ref(null) @@ -233,5 +337,13 @@ function formatDate(value) { diff --git a/frontend/src/views/settings/PrintedPartsSettings.vue b/frontend/src/views/settings/PrintedPartsSettings.vue index f4e1efb..fd2469b 100644 --- a/frontend/src/views/settings/PrintedPartsSettings.vue +++ b/frontend/src/views/settings/PrintedPartsSettings.vue @@ -49,6 +49,22 @@

+
+ +
+ +

No roles loaded

+
+

+ Every active member of a selected role receives low-stock alerts. +

+
+
{ const usersResponse = await usersApi.list() users.value = (usersResponse.data.data || []).filter( candidate => candidate.isactive && candidate.email) + selectedRoleids.value = (values.value.printedparts_alert_roleids || '') + .split(',').map(id => id.trim()).filter(Boolean) + const rolesResponse = await usersApi.roles.list() + roles.value = rolesResponse.data.data || [] } catch (loadError) { error.value = 'Could not load settings' console.error(loadError) @@ -118,6 +142,7 @@ async function save() { error.value = '' try { values.value.printedparts_alert_userids = selectedUserids.value.join(',') + values.value.printedparts_alert_roleids = selectedRoleids.value.join(',') for (const key of KEYS) { await settingsApi.update(key, String(values.value[key] ?? '')) } diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 409827d..83720b2 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -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//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//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=, note=. + 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//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/', 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') diff --git a/plugins/printedparts/migrations/versions/0002_printeditemfiles.py b/plugins/printedparts/migrations/versions/0002_printeditemfiles.py new file mode 100644 index 0000000..d0b2c9a --- /dev/null +++ b/plugins/printedparts/migrations/versions/0002_printeditemfiles.py @@ -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') diff --git a/plugins/printedparts/models/__init__.py b/plugins/printedparts/models/__init__.py index bc0397c..b39cb08 100644 --- a/plugins/printedparts/models/__init__.py +++ b/plugins/printedparts/models/__init__.py @@ -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'] diff --git a/plugins/printedparts/models/printeditem.py b/plugins/printedparts/models/printeditem.py index b45ee49..9f924e1 100644 --- a/plugins/printedparts/models/printeditem.py +++ b/plugins/printedparts/models/printeditem.py @@ -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, + } diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index f2327b2..38a82c9 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 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: diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index ae0f605..be2c5a0 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -45,6 +45,7 @@ from shopdb.core.models import ( AssetRelationship, RelationshipType, User, + Role, ) # Response + pagination helpers for plugin API blueprints @@ -271,6 +272,7 @@ __all__ = [ 'send_email', 'send_alert', 'User', + 'Role', # CMMC USB check-in/out database 'cmmc_usb_connection', ] diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 5a8f25c..67c8702 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -53,7 +53,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'measuringtools': ('measuringtooltypes', 'measuringtools'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'notifications': ('notificationtypes', 'notifications'), - 'printedparts': ('printeditems', 'printeditemtransactions'), + 'printedparts': ('printeditems', 'printeditemtransactions', + 'printeditemfiles'), '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 a17806b..f001ef8 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -55,7 +55,7 @@ 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' +EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0002files' # notifications indexes businessunitid on top of its anchor. EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx' diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py index ce07b4b..e0c06ac 100644 --- a/tests/test_plugins/test_printedparts_ledger.py +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -260,3 +260,69 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item): assert client.post(f'/api/printedparts/items/{item}/restore', headers=auth_headers).status_code == 200 assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200 + + +def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path): + """Uploads mint sequential revisions; download returns the original name.""" + import io + + first = client.post(f'/api/printedparts/items/{item}/files', + data={'file': (io.BytesIO(b'solid part'), 'clip_v1.stl'), + 'note': 'initial'}, + headers=auth_headers, + content_type='multipart/form-data') + assert first.status_code == 201, first.get_json() + assert first.get_json()['data']['revision'] == 1 + + second = client.post(f'/api/printedparts/items/{item}/files', + data={'file': (io.BytesIO(b'G1 X0 Y0'), 'clip_v2.gcode')}, + headers=auth_headers, + content_type='multipart/form-data') + assert second.get_json()['data']['revision'] == 2 + + bad = client.post(f'/api/printedparts/items/{item}/files', + data={'file': (io.BytesIO(b'x'), 'malware.exe')}, + headers=auth_headers, + content_type='multipart/form-data') + assert bad.status_code == 400 + + listing = client.get(f'/api/printedparts/items/{item}/files').get_json()['data'] + assert [f['revision'] for f in listing] == [2, 1] + + fileid = listing[1]['fileid'] + download = client.get(f'/api/printedparts/files/{fileid}/download') + assert download.status_code == 200 + assert download.data == b'solid part' + assert 'clip_v1.stl' in download.headers['Content-Disposition'] + + +def test_alert_role_members_receive(client, auth_headers, app, item, + directory_employee, monkeypatch): + """Every active member of a selected role gets the alert.""" + import shopdb.api as contract_surface + captured = {} + monkeypatch.setattr(contract_surface, 'send_email', + lambda to, subject, html, text=None: + captured.setdefault('to', to) or True) + + with app.app_context(): + from shopdb.api import User, Role + from werkzeug.security import generate_password_hash + role = Role(rolename='partscrew', description='3D parts crew') + member = User(username='crewone', email='crewone@site.test', + passwordhash=generate_password_hash('x'), isactive=True) + member.roles.append(role) + db.session.add_all([role, member]) + db.session.commit() + Setting.set('printedparts_alert_roleids', str(role.roleid), + valuetype='string', category='printedparts') + db.session.commit() + + client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 10, 'badge': directory_employee}, + headers=auth_headers) + take = client.post('/api/printedparts/kiosk/take', + json={'itemcode': '3DP-9001', + 'badge': directory_employee, 'quantity': 6}) + assert take.status_code == 200 + assert captured['to'] == ['crewone@site.test']