diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml
index e744c62..210dee2 100644
--- a/argocd/deployment.yaml
+++ b/argocd/deployment.yaml
@@ -27,7 +27,7 @@ spec:
mountPath: /data
containers:
- name: web
- image: git.baumann.gr/adebaumann/labhelper:0.022
+ image: git.baumann.gr/adebaumann/labhelper:0.023
imagePullPolicy: Always
ports:
- containerPort: 8000
diff --git a/boxes/templates/boxes/search.html b/boxes/templates/boxes/search.html
new file mode 100644
index 0000000..15eef14
--- /dev/null
+++ b/boxes/templates/boxes/search.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+ Search - LabHelper
+
+
+
+ ← Back to Home
+
+ Search Things
+
+
+
+
Type at least 2 characters to search
+
+
+
+
+
+
+ | Name |
+ Type |
+ Box |
+ Description |
+
+
+
+
+
+
+
+
+ No results found.
+
+
+
+
+
diff --git a/boxes/templates/boxes/thing_detail.html b/boxes/templates/boxes/thing_detail.html
new file mode 100644
index 0000000..f87347f
--- /dev/null
+++ b/boxes/templates/boxes/thing_detail.html
@@ -0,0 +1,131 @@
+{% load thumbnail %}
+
+
+
+
+
+ {{ thing.name }} - LabHelper
+
+
+
+
+
+ {{ thing.name }}
+
+
+
+ {% if thing.picture %}
+ {% thumbnail thing.picture "300x300" crop="center" as thumb %}
+

+ {% endthumbnail %}
+ {% else %}
+
No image
+ {% endif %}
+
+
+
+
+
Type
+
{{ thing.thing_type.name }}
+
+
+
+
+ {% if thing.description %}
+
+
Description
+
{{ thing.description }}
+
+ {% endif %}
+
+
+
+
diff --git a/boxes/tests.py b/boxes/tests.py
index 8433342..2fdb7d3 100644
--- a/boxes/tests.py
+++ b/boxes/tests.py
@@ -487,3 +487,179 @@ class BoxDetailViewTests(TestCase):
"""Box detail URL should be reversible by name."""
url = reverse('box_detail', kwargs={'box_id': 'BOX001'})
self.assertEqual(url, '/box/BOX001/')
+
+
+class ThingDetailViewTests(TestCase):
+ """Tests for thing detail view."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.client = Client()
+ self.box_type = BoxType.objects.create(
+ name='Standard Box',
+ width=200,
+ height=100,
+ length=300
+ )
+ self.box = Box.objects.create(
+ id='BOX001',
+ box_type=self.box_type
+ )
+ self.thing_type = ThingType.objects.create(name='Electronics')
+ self.thing = Thing.objects.create(
+ name='Arduino Uno',
+ thing_type=self.thing_type,
+ box=self.box,
+ description='A microcontroller board'
+ )
+
+ def test_thing_detail_returns_200(self):
+ """Thing detail page should return 200 status."""
+ response = self.client.get(f'/thing/{self.thing.id}/')
+ self.assertEqual(response.status_code, 200)
+
+ def test_thing_detail_returns_404_for_invalid_thing(self):
+ """Thing detail page should return 404 for non-existent thing."""
+ response = self.client.get('/thing/99999/')
+ self.assertEqual(response.status_code, 404)
+
+ def test_thing_detail_shows_thing_name(self):
+ """Thing detail page should show thing name."""
+ response = self.client.get(f'/thing/{self.thing.id}/')
+ self.assertContains(response, 'Arduino Uno')
+
+ def test_thing_detail_shows_type(self):
+ """Thing detail page should show thing type."""
+ response = self.client.get(f'/thing/{self.thing.id}/')
+ self.assertContains(response, 'Electronics')
+
+ def test_thing_detail_shows_description(self):
+ """Thing detail page should show thing description."""
+ response = self.client.get(f'/thing/{self.thing.id}/')
+ self.assertContains(response, 'A microcontroller board')
+
+ def test_thing_detail_shows_box(self):
+ """Thing detail page should show box info."""
+ response = self.client.get(f'/thing/{self.thing.id}/')
+ self.assertContains(response, 'BOX001')
+ self.assertContains(response, 'Standard Box')
+
+ def test_thing_detail_uses_correct_template(self):
+ """Thing detail page should use correct template."""
+ response = self.client.get(f'/thing/{self.thing.id}/')
+ self.assertTemplateUsed(response, 'boxes/thing_detail.html')
+
+ def test_thing_detail_url_name(self):
+ """Thing detail URL should be reversible by name."""
+ url = reverse('thing_detail', kwargs={'thing_id': 1})
+ self.assertEqual(url, '/thing/1/')
+
+
+class SearchViewTests(TestCase):
+ """Tests for search view."""
+
+ def setUp(self):
+ """Set up test client."""
+ self.client = Client()
+
+ def test_search_returns_200(self):
+ """Search page should return 200 status."""
+ response = self.client.get('/search/')
+ self.assertEqual(response.status_code, 200)
+
+ def test_search_contains_search_input(self):
+ """Search page should contain search input field."""
+ response = self.client.get('/search/')
+ self.assertContains(response, 'id="search-input"')
+
+ def test_search_contains_results_container(self):
+ """Search page should contain results table."""
+ response = self.client.get('/search/')
+ self.assertContains(response, 'id="results-container"')
+
+
+class SearchApiTests(TestCase):
+ """Tests for search API."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.client = Client()
+ self.box_type = BoxType.objects.create(
+ name='Standard Box',
+ width=200,
+ height=100,
+ length=300
+ )
+ self.box = Box.objects.create(
+ id='BOX001',
+ box_type=self.box_type
+ )
+ self.thing_type = ThingType.objects.create(name='Electronics')
+ Thing.objects.create(
+ name='Arduino Uno',
+ thing_type=self.thing_type,
+ box=self.box,
+ description='A microcontroller board'
+ )
+ Thing.objects.create(
+ name='Raspberry Pi',
+ thing_type=self.thing_type,
+ box=self.box
+ )
+
+ def test_search_api_returns_empty_for_short_query(self):
+ """Search API should return empty results for queries under 2 chars."""
+ response = self.client.get('/search/api/?q=a')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()['results'], [])
+
+ def test_search_api_returns_results(self):
+ """Search API should return matching results."""
+ response = self.client.get('/search/api/?q=ard')
+ self.assertEqual(response.status_code, 200)
+ results = response.json()['results']
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0]['name'], 'Arduino Uno')
+
+ def test_search_api_is_case_insensitive(self):
+ """Search API should be case-insensitive."""
+ response = self.client.get('/search/api/?q=ARDUINO')
+ self.assertEqual(response.status_code, 200)
+ results = response.json()['results']
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0]['name'], 'Arduino Uno')
+
+ def test_search_api_truncates_description(self):
+ """Search API should truncate long descriptions."""
+ Thing.objects.create(
+ name='Long Description Item',
+ thing_type=self.thing_type,
+ box=self.box,
+ description='A' * 200
+ )
+ response = self.client.get('/search/api/?q=long')
+ self.assertEqual(response.status_code, 200)
+ results = response.json()['results']
+ self.assertEqual(len(results), 1)
+ self.assertLessEqual(len(results[0]['description']), 100)
+
+ def test_search_api_limits_results(self):
+ """Search API should limit results to 50."""
+ for i in range(60):
+ Thing.objects.create(
+ name=f'Item {i}',
+ thing_type=self.thing_type,
+ box=self.box
+ )
+ response = self.client.get('/search/api/?q=Item')
+ self.assertEqual(response.status_code, 200)
+ results = response.json()['results']
+ self.assertEqual(len(results), 50)
+
+ def test_search_api_includes_type_and_box(self):
+ """Search API results should include type and box info."""
+ response = self.client.get('/search/api/?q=ard')
+ self.assertEqual(response.status_code, 200)
+ results = response.json()['results']
+ self.assertEqual(results[0]['type'], 'Electronics')
+ self.assertEqual(results[0]['box'], 'BOX001')
diff --git a/boxes/views.py b/boxes/views.py
index 921fde6..2ae3b86 100644
--- a/boxes/views.py
+++ b/boxes/views.py
@@ -1,12 +1,12 @@
-from django.http import HttpResponse
+from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render
-from .models import Box
+from .models import Box, Thing
def index(request):
"""Simple index page."""
- html = 'LabHelper
Admin
'
+ html = 'LabHelper
Search Things | Admin
'
return HttpResponse(html)
@@ -18,3 +18,40 @@ def box_detail(request, box_id):
'box': box,
'things': things,
})
+
+
+def thing_detail(request, thing_id):
+ """Display details of a thing."""
+ thing = get_object_or_404(
+ Thing.objects.select_related('thing_type', 'box', 'box__box_type'),
+ pk=thing_id
+ )
+ return render(request, 'boxes/thing_detail.html', {'thing': thing})
+
+
+def search(request):
+ """Search page for things."""
+ return render(request, 'boxes/search.html')
+
+
+def search_api(request):
+ """AJAX endpoint for searching things."""
+ query = request.GET.get('q', '').strip()
+ if len(query) < 2:
+ return JsonResponse({'results': []})
+
+ things = Thing.objects.filter(
+ name__icontains=query
+ ).select_related('thing_type', 'box')[:50]
+
+ results = [
+ {
+ 'id': thing.id,
+ 'name': thing.name,
+ 'type': thing.thing_type.name,
+ 'box': thing.box.id,
+ 'description': thing.description[:100] if thing.description else '',
+ }
+ for thing in things
+ ]
+ return JsonResponse({'results': results})
diff --git a/labhelper/urls.py b/labhelper/urls.py
index d5106ec..4637a65 100644
--- a/labhelper/urls.py
+++ b/labhelper/urls.py
@@ -19,11 +19,14 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
-from boxes.views import box_detail, index
+from boxes.views import box_detail, index, search, search_api, thing_detail
urlpatterns = [
path('', index, name='index'),
path('box//', box_detail, name='box_detail'),
+ path('thing//', thing_detail, name='thing_detail'),
+ path('search/', search, name='search'),
+ path('search/api/', search_api, name='search_api'),
path('admin/', admin.site.urls),
]