diff --git a/.dockerignore b/.dockerignore index fecdb94..5f3cc6b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -43,6 +43,11 @@ docs/ # Implementation tracking .impl-tracker* +.impl-preferences* +.impl-verification/ + +# Binaries (Tailwind standalone CLI for local dev) +bin/ # Old codebase _old/ @@ -52,13 +57,23 @@ _old/ # Claude Code .claude/ +CLAUDE.local.md + +# Issue pipeline and worktrees +.issue-pipeline/ +.worktrees/ + +# Image cache +.image-cache/ # Docker (avoid recursive copies) docker-compose.yml Dockerfile -# Media (runtime data, not build artifact) +# Runtime/dev data (not build artifacts) src/media/ +src/staticfiles/ +src/db.sqlite3 # Traefik config traefik/ diff --git a/.env.example b/.env.example index ed377ed..700f1bb 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,13 @@ CELERY_RESULT_BACKEND=redis://redis:6379/0 # SERVER_EMAIL=errors@example.com # ADMIN_EMAIL=admin@example.com +# Sentry error tracking (feature disabled if SENTRY_DSN not set) +# For self-hosted Sentry, use your instance's DSN +# SENTRY_DSN=https://examplePublicKey@sentry.yourdomain.com/1 +# SENTRY_DSN_JS=https://examplePublicKey@sentry.yourdomain.com/2 +# SENTRY_ENVIRONMENT=production +# SENTRY_TRACES_SAMPLE_RATE=0.1 + # Production-only # DOMAIN=assets.yourdomain.com # ACME_EMAIL=admin@yourdomain.com diff --git a/requirements.in b/requirements.in index 6759d5f..737cd7e 100644 --- a/requirements.in +++ b/requirements.in @@ -42,6 +42,9 @@ weasyprint # AI (optional at runtime, required at build) anthropic +# Error tracking +sentry-sdk[django,celery] + # Colour coloraide diff --git a/requirements.txt b/requirements.txt index 452f71f..40f57d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,14 +2,14 @@ # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # -# pip-compile --output-file=/Users/andrewya/dev/props/.worktrees/issue-10/requirements.txt /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +# pip-compile requirements.in # amqp==5.3.1 # via kombu annotated-types==0.7.0 # via pydantic -anthropic==0.78.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +anthropic==0.84.0 + # via -r requirements.in anyio==4.12.1 # via # anthropic @@ -25,17 +25,17 @@ attrs==25.4.0 # via # service-identity # twisted -autobahn==24.4.2 +autobahn==25.12.2 # via daphne automat==25.4.16 # via twisted billiard==4.2.4 # via celery -black==26.1.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in -boto3==1.42.43 +black==26.3.0 + # via -r requirements.in +boto3==1.42.63 # via django-storages -botocore==1.42.43 +botocore==1.42.63 # via # boto3 # s3transfer @@ -43,24 +43,29 @@ brotli==1.2.0 # via fonttools build==1.4.0 # via pip-tools +cbor2==5.8.0 + # via autobahn celery[redis]==5.6.2 # via - # -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # -r requirements.in # django-celery-beat -certifi==2026.1.4 + # sentry-sdk +certifi==2026.2.25 # via # httpcore # httpx + # sentry-sdk cffi==2.0.0 # via + # autobahn # cryptography # weasyprint channels==4.3.2 # via - # -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # -r requirements.in # channels-redis channels-redis==4.3.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in click==8.3.1 # via # black @@ -75,13 +80,13 @@ click-plugins==1.1.1.2 # via celery click-repl==0.3.0 # via celery -coloraide==8.3 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +coloraide==8.6 + # via -r requirements.in constantly==23.10.4 # via twisted -coverage[toml]==7.13.3 +coverage[toml]==7.13.4 # via pytest-cov -cron-descriptor==2.0.6 +cron-descriptor==1.4.5 # via django-celery-beat cryptography==46.0.5 # via @@ -91,32 +96,33 @@ cryptography==46.0.5 cssselect2==0.9.0 # via weasyprint daphne==4.2.1 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in distro==1.9.0 # via anthropic -django==5.2.11 +django==5.2.12 # via - # -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # -r requirements.in # channels # django-celery-beat # django-htmx # django-storages # django-timezone-field # django-unfold -django-celery-beat==2.8.1 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in -django-gravatar2==1.4.4 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # sentry-sdk +django-celery-beat==2.9.0 + # via -r requirements.in +django-gravatar2==1.4.5 + # via -r requirements.in django-htmx==1.27.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in django-ratelimit==4.1.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in django-storages[boto3]==1.14.6 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in django-timezone-field==7.2.1 # via django-celery-beat -django-unfold==0.78.1 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +django-unfold==0.83.1 + # via -r requirements.in docstring-parser==0.17.0 # via anthropic et-xmlfile==2.0.0 @@ -124,15 +130,15 @@ et-xmlfile==2.0.0 execnet==2.1.2 # via pytest-xdist factory-boy==3.3.3 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in -faker==40.1.2 + # via -r requirements.in +faker==40.8.0 # via factory-boy flake8==7.3.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in fonttools[woff]==4.61.1 # via weasyprint -gunicorn==25.0.2 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +gunicorn==25.1.0 + # via -r requirements.in h11==0.16.0 # via httpcore httpcore==1.0.9 @@ -153,8 +159,8 @@ incremental==24.11.0 # via twisted iniconfig==2.3.0 # via pytest -isort==7.0.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +isort==8.0.1 + # via -r requirements.in jiter==0.13.0 # via anthropic jmespath==1.1.0 @@ -166,11 +172,13 @@ kombu[redis]==5.6.2 mccabe==0.7.0 # via flake8 msgpack==1.1.2 - # via channels-redis + # via + # autobahn + # channels-redis mypy-extensions==1.1.0 # via black openpyxl==3.1.5 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in packaging==26.0 # via # black @@ -182,18 +190,18 @@ packaging==26.0 # wheel pathspec==1.0.4 # via black -pi-heif==1.2.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +pi-heif==1.3.0 + # via -r requirements.in pillow==12.1.1 # via - # -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # -r requirements.in # pi-heif # python-barcode # qrcode # weasyprint -pip-tools==7.5.2 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in -platformdirs==4.5.1 +pip-tools==7.5.3 + # via -r requirements.in +platformdirs==4.9.4 # via black pluggy==1.6.0 # via @@ -201,10 +209,12 @@ pluggy==1.6.0 # pytest-cov prompt-toolkit==3.0.52 # via click-repl -psycopg[binary]==3.3.2 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in -psycopg-binary==3.3.2 +psycopg[binary]==3.3.3 + # via -r requirements.in +psycopg-binary==3.3.3 # via psycopg +py-ubjson==0.16.1 + # via autobahn pyasn1==0.6.2 # via # pyasn1-modules @@ -240,15 +250,15 @@ pytest==9.0.2 # pytest-django # pytest-xdist pytest-asyncio==1.3.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in pytest-cov==7.0.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in -pytest-django==4.11.1 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in +pytest-django==4.12.0 + # via -r requirements.in pytest-xdist==3.8.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in python-barcode[images]==0.16.1 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in python-crontab==3.3.0 # via django-celery-beat python-dateutil==2.9.0.post0 @@ -258,14 +268,16 @@ python-dateutil==2.9.0.post0 pytokens==0.4.1 # via black qrcode[pil]==8.2 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in redis==6.4.0 # via - # -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # -r requirements.in # channels-redis # kombu s3transfer==0.16.0 # via boto3 +sentry-sdk[celery,django]==2.54.0 + # via -r requirements.in service-identity==24.2.0 # via twisted six==1.17.0 @@ -278,16 +290,15 @@ tinycss2==1.5.1 # via # cssselect2 # weasyprint -tinyhtml5==2.0.0 +tinyhtml5==2.1.0 # via weasyprint twisted[tls]==25.5.0 # via daphne -txaio==25.9.2 +txaio==25.12.2 # via autobahn typing-extensions==4.15.0 # via # anthropic - # cron-descriptor # pydantic # pydantic-core # twisted @@ -300,17 +311,21 @@ tzdata==2025.3 # kombu tzlocal==5.3.1 # via celery +ujson==5.11.0 + # via autobahn urllib3==2.6.3 - # via botocore + # via + # botocore + # sentry-sdk vine==5.1.0 # via # amqp # celery # kombu -wcwidth==0.5.3 +wcwidth==0.6.0 # via prompt-toolkit weasyprint==68.1 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in + # via -r requirements.in webencodings==0.5.1 # via # cssselect2 @@ -318,8 +333,8 @@ webencodings==0.5.1 # tinyhtml5 wheel==0.46.3 # via pip-tools -whitenoise==6.11.0 - # via -r /Users/andrewya/dev/props/.worktrees/issue-10/requirements.in +whitenoise==6.12.0 + # via -r requirements.in zope-interface==8.2 # via twisted zopfli==0.4.1 diff --git a/src/assets/migrations/0039_asset_search_vector.py b/src/assets/migrations/0039_asset_search_vector.py new file mode 100644 index 0000000..9df7ef1 --- /dev/null +++ b/src/assets/migrations/0039_asset_search_vector.py @@ -0,0 +1,90 @@ +"""Add SearchVectorField with GIN index and PostgreSQL trigger to Asset.""" + +import django.contrib.postgres.indexes +from django.contrib.postgres.search import SearchVectorField +from django.db import migrations + + +def backfill_search_vector(apps, schema_editor): + """Populate search_vector for existing rows (PostgreSQL only).""" + if schema_editor.connection.vendor != "postgresql": + return + schema_editor.execute(""" + UPDATE assets_asset + SET search_vector = + setweight(to_tsvector('english', coalesce(name, '')), 'A') || + setweight(to_tsvector('english', coalesce(description, '')), 'B') + """) + + +def create_trigger(apps, schema_editor): + """Create PostgreSQL trigger to auto-update search_vector.""" + if schema_editor.connection.vendor != "postgresql": + return + schema_editor.execute(""" + CREATE OR REPLACE FUNCTION assets_asset_search_vector_update() + RETURNS trigger AS $$ + BEGIN + NEW.search_vector := + setweight(to_tsvector('english', coalesce(NEW.name, '')), 'A') || + setweight( + to_tsvector('english', coalesce(NEW.description, '')), 'B' + ); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS assets_asset_search_vector_trigger + ON assets_asset; + + CREATE TRIGGER assets_asset_search_vector_trigger + BEFORE INSERT OR UPDATE OF name, description + ON assets_asset + FOR EACH ROW + EXECUTE FUNCTION assets_asset_search_vector_update(); + """) + + +def drop_trigger(apps, schema_editor): + """Drop the search_vector trigger and function (reverse).""" + if schema_editor.connection.vendor != "postgresql": + return + schema_editor.execute(""" + DROP TRIGGER IF EXISTS assets_asset_search_vector_trigger + ON assets_asset; + DROP FUNCTION IF EXISTS assets_asset_search_vector_update(); + """) + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0038_location_created_at_not_null"), + ] + + operations = [ + # 1. Add the SearchVectorField column + migrations.AddField( + model_name="asset", + name="search_vector", + field=SearchVectorField(editable=False, null=True), + ), + # 2. Add GIN index on the new column + migrations.AddIndex( + model_name="asset", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_vector"], + name="idx_asset_search_vector", + ), + ), + # 3. Backfill existing rows + migrations.RunPython( + backfill_search_vector, + reverse_code=migrations.RunPython.noop, + ), + # 4. Create trigger for automatic updates + migrations.RunPython( + create_trigger, + reverse_code=drop_trigger, + ), + ] diff --git a/src/assets/models.py b/src/assets/models.py index 95bcd5c..9b255d9 100644 --- a/src/assets/models.py +++ b/src/assets/models.py @@ -7,6 +7,8 @@ from barcode.writer import ImageWriter from django.conf import settings +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVectorField from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.files.base import ContentFile @@ -357,6 +359,8 @@ class Asset(models.Model): related_name="created_assets", ) + search_vector = SearchVectorField(null=True, editable=False) + objects = AssetManager() class Meta: @@ -375,6 +379,10 @@ class Meta: fields=["is_serialised"], name="idx_asset_is_serialised", ), + GinIndex( + fields=["search_vector"], + name="idx_asset_search_vector", + ), ] permissions = [ ("can_checkout_asset", "Can check out assets"), diff --git a/src/assets/services/bulk.py b/src/assets/services/bulk.py index 3718f21..22a2b6c 100644 --- a/src/assets/services/bulk.py +++ b/src/assets/services/bulk.py @@ -2,9 +2,10 @@ from django.contrib.auth import get_user_model from django.db import transaction as db_transaction -from django.db.models import F, Q +from django.db.models import F from ..models import Asset, AssetSerial, Category, Location, Transaction +from .search import build_asset_search User = get_user_model() @@ -52,16 +53,7 @@ def build_asset_filter_queryset(filters: dict): q = filters.get("q", "") 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() + queryset = build_asset_search(queryset, q, include_nfc=True) department = filters.get("department", "") if department: @@ -121,12 +113,7 @@ def build_bulk_queryset( queryset = queryset.filter(status=filters["status"]) q = filters.get("q", "") if q: - queryset = queryset.filter( - Q(name__icontains=q) - | Q(description__icontains=q) - | Q(barcode__icontains=q) - | Q(tags__name__icontains=q) - ).distinct() + queryset = build_asset_search(queryset, q, include_nfc=True) if filters.get("department"): queryset = queryset.filter( category__department_id=filters["department"] diff --git a/src/assets/services/resolve.py b/src/assets/services/resolve.py index ce4c90a..8b0f851 100644 --- a/src/assets/services/resolve.py +++ b/src/assets/services/resolve.py @@ -5,9 +5,8 @@ flexible asset identifiers. """ -from django.db.models import Q - from assets.models import Asset, AssetSerial, NFCTag +from assets.services.search import build_asset_search def _truncate(value, max_len=100): @@ -128,16 +127,13 @@ def resolve_asset_from_input(asset_id=None, search=None, barcode=None): ) # 3e. Broad text match (name, description, tags, category) - matches = ( - Asset.objects.filter(status="active") - .filter( - Q(name__icontains=search) - | Q(description__icontains=search) - | Q(tags__name__icontains=search) - | Q(category__name__icontains=search) - ) - .distinct()[:2] - ) + base_qs = Asset.objects.filter(status="active") + matches = build_asset_search( + base_qs, + search, + include_nfc=False, + include_category=True, + )[:2] results = list(matches) if len(results) == 1: return results[0], None diff --git a/src/assets/services/search.py b/src/assets/services/search.py index f6585d0..c4def0b 100644 --- a/src/assets/services/search.py +++ b/src/assets/services/search.py @@ -1,30 +1,136 @@ -"""Asset text search helpers.""" +"""Asset text search helpers. -from django.db.models import Q +Provides PostgreSQL full-text search (FTS) for Asset querysets, with +icontains fallback for identifier fields (barcode, NFC tag IDs) that +don't benefit from stemming/tokenisation. + +Falls back to icontains-only search on non-PostgreSQL backends (e.g. +SQLite in tests). +""" + +from django.db import connection +from django.db.models import Case, Q, Value, When +from django.db.models.fields import FloatField MAX_SEARCH_WORDS = 20 -def build_asset_text_query(q): - """Build Q object matching all words in q across asset text fields. +def _is_postgres(): + return connection.vendor == "postgresql" - 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. +def _build_fts_search(queryset, words, search_text, icontains_q): + """FTS search path for PostgreSQL. + + Uses the pre-computed search_vector field (updated by a DB trigger) + with a GIN index for fast full-text search. """ - words = q.split()[:MAX_SEARCH_WORDS] - if not words: - return Q(pk__in=[]) - combined = Q() + from django.contrib.postgres.search import SearchQuery, SearchRank + + search_query = SearchQuery(search_text, search_type="websearch") + + # Filter on the stored search_vector field (GIN-indexed) + fts_filter = Q(search_vector=search_query) + + # Tags via icontains (short strings, stemming adds little value; + # M2M joins in SearchVector cause row duplication issues) + tag_q = Q() for word in words: - word_q = ( + tag_q &= Q(tags__name__icontains=word) + + combined_filter = fts_filter | tag_q | icontains_q + + barcode_exact = Case( + When(barcode__iexact=search_text, then=Value(10.0)), + default=Value(0.0), + output_field=FloatField(), + ) + + return ( + queryset.annotate( + fts_rank=SearchRank("search_vector", search_query), + barcode_boost=barcode_exact, + ) + .filter(combined_filter) + .distinct() + .order_by("-barcode_boost", "-fts_rank") + ) + + +def _build_icontains_search(queryset, words, search_text, icontains_q): + """icontains fallback for non-PostgreSQL backends.""" + # Build word-AND query across text fields + text_q = Q() + for word in words: + text_q &= ( Q(name__icontains=word) | Q(description__icontains=word) - | Q(barcode__icontains=word) | Q(tags__name__icontains=word) ) - combined &= word_q - return combined + + combined_filter = text_q | icontains_q + + barcode_exact = Case( + When(barcode__iexact=search_text, then=Value(10.0)), + default=Value(0.0), + output_field=FloatField(), + ) + + return ( + queryset.annotate(barcode_boost=barcode_exact) + .filter(combined_filter) + .distinct() + .order_by("-barcode_boost") + ) + + +def build_asset_search( + queryset, + q, + include_nfc=True, + include_category=False, +): + """Apply search to an Asset queryset. + + Uses PostgreSQL FTS when available, falls back to icontains on other + backends. + + - FTS/icontains on: name, description, tag names + - icontains on: barcode (identifier, not prose) + - icontains on: NFC tag IDs (if include_nfc=True) + - icontains on: category name (if include_category=True) + + Args: + queryset: Base Asset queryset to filter. + q: Search string (space-separated words, ANDed). + include_nfc: Include NFC tag ID substring matching. + include_category: Include category name substring matching. + + Returns: + Filtered, distinct queryset ordered by relevance. + """ + words = q.split()[:MAX_SEARCH_WORDS] + if not words: + return queryset.none() + + search_text = " ".join(words) + + # Identifier fields: always icontains (not prose, no stemming benefit) + icontains_q = Q() + for word in words: + word_q = Q(barcode__icontains=word) + if include_nfc: + word_q |= Q( + nfc_tags__tag_id__icontains=word, + nfc_tags__removed_at__isnull=True, + ) + if include_category: + word_q |= Q(category__name__icontains=word) + icontains_q &= word_q + + if _is_postgres(): + return _build_fts_search(queryset, words, search_text, icontains_q) + else: + return _build_icontains_search( + queryset, words, search_text, icontains_q + ) diff --git a/src/assets/tests/test_ai.py b/src/assets/tests/test_ai.py index 1aecf0a..1b1cf45 100644 --- a/src/assets/tests/test_ai.py +++ b/src/assets/tests/test_ai.py @@ -1066,24 +1066,41 @@ def test_dashboard_shows_ai_daily_usage_and_remaining( class TestAIEdgeCases: """S7.11 — AI image analysis edge cases.""" - def test_vv755_large_image_memory_check(self, admin_user): + @override_settings(ANTHROPIC_API_KEY="test-key") + def test_vv755_large_image_memory_check(self, admin_user, tmp_path): """VV755: Very large image should fail AI analysis gracefully, not crash the worker.""" + import io + + from PIL import Image as PILImage + + from django.core.files.uploadedfile import SimpleUploadedFile + asset = AssetFactory(name="Big Image Asset") + + # Create a real image file so FieldFile.read() works + buf = io.BytesIO() + PILImage.new("RGB", (10, 10), "red").save(buf, format="JPEG") + buf.seek(0) + upload = SimpleUploadedFile( + "big.jpg", buf.read(), content_type="image/jpeg" + ) img = AssetImage.objects.create( asset=asset, - image="assets/test.jpg", + image=upload, is_primary=True, ai_processing_status="pending", ) from assets.services.ai import analyse_image - mock_img = MagicMock() - mock_img.size = (8000, 6000) - mock_img.mode = "RGB" + mock_pil_img = MagicMock() + mock_pil_img.size = (8000, 6001) # 48_008_000 > 48_000_000 limit + mock_pil_img.mode = "RGB" - with patch("PIL.Image.open", return_value=mock_img): + # PIL.Image.open returns oversized dimensions to trigger + # the "too large" guard in the task + with patch("PIL.Image.open", return_value=mock_pil_img): with patch("anthropic.Anthropic"): try: analyse_image(img.pk) diff --git a/src/assets/tests/test_services.py b/src/assets/tests/test_services.py index b005bcc..e87baf5 100644 --- a/src/assets/tests/test_services.py +++ b/src/assets/tests/test_services.py @@ -242,12 +242,218 @@ 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.""" +class TestBuildAssetSearch: + """Tests for build_asset_search FTS search service.""" - def test_multi_word_requires_all_words(self, category, location, user): + def test_search_by_name(self, category, location, user): + """Search matches asset by name (case-insensitive).""" + from assets.services.search import build_asset_search + + hit = AssetFactory( + name="Victorian Chandelier", + category=category, + current_location=location, + created_by=user, + ) + miss = AssetFactory( + name="Wooden Table", + category=category, + current_location=location, + created_by=user, + ) + results = build_asset_search(Asset.objects.all(), "chandelier") + assert hit in results + assert miss not in results + + def test_search_by_name_case_insensitive(self, category, location, user): + """FTS search is case-insensitive.""" + from assets.services.search import build_asset_search + + hit = AssetFactory( + name="Victorian Chandelier", + category=category, + current_location=location, + created_by=user, + ) + results = build_asset_search(Asset.objects.all(), "CHANDELIER") + assert hit in results + + def test_search_by_description(self, category, location, user): + """Search matches asset by description text.""" + from assets.services.search import build_asset_search + + hit = AssetFactory( + name="Prop Item", + description="A beautiful ornate golden frame", + category=category, + current_location=location, + created_by=user, + ) + miss = AssetFactory( + name="Other Item", + description="A simple wooden box", + category=category, + current_location=location, + created_by=user, + ) + results = build_asset_search(Asset.objects.all(), "ornate golden") + assert hit in results + assert miss not in results + + def test_search_by_barcode_substring(self, category, location, user): + """Barcode search uses substring match (not FTS).""" + from assets.services.search import build_asset_search + + hit = AssetFactory( + name="Some Asset", + barcode="PROP-00142", + category=category, + current_location=location, + created_by=user, + ) + miss = AssetFactory( + name="Other Asset", + barcode="PROP-00999", + category=category, + current_location=location, + created_by=user, + ) + results = build_asset_search(Asset.objects.all(), "PROP-001") + assert hit in results + assert miss not in results + + def test_search_by_tag_name(self, category, location, user): + """Search matches asset by associated tag name.""" + from assets.services.search import build_asset_search + + hit = AssetFactory( + name="Tagged Asset", + category=category, + current_location=location, + created_by=user, + ) + tag = TagFactory(name="fragile") + hit.tags.add(tag) + + miss = AssetFactory( + name="Untagged Asset", + category=category, + current_location=location, + created_by=user, + ) + results = build_asset_search(Asset.objects.all(), "fragile") + assert hit in results + assert miss not in results + + def test_search_by_nfc_tag_id(self, category, location, user): + """Search matches asset by NFC tag ID when include_nfc=True.""" + from assets.services.search import build_asset_search + + hit = AssetFactory( + name="NFC Asset", + category=category, + current_location=location, + created_by=user, + ) + NFCTagFactory( + tag_id="04:A3:B2:C1:D4:E5:F6", asset=hit, assigned_by=user + ) + + miss = AssetFactory( + name="Other Asset", + category=category, + current_location=location, + created_by=user, + ) + results = build_asset_search( + Asset.objects.all(), "04:A3:B2", include_nfc=True + ) + assert hit in results + assert miss not in results + + def test_search_nfc_excluded_by_default_false( + self, category, location, user + ): + """NFC tag search excluded when include_nfc=False.""" + from assets.services.search import build_asset_search + + asset = AssetFactory( + name="NFC Only Asset", + category=category, + current_location=location, + created_by=user, + ) + NFCTagFactory( + tag_id="04:A3:B2:C1:D4:E5:F6", asset=asset, assigned_by=user + ) + results = build_asset_search( + Asset.objects.all(), "04:A3:B2", include_nfc=False + ) + assert asset not in results + + def test_search_excludes_removed_nfc_tags(self, category, location, user): + """Removed NFC tags are excluded from search results.""" + from django.utils import timezone + + from assets.services.search import build_asset_search + + asset = AssetFactory( + name="Removed NFC Asset", + category=category, + current_location=location, + created_by=user, + ) + NFCTagFactory( + tag_id="04:AA:BB:CC:DD:EE:FF", + asset=asset, + assigned_by=user, + removed_at=timezone.now(), + ) + results = build_asset_search( + Asset.objects.all(), "04:AA:BB", include_nfc=True + ) + assert asset not in results + + def test_search_by_category_name(self, category, location, user): + """Search matches asset by category name when include_category=True.""" + from assets.services.search import build_asset_search + + hit = AssetFactory( + name="Some Prop", + category=category, + current_location=location, + created_by=user, + ) + # category fixture has a name — search for it + results = build_asset_search( + Asset.objects.all(), + category.name, + include_category=True, + ) + assert hit in results + + def test_search_category_excluded_by_default( + self, category, location, user + ): + """Category search excluded when include_category=False (default).""" + from assets.services.search import build_asset_search + + AssetFactory( + name="Unique Zephyr", + category=category, + current_location=location, + created_by=user, + ) + # Search by category name only — should not match + results = build_asset_search( + Asset.objects.all(), category.name, include_category=False + ) + # The asset name doesn't contain the category name, so no match + assert results.count() == 0 + + def test_multi_word_and_semantics(self, category, location, user): """Multi-word query ANDs all words: both must match.""" - from assets.services.search import build_asset_text_query + from assets.services.search import build_asset_search hit = AssetFactory( name="Blue Bonnet Hat", @@ -261,55 +467,87 @@ def test_multi_word_requires_all_words(self, category, location, user): current_location=location, created_by=user, ) - q = build_asset_text_query("blue bonnet") - results = Asset.objects.filter(q).distinct() + results = build_asset_search(Asset.objects.all(), "blue bonnet") assert hit in results assert miss not in results + def test_empty_query_returns_empty(self, asset): + """Empty string query returns empty queryset.""" + from assets.services.search import build_asset_search + + results = build_asset_search(Asset.objects.all(), "") + assert results.count() == 0 + + def test_whitespace_only_returns_empty(self, asset): + """Whitespace-only query returns empty queryset.""" + from assets.services.search import build_asset_search + + results = build_asset_search(Asset.objects.all(), " ") + assert results.count() == 0 + + def test_no_duplicates_with_multiple_tags(self, category, location, user): + """Asset with multiple matching tags appears only once.""" + from assets.services.search import build_asset_search + + asset = AssetFactory( + name="Multi Tag Asset", + category=category, + current_location=location, + created_by=user, + ) + tag1 = TagFactory(name="vintage") + tag2 = TagFactory(name="vintage-style") + asset.tags.add(tag1, tag2) + + results = build_asset_search(Asset.objects.all(), "vintage") + assert list(results.filter(pk=asset.pk)).count(asset) == 1 + 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, - ) + from assets.services.search import MAX_SEARCH_WORDS, build_asset_search - # 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:]) + words_20 = [f"wordtest{i}" for i in range(MAX_SEARCH_WORDS)] asset = AssetFactory( - name=name, - description=desc, + name=" ".join(words_20[:5]), + description=" ".join(words_20[5:]), 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() - ) + # 20 words: should match + results = build_asset_search(Asset.objects.all(), " ".join(words_20)) + assert asset in results - # 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() + # 21 words where extra doesn't match — still matches (21st ignored) + results = build_asset_search( + Asset.objects.all(), " ".join(words_20) + " nonexistentxyz" ) + assert asset in results - 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 + def test_barcode_exact_match_ranks_above_name( + self, category, location, user + ): + """Asset with exact barcode match ranks higher than name-only match.""" + from assets.services.search import build_asset_search - q = build_asset_text_query(" ") - assert Asset.objects.filter(q).count() == 0 + barcode_hit = AssetFactory( + name="Generic Item", + barcode="PROP-00142", + category=category, + current_location=location, + created_by=user, + ) + name_hit = AssetFactory( + name="PROP-00142 Label Backup", + barcode="PROP-99999", + category=category, + current_location=location, + created_by=user, + ) + results = list(build_asset_search(Asset.objects.all(), "PROP-00142")) + assert barcode_hit in results + # Barcode exact match should be first + assert results.index(barcode_hit) < results.index(name_hit) class TestExportService: diff --git a/src/assets/tests/test_views.py b/src/assets/tests/test_views.py index 2a520fa..0839e15 100644 --- a/src/assets/tests/test_views.py +++ b/src/assets/tests/test_views.py @@ -7680,7 +7680,7 @@ def test_long_input_truncated_in_error(self): @pytest.mark.django_db class TestExportWithWordSearch: - """Export view uses build_asset_text_query for multi-word search.""" + """Export view uses build_asset_search for multi-word search.""" def test_export_multi_word_search_finds_matching_asset( self, admin_client, category, location, user diff --git a/src/assets/views.py b/src/assets/views.py index 0635d76..d9ac0d9 100644 --- a/src/assets/views.py +++ b/src/assets/views.py @@ -69,7 +69,7 @@ can_handover_asset, get_user_role, ) -from .services.search import build_asset_text_query +from .services.search import build_asset_search BARCODE_PATTERN = re.compile(r"^[A-Z]+-[A-Z0-9]+$", re.IGNORECASE) @@ -313,13 +313,7 @@ def asset_list(request): q = request.GET.get("q", "").strip()[:200] if q: - 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() + queryset = build_asset_search(queryset, q, include_nfc=True) # Filters department = request.GET.get("department") @@ -3562,9 +3556,10 @@ def asset_search(request): 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) + base_qs = Asset.objects.filter(status="active") + filtered_qs = build_asset_search( + base_qs, q, include_nfc=False, include_category=True + ) primary_image_prefetch = Prefetch( "images", @@ -3572,10 +3567,7 @@ def asset_search(request): to_attr="primary_images", ) qs = ( - Asset.objects.filter(status="active") - .filter(text_q | category_q) - .distinct() - .annotate( + filtered_qs.annotate( relevance=Case( When(barcode__iexact=q, then=Value(1)), When(barcode__icontains=q, then=Value(2)), @@ -4190,7 +4182,7 @@ def export_assets(request): q = request.GET.get("q", "").strip()[:200] if q: - queryset = queryset.filter(build_asset_text_query(q)).distinct() + queryset = build_asset_search(queryset, q, include_nfc=False) buffer = export_assets_xlsx(queryset) @@ -4548,16 +4540,7 @@ def print_all_filtered_labels(request): q = request.GET.get("q", "") 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() + queryset = build_asset_search(queryset, q, include_nfc=True) department = request.GET.get("department") if department: diff --git a/src/conftest.py b/src/conftest.py index e7b1be4..aafbb36 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -26,6 +26,12 @@ settings.CELERY_TASK_ALWAYS_EAGER = True settings.CELERY_TASK_EAGER_PROPAGATES = True +# Disable AI analysis in tests — prevents eager Celery tasks from +# hitting the real Anthropic API when ANTHROPIC_API_KEY is set in +# the Docker environment. Tests that specifically exercise AI +# behaviour mock the API client directly. +settings.ANTHROPIC_API_KEY = "" + # Use in-memory cache for tests (avoids Redis connection errors) settings.CACHES = { "default": { diff --git a/src/props/celery.py b/src/props/celery.py index cbd1eaf..038578e 100644 --- a/src/props/celery.py +++ b/src/props/celery.py @@ -9,3 +9,8 @@ app = Celery("props") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() + +# Sentry integration for Celery is auto-configured by sentry-sdk +# when the Django integration is active and SENTRY_DSN is set. +# The sentry_sdk.init() call in settings.py handles this via the +# CeleryIntegration that ships with sentry-sdk[celery]. diff --git a/src/props/context_processors.py b/src/props/context_processors.py index 5383240..e833028 100644 --- a/src/props/context_processors.py +++ b/src/props/context_processors.py @@ -62,6 +62,11 @@ def site_settings(request): "logo_url": logo_url, "color_mode": color_mode, "app_version": settings.APP_VERSION, + "SENTRY_DSN_JS": getattr(settings, "SENTRY_DSN_JS", ""), + "SENTRY_ENVIRONMENT": getattr(settings, "SENTRY_ENVIRONMENT", ""), + "SENTRY_TRACES_SAMPLE_RATE": getattr( + settings, "SENTRY_TRACES_SAMPLE_RATE", 0.1 + ), } diff --git a/src/props/settings.py b/src/props/settings.py index cc486b2..f8cba4a 100644 --- a/src/props/settings.py +++ b/src/props/settings.py @@ -3,6 +3,8 @@ import os from pathlib import Path +import sentry_sdk + from django.urls import reverse_lazy BASE_DIR = Path(__file__).resolve().parent.parent @@ -37,6 +39,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.postgres", "django_htmx", "django_celery_beat", "django_gravatar", @@ -538,6 +541,30 @@ }, } +# Sentry error tracking +# SENTRY_DSN enables backend (Python/Django/Celery) reporting. +# SENTRY_DSN_JS enables browser JS reporting (frontend + admin). +SENTRY_DSN = os.environ.get("SENTRY_DSN", "") +SENTRY_DSN_JS = os.environ.get("SENTRY_DSN_JS", "") +SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT", "development") +try: + SENTRY_TRACES_SAMPLE_RATE = float( + os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "0.1") + ) +except (TypeError, ValueError): + SENTRY_TRACES_SAMPLE_RATE = 0.1 + +if SENTRY_DSN: + sentry_sdk.init( + dsn=SENTRY_DSN, + environment=SENTRY_ENVIRONMENT, + release=APP_VERSION, + traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, + send_default_pii=True, + # Profile 100% of sampled transactions + profiles_sample_rate=1.0, + ) + # Logging — ensure tracebacks appear in container logs even with DEBUG=False LOGGING = { "version": 1, diff --git a/src/props/tests/test_infrastructure.py b/src/props/tests/test_infrastructure.py index c9837de..5d43ad2 100644 --- a/src/props/tests/test_infrastructure.py +++ b/src/props/tests/test_infrastructure.py @@ -397,7 +397,7 @@ def capture_init(self, *args, **kwargs): assert len(sent_messages) == 1 msg = sent_messages[0] - assert "PROPS" in msg.body + assert settings.SITE_NAME in msg.body @patch("django.core.mail.EmailMultiAlternatives.send") def test_handles_list_recipient(self, mock_send, db): diff --git a/src/templates/admin/base_site.html b/src/templates/admin/base_site.html new file mode 100644 index 0000000..28896e4 --- /dev/null +++ b/src/templates/admin/base_site.html @@ -0,0 +1,28 @@ +{% extends "admin/base.html" %} + +{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block branding %} + {% include "unfold/helpers/site_branding.html" %} +{% endblock %} + +{% block extrahead %} + {{ block.super }} + {% if SENTRY_DSN_JS %} + + + {% endif %} +{% endblock %} + +{% block nav-global %}{% endblock %} diff --git a/src/templates/base.html b/src/templates/base.html index ac80727..eaf3394 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -38,6 +38,21 @@ } {% endif %} + {% if SENTRY_DSN_JS %} + + + {% endif %} {% block extra_head %}{% endblock %}