Box management page added
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 20s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 5s
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 20s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 5s
This commit is contained in:
@@ -27,7 +27,7 @@ spec:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.042
|
image: git.baumann.gr/adebaumann/labhelper:0.043
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .models import Thing
|
from .models import Box, BoxType, Thing
|
||||||
|
|
||||||
|
|
||||||
class ThingForm(forms.ModelForm):
|
class ThingForm(forms.ModelForm):
|
||||||
@@ -24,6 +24,32 @@ class ThingPictureForm(forms.ModelForm):
|
|||||||
fields = ('picture',)
|
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(
|
ThingFormSet = forms.modelformset_factory(
|
||||||
Thing,
|
Thing,
|
||||||
form=ThingForm,
|
form=ThingForm,
|
||||||
|
|||||||
216
boxes/templates/boxes/box_management.html
Normal file
216
boxes/templates/boxes/box_management.html
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Box Management - LabHelper{% endblock %}
|
||||||
|
|
||||||
|
{% block page_header %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="fas fa-boxes"></i> Box Management</h1>
|
||||||
|
<p class="breadcrumb">
|
||||||
|
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||||
|
Box Management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<h2><i class="fas fa-cube"></i> Box Types</h2>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'add_box_type' %}" style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 10px;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<h3 style="margin-bottom: 15px; color: #667eea; font-size: 18px;">Add New Box Type</h3>
|
||||||
|
<div style="display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Name</label>
|
||||||
|
{{ box_type_form.name }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Width (mm)</label>
|
||||||
|
{{ box_type_form.width }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Height (mm)</label>
|
||||||
|
{{ box_type_form.height }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Length (mm)</label>
|
||||||
|
{{ box_type_form.length }}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-plus"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
|
||||||
|
{% for box_type in box_types %}
|
||||||
|
<div class="box-type-card" style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 2px solid #f0f0f0; transition: all 0.3s;" id="box-type-card-{{ box_type.id }}">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
|
||||||
|
<h3 style="margin: 0; color: #667eea; font-size: 20px; font-weight: 700;" id="box-type-name-{{ box_type.id }}">{{ box_type.name }}</h3>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button onclick="toggleEditBoxType({{ box_type.id }})" id="edit-btn-{{ box_type.id }}" style="background: none; border: none; cursor: pointer; color: #667eea; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#764ba2'" onmouseout="this.style.color='#667eea'">
|
||||||
|
<i class="fas fa-edit" style="font-size: 18px;"></i>
|
||||||
|
</button>
|
||||||
|
{% if not box_type.boxes.exists %}
|
||||||
|
<form method="post" action="{% url 'delete_box_type' box_type.id %}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this box type?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
|
||||||
|
<i class="fas fa-trash" style="font-size: 18px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="box-type-view-{{ box_type.id }}" style="color: #666; font-size: 14px; line-height: 1.6;">
|
||||||
|
<p style="margin: 5px 0;"><i class="fas fa-ruler-horizontal" style="width: 20px; color: #999;"></i> Width: <strong>{{ box_type.width }} mm</strong></p>
|
||||||
|
<p style="margin: 5px 0;"><i class="fas fa-ruler-vertical" style="width: 20px; color: #999;"></i> Height: <strong>{{ box_type.height }} mm</strong></p>
|
||||||
|
<p style="margin: 5px 0;"><i class="fas fa-arrows-alt-h" style="width: 20px; color: #999;"></i> Length: <strong>{{ box_type.length }} mm</strong></p>
|
||||||
|
</div>
|
||||||
|
<form id="box-type-edit-{{ box_type.id }}" method="post" action="{% url 'edit_box_type' box_type.id %}" style="display: none;" onsubmit="return confirm('Save changes?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Name</label>
|
||||||
|
<input type="text" name="name" value="{{ box_type.name }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Width</label>
|
||||||
|
<input type="number" name="width" value="{{ box_type.width }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Height</label>
|
||||||
|
<input type="number" name="height" value="{{ box_type.height }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Length</label>
|
||||||
|
<input type="number" name="length" value="{{ box_type.length }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm" style="width: 100%;">
|
||||||
|
<i class="fas fa-save"></i> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0; color: #888; font-size: 13px;">
|
||||||
|
<i class="fas fa-box"></i> {{ box_type.boxes.count }} box{{ box_type.boxes.count|pluralize:"es" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2><i class="fas fa-box"></i> Boxes</h2>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'add_box' %}" style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 10px;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<h3 style="margin-bottom: 15px; color: #667eea; font-size: 18px;">Add New Box</h3>
|
||||||
|
<div style="display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Box ID</label>
|
||||||
|
{{ box_form.id }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Box Type</label>
|
||||||
|
{{ box_form.box_type }}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-plus"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px;">
|
||||||
|
{% for box in boxes %}
|
||||||
|
<div class="box-card" style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 2px solid #f0f0f0; transition: all 0.3s;" id="box-card-{{ box.id }}">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
|
||||||
|
<h3 style="margin: 0; color: #667eea; font-size: 24px; font-weight: 700;" id="box-id-{{ box.id }}">{{ box.id }}</h3>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button onclick="toggleEditBox('{{ box.id }}')" id="edit-box-btn-{{ box.id }}" style="background: none; border: none; cursor: pointer; color: #667eea; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#764ba2'" onmouseout="this.style.color='#667eea'">
|
||||||
|
<i class="fas fa-edit" style="font-size: 18px;"></i>
|
||||||
|
</button>
|
||||||
|
{% if not box.things.exists %}
|
||||||
|
<form method="post" action="{% url 'delete_box' box.id %}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this box?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
|
||||||
|
<i class="fas fa-trash" style="font-size: 18px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="box-view-{{ box.id }}" style="color: #666; font-size: 14px; margin-bottom: 15px;">
|
||||||
|
<p style="margin: 5px 0;"><i class="fas fa-cube" style="width: 20px; color: #999;"></i> Type: <strong>{{ box.box_type.name }}</strong></p>
|
||||||
|
</div>
|
||||||
|
<form id="box-edit-{{ box.id }}" method="post" action="{% url 'edit_box' box.id %}" style="display: none;" onsubmit="return confirm('Save changes?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Box ID</label>
|
||||||
|
<input type="text" name="id" value="{{ box.id }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Box Type</label>
|
||||||
|
<select name="box_type" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||||
|
{% for type in box_types %}
|
||||||
|
<option value="{{ type.id }}" {% if type.id == box.box_type.id %}selected{% endif %}>{{ type.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm" style="width: 100%;">
|
||||||
|
<i class="fas fa-save"></i> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div style="padding-top: 15px; border-top: 1px solid #e0e0e0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<a href="{% url 'box_detail' box.id %}" class="btn btn-sm">
|
||||||
|
<i class="fas fa-eye"></i> View Contents
|
||||||
|
</a>
|
||||||
|
<span style="color: #888; font-size: 13px;">
|
||||||
|
<i class="fas fa-cube"></i> {{ box.things.count }} thing{{ box.things.count|pluralize }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function toggleEditBoxType(id) {
|
||||||
|
var viewDiv = document.getElementById('box-type-view-' + id);
|
||||||
|
var editForm = document.getElementById('box-type-edit-' + id);
|
||||||
|
var editBtn = document.getElementById('edit-btn-' + id);
|
||||||
|
|
||||||
|
if (editForm.style.display === 'none') {
|
||||||
|
viewDiv.style.display = 'none';
|
||||||
|
editForm.style.display = 'block';
|
||||||
|
editBtn.innerHTML = '<i class="fas fa-times" style="font-size: 18px;"></i>';
|
||||||
|
} else {
|
||||||
|
viewDiv.style.display = 'block';
|
||||||
|
editForm.style.display = 'none';
|
||||||
|
editBtn.innerHTML = '<i class="fas fa-edit" style="font-size: 18px;"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEditBox(id) {
|
||||||
|
var viewDiv = document.getElementById('box-view-' + id);
|
||||||
|
var editForm = document.getElementById('box-edit-' + id);
|
||||||
|
var editBtn = document.getElementById('edit-box-btn-' + id);
|
||||||
|
|
||||||
|
if (editForm.style.display === 'none') {
|
||||||
|
viewDiv.style.display = 'none';
|
||||||
|
editForm.style.display = 'block';
|
||||||
|
editBtn.innerHTML = '<i class="fas fa-times" style="font-size: 18px;"></i>';
|
||||||
|
} else {
|
||||||
|
viewDiv.style.display = 'block';
|
||||||
|
editForm.style.display = 'none';
|
||||||
|
editBtn.innerHTML = '<i class="fas fa-edit" style="font-size: 18px;"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
326
boxes/tests.py
326
boxes/tests.py
@@ -587,7 +587,7 @@ class ThingDetailViewTests(AuthTestCase):
|
|||||||
new_box = Box.objects.create(id='BOX002', box_type=self.box_type)
|
new_box = Box.objects.create(id='BOX002', box_type=self.box_type)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/',
|
||||||
{'new_box': 'BOX002'}
|
{'action': 'move', 'new_box': 'BOX002'}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
||||||
self.thing.refresh_from_db()
|
self.thing.refresh_from_db()
|
||||||
@@ -986,3 +986,327 @@ class LoginViewTests(TestCase):
|
|||||||
self.client.login(username='testuser', password='testpass123')
|
self.client.login(username='testuser', password='testpass123')
|
||||||
response = self.client.post('/logout/')
|
response = self.client.post('/logout/')
|
||||||
self.assertRedirects(response, '/login/')
|
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)
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ from django.db.models import Q
|
|||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
from .forms import ThingFormSet, ThingPictureForm
|
from .forms import (
|
||||||
from .models import Box, Thing, ThingType
|
BoxForm,
|
||||||
|
BoxTypeForm,
|
||||||
|
ThingFormSet,
|
||||||
|
ThingPictureForm,
|
||||||
|
)
|
||||||
|
from .models import Box, BoxType, Thing, ThingType
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -146,16 +151,96 @@ def add_things(request, box_id):
|
|||||||
def thing_type_detail(request, type_id):
|
def thing_type_detail(request, type_id):
|
||||||
"""Display details of a thing type with its hierarchy and things."""
|
"""Display details of a thing type with its hierarchy and things."""
|
||||||
thing_type = get_object_or_404(ThingType, pk=type_id)
|
thing_type = get_object_or_404(ThingType, pk=type_id)
|
||||||
|
|
||||||
descendants = thing_type.get_descendants(include_self=True)
|
descendants = thing_type.get_descendants(include_self=True)
|
||||||
things_by_type = {}
|
things_by_type = {}
|
||||||
|
|
||||||
for descendant in descendants:
|
for descendant in descendants:
|
||||||
things = descendant.things.select_related('box', 'box__box_type').all()
|
things = descendant.things.select_related('box', 'box__box_type').all()
|
||||||
if things:
|
if things:
|
||||||
things_by_type[descendant] = things
|
things_by_type[descendant] = things
|
||||||
|
|
||||||
return render(request, 'boxes/thing_type_detail.html', {
|
return render(request, 'boxes/thing_type_detail.html', {
|
||||||
'thing_type': thing_type,
|
'thing_type': thing_type,
|
||||||
'things_by_type': things_by_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')
|
||||||
|
|||||||
@@ -228,6 +228,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="/"><i class="fas fa-home"></i> Home</a>
|
<a href="/"><i class="fas fa-home"></i> Home</a>
|
||||||
|
<a href="/box-management/"><i class="fas fa-boxes"></i> Box Management</a>
|
||||||
<a href="/search/"><i class="fas fa-search"></i> Search</a>
|
<a href="/search/"><i class="fas fa-search"></i> Search</a>
|
||||||
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
|
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
|
|||||||
@@ -20,12 +20,34 @@ from django.contrib import admin
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.contrib.auth import views as auth_views
|
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 = [
|
urlpatterns = [
|
||||||
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
||||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||||
path('', index, name='index'),
|
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/<int:type_id>/edit/', edit_box_type, name='edit_box_type'),
|
||||||
|
path('box-type/<int:type_id>/delete/', delete_box_type, name='delete_box_type'),
|
||||||
|
path('box/add/', add_box, name='add_box'),
|
||||||
|
path('box/<str:box_id>/edit/', edit_box, name='edit_box'),
|
||||||
|
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
|
||||||
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
||||||
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
|
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
|
||||||
path('thing-type/<int:type_id>/', thing_type_detail, name='thing_type_detail'),
|
path('thing-type/<int:type_id>/', thing_type_detail, name='thing_type_detail'),
|
||||||
|
|||||||
Reference in New Issue
Block a user