Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:

test:
name: Test
runs-on: ubuntu-latest
runs-on: ubuntu-latest-large
needs: build
steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:

test:
name: Test
runs-on: ubuntu-latest
runs-on: ubuntu-latest-large
needs: build
steps:
- uses: actions/checkout@v4
Expand Down
13 changes: 13 additions & 0 deletions src/assets/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
30 changes: 30 additions & 0 deletions src/assets/services/search.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions src/assets/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
81 changes: 78 additions & 3 deletions src/assets/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading