The tap-anywhere wedge refocus stole focus from the manual-entry field the moment it was tapped - the handler now only reclaims focus from dead space, never from a real control. Manual entry works without a physical keyboard: badge entry uses the TouchKeypad (an SSO is digits), and item lookup accepts bare digits resolved by row id - the digits in a minted code are the id, which also keeps labels printed under an older prefix scannable after the prefix changes.
674 lines
26 KiB
Python
674 lines
26 KiB
Python
"""Printedparts plugin API routes.
|
|
|
|
Reads are open (jwt optional) like every list surface; mutations arrive in
|
|
later stages with permission gates. The kiosk endpoints (unauthenticated by
|
|
explicit decision - see the proposal) also land later.
|
|
"""
|
|
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required
|
|
from sqlalchemy import or_
|
|
|
|
from shopdb.api import (
|
|
db,
|
|
success_response,
|
|
error_response,
|
|
paginated_response,
|
|
ErrorCodes,
|
|
get_pagination_params,
|
|
paginate_query,
|
|
require_permission,
|
|
)
|
|
|
|
from ..models import PrintedItem
|
|
|
|
printedparts_bp = Blueprint('printedparts', __name__)
|
|
|
|
|
|
@printedparts_bp.route('/items', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_items():
|
|
"""List printed items, paginated; search + low-stock filter."""
|
|
page, per_page = get_pagination_params(request)
|
|
query = PrintedItem.query
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(PrintedItem.isactive == True)
|
|
if search := request.args.get('search'):
|
|
like = f'%{search}%'
|
|
query = query.filter(or_(
|
|
PrintedItem.itemcode.ilike(like),
|
|
PrintedItem.itemname.ilike(like),
|
|
PrintedItem.itemdescription.ilike(like),
|
|
PrintedItem.binlocation.ilike(like),
|
|
))
|
|
if request.args.get('lowstock', '').lower() == 'true':
|
|
query = query.filter(
|
|
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
|
|
query = query.order_by(PrintedItem.itemname)
|
|
items, total = paginate_query(query, page, per_page)
|
|
return paginated_response(
|
|
[item.to_dict() for item in items], page, per_page, total)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_item(item_id: int):
|
|
"""Get one printed item with its recent transactions."""
|
|
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)
|
|
data = item.to_dict()
|
|
recent = (item.transactions
|
|
.order_by(db.desc('transactiondate'))
|
|
.limit(25).all())
|
|
data['recenttransactions'] = [t.to_dict() for t in recent]
|
|
return success_response(data)
|
|
|
|
|
|
# --- catalog mutations (stage 6 adds permission gates on top of jwt) --------
|
|
|
|
import glob
|
|
import os
|
|
|
|
from flask import current_app
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from shopdb.api import Setting
|
|
|
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
IMAGE_URL_PREFIX = '/api/printedparts/image/'
|
|
|
|
EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold',
|
|
'binlocation', 'printnotes')
|
|
|
|
|
|
def _imagedir():
|
|
return os.path.join(current_app.instance_path, 'printedpartsimages')
|
|
|
|
|
|
def _mint_itemcode(item):
|
|
"""Set itemcode from the configured prefix + the flushed row id."""
|
|
prefix = Setting.get('printedparts_code_prefix') or '3DP'
|
|
item.itemcode = f'{prefix}{item.printeditemid:04d}'
|
|
|
|
|
|
@printedparts_bp.route('/items', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.create')
|
|
def create_item():
|
|
"""Create a printed item; the itemcode is minted from the row id."""
|
|
data = request.get_json() or {}
|
|
itemname = (data.get('itemname') or '').strip()
|
|
if not itemname:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
|
|
|
|
threshold = data.get('lowstockthreshold')
|
|
if threshold is None:
|
|
threshold = int(Setting.get('printedparts_default_threshold') or 5)
|
|
|
|
item = PrintedItem(
|
|
itemname=itemname,
|
|
itemdescription=data.get('itemdescription'),
|
|
lowstockthreshold=threshold,
|
|
binlocation=data.get('binlocation'),
|
|
printnotes=data.get('printnotes'),
|
|
quantityonhand=0,
|
|
)
|
|
db.session.add(item)
|
|
db.session.flush() # assigns printeditemid
|
|
_mint_itemcode(item)
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Printed item created',
|
|
http_code=201)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.edit')
|
|
def update_item(item_id: int):
|
|
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
|
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)
|
|
data = request.get_json() or {}
|
|
if 'quantityonhand' in data:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'quantityonhand is ledger-managed; use restock or adjust')
|
|
for field in EDITABLE_FIELDS:
|
|
if field in data:
|
|
setattr(item, field, data[field])
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Printed item updated')
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.delete')
|
|
def delete_item(item_id: int):
|
|
"""Soft-retire an item; its ledger history stays."""
|
|
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)
|
|
item.isactive = False
|
|
db.session.commit()
|
|
return success_response(message='Printed item retired')
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/restore', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.delete')
|
|
def restore_item(item_id: int):
|
|
"""Bring a retired item back; code, photo, and history are intact."""
|
|
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)
|
|
item.isactive = True
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Printed item restored')
|
|
|
|
|
|
# --- item image: the models.py upload/serve/delete trio ---------------------
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.edit')
|
|
def upload_item_image(item_id: int):
|
|
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
|
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 IMAGE_EXTENSIONS:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'Unsupported image type {ext}')
|
|
|
|
imagedir = _imagedir()
|
|
os.makedirs(imagedir, exist_ok=True)
|
|
for old in glob.glob(os.path.join(
|
|
imagedir, secure_filename(f'printeditem-{item_id}') + '.*')):
|
|
os.remove(old)
|
|
|
|
filename = secure_filename(f'printeditem-{item_id}{ext}')
|
|
upload.save(os.path.join(imagedir, filename))
|
|
item.imageurl = f'{IMAGE_URL_PREFIX}{filename}'
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Item image uploaded')
|
|
|
|
|
|
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
|
|
def serve_item_image(filename):
|
|
"""Serve an uploaded item image (public - kiosk and list read it)."""
|
|
from flask import send_from_directory
|
|
return send_from_directory(_imagedir(), filename)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.delete')
|
|
def delete_item_image(item_id: int):
|
|
"""Clear an item image; delete the file only if this plugin owns it."""
|
|
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)
|
|
url = item.imageurl or ''
|
|
if url.startswith(IMAGE_URL_PREFIX):
|
|
filename = secure_filename(url[len(IMAGE_URL_PREFIX):])
|
|
path = os.path.join(_imagedir(), filename)
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
item.imageurl = None
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Item image removed')
|
|
|
|
|
|
# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock)
|
|
|
|
from ..models import PrintedItemTransaction
|
|
from ..services.badges import BadgeError, resolve_badge
|
|
|
|
|
|
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None):
|
|
"""Append a ledger row and move the cached quantity in ONE commit.
|
|
|
|
The single-commit invariant is what keeps quantityonhand equal to the
|
|
ledger sum; every write path must go through here. Fires the low-stock
|
|
alert when this write CROSSES the item's threshold downward - crossing
|
|
(not being below) is the natural debounce: one alert per depletion, and
|
|
restocking above the threshold rearms it.
|
|
"""
|
|
quantitybefore = item.quantityonhand
|
|
item.quantityonhand += quantitychange
|
|
db.session.add(PrintedItemTransaction(
|
|
printeditemid=item.printeditemid,
|
|
transactiontype=transactiontype,
|
|
quantitychange=quantitychange,
|
|
employeesso=sso,
|
|
employeename=name,
|
|
reason=reason,
|
|
))
|
|
db.session.commit()
|
|
if (quantitychange < 0
|
|
and quantitybefore > item.lowstockthreshold
|
|
and item.quantityonhand <= item.lowstockthreshold):
|
|
_send_lowstock_alert(item)
|
|
|
|
|
|
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, Role
|
|
recipients = []
|
|
userids = (Setting.get('printedparts_alert_userids') or '').strip()
|
|
for rawid in userids.split(','):
|
|
rawid = rawid.strip()
|
|
if not rawid.isdigit():
|
|
continue
|
|
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())
|
|
# dedupe, order-preserving
|
|
return list(dict.fromkeys(recipients))
|
|
|
|
|
|
def _send_lowstock_alert(item):
|
|
"""Best-effort email when an item crosses its low-stock threshold.
|
|
|
|
Recipients: Setting printedparts_alert_email (comma-separated), falling
|
|
back to the site's alert_recipients. Never fails the transaction - the
|
|
ledger write already committed."""
|
|
from shopdb.api import send_email, send_alert
|
|
subject = (f'Low stock: {item.itemname} ({item.itemcode}) - '
|
|
f'{item.quantityonhand} left')
|
|
html = (f'<p><strong>{item.itemname}</strong> ({item.itemcode}) is down '
|
|
f'to <strong>{item.quantityonhand}</strong> '
|
|
f'(threshold {item.lowstockthreshold}).</p>'
|
|
f'<p>Bin: {item.binlocation or "-"}</p>'
|
|
f'<p>Time to print more.</p>')
|
|
try:
|
|
recipients = _alert_recipients()
|
|
if recipients:
|
|
send_email(recipients, subject, html)
|
|
else:
|
|
send_alert(subject, html)
|
|
except Exception:
|
|
import logging
|
|
logging.getLogger(__name__).exception(
|
|
'Low-stock alert failed for %s', item.itemcode)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.restock')
|
|
def restock_item(item_id: int):
|
|
"""Add freshly printed stock. Body: {quantity, badge}."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item or not item.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
quantity = data.get('quantity')
|
|
if not isinstance(quantity, int) or quantity < 1:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'quantity must be a positive integer')
|
|
try:
|
|
sso, name = resolve_badge(data.get('badge'))
|
|
except BadgeError as badge_error:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
|
http_code=422)
|
|
_ledger_write(item, 'restock', quantity, sso, name)
|
|
return success_response(item.to_dict(), message='Stock added')
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.restock')
|
|
def adjust_item(item_id: int):
|
|
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item or not item.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
quantitychange = data.get('quantitychange')
|
|
if not isinstance(quantitychange, int) or quantitychange == 0:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'quantitychange must be a non-zero integer')
|
|
reason = (data.get('reason') or '').strip()
|
|
if not reason:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'reason is required for an adjustment')
|
|
if item.quantityonhand + quantitychange < 0:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Adjustment would drive stock below zero '
|
|
f'(on hand: {item.quantityonhand})')
|
|
try:
|
|
sso, name = resolve_badge(data.get('badge'))
|
|
except BadgeError as badge_error:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
|
http_code=422)
|
|
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
|
return success_response(item.to_dict(), message='Stock adjusted')
|
|
|
|
|
|
# --- kiosk: UNauthenticated by decision record --------------------------------
|
|
# The take endpoint is the product's first open WRITE. The proposal's decision
|
|
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
|
|
# physically rate-limited. It can reduce stock of an active item and nothing
|
|
# else; identity comes from the badge resolved server-side, never the client.
|
|
|
|
def _kiosk_find_item(itemcode):
|
|
"""Resolve a scanned or typed code to an active item.
|
|
|
|
Accepts the full code (WJRP0042) or bare digits from the touch keypad
|
|
(42 -> prefix + zero-pad), so manual entry never needs letters."""
|
|
itemcode = (itemcode or '').strip()
|
|
item = PrintedItem.query.filter(
|
|
PrintedItem.itemcode == itemcode,
|
|
PrintedItem.isactive == True).first()
|
|
if not item and itemcode.isdigit():
|
|
# The digits in a minted code ARE the row id, so id lookup keeps
|
|
# working even for labels printed under an older prefix.
|
|
candidate = db.session.get(PrintedItem, int(itemcode))
|
|
if candidate and candidate.isactive:
|
|
item = candidate
|
|
return item
|
|
|
|
|
|
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
|
|
def kiosk_item(itemcode):
|
|
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
|
|
item = _kiosk_find_item(itemcode)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'No part matches that barcode', http_code=404)
|
|
return success_response(item.to_dict())
|
|
|
|
|
|
@printedparts_bp.route('/kiosk/take', methods=['POST'])
|
|
def kiosk_take():
|
|
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
|
|
data = request.get_json() or {}
|
|
|
|
item = _kiosk_find_item(data.get('itemcode'))
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'No part matches that barcode', http_code=404)
|
|
|
|
quantity = data.get('quantity')
|
|
if not isinstance(quantity, int) or quantity < 1:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Enter how many you are taking')
|
|
if quantity > item.quantityonhand:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Only {item.quantityonhand} on hand - take fewer or see the '
|
|
f'parts team')
|
|
|
|
try:
|
|
sso, name = resolve_badge(data.get('badge'))
|
|
except BadgeError as badge_error:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
|
http_code=422)
|
|
|
|
_ledger_write(item, 'take', -quantity, sso, name)
|
|
return success_response(item.to_dict(),
|
|
message=f'Took {quantity}, {item.quantityonhand} left')
|
|
|
|
|
|
# --- reports (merged into GET /api/reports while the plugin is enabled) ------
|
|
|
|
import csv
|
|
import io
|
|
|
|
from flask import Response
|
|
from sqlalchemy import func
|
|
|
|
|
|
def _csv_response(rows, columns, filename):
|
|
"""CSV download; local helper because generate_csv is not on the
|
|
contract surface (shopdb.api)."""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(columns)
|
|
for row in rows:
|
|
writer.writerow([row.get(column, '') for column in columns])
|
|
return Response(
|
|
output.getvalue(), mimetype='text/csv',
|
|
headers={'Content-Disposition': f'attachment; filename={filename}'})
|
|
|
|
|
|
@printedparts_bp.route('/reports/stock', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def report_stock():
|
|
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
|
|
|
|
ledgerdelta should always be 0; anything else means a write path
|
|
bypassed the single-commit rule and needs finding.
|
|
"""
|
|
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
|
|
# string and breaks the delta arithmetic's type.
|
|
ledger = {itemid: int(total) for itemid, total in
|
|
db.session.query(
|
|
PrintedItemTransaction.printeditemid,
|
|
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
|
|
.group_by(PrintedItemTransaction.printeditemid).all()}
|
|
rows = []
|
|
for item in PrintedItem.query.filter_by(isactive=True).order_by(
|
|
PrintedItem.itemname).all():
|
|
rows.append({
|
|
'itemcode': item.itemcode,
|
|
'itemname': item.itemname,
|
|
'binlocation': item.binlocation or '',
|
|
'quantityonhand': item.quantityonhand,
|
|
'lowstockthreshold': item.lowstockthreshold,
|
|
'islowstock': item.islowstock,
|
|
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
|
|
})
|
|
columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand',
|
|
'lowstockthreshold', 'islowstock', 'ledgerdelta']
|
|
if request.args.get('format') == 'csv':
|
|
return _csv_response(rows, columns, 'printedparts-stock.csv')
|
|
return success_response({'columns': columns, 'rows': rows})
|
|
|
|
|
|
@printedparts_bp.route('/reports/consumption', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def report_consumption():
|
|
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
|
|
days = request.args.get('days', 30, type=int)
|
|
query = (db.session.query(
|
|
PrintedItem.itemcode,
|
|
PrintedItem.itemname,
|
|
func.count(PrintedItemTransaction.transactionid),
|
|
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
|
|
.join(PrintedItemTransaction,
|
|
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
|
|
.filter(PrintedItemTransaction.transactiontype == 'take'))
|
|
if days > 0:
|
|
from datetime import datetime, timedelta, timezone
|
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
|
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
|
|
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
|
|
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
|
|
'quantitytaken': int(taken)}
|
|
for code, name, takes, taken in query.all()]
|
|
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
|
|
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
|
|
if request.args.get('format') == 'csv':
|
|
return _csv_response(rows, columns, 'printedparts-consumption.csv')
|
|
return success_response({'columns': columns, 'rows': rows, 'days': days})
|
|
|
|
|
|
@printedparts_bp.route('/reports/by-person', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def report_by_person():
|
|
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
|
|
days = request.args.get('days', 30, type=int)
|
|
query = (db.session.query(
|
|
PrintedItemTransaction.employeesso,
|
|
func.max(PrintedItemTransaction.employeename),
|
|
func.count(PrintedItemTransaction.transactionid),
|
|
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
|
|
.filter(PrintedItemTransaction.transactiontype == 'take'))
|
|
if days > 0:
|
|
from datetime import datetime, timedelta, timezone
|
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
|
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
|
|
query = query.group_by(PrintedItemTransaction.employeesso)
|
|
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
|
|
'quantitytaken': int(taken)}
|
|
for sso, name, takes, taken in query.all()]
|
|
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
|
|
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
|
|
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')
|