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
+
+ +
+ + + + + + + + + + + +
NameTypeBoxDescription
+
+ + + + + + 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 %} + {{ thing.name }} + {% endthumbnail %} + {% else %} +
No image
+ {% endif %} +
+ +
+
+
Type
+
{{ thing.thing_type.name }}
+
+ +
+
Location
+
+ Box {{ thing.box.id }} + ({{ thing.box.box_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), ]