Files
shopdb-flask/plugins/printedparts/api/routes.py
cproudlock 151d7c4f44 Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one
feature at a time: a securesend_allow_guests setting, an enable flag per kiosk,
and very nearly a third for guides - each with its own settings page, its own
vocabulary and its own hand-written branch inside a route. Every one of them
answered a permissions question, and this app already administers permissions
per role.

So a caller with no token is not nobody: they hold the seeded `guest` role.
require_permission consults it when there is no identity, which is the entire
mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a
site opens one by ticking a box in the same grid it uses for every other role,
and closes it the same way. Revocation is read on every request rather than
cached, because taking guest access away is what somebody does the moment they
realise what they opened, and it has to bite immediately.

The redundant @jwt_required() above require_permission is gone from 184 routes.
It was belt-and-braces before; now it is actively wrong, because it refuses a
request with no token before the permission check can have an opinion, so a
grant would appear to do nothing.

What a guest may hold is bounded. Any .view or .read permission is grantable.
A WRITE has to be blessed by whoever owns it, through the new
get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the
only case that exists: sending a password to a vendor is the one write this
product means to offer somebody who never signs in. Everything else is refused
by the API and greyed out in the grid rather than warned about - a checkbox
that quietly makes deleting an asset anonymous is not a warning anybody reads
twice. A guest write records no creator: the trail says when and from where,
never who, and that is the reason for the bar.

GE-Enforce and Slides gained read permissions so they can be opened to guests
without handing out the manage right. Their read routes accept either through
require_any_permission, so nothing an existing manager could reach has moved.

The navigation endpoint now returns only what the caller can actually reach.
Without that a signed-out visitor gets a sidebar of the whole app where every
link bounces off the login page, which reads as a broken site rather than as a
closed door. A nav item may name several permissions; holding any one is
enough, which is what GE-Enforce and Slides need now that a viewer and a
manager hold different strings.

Two things this found on the way: current_user is a LocalProxy, so
`current_user is None` is False for a request with nobody behind it - harmless
under a required JWT, an AttributeError the moment verification became
optional. And an image pasted into a guide before its first save was never
attached to it, so it was visible to editors and invisible to every reader;
saving now attaches it, and `flask guides fix-images` repairs what is stored.

The guides guest setting written earlier today is gone. Granting guides.view to
the guest role replaces it, which is the point.
2026-08-27 12:51:58 -04:00

751 lines
30 KiB
Python

"""Printedparts plugin API routes.
Access model: browsing the catalog (items, detail, file listings) requires
the printedparts.view permission; every mutation carries its own permission.
Deliberately open: the kiosk endpoints (decision record in the proposal),
the image serve and file download (fetched by <img> tags and anchor
downloads, which cannot carry a JWT header), and the reports (jwt-optional
like every other report in the product).
"""
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'])
@require_permission('printedparts.view')
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.gagelabtag.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'])
@require_permission('printedparts.view')
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'])
@require_permission('printedparts.create')
def create_item():
"""Create a printed item.
The internal itemcode is always auto-minted; the OPTIONAL gagelabtag
carries the gage lab's assigned WJRP asset number (unique-checked)."""
data = request.get_json() or {}
itemname = (data.get('itemname') or '').strip()
if not itemname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
if gagelabtag and PrintedItem.query.filter_by(gagelabtag=gagelabtag).first():
return error_response(ErrorCodes.CONFLICT,
f'Gage lab tag {gagelabtag} is already in use',
http_code=409)
threshold = data.get('lowstockthreshold')
if threshold is None:
threshold = int(Setting.get('printedparts_default_threshold') or 5)
item = PrintedItem(
itemname=itemname,
gagelabtag=gagelabtag or None,
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'])
@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')
if 'gagelabtag' in data:
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
if gagelabtag:
clash = PrintedItem.query.filter(
PrintedItem.gagelabtag == gagelabtag,
PrintedItem.printeditemid != item.printeditemid).first()
if clash:
return error_response(
ErrorCodes.CONFLICT,
f'Gage lab tag {gagelabtag} is already in use',
http_code=409)
item.gagelabtag = gagelabtag or None
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'])
@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'])
@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'])
@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)."""
return send_upload(_imagedir(), filename)
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
@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,
revision=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,
revision=revision,
))
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_webhook, Setting
code = item.gagelabtag or item.itemcode
subject = (f'Low stock: {item.itemname} ({code}) - '
f'{item.quantityonhand} left')
# Absolute link to the item page when the site base URL is configured
# (e.g. https://<host>/ops). Emails have no request context to derive it.
base = (Setting.get('site_base_url') or '').rstrip('/')
item_url = f'{base}/printedparts/{item.printeditemid}' if base else ''
link_html = f'<p><a href="{item_url}">View {code}</a></p>' if item_url else ''
html = (f'<p><strong>{item.itemname}</strong> ({code}) is down '
f'to <strong>{item.quantityonhand}</strong> '
f'(threshold {item.lowstockthreshold}).</p>'
f'<p>Bin: {item.binlocation or "-"}</p>'
f'{link_html}'
f'<p>Time to print more.</p>')
webhook_text = (f'**{item.itemname}** ({code}) is down to '
f'{item.quantityonhand} (threshold {item.lowstockthreshold}). '
f'Bin: {item.binlocation or "-"}.'
+ (f' [View]({item_url})' if item_url else ''))
try:
# Webhook: the selected support team's own webhook, else the site
# default (send_webhook falls back to alert_webhook_url when url=None).
send_webhook(subject, webhook_text, url=_alert_team_webhook())
# Email: this plugin's recipients, else the site's alert_recipients.
recipients = _alert_recipients() or _site_alert_recipients()
if recipients:
send_email(recipients, subject, html)
except Exception:
import logging
logging.getLogger(__name__).exception(
'Low-stock alert failed for %s', item.itemcode)
def _site_alert_recipients():
"""Site-wide alert_recipients setting as a clean list (email fallback)."""
from shopdb.api import Setting
raw = Setting.get('alert_recipients') or ''
return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()]
def _alert_team_webhook():
"""Webhook URL of the support team chosen for printedparts alerts, or None.
printedparts_alert_supportteamid selects a SupportTeam; alerts route to that
team's webhook. None -> send_webhook uses the site-wide default."""
from shopdb.api import Setting
team_id = Setting.get('printedparts_alert_supportteamid')
if not team_id:
return None
try:
from shopdb.api import SupportTeam
team = db.session.get(SupportTeam, int(team_id))
except (ValueError, TypeError):
return None
return (team.webhookurl or None) if team else None
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
@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'])
@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. A label QR
carries 'TAG|rev'; the revision is stripped here (it identifies the physical
part's file rev, not a different item)."""
scanned = (itemcode or '').split('|', 1)[0].strip().upper()
item = PrintedItem.query.filter(
or_(PrintedItem.itemcode == scanned,
PrintedItem.gagelabtag == scanned),
PrintedItem.isactive == True).first()
if not item and scanned.isdigit():
# Bare digits from the touch keypad match the NUMBER inside either
# identifier (internal code or gage-lab tag). Small catalog: scan
# actives and compare numeric tails; only a UNIQUE match counts.
wanted = int(scanned)
matches = []
for candidate in PrintedItem.query.filter_by(isactive=True).all():
for value in (candidate.itemcode, candidate.gagelabtag):
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
if tail and int(tail) == wanted:
matches.append(candidate)
break
if len(matches) == 1:
item = matches[0]
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)
# Print-file revision the scanned part carried: explicit field, else the
# 'TAG|rev' tail of the scanned code. Recorded for traceability.
revision = data.get('revision')
if revision is None and '|' in (data.get('itemcode') or ''):
tail = data['itemcode'].split('|', 1)[1].strip()
revision = tail if tail.isdigit() else None
if revision is not None:
try:
revision = int(revision)
except (ValueError, TypeError):
revision = None
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
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 shopdb.api import send_upload
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'])
@require_permission('printedparts.view')
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'])
@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."""
record = db.session.get(PrintedItemFile, file_id)
if not record:
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
http_code=404)
return send_upload(_filedir(), record.storedfilename,
as_attachment=True,
download_name=record.filename)
@printedparts_bp.route('/files/<int:file_id>', methods=['DELETE'])
@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')