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
+
+
+
+
+ {% for box_type in box_types %}
+
+
+
{{ box_type.name }}
+
+
+ {% if not box_type.boxes.exists %}
+
+ {% 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
+
+
+
+
+ {% for box in boxes %}
+
+
+
{{ box.id }}
+
+
+ {% if not box.things.exists %}
+
+ {% 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 @@
Home
+
Box Management
Search
Admin
{% if user.is_authenticated %}
diff --git a/labhelper/urls.py b/labhelper/urls.py
index 1537b00..b863685 100644
--- a/labhelper/urls.py
+++ b/labhelper/urls.py
@@ -20,12 +20,34 @@ from django.contrib import admin
from django.urls import path
from django.contrib.auth import views as auth_views
-from boxes.views import add_things, box_detail, index, search, search_api, thing_detail, thing_type_detail
+from boxes.views import (
+ add_box,
+ add_box_type,
+ add_things,
+ box_detail,
+ box_management,
+ delete_box,
+ delete_box_type,
+ edit_box,
+ edit_box_type,
+ index,
+ search,
+ search_api,
+ thing_detail,
+ thing_type_detail,
+)
urlpatterns = [
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('', index, name='index'),
+ path('box-management/', box_management, name='box_management'),
+ path('box-type/add/', add_box_type, name='add_box_type'),
+ path('box-type/
/edit/', edit_box_type, name='edit_box_type'),
+ path('box-type//delete/', delete_box_type, name='delete_box_type'),
+ path('box/add/', add_box, name='add_box'),
+ path('box//edit/', edit_box, name='edit_box'),
+ path('box//delete/', delete_box, name='delete_box'),
path('box//', box_detail, name='box_detail'),
path('thing//', thing_detail, name='thing_detail'),
path('thing-type//', thing_type_detail, name='thing_type_detail'),