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

@@ -479,8 +479,8 @@ What `shopdb.api` exposes:
- Import mode: `apply_import_timestamps`, `import_mode_active`, - Import mode: `apply_import_timestamps`, `import_mode_active`,
`parse_import_datetime` `parse_import_datetime`
- Legacy employee directory: `employee_connection` - Legacy employee directory: `employee_connection`
- `User` (0.13.0) - the account model, e.g. resolving alert recipients' - `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
emails from selected user ids alert recipients' emails from selected user ids or role membership
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and - Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
`send_alert(subject, html, text=None)` - settings-first, no-op safe when `send_alert(subject, html, text=None)` - settings-first, no-op safe when
email is unconfigured; send_alert targets the site's alert_recipients email is unconfigured; send_alert targets the site's alert_recipients

View File

@@ -347,6 +347,30 @@ the site wanted `WJRP0042`, not `WJRP-0042`.
2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their 2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their
codes - itemcode is an immutable label once printed on a bin. 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-<id>-rev<n><ext>` 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) ## Where each pattern lives (cheat sheet)

View File

@@ -1168,5 +1168,19 @@ export const printedpartsApi = {
}, },
kioskTake(data) { kioskTake(data) {
return api.post('/printedparts/kiosk/take', 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}`)
} }
} }

View File

@@ -68,6 +68,57 @@
</div> </div>
<div class="content-column"> <div class="content-column">
<div class="section-card">
<h3 class="section-title">Print files</h3>
<div class="file-upload-row">
<input ref="fileInput" type="file"
accept=".stl,.3mf,.gcode,.gco,.bgcode,.step,.stp,.obj,.amf" />
<input v-model="fileNote" type="text" class="form-control"
placeholder="What changed? (optional)" />
<button class="btn btn-primary btn-sm" :disabled="fileUploading"
@click="uploadRevision">
{{ fileUploading ? 'Uploading...' : 'Upload revision' }}
</button>
</div>
<div v-if="fileError" class="error-message">{{ fileError }}</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Rev</th>
<th>File</th>
<th>Size</th>
<th>By</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="revision in files" :key="revision.fileid"
:class="{ 'current-revision': revision === files[0] }">
<td>{{ revision.revision }}</td>
<td>
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)">
{{ revision.filename }}
</a>
<span v-if="revision === files[0]" class="badge badge-success">current</span>
</td>
<td>{{ formatSize(revision.filesize) }}</td>
<td :title="revision.uploadeddate">{{ revision.uploadedby }}</td>
<td>{{ revision.uploadnote || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm"
@click="removeRevision(revision)">Delete</button>
</td>
</tr>
<tr v-if="files.length === 0">
<td colspan="6" class="empty-state">No print file uploaded yet</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="section-card"> <div class="section-card">
<h3 class="section-title">Recent transactions</h3> <h3 class="section-title">Recent transactions</h3>
<div class="table-container"> <div class="table-container">
@@ -151,6 +202,7 @@ onMounted(async () => {
try { try {
const response = await printedpartsApi.get(route.params.id) const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data item.value = response.data.data
await loadFiles()
} catch (loadError) { } catch (loadError) {
console.error('Error loading printed item:', loadError) console.error('Error loading printed item:', loadError)
} finally { } 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 ledgerOpen = ref(false)
const ledgerMode = ref('restock') const ledgerMode = ref('restock')
const ledgerQuantity = ref(null) const ledgerQuantity = ref(null)
@@ -233,5 +337,13 @@ function formatDate(value) {
<style scoped> <style scoped>
.hero-actions { margin-top: 0.75rem; } .hero-actions { margin-top: 0.75rem; }
.qty-out { color: var(--danger); } .qty-out { color: var(--danger); }
.file-upload-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.current-revision td { font-weight: 600; }
.qty-in { color: var(--success); } .qty-in { color: var(--success); }
</style> </style>

View File

@@ -49,6 +49,22 @@
</p> </p>
</div> </div>
<div class="form-group">
<label>Alert roles</label>
<div class="user-picker">
<label v-for="role in roles" :key="role.roleid" class="user-row">
<input type="checkbox" :value="String(role.roleid)"
v-model="selectedRoleids" />
<span>{{ role.rolename }}</span>
<span class="user-email">{{ role.description }}</span>
</label>
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
</div>
<p class="field-hint">
Every active member of a selected role receives low-stock alerts.
</p>
</div>
<div class="form-group"> <div class="form-group">
<label>Additional alert emails</label> <label>Additional alert emails</label>
<input v-model="values.printedparts_alert_email" type="text" <input v-model="values.printedparts_alert_email" type="text"
@@ -76,7 +92,8 @@ const KEYS = [
'printedparts_default_threshold', 'printedparts_default_threshold',
'printedparts_unknown_badge', 'printedparts_unknown_badge',
'printedparts_alert_email', 'printedparts_alert_email',
'printedparts_alert_userids' 'printedparts_alert_userids',
'printedparts_alert_roleids'
] ]
const values = ref({ const values = ref({
@@ -84,10 +101,13 @@ const values = ref({
printedparts_default_threshold: 5, printedparts_default_threshold: 5,
printedparts_unknown_badge: 'deny', printedparts_unknown_badge: 'deny',
printedparts_alert_email: '', printedparts_alert_email: '',
printedparts_alert_userids: '' printedparts_alert_userids: '',
printedparts_alert_roleids: ''
}) })
const users = ref([]) const users = ref([])
const selectedUserids = ref([]) const selectedUserids = ref([])
const roles = ref([])
const selectedRoleids = ref([])
const saving = ref(false) const saving = ref(false)
const message = ref('') const message = ref('')
const error = ref('') const error = ref('')
@@ -106,6 +126,10 @@ onMounted(async () => {
const usersResponse = await usersApi.list() const usersResponse = await usersApi.list()
users.value = (usersResponse.data.data || []).filter( users.value = (usersResponse.data.data || []).filter(
candidate => candidate.isactive && candidate.email) 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) { } catch (loadError) {
error.value = 'Could not load settings' error.value = 'Could not load settings'
console.error(loadError) console.error(loadError)
@@ -118,6 +142,7 @@ async function save() {
error.value = '' error.value = ''
try { try {
values.value.printedparts_alert_userids = selectedUserids.value.join(',') values.value.printedparts_alert_userids = selectedUserids.value.join(',')
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
for (const key of KEYS) { for (const key of KEYS) {
await settingsApi.update(key, String(values.value[key] ?? '')) await settingsApi.update(key, String(values.value[key] ?? ''))
} }

View File

@@ -268,7 +268,7 @@ def _alert_recipients():
"""Merge selected shopdb users' account emails with the free-text list. """Merge selected shopdb users' account emails with the free-text list.
Empty result means fall back to the site-wide alert_recipients.""" Empty result means fall back to the site-wide alert_recipients."""
from shopdb.api import User from shopdb.api import User, Role
recipients = [] recipients = []
userids = (Setting.get('printedparts_alert_userids') or '').strip() userids = (Setting.get('printedparts_alert_userids') or '').strip()
for rawid in userids.split(','): for rawid in userids.split(','):
@@ -278,6 +278,15 @@ def _alert_recipients():
user = db.session.get(User, int(rawid)) user = db.session.get(User, int(rawid))
if user and user.isactive and user.email: if user and user.isactive and user.email:
recipients.append(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() extra = (Setting.get('printedparts_alert_email') or '').strip()
recipients.extend(address.strip() for address in extra.split(',') recipients.extend(address.strip() for address in extra.split(',')
if address.strip()) if address.strip())
@@ -525,3 +534,126 @@ def report_by_person():
if request.args.get('format') == 'csv': if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-by-person.csv') return _csv_response(rows, columns, 'printedparts-by-person.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days}) 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.""" """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, 'reason': self.reason,
'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None, '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.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, Setting from shopdb.api import db, Setting
from .models import PrintedItem, PrintedItemTransaction from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
from .api import printedparts_bp from .api import printedparts_bp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ class PrintedpartsPlugin(BasePlugin):
return printedparts_bp return printedparts_bp
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
return [PrintedItem, PrintedItemTransaction] return [PrintedItem, PrintedItemTransaction, PrintedItemFile]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:
logger.info(f'Printedparts plugin initialized (v{self.meta.version})') logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
@@ -136,6 +136,9 @@ class PrintedpartsPlugin(BasePlugin):
('printedparts_alert_userids', '', 'string', ('printedparts_alert_userids', '', 'string',
'Comma-separated shopdb user ids whose account emails receive ' 'Comma-separated shopdb user ids whose account emails receive '
'low-stock alerts'), '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: for key, value, valuetype, description in defaults:
if Setting.get(key) is None: if Setting.get(key) is None:

View File

@@ -45,6 +45,7 @@ from shopdb.core.models import (
AssetRelationship, AssetRelationship,
RelationshipType, RelationshipType,
User, User,
Role,
) )
# Response + pagination helpers for plugin API blueprints # Response + pagination helpers for plugin API blueprints
@@ -271,6 +272,7 @@ __all__ = [
'send_email', 'send_email',
'send_alert', 'send_alert',
'User', 'User',
'Role',
# CMMC USB check-in/out database # CMMC USB check-in/out database
'cmmc_usb_connection', 'cmmc_usb_connection',
] ]

View File

@@ -53,7 +53,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'measuringtools': ('measuringtooltypes', 'measuringtools'), 'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'), 'notifications': ('notificationtypes', 'notifications'),
'printedparts': ('printeditems', 'printeditemtransactions'), 'printedparts': ('printeditems', 'printeditemtransactions',
'printeditemfiles'),
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'), 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
'slides': ('tvslides',), 'slides': ('tvslides',),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),

View File

@@ -55,7 +55,7 @@ EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor. # usb drops the dead usbcheckouts.machineid column on top of its anchor.
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
# printedparts is post-cutover: its 0001 really creates its tables. # 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. # notifications indexes businessunitid on top of its anchor.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx' EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'

View File

@@ -260,3 +260,69 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
assert client.post(f'/api/printedparts/items/{item}/restore', assert client.post(f'/api/printedparts/items/{item}/restore',
headers=auth_headers).status_code == 200 headers=auth_headers).status_code == 200
assert client.get('/api/printedparts/kiosk/item/3DP-9001').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']