diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml
index 210dee2..1f74c74 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.023
+ image: git.baumann.gr/adebaumann/labhelper:0.024
imagePullPolicy: Always
ports:
- containerPort: 8000
diff --git a/boxes/forms.py b/boxes/forms.py
new file mode 100644
index 0000000..e0945d0
--- /dev/null
+++ b/boxes/forms.py
@@ -0,0 +1,24 @@
+from django import forms
+
+from .models import Thing
+
+
+class ThingForm(forms.ModelForm):
+ """Form for adding a Thing."""
+
+ class Meta:
+ model = Thing
+ fields = ('name', 'thing_type', 'description', 'picture')
+ widgets = {
+ 'name': forms.TextInput(attrs={'class': 'form-control'}),
+ 'thing_type': forms.Select(attrs={'class': 'form-control'}),
+ 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
+ }
+
+
+ThingFormSet = forms.modelformset_factory(
+ Thing,
+ form=ThingForm,
+ extra=1,
+ can_delete=False
+)
diff --git a/boxes/templates/boxes/add_things.html b/boxes/templates/boxes/add_things.html
new file mode 100644
index 0000000..733d9f0
--- /dev/null
+++ b/boxes/templates/boxes/add_things.html
@@ -0,0 +1,215 @@
+
+
+
+
+
+ Add Things to Box {{ box.id }} - LabHelper
+
+
+
+ ← Home
+
+ Add Things to Box {{ box.id }}
+
+
+
+ Box: {{ box.id }} ({{ box.box_type.name }})
+
+
+ {% if formset.non_form_errors %}
+
+ {% for form_errors in formset.non_form_errors %}
+ {% for field, errors in form_errors.items %}
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+ {% endfor %}
+ {% endfor %}
+
+ {% endif %}
+
+ {% if formset.total_form_count %}
+
+ {% endif %}
+
+
+
diff --git a/boxes/templates/boxes/box_detail.html b/boxes/templates/boxes/box_detail.html
index 68d3a87..548b913 100644
--- a/boxes/templates/boxes/box_detail.html
+++ b/boxes/templates/boxes/box_detail.html
@@ -79,6 +79,8 @@
Type: {{ box.box_type.name }}
({{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm)
+
+
+ Add Things
{% if things %}
diff --git a/boxes/tests.py b/boxes/tests.py
index 2fdb7d3..6f865ef 100644
--- a/boxes/tests.py
+++ b/boxes/tests.py
@@ -663,3 +663,126 @@ class SearchApiTests(TestCase):
results = response.json()['results']
self.assertEqual(results[0]['type'], 'Electronics')
self.assertEqual(results[0]['box'], 'BOX001')
+
+
+class AddThingsViewTests(TestCase):
+ """Tests for add things 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')
+
+ def test_add_things_get_request(self):
+ """Add things page should return 200 for GET request."""
+ response = self.client.get(f'/box/{self.box.id}/add/')
+ self.assertEqual(response.status_code, 200)
+
+ def test_add_things_shows_box_id(self):
+ """Add things page should show box ID."""
+ response = self.client.get(f'/box/{self.box.id}/add/')
+ self.assertContains(response, 'BOX001')
+ self.assertContains(response, 'Standard Box')
+
+ def test_add_things_shows_form(self):
+ """Add things page should show form."""
+ response = self.client.get(f'/box/{self.box.id}/add/')
+ self.assertContains(response, 'Save Things')
+ self.assertContains(response, 'Name')
+ self.assertContains(response, 'Type')
+ self.assertContains(response, 'Description')
+ self.assertContains(response, 'Picture')
+
+ def test_add_things_post_valid(self):
+ """Adding valid things should redirect to box detail."""
+ response = self.client.post(f'/box/{self.box.id}/add/', {
+ 'form-TOTAL_FORMS': '3',
+ 'form-0-name': 'Arduino Uno',
+ 'form-0-thing_type': self.thing_type.id,
+ 'form-0-description': 'A microcontroller',
+ 'form-1-name': 'LED Strip',
+ 'form-1-thing_type': self.thing_type.id,
+ 'form-1-description': 'Lighting component',
+ })
+ self.assertRedirects(response, f'/box/{self.box.id}/')
+ self.assertEqual(Thing.objects.count(), 2)
+
+ def test_add_things_post_required_name(self):
+ """Adding things without name should show error."""
+ response = self.client.post(f'/box/{self.box.id}/add/', {
+ 'form-TOTAL_FORMS': '2',
+ 'form-0-thing_type': self.thing_type.id,
+ 'form-1-thing_type': self.thing_type.id,
+ })
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'This field is required')
+
+ def test_add_things_post_partial_valid_invalid(self):
+ """Partial submission: one valid, one missing name."""
+ response = self.client.post(f'/box/{self.box.id}/add/', {
+ 'form-TOTAL_FORMS': '2',
+ 'form-0-name': 'Arduino Uno',
+ 'form-0-thing_type': self.thing_type.id,
+ 'form-1-thing_type': self.thing_type.id,
+ })
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'This field is required')
+ self.assertEqual(Thing.objects.count(), 1)
+
+ def test_add_things_creates_thing_types(self):
+ """Can create new thing types while adding things."""
+ new_type = ThingType.objects.create(name='Components')
+ response = self.client.post(f'/box/{self.box.id}/add/', {
+ 'form-TOTAL_FORMS': '2',
+ 'form-0-name': 'Resistor',
+ 'form-0-thing_type': new_type.id,
+ 'form-1-name': 'Capacitor',
+ 'form-1-thing_type': new_type.id,
+ })
+ self.assertRedirects(response, f'/box/{self.box.id}/')
+ self.assertEqual(Thing.objects.count(), 2)
+ self.assertEqual(Thing.objects.filter(thing_type=new_type).count(), 2)
+
+ def test_add_things_empty_all(self):
+ """Submitting empty forms should not create anything."""
+ response = self.client.post(f'/box/{self.box.id}/add/', {
+ 'form-TOTAL_FORMS': '2',
+ 'form-0-name': '',
+ 'form-0-thing_type': self.thing_type.id,
+ 'form-1-name': '',
+ 'form-1-thing_type': self.thing_type.id,
+ })
+ self.assertRedirects(response, f'/box/{self.box.id}/')
+ self.assertEqual(Thing.objects.count(), 0)
+
+ def test_add_things_box_not_exists(self):
+ """Adding things to non-existent box should return 404."""
+ response = self.client.get('/box/INVALID/add/')
+ self.assertEqual(response.status_code, 404)
+
+ def test_add_things_populates_box(self):
+ """Created things should be assigned to the correct box."""
+ self.thing_type_2 = ThingType.objects.create(name='Mechanical')
+ response = self.client.post(f'/box/{self.box.id}/add/', {
+ 'form-TOTAL_FORMS': '3',
+ 'form-0-name': 'Bolt',
+ 'form-0-thing_type': self.thing_type_2.id,
+ 'form-1-name': 'Nut',
+ 'form-1-thing_type': self.thing_type_2.id,
+ 'form-2-name': 'Washer',
+ 'form-2-thing_type': self.thing_type_2.id,
+ })
+ self.assertRedirects(response, f'/box/{self.box.id}/')
+ things = Thing.objects.all()
+ for thing in things:
+ self.assertEqual(thing.box, self.box)
diff --git a/boxes/views.py b/boxes/views.py
index 2ae3b86..2c8db81 100644
--- a/boxes/views.py
+++ b/boxes/views.py
@@ -1,6 +1,7 @@
from django.http import HttpResponse, JsonResponse
-from django.shortcuts import get_object_or_404, render
+from django.shortcuts import get_object_or_404, redirect, render
+from .forms import ThingFormSet
from .models import Box, Thing
@@ -55,3 +56,34 @@ def search_api(request):
for thing in things
]
return JsonResponse({'results': results})
+
+
+def add_things(request, box_id):
+ """Add multiple things to a box at once."""
+ box = get_object_or_404(Box, pk=box_id)
+
+ if request.method == 'POST':
+ formset = ThingFormSet(request.POST)
+
+ if formset.is_valid():
+ things = formset.save(commit=False)
+ created_count = 0
+ for thing in things:
+ if thing.name or thing.thing_type or thing.description or thing.picture:
+ thing.box = box
+ thing.save()
+ created_count += 1
+ print(f'DEBUG: created_count={created_count}')
+ if created_count > 0:
+ print(f'DEBUG: Redirecting to box_detail with box_id={box_id}')
+ return redirect('box_detail', box_id=box_id)
+ else:
+ print('DEBUG: No valid data submitted')
+
+ else:
+ formset = ThingFormSet()
+
+ return render(request, 'boxes/add_things.html', {
+ 'box': box,
+ 'formset': formset,
+ })
diff --git a/labhelper/urls.py b/labhelper/urls.py
index 4637a65..03bad63 100644
--- a/labhelper/urls.py
+++ b/labhelper/urls.py
@@ -19,12 +19,13 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
-from boxes.views import box_detail, index, search, search_api, thing_detail
+from boxes.views import add_things, 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('box//add/', add_things, name='add_things'),
path('search/', search, name='search'),
path('search/api/', search_api, name='search_api'),
path('admin/', admin.site.urls),