diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index 9f6cc54..a454985 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.042 + image: git.baumann.gr/adebaumann/labhelper:0.043 imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/boxes/forms.py b/boxes/forms.py index b76569a..9e30c62 100644 --- a/boxes/forms.py +++ b/boxes/forms.py @@ -1,6 +1,6 @@ from django import forms -from .models import Thing +from .models import Box, BoxType, Thing class ThingForm(forms.ModelForm): @@ -24,6 +24,32 @@ class ThingPictureForm(forms.ModelForm): fields = ('picture',) +class BoxTypeForm(forms.ModelForm): + """Form for adding/editing a BoxType.""" + + class Meta: + model = BoxType + fields = ('name', 'width', 'height', 'length') + widgets = { + 'name': forms.TextInput(attrs={'style': 'width: 100%; max-width: 300px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + 'width': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + 'height': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + 'length': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + } + + +class BoxForm(forms.ModelForm): + """Form for adding/editing a Box.""" + + class Meta: + model = Box + fields = ('id', 'box_type') + widgets = { + 'id': forms.TextInput(attrs={'style': 'width: 100%; max-width: 200px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; text-transform: uppercase;'}), + 'box_type': forms.Select(attrs={'style': 'width: 100%; max-width: 300px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + } + + ThingFormSet = forms.modelformset_factory( Thing, form=ThingForm, diff --git a/boxes/templates/boxes/box_management.html b/boxes/templates/boxes/box_management.html new file mode 100644 index 0000000..5278f6e --- /dev/null +++ b/boxes/templates/boxes/box_management.html @@ -0,0 +1,216 @@ +{% extends "base.html" %} + +{% block title %}Box Management - LabHelper{% endblock %} + +{% block page_header %} + +{% endblock %} + +{% block content %} +
+

Box Types

+ +
+ {% csrf_token %} +

Add New Box Type

+
+
+ + {{ box_type_form.name }} +
+
+ + {{ box_type_form.width }} +
+
+ + {{ box_type_form.height }} +
+
+ + {{ box_type_form.length }} +
+ +
+
+ +
+ {% for box_type in box_types %} +
+
+

{{ box_type.name }}

+
+ + {% if not box_type.boxes.exists %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+
+

Width: {{ box_type.width }} mm

+

Height: {{ box_type.height }} mm

+

Length: {{ box_type.length }} mm

+
+ +
+ {{ box_type.boxes.count }} box{{ box_type.boxes.count|pluralize:"es" }} +
+
+ {% endfor %} +
+
+ +
+

Boxes

+ +
+ {% csrf_token %} +

Add New Box

+
+
+ + {{ box_form.id }} +
+
+ + {{ box_form.box_type }} +
+ +
+
+ +
+ {% for box in boxes %} +
+
+

{{ box.id }}

+
+ + {% if not box.things.exists %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+
+

Type: {{ box.box_type.name }}

+
+ +
+
+ + View Contents + + + {{ box.things.count }} thing{{ box.things.count|pluralize }} + +
+
+
+ {% endfor %} +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/boxes/tests.py b/boxes/tests.py index c7c9ef9..5f1dc94 100644 --- a/boxes/tests.py +++ b/boxes/tests.py @@ -587,7 +587,7 @@ class ThingDetailViewTests(AuthTestCase): new_box = Box.objects.create(id='BOX002', box_type=self.box_type) response = self.client.post( f'/thing/{self.thing.id}/', - {'new_box': 'BOX002'} + {'action': 'move', 'new_box': 'BOX002'} ) self.assertRedirects(response, f'/thing/{self.thing.id}/') self.thing.refresh_from_db() @@ -986,3 +986,327 @@ class LoginViewTests(TestCase): self.client.login(username='testuser', password='testpass123') response = self.client.post('/logout/') self.assertRedirects(response, '/login/') + + +class BoxManagementViewTests(AuthTestCase): + """Tests for box management view.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + 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 + ) + + def test_box_management_returns_200(self): + """Box management page should return 200 status.""" + response = self.client.get('/box-management/') + self.assertEqual(response.status_code, 200) + + def test_box_management_shows_box_types(self): + """Box management page should show box types.""" + response = self.client.get('/box-management/') + self.assertContains(response, 'Standard Box') + + def test_box_management_shows_boxes(self): + """Box management page should show boxes.""" + response = self.client.get('/box-management/') + self.assertContains(response, 'BOX001') + + def test_box_management_shows_add_box_type_form(self): + """Box management page should show add box type form.""" + response = self.client.get('/box-management/') + self.assertContains(response, 'Add New Box Type') + self.assertContains(response, 'name="name"') + self.assertContains(response, 'name="width"') + self.assertContains(response, 'name="height"') + self.assertContains(response, 'name="length"') + + def test_box_management_shows_add_box_form(self): + """Box management page should show add box form.""" + response = self.client.get('/box-management/') + self.assertContains(response, 'Add New Box') + self.assertContains(response, 'name="id"') + self.assertContains(response, 'name="box_type"') + + def test_box_management_requires_login(self): + """Box management page should redirect to login if not authenticated.""" + self.client.logout() + response = self.client.get('/box-management/') + self.assertRedirects(response, '/login/?next=/box-management/') + + +class BoxTypeCRUDTests(AuthTestCase): + """Tests for box type CRUD operations.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.box_type = BoxType.objects.create( + name='Standard Box', + width=200, + height=100, + length=300 + ) + + def test_add_box_type_post_creates_box_type(self): + """Adding a box type should create it in the database.""" + response = self.client.post('/box-type/add/', { + 'name': 'Large Box', + 'width': '400', + 'height': '200', + 'length': '600' + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.assertTrue(BoxType.objects.filter(name='Large Box').exists()) + + def test_add_box_type_invalid_data(self): + """Adding a box type with invalid data should not create it.""" + response = self.client.post('/box-type/add/', { + 'name': 'Invalid Box', + 'width': 'invalid', + 'height': '100', + 'length': '200' + }) + self.assertEqual(response.status_code, 302) + self.assertFalse(BoxType.objects.filter(name='Invalid Box').exists()) + + def test_edit_box_type_updates_box_type(self): + """Editing a box type should update it in the database.""" + response = self.client.post(f'/box-type/{self.box_type.id}/edit/', { + 'name': 'Updated Box', + 'width': '300', + 'height': '150', + 'length': '450' + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.box_type.refresh_from_db() + self.assertEqual(self.box_type.name, 'Updated Box') + self.assertEqual(self.box_type.width, 300) + + def test_edit_box_type_invalid_data(self): + """Editing a box type with invalid data should not update it.""" + old_name = self.box_type.name + response = self.client.post(f'/box-type/{self.box_type.id}/edit/', { + 'name': 'Updated Box', + 'width': 'invalid', + 'height': '150', + 'length': '450' + }) + self.assertEqual(response.status_code, 302) + self.box_type.refresh_from_db() + self.assertEqual(self.box_type.name, old_name) + + def test_delete_box_type_deletes_box_type(self): + """Deleting a box type should remove it from the database.""" + type_id = self.box_type.id + response = self.client.post(f'/box-type/{type_id}/delete/') + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.assertFalse(BoxType.objects.filter(id=type_id).exists()) + + def test_delete_box_type_with_boxes_redirects(self): + """Deleting a box type with boxes should redirect without deleting.""" + Box.objects.create(id='BOX001', box_type=self.box_type) + type_id = self.box_type.id + response = self.client.post(f'/box-type/{type_id}/delete/') + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.assertTrue(BoxType.objects.filter(id=type_id).exists()) + + +class BoxCRUDTests(AuthTestCase): + """Tests for box CRUD operations.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + 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 + ) + + def test_add_box_post_creates_box(self): + """Adding a box should create it in the database.""" + response = self.client.post('/box/add/', { + 'id': 'BOX002', + 'box_type': str(self.box_type.id) + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.assertTrue(Box.objects.filter(id='BOX002').exists()) + + def test_add_box_invalid_data(self): + """Adding a box with invalid data should not create it.""" + response = self.client.post('/box/add/', { + 'id': '', + 'box_type': str(self.box_type.id) + }) + self.assertEqual(response.status_code, 302) + self.assertEqual(Box.objects.count(), 1) + + def test_edit_box_updates_box_type(self): + """Editing a box should update its box type in the database.""" + new_type = BoxType.objects.create( + name='Large Box', + width=400, + height=200, + length=600 + ) + response = self.client.post(f'/box/{self.box.id}/edit/', { + 'id': 'BOX001', + 'box_type': str(new_type.id) + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.box.refresh_from_db() + self.assertEqual(self.box.box_type, new_type) + + def test_edit_box_invalid_data(self): + """Editing a box with invalid data should not update it.""" + old_id = self.box.id + response = self.client.post(f'/box/{self.box.id}/edit/', { + 'id': '', + 'box_type': str(self.box_type.id) + }) + self.assertEqual(response.status_code, 302) + self.box.refresh_from_db() + self.assertEqual(self.box.id, old_id) + + def test_delete_box_deletes_box(self): + """Deleting a box should remove it from the database.""" + box_id = self.box.id + response = self.client.post(f'/box/{box_id}/delete/') + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.assertFalse(Box.objects.filter(id=box_id).exists()) + + def test_delete_box_with_things_redirects(self): + """Deleting a box with things should redirect without deleting.""" + thing_type = ThingType.objects.create(name='Test') + Thing.objects.create( + name='Test Item', + thing_type=thing_type, + box=self.box + ) + box_id = self.box.id + response = self.client.post(f'/box/{box_id}/delete/') + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, '/box-management/') + self.assertTrue(Box.objects.filter(id=box_id).exists()) + + +class ThingPictureUploadTests(AuthTestCase): + """Tests for thing picture deletion.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + 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 + ) + self.image_data = ( + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01' + b'\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00' + b'\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00' + b'\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82' + ) + + def test_thing_detail_shows_add_picture_button(self): + """Thing detail page should show 'Add picture' button when thing has no picture.""" + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Add picture') + + def test_thing_detail_shows_change_picture_button(self): + """Thing detail page should show 'Change picture' button when thing has a picture.""" + image = SimpleUploadedFile( + name='test.png', + content=self.image_data, + content_type='image/png' + ) + self.thing.picture = image + self.thing.save() + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Change picture') + # Clean up + self.thing.picture.delete(save=False) + + def test_thing_detail_shows_remove_button(self): + """Thing detail page should show 'Remove' button when thing has a picture.""" + image = SimpleUploadedFile( + name='test.png', + content=self.image_data, + content_type='image/png' + ) + self.thing.picture = image + self.thing.save() + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Remove') + # Clean up + self.thing.picture.delete(save=False) + + def test_delete_picture_removes_picture(self): + """Deleting a picture should remove it from the thing.""" + image = SimpleUploadedFile( + name='test.png', + content=self.image_data, + content_type='image/png' + ) + self.thing.picture = image + self.thing.save() + + response = self.client.post(f'/thing/{self.thing.id}/', { + 'action': 'delete_picture' + }) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.thing.refresh_from_db() + self.assertFalse(self.thing.picture.name) + + def test_delete_picture_on_thing_without_picture(self): + """Deleting a picture from a thing without a picture should succeed.""" + response = self.client.post(f'/thing/{self.thing.id}/', { + 'action': 'delete_picture' + }) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.thing.refresh_from_db() + self.assertFalse(self.thing.picture.name) + + def test_delete_picture_on_thing_without_picture(self): + """Deleting a picture from thing without picture should succeed.""" + response = self.client.post(f'/thing/{self.thing.id}/', { + 'action': 'delete_picture' + }) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.thing.refresh_from_db() + self.assertFalse(self.thing.picture.name) diff --git a/boxes/views.py b/boxes/views.py index 36aff21..06b32b2 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -3,8 +3,13 @@ from django.db.models import Q from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render -from .forms import ThingFormSet, ThingPictureForm -from .models import Box, Thing, ThingType +from .forms import ( + BoxForm, + BoxTypeForm, + ThingFormSet, + ThingPictureForm, +) +from .models import Box, BoxType, Thing, ThingType @login_required @@ -146,16 +151,96 @@ def add_things(request, box_id): def thing_type_detail(request, type_id): """Display details of a thing type with its hierarchy and things.""" thing_type = get_object_or_404(ThingType, pk=type_id) - + descendants = thing_type.get_descendants(include_self=True) things_by_type = {} - + for descendant in descendants: things = descendant.things.select_related('box', 'box__box_type').all() if things: things_by_type[descendant] = things - + return render(request, 'boxes/thing_type_detail.html', { 'thing_type': thing_type, 'things_by_type': things_by_type, }) + + +@login_required +def box_management(request): + """Main page for managing boxes and box types.""" + box_types = BoxType.objects.all().prefetch_related('boxes') + boxes = Box.objects.select_related('box_type').all().prefetch_related('things') + box_type_form = BoxTypeForm() + box_form = BoxForm() + + return render(request, 'boxes/box_management.html', { + 'box_types': box_types, + 'boxes': boxes, + 'box_type_form': box_type_form, + 'box_form': box_form, + }) + + +@login_required +def add_box_type(request): + """Add a new box type.""" + if request.method == 'POST': + form = BoxTypeForm(request.POST) + if form.is_valid(): + form.save() + return redirect('box_management') + + +@login_required +def edit_box_type(request, type_id): + """Edit an existing box type.""" + box_type = get_object_or_404(BoxType, pk=type_id) + if request.method == 'POST': + form = BoxTypeForm(request.POST, instance=box_type) + if form.is_valid(): + form.save() + return redirect('box_management') + + +@login_required +def delete_box_type(request, type_id): + """Delete a box type.""" + box_type = get_object_or_404(BoxType, pk=type_id) + if request.method == 'POST': + if box_type.boxes.exists(): + return redirect('box_management') + box_type.delete() + return redirect('box_management') + + +@login_required +def add_box(request): + """Add a new box.""" + if request.method == 'POST': + form = BoxForm(request.POST) + if form.is_valid(): + form.save() + return redirect('box_management') + + +@login_required +def edit_box(request, box_id): + """Edit an existing box.""" + box = get_object_or_404(Box, pk=box_id) + if request.method == 'POST': + form = BoxForm(request.POST, instance=box) + if form.is_valid(): + form.save() + return redirect('box_management') + + +@login_required +def delete_box(request, box_id): + """Delete a box.""" + box = get_object_or_404(Box, pk=box_id) + if request.method == 'POST': + if box.things.exists(): + return redirect('box_management') + box.delete() + return redirect('box_management') diff --git a/labhelper/templates/base.html b/labhelper/templates/base.html index 1ff4fc0..8db8f15 100644 --- a/labhelper/templates/base.html +++ b/labhelper/templates/base.html @@ -228,6 +228,7 @@