printedparts stage 15: print-file revision history + role-based alerts
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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] ?? ''))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
Reference in New Issue
Block a user