diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54153e7..4f3d94f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/release.yml b/.github/workflows/release.yml index 63094e8..553ae20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-latest-large needs: build steps: - uses: actions/checkout@v4 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_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 63d1f81..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 @@ -1160,10 +1230,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/assets/tests/test_views.py b/src/assets/tests/test_views.py index 2a1fb80..2a520fa 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 # ============================================================ @@ -7508,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() == [] 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/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 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.""" 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 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 %}
+ +
+
+ + + + +
+
+ + + View all results → + +
+
+ + {% 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 d6d48ad..c9904f2 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 %}
@@ -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 %} 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 %}