Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -368,6 +368,48 @@ def register_error_handlers(app: Flask):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'The database rejected that change.', http_code=400)
|
||||
|
||||
# STRICT_TRANS_TABLES (see config.SQLALCHEMY_ENGINE_OPTIONS) turns what
|
||||
# MySQL used to silently adjust into an error: a value too long for its
|
||||
# column, a date that is not a date, a number that is not a number. That is
|
||||
# the behaviour we want - the alternative is a 201 for a row the caller did
|
||||
# not send - but the error is the CALLER's, so it must not read as a server
|
||||
# fault. Without this it surfaced as a 500 with a traceback in the log.
|
||||
from sqlalchemy.exc import DataError
|
||||
|
||||
@app.errorhandler(DataError)
|
||||
def handle_data_error(error):
|
||||
from .extensions import db
|
||||
db.session.rollback()
|
||||
message = str(getattr(error, 'orig', error))
|
||||
app.logger.warning('data error: %s', message)
|
||||
column = _column_from_data_error(message)
|
||||
if 'too long' in message.lower():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'That value is too long for %s.' % (column or 'this field'),
|
||||
http_code=400)
|
||||
if 'incorrect date' in message.lower() or \
|
||||
'incorrect datetime' in message.lower():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'That is not a valid date for %s.' % (column or 'this field'),
|
||||
http_code=400)
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'The database rejected that value%s.'
|
||||
% (' for %s' % column if column else ''),
|
||||
http_code=400)
|
||||
|
||||
# A wrong query argument is the caller's mistake. Registered app-wide so a
|
||||
# list route does not have to remember to catch it - forgetting was the
|
||||
# whole defect, in six routes across five plugins.
|
||||
from .utils.queryargs import QueryArgError
|
||||
|
||||
@app.errorhandler(QueryArgError)
|
||||
def handle_query_arg_error(error):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(error),
|
||||
http_code=400)
|
||||
|
||||
@app.errorhandler(ShopDBException)
|
||||
def handle_shopdb_exception(error):
|
||||
http_codes = {
|
||||
@@ -393,6 +435,48 @@ def register_error_handlers(app: Flask):
|
||||
http_code=404
|
||||
)
|
||||
|
||||
# Werkzeug answers these three itself, in HTML, so a JSON client asking a
|
||||
# question slightly wrong got a page of markup with no error code and no
|
||||
# envelope - and on a route the caller cannot see, the 405 also confirmed
|
||||
# that the path exists, which is how a gated kiosk route meant to look
|
||||
# absent announced itself.
|
||||
#
|
||||
# Only under /api/. Everything else is the SPA, which needs the framework's
|
||||
# own behaviour (and its own catch-all) left alone.
|
||||
from flask import request as flask_request
|
||||
|
||||
def _api_request():
|
||||
return flask_request.path.startswith('/api/')
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed_error(error):
|
||||
if not _api_request():
|
||||
return error
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'That method is not allowed on this endpoint.',
|
||||
http_code=405)
|
||||
|
||||
@app.errorhandler(415)
|
||||
def unsupported_media_type_error(error):
|
||||
if not _api_request():
|
||||
return error
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'This endpoint expects a JSON body; send '
|
||||
'Content-Type: application/json.',
|
||||
http_code=415)
|
||||
|
||||
@app.errorhandler(400)
|
||||
def bad_request_error(error):
|
||||
if not _api_request():
|
||||
return error
|
||||
# Werkzeug raises this for malformed JSON before any route sees it.
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'The request body could not be read as JSON.',
|
||||
http_code=400)
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
return error_response(
|
||||
@@ -410,6 +494,20 @@ def register_error_handlers(app: Flask):
|
||||
)
|
||||
|
||||
|
||||
def _column_from_data_error(message):
|
||||
"""Pull the column name out of a MySQL data error, when it names one.
|
||||
|
||||
MySQL says "Data too long for column 'username' at row 1"; SQLite says
|
||||
nothing of the sort. Best effort on purpose - a message that names the
|
||||
field is much more useful than a generic one, and a message that names
|
||||
nothing is still better than a 500.
|
||||
"""
|
||||
import re
|
||||
|
||||
found = re.search(r"column '([^']+)'", message or '')
|
||||
return found.group(1) if found else None
|
||||
|
||||
|
||||
def register_frontend_routes(app: Flask):
|
||||
"""Serve Vue frontend static files."""
|
||||
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')
|
||||
|
||||
@@ -110,6 +110,19 @@ from shopdb.utils.uploads import send_upload, harden_upload_response
|
||||
# nothing. See shopdb/utils/clientip.py.
|
||||
from shopdb.utils.clientip import client_ip, strip_port
|
||||
|
||||
# Strict date parsing. On the contract surface because every plugin that stores
|
||||
# a date needs it and the two that rolled their own both swallowed the error.
|
||||
from shopdb.utils.queryargs import QueryArgError, intarg, intargs
|
||||
|
||||
# One badge vocabulary for the site: both kiosks read the same physical badges.
|
||||
from shopdb.utils.badges import BadgeShapeError, badge_kind, normalise_badge
|
||||
|
||||
from shopdb.utils.datevalues import (
|
||||
DateValueError,
|
||||
parse_date,
|
||||
parse_date_fields,
|
||||
)
|
||||
|
||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||
from shopdb.utils.import_mode import (
|
||||
apply_import_timestamps,
|
||||
@@ -350,6 +363,15 @@ __all__ = [
|
||||
# Response + pagination helpers
|
||||
'success_response',
|
||||
'error_response',
|
||||
'DateValueError',
|
||||
'QueryArgError',
|
||||
'BadgeShapeError',
|
||||
'badge_kind',
|
||||
'normalise_badge',
|
||||
'intarg',
|
||||
'intargs',
|
||||
'parse_date',
|
||||
'parse_date_fields',
|
||||
'paginated_response',
|
||||
'ErrorCodes',
|
||||
'get_pagination_params',
|
||||
|
||||
@@ -38,7 +38,26 @@ class Config:
|
||||
# Pin the client connection to utf8mb4 so reads/writes match the
|
||||
# utf8mb4 schema regardless of the server default charset. TestingConfig
|
||||
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
|
||||
'connect_args': {'charset': 'utf8mb4'},
|
||||
#
|
||||
# STRICT_TRANS_TABLES is the other half, and it is a correctness
|
||||
# setting rather than a preference. Without it MySQL accepts a write it
|
||||
# cannot store and adjusts it: a 257-character username becomes the
|
||||
# first 100 characters, a malformed date becomes '0000-00-00', an
|
||||
# over-long asset number is cut at 50 and then collides with a
|
||||
# different one. Every one of those was found in the product answering
|
||||
# 200 or 201, because nothing in the stack knew the value had changed.
|
||||
#
|
||||
# Set on the CONNECTION rather than left to the server, so a site whose
|
||||
# my.cnf says otherwise still gets it - the sql_mode seen on the 5.6
|
||||
# dev server was NO_ENGINE_SUBSTITUTION alone. NO_ENGINE_SUBSTITUTION is
|
||||
# kept alongside it, since dropping it would change how a CREATE TABLE
|
||||
# naming an unavailable engine behaves.
|
||||
'connect_args': {
|
||||
'charset': 'utf8mb4',
|
||||
'init_command':
|
||||
"SET SESSION sql_mode='STRICT_TRANS_TABLES,"
|
||||
"NO_ENGINE_SUBSTITUTION'",
|
||||
},
|
||||
}
|
||||
|
||||
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
|
||||
|
||||
@@ -163,7 +163,18 @@ def update_apitoken(tokenid: int):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty')
|
||||
token.name = newname
|
||||
if 'isactive' in data:
|
||||
token.isactive = bool(data['isactive'])
|
||||
# Revocation is ONE WAY. A revoked token's secret is out there - that
|
||||
# is why it was revoked - and re-activating the row made the same
|
||||
# secret work again, so "revoked" meant "paused until somebody flips it
|
||||
# back". Deactivating through this route is still allowed; bringing one
|
||||
# back is not, and the answer says to mint a new one.
|
||||
wanted = bool(data['isactive'])
|
||||
if wanted and not token.isactive:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'A revoked token cannot be re-activated - its secret is '
|
||||
'already out. Create a new token instead.')
|
||||
token.isactive = wanted
|
||||
if 'scopes' in data:
|
||||
scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user)
|
||||
if scope_error is not None:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import re
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
@@ -20,6 +21,7 @@ from shopdb.utils.responses import (
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.utils.import_mode import apply_import_timestamps
|
||||
from shopdb.utils.datevalues import DateValueError, parse_date
|
||||
|
||||
|
||||
# Uploads live in the instance dir, like model images and part photos, so a
|
||||
@@ -326,6 +328,24 @@ def create_version(app_id: int):
|
||||
if not data or not data.get('version'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required')
|
||||
|
||||
# A digest is a claim the delivery path CHECKS against, so a wrong one is
|
||||
# worse than none: an over-long value was silently truncated to the column
|
||||
# width and came back as 64 plausible hex characters, indistinguishable
|
||||
# from a real SHA-256. Either it is a SHA-256 or it is refused.
|
||||
digest = (data.get('sha256') or '').strip().lower()
|
||||
if digest and not re.fullmatch(r'[0-9a-f]{64}', digest):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'sha256 must be 64 hexadecimal characters')
|
||||
|
||||
# Refused rather than stored raw: a malformed date used to reach MySQL,
|
||||
# which (before STRICT_TRANS_TABLES) wrote 0000-00-00 and then broke every
|
||||
# read of this application.
|
||||
try:
|
||||
releasedate = parse_date(data.get('releasedate'), 'releasedate')
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
|
||||
if AppVersion.query.filter_by(appid=app_id, version=data['version']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
@@ -336,9 +356,9 @@ def create_version(app_id: int):
|
||||
version = AppVersion(
|
||||
appid=app_id,
|
||||
version=data['version'],
|
||||
releasedate=data.get('releasedate'),
|
||||
releasedate=releasedate,
|
||||
notes=data.get('notes'),
|
||||
sha256=data.get('sha256'),
|
||||
sha256=digest or None,
|
||||
silentargs=data.get('silentargs')
|
||||
)
|
||||
|
||||
@@ -558,9 +578,20 @@ def upload_application_image(app_id: int):
|
||||
|
||||
|
||||
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
def serve_application_image(filename):
|
||||
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
||||
"""Serve an uploaded application image.
|
||||
|
||||
Ungated, like every other image route in the product (the branding logo,
|
||||
the map blueprint, an employee photo, a printed-part picture). A browser
|
||||
fetching <img src="/api/applications/image/application-3.png"> sends no
|
||||
Authorization header, so a permission check here does not protect the file
|
||||
- it just guarantees the picture never appears. The tile fell back to its
|
||||
broken-image handler on every page, including for an admin.
|
||||
|
||||
What is exposed is an application icon to somebody who already knows the
|
||||
filename, which is the same posture as its four siblings. Registered in
|
||||
tests/test_core/test_public_read_surface.py with that reasoning.
|
||||
"""
|
||||
return send_upload(_appimage_dir(), filename)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from shopdb.utils.responses import (
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.utils.queryargs import intarg
|
||||
|
||||
from shopdb.utils.authz import require_permission
|
||||
from shopdb.utils.import_mode import apply_import_timestamps
|
||||
@@ -365,20 +366,20 @@ def list_assets():
|
||||
query = query.join(AssetType).filter(AssetType.assettype == type_name)
|
||||
|
||||
# Type filter by ID
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Asset.assettypeid == int(type_id))
|
||||
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||
query = query.filter(Asset.assettypeid == type_id)
|
||||
|
||||
# Status filter
|
||||
if status_id := request.args.get('statusid', request.args.get('status_id')):
|
||||
query = query.filter(Asset.statusid == int(status_id))
|
||||
if (status_id := intarg('statusid', intarg('status_id'))) is not None:
|
||||
query = query.filter(Asset.statusid == status_id)
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# Sorting
|
||||
sort_by = request.args.get('sort', 'assetnumber')
|
||||
@@ -1011,17 +1012,26 @@ def get_assets_map():
|
||||
Asset.mapy.isnot(None)
|
||||
)
|
||||
|
||||
# Dualpath single-machine collapse: hide the secondary bay marker so a
|
||||
# dual-bay pair shows one marker; the primary carries the partner label.
|
||||
# Gated on the site setting (default on).
|
||||
# Dualpath single-machine collapse on the map: a secondary bay draws its
|
||||
# OWN marker when somebody has positioned it, and is represented by the
|
||||
# primary's marker when nobody has.
|
||||
#
|
||||
# It used to be excluded unconditionally, which made setting a position on
|
||||
# a secondary bay a silent no-op: the form accepted the pin, stored it, and
|
||||
# the map never drew it. One marker per pair is right when the pair sits in
|
||||
# one place, which is the common case and still the default - but a
|
||||
# dual-bay machine whose second bay is physically somewhere else is exactly
|
||||
# what positioning it separately means.
|
||||
#
|
||||
# No extra filtering is needed for the fallback half: the query above
|
||||
# already requires mapx/mapy, so an unpositioned secondary never reaches
|
||||
# here and the primary's marker stands for both, carrying the combined
|
||||
# label.
|
||||
from shopdb.core.services.dualpath import (
|
||||
resolve_dualpath_pairs, dualpath_single_machine_enabled)
|
||||
dualpath_collapse = None
|
||||
if dualpath_single_machine_enabled():
|
||||
dualpath_collapse = resolve_dualpath_pairs()
|
||||
if dualpath_collapse.secondaryassetids:
|
||||
query = query.filter(
|
||||
Asset.assetid.notin_(dualpath_collapse.secondaryassetids))
|
||||
|
||||
selected_assettype = request.args.get('assettype')
|
||||
|
||||
@@ -1030,8 +1040,7 @@ def get_assets_map():
|
||||
query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
|
||||
|
||||
# Filter by subtype (depends on asset type) - case-insensitive matching
|
||||
if subtype_id := request.args.get('subtype'):
|
||||
subtype_id = int(subtype_id)
|
||||
if (subtype_id := intarg('subtype')) is not None:
|
||||
# Normalize the underscore DB form (measuring_tool, network_device) to
|
||||
# the space form the branches below compare against.
|
||||
asset_type_lower = (
|
||||
@@ -1079,16 +1088,16 @@ def get_assets_map():
|
||||
pass
|
||||
|
||||
# Filter by business unit
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid')) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# Filter by status
|
||||
if status_id := request.args.get('statusid'):
|
||||
query = query.filter(Asset.statusid == int(status_id))
|
||||
if (status_id := intarg('statusid')) is not None:
|
||||
query = query.filter(Asset.statusid == status_id)
|
||||
|
||||
# Filter by location
|
||||
if location_id := request.args.get('locationid'):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid')) is not None:
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
|
||||
# Search filter
|
||||
if search := request.args.get('search'):
|
||||
@@ -1102,6 +1111,10 @@ def get_assets_map():
|
||||
|
||||
assets = query.all()
|
||||
|
||||
# Every asset that ends up with a marker. Used to decide whether a dual-bay
|
||||
# partner draws its own pin or is represented by this one.
|
||||
positioned_assetids = {asset.assetid for asset in assets}
|
||||
|
||||
# Batch-load primary IPs in a single query instead of N+1 per asset.
|
||||
# Prefer isprimary=True IP, fall back to any IP (comtypeid=1).
|
||||
asset_ids = [a.assetid for a in assets]
|
||||
@@ -1153,10 +1166,17 @@ def get_assets_map():
|
||||
if type_data:
|
||||
item['typedata'] = type_data
|
||||
|
||||
# combined-label partner for a collapsed dual-bay pair (label only)
|
||||
# Combined label for a dual-bay pair, and ONLY when this marker really
|
||||
# stands for both bays. A partner that draws its own marker gets its
|
||||
# own name: labelling two separate pins '2007 / 2008' would say the
|
||||
# same machine is in two places.
|
||||
if dualpath_collapse:
|
||||
partner = dualpath_collapse.partnerbyasset.get(asset.assetid)
|
||||
item['dualpathpartner'] = partner['assetnumber'] if partner else None
|
||||
partner_has_marker = bool(
|
||||
partner and partner['assetid'] in positioned_assetids)
|
||||
item['dualpathpartner'] = (
|
||||
partner['assetnumber'] if partner and not partner_has_marker
|
||||
else None)
|
||||
|
||||
data.append(item)
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.core.models import AuditLog
|
||||
from shopdb.extensions import db
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.responses import (success_response, error_response,
|
||||
paginated_response, ErrorCodes)
|
||||
from shopdb.utils.authz import require_permission
|
||||
|
||||
auditlogs_bp = Blueprint('auditlogs', __name__)
|
||||
@@ -123,15 +124,12 @@ def list_auditlogs():
|
||||
for r in rows:
|
||||
r['userfullname'] = fullnames.get(str(r.get('username')))
|
||||
|
||||
return success_response(
|
||||
rows,
|
||||
meta={
|
||||
'page': page,
|
||||
'perpage': perpage,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages
|
||||
}
|
||||
)
|
||||
# paginated_response, like every other list in the product. This one built
|
||||
# its own meta with the fields FLAT and named 'pages', so a client written
|
||||
# against the documented meta.pagination.{page,perpage,total,totalpages}
|
||||
# found nothing where it looked - on the one list whose whole purpose is to
|
||||
# be read by somebody checking what happened.
|
||||
return paginated_response(rows, page, perpage, pagination.total)
|
||||
|
||||
|
||||
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
|
||||
|
||||
@@ -36,10 +36,27 @@ def _blueprint_dir():
|
||||
|
||||
# =============================================================================
|
||||
# Read - public
|
||||
#
|
||||
# ADR-017 rule 6: the level list is readable without authentication, because the
|
||||
# printer installer map runs optional-auth before anyone logs in and needs a
|
||||
# blueprint. It follows the precedent of printer install-list and the slide
|
||||
# feed, both still public.
|
||||
#
|
||||
# afce2437 ("Reading anything needs a permission") swept 165 open GET routes
|
||||
# and caught these three in the net. Fail-closed, so nothing leaked - but the
|
||||
# anonymous installer map has drawn no floor plan since, while this same file
|
||||
# kept the docstring saying the reads are public, the API inventory kept
|
||||
# declaring auth "none", and PrinterInstallerMap.vue kept its comment
|
||||
# explaining why. Four places agreed and the code did not.
|
||||
#
|
||||
# Restored rather than amending the ADR: blueprint paths and level names are
|
||||
# not secrets, the asset positions on them already render on public kiosk
|
||||
# pages, and the feature the rule exists for is broken without it. These three
|
||||
# are registered in tests/test_core/test_public_read_surface.py.
|
||||
# =============================================================================
|
||||
|
||||
@maplevels_bp.route('', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
@jwt_required(optional=True)
|
||||
def list_levels():
|
||||
"""Every active level, grouped by building, in display order.
|
||||
|
||||
@@ -74,7 +91,7 @@ def list_levels():
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
@jwt_required(optional=True)
|
||||
def get_level(levelid):
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level or not level.isactive:
|
||||
@@ -84,7 +101,7 @@ def get_level(levelid):
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
@jwt_required(optional=True)
|
||||
def serve_blueprint(filename, levelid=None):
|
||||
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
||||
return send_upload(_blueprint_dir(), filename)
|
||||
@@ -160,9 +177,17 @@ def create_level():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('levelname') or '').strip()
|
||||
buildingid = data.get('buildingid')
|
||||
# Names the fields this route actually reads. It said 'buildingname',
|
||||
# which this handler never looks at, so somebody who omitted the level name
|
||||
# was told to supply something else entirely.
|
||||
if not name or not buildingid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'buildingname and buildingid are required')
|
||||
missing = [field for field, value in
|
||||
(('levelname', name), ('buildingid', buildingid))
|
||||
if not value]
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'%s %s required' % (' and '.join(missing),
|
||||
'is' if len(missing) == 1 else 'are'))
|
||||
if not db.session.get(Building, buildingid):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||
http_code=404)
|
||||
|
||||
@@ -235,7 +235,6 @@ def upload_model_image(model_id: int):
|
||||
|
||||
|
||||
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
def serve_model_image(filename):
|
||||
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
||||
return send_upload(_modelimage_dir(), filename)
|
||||
|
||||
@@ -13,6 +13,7 @@ from shopdb.core.models import (
|
||||
)
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_permission
|
||||
from shopdb.utils.queryargs import intarg
|
||||
|
||||
reports_bp = Blueprint('reports', __name__)
|
||||
|
||||
@@ -62,8 +63,8 @@ def machines_by_type():
|
||||
)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid')) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# Dualpath single-machine collapse: exclude the secondary bay so a dual-bay
|
||||
# pair counts once per type bucket. Gated on the site setting (default on).
|
||||
@@ -128,12 +129,12 @@ def assets_by_status():
|
||||
)
|
||||
|
||||
# Asset type filter
|
||||
if type_id := request.args.get('assettypeid'):
|
||||
query = query.filter(Asset.assettypeid == int(type_id))
|
||||
if (type_id := intarg('assettypeid')) is not None:
|
||||
query = query.filter(Asset.assettypeid == type_id)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid')) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
query = query.group_by(
|
||||
AssetStatus.statusid,
|
||||
@@ -175,7 +176,7 @@ def kb_popularity():
|
||||
- limit: Number of results (default 20)
|
||||
- format: 'json' (default) or 'csv'
|
||||
"""
|
||||
limit = min(int(request.args.get('limit', 20)), 100)
|
||||
limit = min(intarg('limit', 20), 100)
|
||||
|
||||
# Knowledge Base is a plugin; degrade gracefully if it is not installed.
|
||||
try:
|
||||
@@ -362,10 +363,10 @@ def asset_inventory():
|
||||
AssetType.isactive == True
|
||||
)
|
||||
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
by_type = by_type.filter(Asset.businessunitid == int(bu_id))
|
||||
if loc_id := request.args.get('locationid'):
|
||||
by_type = by_type.filter(Asset.locationid == int(loc_id))
|
||||
if (bu_id := intarg('businessunitid')) is not None:
|
||||
by_type = by_type.filter(Asset.businessunitid == bu_id)
|
||||
if (loc_id := intarg('locationid')) is not None:
|
||||
by_type = by_type.filter(Asset.locationid == loc_id)
|
||||
|
||||
by_type = by_type.group_by(AssetType.assettype).all()
|
||||
|
||||
@@ -380,10 +381,10 @@ def asset_inventory():
|
||||
AssetStatus.isactive == True
|
||||
)
|
||||
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
by_status = by_status.filter(Asset.businessunitid == int(bu_id))
|
||||
if loc_id := request.args.get('locationid'):
|
||||
by_status = by_status.filter(Asset.locationid == int(loc_id))
|
||||
if (bu_id := intarg('businessunitid')) is not None:
|
||||
by_status = by_status.filter(Asset.businessunitid == bu_id)
|
||||
if (loc_id := intarg('locationid')) is not None:
|
||||
by_status = by_status.filter(Asset.locationid == loc_id)
|
||||
|
||||
by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all()
|
||||
|
||||
@@ -398,8 +399,8 @@ def asset_inventory():
|
||||
Location.isactive == True
|
||||
)
|
||||
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
by_location = by_location.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid')) is not None:
|
||||
by_location = by_location.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
by_location = by_location.group_by(Location.locationname).all()
|
||||
|
||||
|
||||
@@ -244,6 +244,18 @@ def _get_asset_result(asset, query, relevance=None):
|
||||
if relevance is None:
|
||||
relevance = 15
|
||||
|
||||
# A retired asset never outranks a live one, whatever it matched on: the
|
||||
# person searching almost always wants the machine that is still on the
|
||||
# floor. Flagged too, so the result can say so rather than looking like any
|
||||
# other hit.
|
||||
# getattr, because this builder is also called with lightweight
|
||||
# stand-ins that carry only the fields a result needs. An object that does
|
||||
# not say is treated as live, which is the safe direction: a live asset
|
||||
# ranked correctly beats a live asset buried as retired.
|
||||
retired = getattr(asset, 'isactive', True) is False
|
||||
if retired:
|
||||
relevance = max(1, relevance - 60)
|
||||
|
||||
return {
|
||||
'type': asset_type_name,
|
||||
'id': result_id,
|
||||
@@ -251,7 +263,8 @@ def _get_asset_result(asset, query, relevance=None):
|
||||
'subtitle': subtitle,
|
||||
'location': location_name,
|
||||
'url': url,
|
||||
'relevance': relevance
|
||||
'relevance': relevance,
|
||||
'retired': retired
|
||||
}
|
||||
|
||||
|
||||
@@ -499,11 +512,17 @@ def _search_assets(query, search_term):
|
||||
joinedload(Asset.assettype),
|
||||
joinedload(Asset.location),
|
||||
).filter(
|
||||
Asset.isactive == True,
|
||||
# Retired assets are INCLUDED, and ranked below live ones. Retire
|
||||
# is a soft delete whose confirm dialog promises the record can be
|
||||
# restored - and the one place somebody looks for a machine they
|
||||
# retired last month is the search box. Excluding them meant the
|
||||
# record existed, was reachable by URL, and could not be found by
|
||||
# anybody who did not already know where it was.
|
||||
_word_match(query, Asset.assetnumber, Asset.name,
|
||||
Asset.serialnumber, Asset.notes,
|
||||
Asset.gaugelabreference, Asset.maintenancereference)
|
||||
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
|
||||
).order_by(Asset.isactive.desc(), Asset.assetnumber,
|
||||
Asset.assetid).limit(15).all()
|
||||
|
||||
for asset in assets:
|
||||
relevance = 15
|
||||
|
||||
@@ -811,6 +811,34 @@ def update_setting(key: str):
|
||||
if _is_secret(key) and value == SECRET_MASK:
|
||||
return success_response(_serialize_setting(setting), message='Setting unchanged')
|
||||
|
||||
# The declared valuetype is a promise about what every reader will get, and
|
||||
# nothing checked it: "A6-not-a-number" was stored verbatim on an integer
|
||||
# key and every consumer then read 0, because Setting.get_typed_value
|
||||
# swallows the ValueError. The setting looked set, the behaviour was the
|
||||
# default, and nothing said so.
|
||||
valuetype = (setting.valuetype or 'string').lower()
|
||||
if value is not None and not isinstance(value, bool):
|
||||
text = str(value).strip()
|
||||
if valuetype == 'integer' and text:
|
||||
try:
|
||||
int(text)
|
||||
except ValueError:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'%s is a whole-number setting (got %r)' % (key, value))
|
||||
elif valuetype == 'float' and text:
|
||||
try:
|
||||
float(text)
|
||||
except ValueError:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'%s is a number setting (got %r)' % (key, value))
|
||||
elif valuetype == 'boolean' and text:
|
||||
if text.lower() not in ('true', 'false', '1', '0', 'yes', 'no'):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'%s is a true/false setting (got %r)' % (key, value))
|
||||
|
||||
# Convert value to string for storage
|
||||
if isinstance(value, bool):
|
||||
setting.value = 'true' if value else 'false'
|
||||
@@ -1460,14 +1488,13 @@ def build_default_settings():
|
||||
'Incoming Webhook), adaptivecard (Teams Workflow), '
|
||||
'or json (generic {title,text})'
|
||||
},
|
||||
# Audit log settings
|
||||
{
|
||||
'key': 'audit_retention_days',
|
||||
'value': '90',
|
||||
'valuetype': 'integer',
|
||||
'category': 'audit',
|
||||
'description': 'Number of days to retain audit logs (0 = keep forever)'
|
||||
},
|
||||
# Audit log retention lives at 'audit_retentiondays' (category site),
|
||||
# defined above and read by shopdb/core/services/auditretention.py. A
|
||||
# second key 'audit_retention_days' was seeded here with a different
|
||||
# default and a different category, and the Audit & Logging page wrote
|
||||
# to THAT one - so the page saved a value, read it back, and nothing
|
||||
# ever consulted it. Removed rather than aliased: two keys for one
|
||||
# setting is how this happened.
|
||||
# Authentication settings
|
||||
{
|
||||
'key': 'saml_enabled',
|
||||
|
||||
@@ -187,9 +187,28 @@ def update_user(userid: int):
|
||||
user.failedlogins = 0
|
||||
changes['unlocked'] = {'old': True, 'new': False}
|
||||
|
||||
# Password change
|
||||
# Password change.
|
||||
#
|
||||
# An ADMIN may set one here - that is how a temporary password is issued,
|
||||
# and mustchangepassword forces the holder to replace it at next login.
|
||||
# Somebody changing their OWN password may not: this route asks for no
|
||||
# current password and enforces no length, so it was a way around
|
||||
# /api/auth/change-password, which requires both. The two rules have to
|
||||
# agree or the weaker one is the real one.
|
||||
if 'password' in data and data['password']:
|
||||
if not current_user.hasrole('admin'):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'Change your own password at /api/auth/change-password, which '
|
||||
'requires your current one.',
|
||||
http_code=403)
|
||||
if len(data['password']) < 8:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Password must be at least 8 characters')
|
||||
user.passwordhash = generate_password_hash(data['password'])
|
||||
# Issued by somebody else, so it is temporary by definition.
|
||||
user.mustchangepassword = True
|
||||
changes['password'] = {'old': '***', 'new': '***'}
|
||||
|
||||
if changes:
|
||||
|
||||
@@ -256,6 +256,24 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
elif hasattr(self, 'measuringtool') and self.measuringtool:
|
||||
result['pluginid'] = self.measuringtool.measuringtoolid
|
||||
|
||||
# What this asset OWNS, before any inheritance is merged in below.
|
||||
#
|
||||
# The merge is right for a reader: a detail page, a list and the map all
|
||||
# want the resolved location, whoever it came from. It is wrong for a
|
||||
# FORM, which reads the same payload and cannot tell an owned value
|
||||
# from a derived one - so the PC form pre-selected an inherited
|
||||
# location, the operator saved something else entirely, and the
|
||||
# inherited value was written back as the record's own. The asset then
|
||||
# stopped following its parent, silently, on an edit that never touched
|
||||
# location.
|
||||
#
|
||||
# Always emitted, even when nothing is inherited, so a client can bind
|
||||
# to these without checking whether the merge happened.
|
||||
result['ownlocationid'] = self.locationid
|
||||
result['ownmapx'] = self.mapx
|
||||
result['ownmapy'] = self.mapy
|
||||
result['ownlevelid'] = self.levelid
|
||||
|
||||
# Include inherited location if this asset has no location data
|
||||
if include_inherited_location:
|
||||
inherited = self.get_inherited_location()
|
||||
|
||||
@@ -1,159 +1,192 @@
|
||||
"""Audit log model for tracking changes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC for DB columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Audit log for tracking user actions.
|
||||
|
||||
Records who did what, when, from where, and what changed.
|
||||
"""
|
||||
__tablename__ = 'auditlogs'
|
||||
|
||||
auditlogid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Who
|
||||
userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
|
||||
username = db.Column(db.String(100), nullable=True) # Denormalized for history
|
||||
|
||||
# When
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True)
|
||||
|
||||
# Where (client info)
|
||||
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
|
||||
useragent = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# What
|
||||
action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
|
||||
entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
|
||||
entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
|
||||
entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
|
||||
|
||||
# Changes (stored as JSON text)
|
||||
_changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
|
||||
|
||||
@property
|
||||
def changes(self):
|
||||
"""Get changes as dict."""
|
||||
if self._changes:
|
||||
import json
|
||||
try:
|
||||
return json.loads(self._changes)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@changes.setter
|
||||
def changes(self, value):
|
||||
"""Set changes from dict."""
|
||||
if value is not None:
|
||||
import json
|
||||
self._changes = json.dumps(value)
|
||||
else:
|
||||
self._changes = None
|
||||
|
||||
# Additional context
|
||||
details = db.Column(db.Text, nullable=True) # Optional description
|
||||
|
||||
# Relationship
|
||||
user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'auditlogid': self.auditlogid,
|
||||
'userid': self.userid,
|
||||
'username': self.username,
|
||||
'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
|
||||
'ipaddress': self.ipaddress,
|
||||
'useragent': self.useragent,
|
||||
'action': self.action,
|
||||
'entitytype': self.entitytype,
|
||||
'entityid': self.entityid,
|
||||
'entityname': self.entityname,
|
||||
'changes': self.changes,
|
||||
'details': self.details
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def log(cls, action: str, entitytype: str, entityid: int = None,
|
||||
entityname: str = None, changes: dict = None, details: str = None,
|
||||
user=None, request=None):
|
||||
"""
|
||||
Create an audit log entry.
|
||||
|
||||
Args:
|
||||
action: 'created', 'updated', 'deleted'
|
||||
entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
|
||||
entityid: ID of the affected record
|
||||
entityname: Human-readable name/identifier
|
||||
changes: Dict of field changes {"field": {"old": x, "new": y}}
|
||||
details: Optional description
|
||||
user: Current user object (or will try to get from flask-jwt-extended)
|
||||
request: Flask request object (or will try to get current request)
|
||||
"""
|
||||
from flask import request as flask_request
|
||||
from flask_jwt_extended import current_user, verify_jwt_in_request
|
||||
|
||||
# Get user info
|
||||
if user is None:
|
||||
try:
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = current_user
|
||||
except:
|
||||
pass
|
||||
|
||||
userid = user.userid if user else None
|
||||
username = user.username if user else None
|
||||
|
||||
# Get request info
|
||||
request_obj = request or flask_request
|
||||
ipaddress = None
|
||||
useragent = None
|
||||
|
||||
if request_obj:
|
||||
# First-hop client IP, port stripped (ARR forwards clientip:port).
|
||||
from shopdb.utils.clientip import client_ip
|
||||
ipaddress = client_ip(request_obj)
|
||||
useragent = request_obj.headers.get('User-Agent', '')[:255]
|
||||
|
||||
entry = cls(
|
||||
userid=userid,
|
||||
username=username,
|
||||
ipaddress=ipaddress,
|
||||
useragent=useragent,
|
||||
action=action,
|
||||
entitytype=entitytype,
|
||||
entityid=entityid,
|
||||
entityname=entityname,
|
||||
changes=changes,
|
||||
details=details
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
# Don't commit here - let the caller handle transaction
|
||||
return entry
|
||||
|
||||
@classmethod
|
||||
def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
|
||||
"""Log a create action."""
|
||||
entityname = getattr(entity, name_field, None) or str(entity)
|
||||
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
||||
return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
|
||||
|
||||
@classmethod
|
||||
def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
|
||||
"""Log an update action with changes."""
|
||||
entityname = getattr(entity, name_field, None) or str(entity)
|
||||
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
||||
return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
|
||||
|
||||
@classmethod
|
||||
def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
|
||||
"""Log a delete action."""
|
||||
return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)
|
||||
"""Audit log model for tracking changes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC for DB columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _jsonsafe(value):
|
||||
"""Last-resort encoder for a value the audit log was handed.
|
||||
|
||||
Dates and datetimes become ISO strings, Decimals become floats, and
|
||||
anything else becomes its repr rather than an exception. An audit entry
|
||||
that reads a little awkwardly is worth more than a failed write: the
|
||||
alternative, and what happened, is losing the change AND the record of it.
|
||||
"""
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
|
||||
if isinstance(value, (datetime, date, time)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (set, frozenset)):
|
||||
return sorted(str(item) for item in value)
|
||||
if isinstance(value, bytes):
|
||||
return value.decode('utf-8', 'replace')
|
||||
return repr(value)
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Audit log for tracking user actions.
|
||||
|
||||
Records who did what, when, from where, and what changed.
|
||||
"""
|
||||
__tablename__ = 'auditlogs'
|
||||
|
||||
auditlogid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Who
|
||||
userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
|
||||
username = db.Column(db.String(100), nullable=True) # Denormalized for history
|
||||
|
||||
# When
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True)
|
||||
|
||||
# Where (client info)
|
||||
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
|
||||
useragent = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# What
|
||||
action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
|
||||
entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
|
||||
entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
|
||||
entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
|
||||
|
||||
# Changes (stored as JSON text)
|
||||
_changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
|
||||
|
||||
@property
|
||||
def changes(self):
|
||||
"""Get changes as dict."""
|
||||
if self._changes:
|
||||
import json
|
||||
try:
|
||||
return json.loads(self._changes)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@changes.setter
|
||||
def changes(self, value):
|
||||
"""Set changes from dict.
|
||||
|
||||
Serialised with a fallback for the types a model actually holds. A bare
|
||||
json.dumps raises on a date, a datetime or a Decimal, and the caller is
|
||||
a route building {'old': getattr(row, field)} straight off the model -
|
||||
so the moment an audited field held a date, the whole request died.
|
||||
|
||||
That is not hypothetical: PUT /api/machines/<id> returned 500 and rolled
|
||||
the edit back for ANY machine with a maintenance date recorded,
|
||||
including a rename that never touched the date. The audit log refusing
|
||||
to record a change must not be what stops the change.
|
||||
"""
|
||||
if value is not None:
|
||||
import json
|
||||
self._changes = json.dumps(value, default=_jsonsafe)
|
||||
else:
|
||||
self._changes = None
|
||||
|
||||
# Additional context
|
||||
details = db.Column(db.Text, nullable=True) # Optional description
|
||||
|
||||
# Relationship
|
||||
user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'auditlogid': self.auditlogid,
|
||||
'userid': self.userid,
|
||||
'username': self.username,
|
||||
'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
|
||||
'ipaddress': self.ipaddress,
|
||||
'useragent': self.useragent,
|
||||
'action': self.action,
|
||||
'entitytype': self.entitytype,
|
||||
'entityid': self.entityid,
|
||||
'entityname': self.entityname,
|
||||
'changes': self.changes,
|
||||
'details': self.details
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def log(cls, action: str, entitytype: str, entityid: int = None,
|
||||
entityname: str = None, changes: dict = None, details: str = None,
|
||||
user=None, request=None):
|
||||
"""
|
||||
Create an audit log entry.
|
||||
|
||||
Args:
|
||||
action: 'created', 'updated', 'deleted'
|
||||
entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
|
||||
entityid: ID of the affected record
|
||||
entityname: Human-readable name/identifier
|
||||
changes: Dict of field changes {"field": {"old": x, "new": y}}
|
||||
details: Optional description
|
||||
user: Current user object (or will try to get from flask-jwt-extended)
|
||||
request: Flask request object (or will try to get current request)
|
||||
"""
|
||||
from flask import request as flask_request
|
||||
from flask_jwt_extended import current_user, verify_jwt_in_request
|
||||
|
||||
# Get user info
|
||||
if user is None:
|
||||
try:
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = current_user
|
||||
except:
|
||||
pass
|
||||
|
||||
userid = user.userid if user else None
|
||||
username = user.username if user else None
|
||||
|
||||
# Get request info
|
||||
request_obj = request or flask_request
|
||||
ipaddress = None
|
||||
useragent = None
|
||||
|
||||
if request_obj:
|
||||
# First-hop client IP, port stripped (ARR forwards clientip:port).
|
||||
from shopdb.utils.clientip import client_ip
|
||||
ipaddress = client_ip(request_obj)
|
||||
useragent = request_obj.headers.get('User-Agent', '')[:255]
|
||||
|
||||
entry = cls(
|
||||
userid=userid,
|
||||
username=username,
|
||||
ipaddress=ipaddress,
|
||||
useragent=useragent,
|
||||
action=action,
|
||||
entitytype=entitytype,
|
||||
entityid=entityid,
|
||||
entityname=entityname,
|
||||
changes=changes,
|
||||
details=details
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
# Don't commit here - let the caller handle transaction
|
||||
return entry
|
||||
|
||||
@classmethod
|
||||
def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
|
||||
"""Log a create action."""
|
||||
entityname = getattr(entity, name_field, None) or str(entity)
|
||||
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
||||
return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
|
||||
|
||||
@classmethod
|
||||
def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
|
||||
"""Log an update action with changes."""
|
||||
entityname = getattr(entity, name_field, None) or str(entity)
|
||||
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
||||
return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
|
||||
|
||||
@classmethod
|
||||
def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
|
||||
"""Log a delete action."""
|
||||
return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base model class with common fields."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
|
||||
|
||||
@@ -63,6 +63,13 @@ class BaseModel(db.Model):
|
||||
value = getattr(self, c.name)
|
||||
if isinstance(value, datetime):
|
||||
value = value.isoformat() + 'Z'
|
||||
elif isinstance(value, date):
|
||||
# A date, not an instant: no time and no timezone marker. Left
|
||||
# to Flask's encoder it became an HTTP-date
|
||||
# ("Thu, 15 Jan 2026 00:00:00 GMT"), which a browser parses
|
||||
# back into a moment and can render as the day before.
|
||||
# datetime is a subclass of date, so this must stay second.
|
||||
value = value.isoformat()
|
||||
result[c.name] = value
|
||||
return result
|
||||
|
||||
|
||||
@@ -102,14 +102,27 @@ def caller_holds(permission_name: str) -> bool:
|
||||
Guests are included on purpose: a signed-out visitor holds whatever the
|
||||
guest role was granted, so the answer must come from the same place
|
||||
require_permission gets it.
|
||||
|
||||
Scoped personal tokens are included for the same reason, and this is the
|
||||
half that was missing: the answer has to be about the CALLER, and a scoped
|
||||
token is a narrower caller than its owner. Reading the owner's permissions
|
||||
let a token scoped to endpoints.command queue a remote restart, because the
|
||||
elevated check asked the owner rather than the token - the one bypass
|
||||
require_permission exists to prevent, reintroduced by a route that did its
|
||||
own check.
|
||||
"""
|
||||
user = currentuser()
|
||||
if user is None:
|
||||
return guestholds(permission_name)
|
||||
try:
|
||||
return bool(user.haspermission(permission_name))
|
||||
if not user.haspermission(permission_name):
|
||||
return False
|
||||
patscopes = get_jwt().get('patscopes')
|
||||
except Exception:
|
||||
return False
|
||||
if patscopes is not None:
|
||||
return permission_name in patscopes
|
||||
return True
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
|
||||
70
shopdb/utils/badges.py
Normal file
70
shopdb/utils/badges.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""What a scanned badge looks like, in one place.
|
||||
|
||||
Two kiosks read the same physical badges and disagreed about them. The parts
|
||||
kiosk understood both shapes a scanner emits; the USB kiosk took the string
|
||||
raw and compared it to an SSO, so the wrapped form was simply "not recognized".
|
||||
An operator whose keyboard-wedge scanner emits `0270015376BZ` could take a
|
||||
3D-printed part and could not check out a drive - with the same badge, at two
|
||||
cabinets a few metres apart.
|
||||
|
||||
The shapes:
|
||||
|
||||
270015376 the SSO as printed, what a keypad entry looks like
|
||||
0270015376BZ the PayNo wrapper some scanners emit around it
|
||||
|
||||
Both mean the same person. Nothing else is a badge - and in particular a string
|
||||
of NON-ASCII digits is not, however much it looks like one: Arabic-Indic digits
|
||||
pass Python's str.isdigit(), and a badge written in them was accepted, stored as
|
||||
the employee's SSO, and joined to nothing afterwards.
|
||||
|
||||
This lives in core rather than in either plugin because a plugin may not import
|
||||
another plugin, and because a badge is a property of the site, not of a
|
||||
cabinet.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# The PayNo wrapper: a leading zero, the digits, and a BZ suffix.
|
||||
PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
|
||||
|
||||
# ASCII digits only. str.isdigit() is true for Arabic-Indic and other decimal
|
||||
# forms, and int() happily parses them, so a badge in those characters resolved
|
||||
# to a real employee and was then written back in a form nothing else matches.
|
||||
ASCII_DIGITS = re.compile(r'^[0-9]+$')
|
||||
|
||||
|
||||
class BadgeShapeError(ValueError):
|
||||
"""The scanned string is not a badge this site recognises."""
|
||||
|
||||
|
||||
def normalise_badge(raw):
|
||||
"""Return the plain SSO digits for a scanned badge.
|
||||
|
||||
Accepts the bare number and the PayNo wrapper, and raises BadgeShapeError
|
||||
for anything else. The caller decides what to do about a badge that is
|
||||
well-formed but matches no employee - that is a directory question, not a
|
||||
shape one.
|
||||
"""
|
||||
badge = (raw or '').strip()
|
||||
if not badge:
|
||||
raise BadgeShapeError('Scan or enter a badge')
|
||||
if ASCII_DIGITS.match(badge):
|
||||
return badge
|
||||
match = PAYNO_BADGE.match(badge)
|
||||
if match and ASCII_DIGITS.match(match.group(1)):
|
||||
return match.group(1)
|
||||
raise BadgeShapeError('Unrecognized badge format')
|
||||
|
||||
|
||||
def badge_kind(raw):
|
||||
"""'sso' or 'payno' for a recognised badge; raises for anything else.
|
||||
|
||||
The external HR lookup searches a different column for each, so the shape
|
||||
survives normalisation for that one caller.
|
||||
"""
|
||||
badge = (raw or '').strip()
|
||||
if ASCII_DIGITS.match(badge):
|
||||
return 'sso'
|
||||
if PAYNO_BADGE.match(badge):
|
||||
return 'payno'
|
||||
raise BadgeShapeError('Unrecognized badge format')
|
||||
82
shopdb/utils/datevalues.py
Normal file
82
shopdb/utils/datevalues.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Parsing a date out of a request body, strictly.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
Two plugins carried an identical private `_parse_date` that swallowed the
|
||||
error:
|
||||
|
||||
try:
|
||||
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
So an unparseable date and a deliberately cleared one produced the same
|
||||
result, and the write went ahead. `PUT /api/warranty/<id>` with enddate
|
||||
'not-a-date' answered 200 "Warranty updated" and stored NULL - the warranty
|
||||
then vanished from every expiry surface, silently, on the strength of a typo.
|
||||
The measuring-tool calibration date did the same thing and dropped the tool out
|
||||
of the calibration report.
|
||||
|
||||
The rule here: an EXPLICIT empty value clears the field, and anything that is
|
||||
not a date is refused. Those are different intents and they get different
|
||||
answers. Callers turn `DateValueError` into a 400 naming the field, so the
|
||||
person who mistyped it finds out at the point of the mistake rather than at the
|
||||
next audit.
|
||||
|
||||
Accepted forms are deliberately narrow - 'YYYY-MM-DD', and the ISO-8601
|
||||
datetime forms whose leading ten characters are a date, since that is what the
|
||||
frontend's date inputs and every vendor API in the tree emit. Anything looser
|
||||
is a guess about what somebody meant.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
class DateValueError(ValueError):
|
||||
"""A supplied value was neither empty nor a date this accepts.
|
||||
|
||||
Carries the field name so the error message can say which one, which is the
|
||||
whole point of refusing rather than nulling.
|
||||
"""
|
||||
|
||||
def __init__(self, field, value):
|
||||
self.field = field
|
||||
self.value = value
|
||||
super().__init__(
|
||||
'%s must be a date as YYYY-MM-DD (got %r)' % (field, value))
|
||||
|
||||
|
||||
def parse_date(value, field='date'):
|
||||
"""Return a date, or None for an explicitly empty value.
|
||||
|
||||
Raises DateValueError for anything else. None and '' mean "clear it";
|
||||
they are the only values that produce None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
# A number or a list is not a mistyped date, it is the wrong type.
|
||||
raise DateValueError(field, value)
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(text[:10], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
raise DateValueError(field, value)
|
||||
|
||||
|
||||
def parse_date_fields(data, fields):
|
||||
"""Parse several date fields out of a payload at once.
|
||||
|
||||
Only keys PRESENT in the payload are returned, so a caller can tell "not
|
||||
mentioned" (leave it alone) from "sent as empty" (clear it) - a distinction
|
||||
a plain `data.get(...)` throws away, and the reason the alias-clearing bug
|
||||
existed on the PC form.
|
||||
"""
|
||||
return {field: parse_date(data[field], field)
|
||||
for field in fields if field in data}
|
||||
64
shopdb/utils/queryargs.py
Normal file
64
shopdb/utils/queryargs.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Reading a typed value out of the query string, without a 500.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
List endpoints filtered like this:
|
||||
|
||||
if type_id := request.args.get('typeid'):
|
||||
query = query.filter(Machine.machinetypeid == int(type_id))
|
||||
|
||||
`int('abc')` raises, nothing catches it, and the caller gets
|
||||
500 INTERNAL_ERROR with a traceback in the log. A wrong query argument is the
|
||||
caller's mistake and deserves a 400 saying which argument; a 500 says the server
|
||||
is broken and sends whoever is reading the log looking for a fault that is not
|
||||
there. The feature test run found the same shape in six places across five
|
||||
plugins, which makes it a property of how list routes are written rather than of
|
||||
any one route.
|
||||
|
||||
The helpers raise QueryArgError. Routes turn it into a 400 - or, more usually,
|
||||
nothing does anything at all, because the app-level handler in
|
||||
shopdb/__init__.py answers it. That is the point: a route should not have to
|
||||
remember.
|
||||
|
||||
Deliberately NOT the same policy as get_pagination_params, which falls back to
|
||||
a default for a bad page number. A nonsense page is harmless and the caller
|
||||
still wants their list; a nonsense FILTER is not, because silently ignoring it
|
||||
returns rows the caller believes they excluded.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
|
||||
|
||||
class QueryArgError(ValueError):
|
||||
"""A query argument was present but not of the type the route needs."""
|
||||
|
||||
def __init__(self, name, value, expected='a whole number'):
|
||||
self.name = name
|
||||
self.value = value
|
||||
super().__init__('%s must be %s (got %r)' % (name, expected, value))
|
||||
|
||||
|
||||
def intarg(name, default=None, request_obj=None):
|
||||
"""Read an integer query argument.
|
||||
|
||||
Absent or empty -> default. Present but not an integer -> QueryArgError,
|
||||
which the app answers as a 400 naming the argument.
|
||||
"""
|
||||
source = request_obj if request_obj is not None else request
|
||||
raw = source.args.get(name)
|
||||
if raw is None or raw == '':
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise QueryArgError(name, raw)
|
||||
|
||||
|
||||
def intargs(*names, request_obj=None):
|
||||
"""Read several integer arguments at once; returns a dict of those present."""
|
||||
found = {}
|
||||
for name in names:
|
||||
value = intarg(name, request_obj=request_obj)
|
||||
if value is not None:
|
||||
found[name] = value
|
||||
return found
|
||||
Reference in New Issue
Block a user