From 25611dba90b495d30552134939dd4cfc721dadb3 Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 16:11:34 +1100 Subject: [PATCH 1/8] fix: break infinite recursion between updateBulkBar and clearSelectAllMatching updateBulkBar calls clearSelectAllMatching when no checkboxes are checked, and clearSelectAllMatching calls updateBulkBar at the end, causing a Maximum call stack size exceeded error. Pass skipUpdate flag when calling from within updateBulkBar to break the cycle. Co-Authored-By: Claude Opus 4.6 --- src/templates/assets/asset_list.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/templates/assets/asset_list.html b/src/templates/assets/asset_list.html index c5bcc3b..d0b1f77 100644 --- a/src/templates/assets/asset_list.html +++ b/src/templates/assets/asset_list.html @@ -160,7 +160,7 @@

Assets

} } else { bulkBar.classList.add('hidden'); - clearSelectAllMatching(); + clearSelectAllMatching(true); } // Show/hide select-all-matching banner @@ -169,7 +169,7 @@

Assets

selectAllBanner.classList.remove('hidden'); } else { selectAllBanner.classList.add('hidden'); - clearSelectAllMatching(); + clearSelectAllMatching(true); } } } @@ -241,7 +241,7 @@

Assets

updateBulkBar(); }; - window.clearSelectAllMatching = function() { + window.clearSelectAllMatching = function(skipUpdate) { selectAllMatchingInput.value = '0'; document.getElementById('banner-page-text').classList.remove('hidden'); document.getElementById('banner-all-text').classList.add('hidden'); @@ -249,7 +249,7 @@

Assets

var clearBtn = document.getElementById('clear-all-matching-btn'); if (selectBtn) selectBtn.classList.remove('hidden'); if (clearBtn) clearBtn.classList.add('hidden'); - updateBulkBar(); + if (!skipUpdate) updateBulkBar(); }; // Re-initialize event handlers after HTMX swap From e03816ac9eaaae159c6bfc6abcdd68a7b15abb03 Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 16:17:19 +1100 Subject: [PATCH 2/8] fix: improve contrast for badges and buttons in light mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project list: Active badge text green-300 → green-700 (dark: green-300) - Location detail: Check In button text emerald-400 → emerald-700 (dark: emerald-400) - Location detail: Check Out button text brand-400 → brand-700 (dark: brand-400) Co-Authored-By: Claude Opus 4.6 --- src/templates/assets/location_detail.html | 4 ++-- src/templates/assets/project_list.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/templates/assets/location_detail.html b/src/templates/assets/location_detail.html index d6d48ad..d783437 100644 --- a/src/templates/assets/location_detail.html +++ b/src/templates/assets/location_detail.html @@ -29,8 +29,8 @@

{{ location.name }}

{% endif %} Start Stocktake {% if location.is_checkable and can_checkout_location %} - Check Out Location - Check In Location + Check Out Location + Check In Location {% endif %} {% if v2_printers %}
diff --git a/src/templates/assets/project_list.html b/src/templates/assets/project_list.html index bea7644..d4a5231 100644 --- a/src/templates/assets/project_list.html +++ b/src/templates/assets/project_list.html @@ -33,7 +33,7 @@

Projects

{{ p.name }} {% if p.is_active %} - Yes + Yes {% else %} No {% endif %} From 350ba15e78f8b86d48a4b409b9f1ee26dc93e7b5 Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 17:48:16 +1100 Subject: [PATCH 3/8] ci: use ubuntu-latest-large runners for all workflows Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/develop-image.yml | 2 +- .github/workflows/release.yml | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54153e7..0e801db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ concurrency: jobs: lint: name: Lint & Format - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large steps: - uses: actions/checkout@v4 @@ -34,7 +34,7 @@ jobs: requirements-check: name: Requirements in sync - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large steps: - uses: actions/checkout@v4 @@ -62,7 +62,7 @@ jobs: build: name: Build Docker Image - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large needs: [lint, requirements-check] steps: - uses: actions/checkout@v4 @@ -95,7 +95,7 @@ jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large needs: build steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/develop-image.yml b/.github/workflows/develop-image.yml index eaf102e..b202f11 100644 --- a/.github/workflows/develop-image.yml +++ b/.github/workflows/develop-image.yml @@ -14,7 +14,7 @@ permissions: jobs: build-and-push: name: Build & Push Dev Image - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63094e8..88bd93e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: jobs: version: name: Calculate & Tag Version - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large outputs: version: ${{ steps.calc.outputs.version }} tag: ${{ steps.calc.outputs.tag }} @@ -45,7 +45,7 @@ jobs: build: name: Build Docker Image - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large needs: version steps: - uses: actions/checkout@v4 @@ -78,7 +78,7 @@ jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large needs: build steps: - uses: actions/checkout@v4 @@ -101,7 +101,7 @@ jobs: publish: name: Publish to GHCR - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large needs: [version, test] steps: - name: Download image artifact From 0c8b869440626030a0c0b430b506a4b00563f9ce Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 17:59:25 +1100 Subject: [PATCH 4/8] fix: disable WhiteNoise middleware in tests to prevent race condition With 32 parallel pytest-xdist workers on larger CI runners, WhiteNoise's scantree() races against staticfiles being modified, causing FileNotFoundError on hashed static files. Co-Authored-By: Claude Opus 4.6 --- src/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/conftest.py b/src/conftest.py index 0264775..e7b1be4 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -14,6 +14,14 @@ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", } +# Disable WhiteNoise in tests — with parallel workers it races on +# scantree() vs collectstatic, causing FileNotFoundError on hashed files. +settings.MIDDLEWARE = [ + m + for m in settings.MIDDLEWARE + if m != "whitenoise.middleware.WhiteNoiseMiddleware" +] + # Run Celery tasks synchronously in tests settings.CELERY_TASK_ALWAYS_EAGER = True settings.CELERY_TASK_EAGER_PROPAGATES = True From e2146cd9722b65b95d21a2ff64806b9959e7fec0 Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 18:07:29 +1100 Subject: [PATCH 5/8] fix: check settings module source for WhiteNoise middleware tests The tests were reading runtime settings.MIDDLEWARE which conftest.py now strips WhiteNoise from to prevent race conditions. Check props.settings module directly instead, matching the pattern used by the storage test. Co-Authored-By: Claude Opus 4.6 --- src/assets/tests/test_services.py | 11 ++++++++--- src/props/tests/test_infrastructure.py | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/assets/tests/test_services.py b/src/assets/tests/test_services.py index 63d1f81..0ccf6a0 100644 --- a/src/assets/tests/test_services.py +++ b/src/assets/tests/test_services.py @@ -1160,10 +1160,15 @@ class TestS3StorageConfiguration: """ def test_whitenoise_in_middleware(self): - """WhiteNoise middleware is present.""" - from django.conf import settings + """WhiteNoise middleware is present in settings module. + + Note: conftest.py removes WhiteNoise from runtime MIDDLEWARE to + avoid race conditions with parallel test workers, so we check + the settings module source directly. + """ + import props.settings as ps - assert any("whitenoise" in m.lower() for m in settings.MIDDLEWARE) + assert any("whitenoise" in m.lower() for m in ps.MIDDLEWARE) def test_whitenoise_staticfiles_backend_in_settings_module(self): """Staticfiles uses WhiteNoise storage backend in settings.py. diff --git a/src/props/tests/test_infrastructure.py b/src/props/tests/test_infrastructure.py index b017d62..c9837de 100644 --- a/src/props/tests/test_infrastructure.py +++ b/src/props/tests/test_infrastructure.py @@ -152,10 +152,15 @@ class TestInfrastructureConfiguration: """V575, V592, V594, V606, V614, V619: Infrastructure settings.""" def test_whitenoise_in_storages(self): - """V575: Static files served via WhiteNoise.""" - from django.conf import settings + """V575: Static files served via WhiteNoise. + + Note: conftest.py removes WhiteNoise from runtime MIDDLEWARE to + avoid race conditions with parallel test workers, so we check + the settings module source directly. + """ + import props.settings as ps - assert any("whitenoise" in m.lower() for m in settings.MIDDLEWARE) + assert any("whitenoise" in m.lower() for m in ps.MIDDLEWARE) def test_tailwind_css_configured(self, client_logged_in): """V592: Tailwind CSS 4.x.""" From 1bb37c40eaea3083515423685d59095a651f06f4 Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 18:08:51 +1100 Subject: [PATCH 6/8] ci: only use large runners for test jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint, requirements-check, build, version, and publish jobs don't benefit from extra cores — only the pytest-xdist test jobs do. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 6 +++--- .github/workflows/develop-image.yml | 2 +- .github/workflows/release.yml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e801db..4f3d94f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ concurrency: jobs: lint: name: Lint & Format - runs-on: ubuntu-latest-large + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -34,7 +34,7 @@ jobs: requirements-check: name: Requirements in sync - runs-on: ubuntu-latest-large + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -62,7 +62,7 @@ jobs: build: name: Build Docker Image - runs-on: ubuntu-latest-large + runs-on: ubuntu-latest needs: [lint, requirements-check] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/develop-image.yml b/.github/workflows/develop-image.yml index b202f11..eaf102e 100644 --- a/.github/workflows/develop-image.yml +++ b/.github/workflows/develop-image.yml @@ -14,7 +14,7 @@ permissions: jobs: build-and-push: name: Build & Push Dev Image - runs-on: ubuntu-latest-large + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88bd93e..553ae20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: jobs: version: name: Calculate & Tag Version - runs-on: ubuntu-latest-large + runs-on: ubuntu-latest outputs: version: ${{ steps.calc.outputs.version }} tag: ${{ steps.calc.outputs.tag }} @@ -45,7 +45,7 @@ jobs: build: name: Build Docker Image - runs-on: ubuntu-latest-large + runs-on: ubuntu-latest needs: version steps: - uses: actions/checkout@v4 @@ -101,7 +101,7 @@ jobs: publish: name: Publish to GHCR - runs-on: ubuntu-latest-large + runs-on: ubuntu-latest needs: [version, test] steps: - name: Download image artifact From bfa39794b2fb706446d13136a68f073e4bab8744 Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 18:29:59 +1100 Subject: [PATCH 7/8] feat: word-based search, dashboard quick search, and thumbnail DRY-up Add word-AND search via build_asset_text_query service (max 20 words), dashboard autocomplete widget with Alpine.js, thumbnail_url property on AssetImage to eliminate duplicated fallback logic across 7 templates and the asset_search view, and tests for the search limit parameter. Co-Authored-By: Claude Opus 4.6 --- src/assets/models.py | 13 ++ src/assets/services/search.py | 30 ++++ src/assets/tests/test_views.py | 163 ++++++++++++++++++ src/assets/views.py | 93 +++++----- src/templates/assets/dashboard.html | 102 ++++++++++- src/templates/assets/drafts_queue.html | 2 +- src/templates/assets/holdlist_detail.html | 44 +++-- src/templates/assets/location_detail.html | 4 +- .../assets/partials/asset_list_results.html | 4 +- 9 files changed, 398 insertions(+), 57 deletions(-) create mode 100644 src/assets/services/search.py diff --git a/src/assets/models.py b/src/assets/models.py index 9a9ea4f..95bcd5c 100644 --- a/src/assets/models.py +++ b/src/assets/models.py @@ -746,6 +746,19 @@ class Meta: ), ] + @property + def thumbnail_url(self): + """Return the best available thumbnail URL. + + Prefers the generated thumbnail; falls back to the full image. + Returns an empty string when no image is set. + """ + if self.thumbnail: + return self.thumbnail.url + if self.image: + return self.image.url + return "" + def __str__(self): return f"Image for {self.asset.name}" diff --git a/src/assets/services/search.py b/src/assets/services/search.py new file mode 100644 index 0000000..f6585d0 --- /dev/null +++ b/src/assets/services/search.py @@ -0,0 +1,30 @@ +"""Asset text search helpers.""" + +from django.db.models import Q + +MAX_SEARCH_WORDS = 20 + + +def build_asset_text_query(q): + """Build Q object matching all words in q across asset text fields. + + Each word must appear in at least one of: name, description, barcode, + or tag name. Words are ANDed together so "blue bonnet" requires both + "blue" and "bonnet" to appear (possibly in different fields). + + At most ``MAX_SEARCH_WORDS`` words are considered; additional words + are silently ignored to bound query complexity. + """ + words = q.split()[:MAX_SEARCH_WORDS] + if not words: + return Q(pk__in=[]) + combined = Q() + for word in words: + word_q = ( + Q(name__icontains=word) + | Q(description__icontains=word) + | Q(barcode__icontains=word) + | Q(tags__name__icontains=word) + ) + combined &= word_q + return combined diff --git a/src/assets/tests/test_views.py b/src/assets/tests/test_views.py index 2a1fb80..784c164 100644 --- a/src/assets/tests/test_views.py +++ b/src/assets/tests/test_views.py @@ -5625,6 +5625,169 @@ def test_category_match_ranked_above_description( assert ids.index(cat_hit.pk) < ids.index(desc_hit.pk) +# ============================================================ +# Word-based search: multi-word queries match individual words +# ============================================================ + + +@pytest.mark.django_db +class TestWordBasedSearch: + """Word-based search matches each word independently across fields.""" + + def test_multi_word_matches_across_name( + self, client_logged_in, category, location, user + ): + """'blue bonnet' matches 'White Bonnet blue trim'.""" + asset = AssetFactory( + name="White Bonnet blue trim", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_list") + resp = client_logged_in.get(url, {"q": "blue bonnet"}) + assert asset.pk in [a.pk for a in resp.context["page_obj"]] + + def test_single_word_matches( + self, client_logged_in, category, location, user + ): + """'bonnet' matches 'Brown bonnet'.""" + asset = AssetFactory( + name="Brown bonnet", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_list") + resp = client_logged_in.get(url, {"q": "bonnet"}) + assert asset.pk in [a.pk for a in resp.context["page_obj"]] + + def test_non_matching_word_excludes( + self, client_logged_in, category, location, user + ): + """'blue bonnet xyz' does NOT match 'Blue headpiece'.""" + asset = AssetFactory( + name="Blue headpiece", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_list") + resp = client_logged_in.get(url, {"q": "blue bonnet xyz"}) + assert asset.pk not in [a.pk for a in resp.context["page_obj"]] + + def test_word_match_across_name_and_description( + self, client_logged_in, category, location, user + ): + """Words can match across different fields.""" + asset = AssetFactory( + name="Red Cape", + description="with blue lining", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_list") + resp = client_logged_in.get(url, {"q": "cape blue"}) + assert asset.pk in [a.pk for a in resp.context["page_obj"]] + + def test_autocomplete_word_search( + self, client_logged_in, category, location, user + ): + """asset_search JSON endpoint uses word-based search.""" + asset = AssetFactory( + name="White Bonnet blue trim", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": "blue bonnet"}) + data = resp.json() + ids = [r["id"] for r in data] + assert asset.pk in ids + + def test_autocomplete_returns_thumbnail_url( + self, client_logged_in, category, location, user + ): + """asset_search JSON response includes thumbnail_url field.""" + AssetFactory( + name="Test Asset Thumb", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": "Test Asset Thumb"}) + data = resp.json() + assert len(data) >= 1 + assert "thumbnail_url" in data[0] + + def test_autocomplete_default_limit( + self, client_logged_in, category, location, user + ): + """asset_search returns at most 20 results by default.""" + for i in range(25): + AssetFactory( + name=f"Widget {i}", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": "Widget"}) + data = resp.json() + assert len(data) == 20 + + def test_autocomplete_custom_limit( + self, client_logged_in, category, location, user + ): + """asset_search respects a custom limit parameter.""" + for i in range(10): + AssetFactory( + name=f"Gadget {i}", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": "Gadget", "limit": "5"}) + data = resp.json() + assert len(data) == 5 + + def test_autocomplete_limit_clamped_to_50( + self, client_logged_in, category, location, user + ): + """asset_search clamps limit to a maximum of 50.""" + for i in range(55): + AssetFactory( + name=f"Doohickey {i}", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": "Doohickey", "limit": "100"}) + data = resp.json() + assert len(data) == 50 + + def test_autocomplete_invalid_limit_defaults_to_20( + self, client_logged_in, category, location, user + ): + """asset_search falls back to 20 when limit is not an integer.""" + for i in range(25): + AssetFactory( + name=f"Thingamajig {i}", + category=category, + current_location=location, + created_by=user, + ) + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": "Thingamajig", "limit": "abc"}) + data = resp.json() + assert len(data) == 20 + + # ============================================================ # V212 (S2.6.1-03): Search by category # ============================================================ diff --git a/src/assets/views.py b/src/assets/views.py index 5c63733..0635d76 100644 --- a/src/assets/views.py +++ b/src/assets/views.py @@ -69,6 +69,7 @@ can_handover_asset, get_user_role, ) +from .services.search import build_asset_text_query BARCODE_PATTERN = re.compile(r"^[A-Z]+-[A-Z0-9]+$", re.IGNORECASE) @@ -309,18 +310,16 @@ def asset_list(request): queryset = queryset.filter(status=status) # Text search - q = request.GET.get("q", "") + q = request.GET.get("q", "").strip()[:200] if q: - queryset = queryset.filter( - Q(name__icontains=q) - | Q(description__icontains=q) - | Q(barcode__icontains=q) - | Q(tags__name__icontains=q) - | Q( - nfc_tags__tag_id__icontains=q, - nfc_tags__removed_at__isnull=True, - ) - ).distinct() + + text_q = build_asset_text_query(q) + # Also match NFC tags (full-phrase, not word-split) + nfc_q = Q( + nfc_tags__tag_id__icontains=q, + nfc_tags__removed_at__isnull=True, + ) + queryset = queryset.filter(text_q | nfc_q).distinct() # Filters department = request.GET.get("department") @@ -3554,18 +3553,27 @@ def asset_search(request): This will be replaced by a composite FTS index in future. """ - q = request.GET.get("q", "").strip() + q = request.GET.get("q", "").strip()[:200] if len(q) < 1: return JsonResponse([], safe=False) + + try: + limit = min(int(request.GET.get("limit", 20)), 50) + except (ValueError, TypeError): + limit = 20 + + text_q = build_asset_text_query(q) + # Also match category name (full phrase, not word-split) + category_q = Q(category__name__icontains=q) + + primary_image_prefetch = Prefetch( + "images", + queryset=AssetImage.objects.filter(is_primary=True), + to_attr="primary_images", + ) qs = ( Asset.objects.filter(status="active") - .filter( - Q(name__icontains=q) - | Q(barcode__icontains=q) - | Q(description__icontains=q) - | Q(tags__name__icontains=q) - | Q(category__name__icontains=q) - ) + .filter(text_q | category_q) .distinct() .annotate( relevance=Case( @@ -3582,20 +3590,25 @@ def asset_search(request): ) ) .select_related("category", "current_location") - .order_by("relevance", "name")[:20] + .prefetch_related(primary_image_prefetch) + .order_by("relevance", "name")[:limit] ) - results = [ - { - "id": a.id, - "name": a.name, - "barcode": a.barcode, - "category": a.category.name if a.category else "", - "location": ( - str(a.current_location) if a.current_location else "" - ), - } - for a in qs - ] + results = [] + for a in qs: + primary = a.primary_images[0] if a.primary_images else None + thumbnail_url = primary.thumbnail_url if primary else "" + results.append( + { + "id": a.id, + "name": a.name, + "barcode": a.barcode, + "category": a.category.name if a.category else "", + "location": ( + str(a.current_location) if a.current_location else "" + ), + "thumbnail_url": thumbnail_url, + } + ) return JsonResponse(results, safe=False) @@ -4174,14 +4187,10 @@ def export_assets(request): if condition: queryset = queryset.filter(condition=condition) - q = request.GET.get("q", "") + q = request.GET.get("q", "").strip()[:200] if q: - queryset = queryset.filter( - Q(name__icontains=q) - | Q(description__icontains=q) - | Q(barcode__icontains=q) - | Q(tags__name__icontains=q) - ).distinct() + + queryset = queryset.filter(build_asset_text_query(q)).distinct() buffer = export_assets_xlsx(queryset) @@ -5148,6 +5157,12 @@ def holdlist_detail(request, pk): ) items = hold_list.items.select_related( "asset", "asset__current_location", "serial", "pulled_by" + ).prefetch_related( + Prefetch( + "asset__images", + queryset=AssetImage.objects.filter(is_primary=True), + to_attr="primary_images", + ) ) from assets.services.holdlists import detect_overlaps, get_effective_dates diff --git a/src/templates/assets/dashboard.html b/src/templates/assets/dashboard.html index 7af0f04..639952c 100644 --- a/src/templates/assets/dashboard.html +++ b/src/templates/assets/dashboard.html @@ -2,6 +2,10 @@ {% block title %}Dashboard - {{ SITE_NAME }}{% endblock %} +{% block extra_head %} + +{% endblock %} + {% block content %}
@@ -41,6 +45,102 @@

Dashboard

{% endif %}
+ +
+
+ + + + +
+ +
+ + {% if pending_approvals_count > 0 %} @@ -163,7 +263,7 @@

Recent Drafts

{% if draft.primary_image %} - + {% else %} {% endif %} diff --git a/src/templates/assets/drafts_queue.html b/src/templates/assets/drafts_queue.html index a068b17..ec59237 100644 --- a/src/templates/assets/drafts_queue.html +++ b/src/templates/assets/drafts_queue.html @@ -59,7 +59,7 @@

Drafts Queue

{% if asset.primary_image %} - {{ asset.name }} + {{ asset.name }} {% else %}
diff --git a/src/templates/assets/holdlist_detail.html b/src/templates/assets/holdlist_detail.html index 2b0d24b..9887aee 100644 --- a/src/templates/assets/holdlist_detail.html +++ b/src/templates/assets/holdlist_detail.html @@ -67,7 +67,7 @@

{{ hold_list.name }}

{% endif %} {% if not hold_list.is_locked and can_write_holdlist %} -
+ {% csrf_token %}
@@ -101,15 +101,25 @@

{{ hold_list.name }}

:id="'search-option-' + item.id" role="option" :aria-selected="highlighted === idx" - class="w-full text-left px-3 py-2 text-sm hover:bg-brand-500/10 transition-colors"> - - - - + class="w-full text-left px-3 py-2 text-sm hover:bg-brand-500/10 transition-colors flex items-center gap-2"> +
+ + +
+
+ + + + +
@@ -188,6 +198,7 @@

{{ hold_list.name }}

+ @@ -199,12 +210,21 @@

{{ hold_list.name }}

{% for loc_name, loc_items in items_by_location.items %} - {% for item in loc_items %} + -
Asset Serial Qty
+ {{ loc_name }}
+
+ {% if item.asset.primary_image %} + + {% else %} + + {% endif %} +
+
{{ item.asset.name }} @@ -271,7 +291,7 @@

{{ hold_list.name }}

{% endfor %} {% empty %}
+
diff --git a/src/templates/assets/location_detail.html b/src/templates/assets/location_detail.html index d783437..c9904f2 100644 --- a/src/templates/assets/location_detail.html +++ b/src/templates/assets/location_detail.html @@ -179,7 +179,7 @@

{{ child.name }}

{% if asset.primary_image %} - + {% else %} {% endif %} @@ -215,7 +215,7 @@

{{ child.name }}

{% if asset.primary_image %} - + {% else %} {% endif %} diff --git a/src/templates/assets/partials/asset_list_results.html b/src/templates/assets/partials/asset_list_results.html index 1f849a7..d59ba4e 100644 --- a/src/templates/assets/partials/asset_list_results.html +++ b/src/templates/assets/partials/asset_list_results.html @@ -36,7 +36,7 @@
{% if asset.primary_image %} - {{ asset.name }} + {{ asset.name }} {% else %} {% endif %} @@ -86,7 +86,7 @@
{% if asset.primary_image %} - + {% else %} {% endif %} From c8edffc622f16e4fd7f71a9f6981f61c63ae5b37 Mon Sep 17 00:00:00 2001 From: Andrew Yager Date: Sat, 28 Feb 2026 18:40:34 +1100 Subject: [PATCH 8/8] test: add regression tests for word-based search and thumbnail_url Cover gaps identified during PR #61 regression review: - AssetImage.thumbnail_url property edge cases - build_asset_text_query multi-word, MAX_SEARCH_WORDS, and empty input - Export view with word-based search - Query truncation to 200 chars across views - Dashboard quick search affordance exposure - asset_search empty query handling Co-Authored-By: Claude Opus 4.6 --- src/assets/tests/test_models.py | 44 +++++++ src/assets/tests/test_services.py | 70 ++++++++++++ src/assets/tests/test_views.py | 183 ++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+) diff --git a/src/assets/tests/test_models.py b/src/assets/tests/test_models.py index 49f624f..cff7d82 100644 --- a/src/assets/tests/test_models.py +++ b/src/assets/tests/test_models.py @@ -271,6 +271,50 @@ def test_setting_primary_unsets_others(self, asset): assert i2.is_primary +class TestAssetImageThumbnailUrl: + """Unit tests for AssetImage.thumbnail_url property.""" + + def test_returns_thumbnail_url_when_thumbnail_set(self, asset): + """When thumbnail field is populated, thumbnail_url returns it.""" + from django.core.files.uploadedfile import SimpleUploadedFile + + img_file = SimpleUploadedFile( + "test.jpg", + b"\xff\xd8\xff\xe0" + b"\x00" * 100, + content_type="image/jpeg", + ) + thumb_file = SimpleUploadedFile( + "thumb.jpg", + b"\xff\xd8\xff\xe0" + b"\x00" * 100, + content_type="image/jpeg", + ) + image = AssetImage.objects.create( + asset=asset, image=img_file, thumbnail=thumb_file + ) + assert image.thumbnail_url == image.thumbnail.url + assert "thumb" in image.thumbnail_url + + def test_falls_back_to_image_url_when_no_thumbnail(self, asset): + """When thumbnail is absent, thumbnail_url falls back to + the full image URL.""" + from django.core.files.uploadedfile import SimpleUploadedFile + + img_file = SimpleUploadedFile( + "test.jpg", + b"\xff\xd8\xff\xe0" + b"\x00" * 100, + content_type="image/jpeg", + ) + image = AssetImage.objects.create(asset=asset, image=img_file) + assert not image.thumbnail + assert image.thumbnail_url == image.image.url + + def test_returns_empty_string_when_neither_set(self, asset): + """When both thumbnail and image are empty, + thumbnail_url returns empty string.""" + image = AssetImage(asset=asset) + assert image.thumbnail_url == "" + + class TestNFCTag: def test_str(self, asset, user): nfc = NFCTag.objects.create( diff --git a/src/assets/tests/test_services.py b/src/assets/tests/test_services.py index 0ccf6a0..b005bcc 100644 --- a/src/assets/tests/test_services.py +++ b/src/assets/tests/test_services.py @@ -242,6 +242,76 @@ def test_merge_moves_transactions(self, asset, user, category, location): assert asset.transactions.filter(action="audit").exists() +class TestBuildAssetTextQuery: + """Tests for build_asset_text_query search helper.""" + + def test_multi_word_requires_all_words(self, category, location, user): + """Multi-word query ANDs all words: both must match.""" + from assets.services.search import build_asset_text_query + + hit = AssetFactory( + name="Blue Bonnet Hat", + category=category, + current_location=location, + created_by=user, + ) + miss = AssetFactory( + name="Red Hat", + category=category, + current_location=location, + created_by=user, + ) + q = build_asset_text_query("blue bonnet") + results = Asset.objects.filter(q).distinct() + assert hit in results + assert miss not in results + + def test_max_search_words_truncation(self, category, location, user): + """The 21st word in a query is silently ignored.""" + from assets.services.search import ( + MAX_SEARCH_WORDS, + build_asset_text_query, + ) + + # Create asset matching 20 words but not the 21st + words_20 = [f"w{i}" for i in range(MAX_SEARCH_WORDS)] + name = " ".join(words_20[:5]) + desc = " ".join(words_20[5:]) + asset = AssetFactory( + name=name, + description=desc, + category=category, + current_location=location, + created_by=user, + ) + # Query with 20 words: should match + q_20 = build_asset_text_query(" ".join(words_20)) + assert ( + Asset.objects.filter(q_20).distinct().filter(pk=asset.pk).exists() + ) + + # Query with 21 words where the extra word doesn't match + q_21 = build_asset_text_query(" ".join(words_20) + " nonexistentword") + # 21st word is ignored, so result should still match + assert ( + Asset.objects.filter(q_21).distinct().filter(pk=asset.pk).exists() + ) + + def test_empty_query_returns_no_results(self, asset): + """Empty string query returns nothing-matching Q.""" + from assets.services.search import build_asset_text_query + + q = build_asset_text_query("") + assert Asset.objects.filter(q).count() == 0 + + def test_whitespace_only_returns_no_results(self, asset): + """Whitespace-only query returns nothing-matching Q.""" + from assets.services.search import build_asset_text_query + + q = build_asset_text_query(" ") + assert Asset.objects.filter(q).count() == 0 + + class TestExportService: def test_export_returns_bytes(self, asset): from assets.services.export import export_assets_xlsx diff --git a/src/assets/tests/test_views.py b/src/assets/tests/test_views.py index 784c164..2a520fa 100644 --- a/src/assets/tests/test_views.py +++ b/src/assets/tests/test_views.py @@ -7671,3 +7671,186 @@ def test_long_input_truncated_in_error(self): assert result is None assert "..." in error assert len(error) < 300 + + +# ============================================================ +# PR #61 REGRESSION: Export with word-based search +# ============================================================ + + +@pytest.mark.django_db +class TestExportWithWordSearch: + """Export view uses build_asset_text_query for multi-word search.""" + + def test_export_multi_word_search_finds_matching_asset( + self, admin_client, category, location, user + ): + """Multi-word search in export finds asset matching all words.""" + AssetFactory( + name="Blue Bonnet Hat", + category=category, + current_location=location, + status="active", + created_by=user, + ) + AssetFactory( + name="Red Hat", + category=category, + current_location=location, + status="active", + created_by=user, + ) + url = reverse("assets:export_assets") + resp = admin_client.get(url, {"q": "blue bonnet"}) + assert resp.status_code == 200 + assert "spreadsheetml" in resp["Content-Type"] + # Parse the exported workbook to verify correct filtering + from io import BytesIO + + import openpyxl + + wb = openpyxl.load_workbook(BytesIO(resp.content)) + ws = wb["Assets"] + # Collect asset names from the first data column (skip header) + names = [ + row[0].value for row in ws.iter_rows(min_row=2) if row[0].value + ] + assert "Blue Bonnet Hat" in names + assert "Red Hat" not in names + + def test_export_multi_word_search_excludes_partial_match( + self, admin_client, category, location, user + ): + """Multi-word search excludes assets matching only one word.""" + AssetFactory( + name="Red Bonnet", + category=category, + current_location=location, + status="active", + created_by=user, + ) + url = reverse("assets:export_assets") + resp = admin_client.get(url, {"q": "blue bonnet"}) + assert resp.status_code == 200 + from io import BytesIO + + import openpyxl + + wb = openpyxl.load_workbook(BytesIO(resp.content)) + ws = wb["Assets"] + names = [ + row[0].value for row in ws.iter_rows(min_row=2) if row[0].value + ] + assert "Red Bonnet" not in names + + +# ============================================================ +# PR #61 REGRESSION: Query truncation to 200 characters +# ============================================================ + + +@pytest.mark.django_db +class TestQueryTruncation: + """Views truncate query strings to 200 chars without error.""" + + def test_asset_list_truncates_long_query( + self, admin_client, category, location, user + ): + """asset_list handles >200 char query gracefully.""" + AssetFactory( + name="UniqueTargetName", + category=category, + current_location=location, + status="active", + created_by=user, + ) + # Query starts with a matching term, padded beyond 200 chars + long_q = "UniqueTargetName " + "x" * 200 + url = reverse("assets:asset_list") + resp = admin_client.get(url, {"q": long_q}) + assert resp.status_code == 200 + + def test_asset_search_truncates_long_query(self, client_logged_in): + """asset_search JSON endpoint handles >200 char query.""" + long_q = "a" * 250 + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": long_q}) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_export_truncates_long_query(self, admin_client, asset): + """export_assets handles >200 char query without error.""" + long_q = "a" * 250 + url = reverse("assets:export_assets") + resp = admin_client.get(url, {"q": long_q}) + assert resp.status_code == 200 + assert "spreadsheetml" in resp["Content-Type"] + + +# ============================================================ +# PR #61 REGRESSION: Dashboard quick search affordance +# ============================================================ + + +@pytest.mark.django_db +class TestDashboardSearchAffordance: + """Dashboard renders the search input and asset_search URL.""" + + def test_dashboard_renders_search_input(self, admin_client, asset): + """Dashboard page contains a search input element.""" + url = reverse("assets:dashboard") + resp = admin_client.get(url) + assert resp.status_code == 200 + content = resp.content.decode() + assert 'type="text"' in content + assert "Quick search" in content + + def test_dashboard_renders_asset_search_url(self, admin_client, asset): + """Dashboard page references the asset_search endpoint.""" + url = reverse("assets:dashboard") + resp = admin_client.get(url) + content = resp.content.decode() + search_url = reverse("assets:asset_search") + assert search_url in content + + def test_dashboard_renders_asset_list_search_link( + self, admin_client, asset + ): + """Dashboard 'View all results' links to asset_list with q.""" + url = reverse("assets:dashboard") + resp = admin_client.get(url) + content = resp.content.decode() + list_url = reverse("assets:asset_list") + assert list_url in content + + +# ============================================================ +# PR #61 REGRESSION: asset_search with empty query +# ============================================================ + + +@pytest.mark.django_db +class TestAssetSearchEmptyQuery: + """asset_search endpoint returns empty list for empty queries.""" + + def test_empty_string_returns_empty_json(self, client_logged_in): + """Empty query string returns empty JSON array.""" + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": ""}) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_whitespace_only_returns_empty_json(self, client_logged_in): + """Whitespace-only query returns empty JSON array.""" + url = reverse("assets:asset_search") + resp = client_logged_in.get(url, {"q": " "}) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_missing_q_param_returns_empty_json(self, client_logged_in): + """No q parameter at all returns empty JSON array.""" + url = reverse("assets:asset_search") + resp = client_logged_in.get(url) + assert resp.status_code == 200 + assert resp.json() == []