Merge remote-tracking branch 'origin/main' into wilm

This commit is contained in:
benjamin.baker
2026-08-24 14:52:59 -04:00
66 changed files with 5026 additions and 391 deletions

View File

@@ -506,6 +506,17 @@ def update_asset(asset_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():

View File

@@ -9,13 +9,14 @@ from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from shopdb.extensions import db
from shopdb.extensions import db, cache
from shopdb.core.models import (
Application, Setting,
Asset, AssetType, Communication, Vendor, Model,
CustomField, CustomFieldValue
)
from shopdb.core.api.settings import get_cached_settings
from shopdb.core.services.spellfix import Vocabulary
from shopdb.utils.responses import success_response
logger = logging.getLogger(__name__)
@@ -31,7 +32,7 @@ search_bp = Blueprint('search', __name__)
# cannot reorder. Relevance is applied afterwards in Python, over a stable set.
def _word_match(query, *columns):
def _word_match(query, *columns, extra=None):
"""SQL clause matching rows that contain EVERY word of the query, each word
in any of the columns, in any order.
@@ -39,14 +40,25 @@ def _word_match(query, *columns):
multi-word search returned nothing. This splits the query into words and ANDs
them (OR across the columns per word): 'CSF Roles' matches a row with 'CSF'
and 'Roles' anywhere in the searched fields.
`extra` is an optional callable taking one word and returning a further
clause to OR in for that word. Use it where a word may be satisfied by a
RELATED row rather than a column of this table - a knowledge base article
matching on its topic's name, say. Splitting per word matters there: without
it a word can only ever be matched by the columns, so a query naming the
topic and a keyword ('CMM Community') matches neither side alone.
"""
words = [w for w in query.split() if w]
if not words:
words = ['']
return db.and_(*[
db.or_(*[c.ilike(f'%{w}%') for c in columns])
for w in words
])
def per_word(word):
clauses = [c.ilike(f'%{word}%') for c in columns]
if extra is not None:
clauses.append(extra(word))
return db.or_(*clauses)
return db.and_(*[per_word(w) for w in words])
def _require_enabled(name):
@@ -253,6 +265,10 @@ def _search_knowledgebase(query, search_term):
subject, which reads as though it were still in service.
A null topic still matches. Not every article is about an application.
The topic's NAME is searched too, per word. An article tagged 'community'
under the CMM topic has neither word in both of its own columns, so a search
for 'CMM Community' found it in neither the plugin's listing nor here.
"""
results = []
try:
@@ -260,12 +276,24 @@ def _search_knowledgebase(query, search_term):
from plugins.knowledgebase.models import KnowledgeBase
retired = db.session.query(Application.appid).filter(
Application.isactive.is_(False))
def topic_named(word):
"""Articles whose topic is an ACTIVE application named like word.
Active only, for the same reason the retired filter above exists: a
decommissioned application is not a topic anyone should be offered.
"""
return KnowledgeBase.appid.in_(
db.session.query(Application.appid).filter(
Application.appname.ilike(f'%{word}%'),
Application.isactive.is_(True)))
kb_articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True,
db.or_(KnowledgeBase.appid.is_(None),
KnowledgeBase.appid.notin_(retired)),
_word_match(query, KnowledgeBase.shortdescription,
KnowledgeBase.keywords)
KnowledgeBase.keywords, extra=topic_named)
).order_by(KnowledgeBase.clicks.desc(),
KnowledgeBase.linkid).limit(20).all()
@@ -976,6 +1004,94 @@ def _check_smart_redirect(query, classification):
return None
# How long a harvested vocabulary is reused. Long enough that a burst of
# searching does not rebuild it, short enough that something entered this
# morning is correctable before lunch.
_VOCABULARY_TTL = 600
# Per-column ceiling on harvested terms. A correction is only offered when a
# search found NOTHING, so the vocabulary exists to catch a typo against the
# names people actually use - not to mirror the database. Without a bound this
# would load every asset number on a site into memory to answer one query.
_VOCABULARY_LIMIT = 5000
def _search_vocabulary():
"""Words worth correcting a search against, harvested from the data.
From the DATA and not a dictionary, because no dictionary holds Genspect,
Telesis, Keyence or wax-trace - and because a vendor added this morning is
then correctable this morning.
Cached: this runs only on a search that found nothing, but a person who
mistypes once usually mistypes twice.
"""
cached = cache.get('search_vocabulary')
if cached is not None:
return cached
vocabulary = Vocabulary()
columns = (
(Asset, (Asset.assetnumber, Asset.name)),
(Vendor, (Vendor.vendor,)),
(Model, (Model.modelnumber,)),
(Application, (Application.appname,)),
(AssetType, (AssetType.assettype,)),
)
for model, fields in columns:
for field in fields:
try:
rows = (db.session.query(field)
.filter(field.isnot(None))
.distinct()
.limit(_VOCABULARY_LIMIT)
.all())
except Exception:
# A plugin table missing on a lean install must not break
# search. No vocabulary is worse than the alternative, not
# fatal.
logger.debug('vocabulary: skipped %s', field, exc_info=True)
continue
for (value,) in rows:
vocabulary.add(value)
cache.set('search_vocabulary', vocabulary, timeout=_VOCABULARY_TTL)
return vocabulary
def _suggestion_for(query):
"""A better spelling of `query`, or None. Never raises into a search."""
try:
return _search_vocabulary().suggest_query(query)
except Exception:
logger.warning('vocabulary lookup failed', exc_info=True)
return None
@search_bp.route('/suggest', methods=['GET'])
@jwt_required(optional=True)
def search_suggest():
"""A better spelling for a query that found nothing, or null.
ITS OWN ENDPOINT, deliberately, rather than a `suggestion` key added to
every list response. Thirty routes take a search parameter across a dozen
files, so folding it into each one is a large change today and a thing every
future plugin has to remember. A page that finds no rows calls this instead;
a page that has not adopted it simply shows no suggestion, which is what it
showed before.
Returns null rather than a poor guess. The caller offers "did you mean" and
a person decides - nothing searches on their behalf.
"""
query = request.args.get('q', '').strip()
if not query or len(query) < 2 or len(query) > 200:
return success_response({'query': query, 'suggestion': None})
return success_response({
'query': query,
'suggestion': _suggestion_for(query),
})
@search_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def global_search():
@@ -1072,6 +1188,21 @@ def global_search():
total_all = len(unique_results)
# A search that found NOTHING is the only evidence of what people cannot
# find, and it is not otherwise recorded anywhere. Logged at INFO with a
# stable prefix so a week of it can be grepped into a list, which is what
# should decide whether spelling correction is worth wiring in - the recent
# multi-word and cross-field matching changes may already have fixed a good
# share of what used to fail. Guessing at that would be building for a
# problem nobody has measured.
suggestion = None
if not unique_results:
logger.info('search-no-results query=%r', query)
# Offered, never applied. The response carries the better spelling and
# the caller decides: silently searching for something else is how
# somebody orders the wrong cartridge.
suggestion = _suggestion_for(query)
# Limit total results
unique_results = unique_results[:50]
@@ -1083,6 +1214,8 @@ def global_search():
'total_all': total_all,
'counts': type_counts,
}
if suggestion:
response_data['suggestion'] = suggestion
redirect = _check_smart_redirect(query, classification)
if redirect: