Add box detail page with thumbnails and view tests
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 2m26s
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 23s
SonarQube Scan / SonarQube Trigger (push) Failing after 19s
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 2m26s
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 23s
SonarQube Scan / SonarQube Trigger (push) Failing after 19s
This commit is contained in:
@@ -18,14 +18,14 @@ spec:
|
|||||||
fsGroupChangePolicy: "OnRootMismatch"
|
fsGroupChangePolicy: "OnRootMismatch"
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: loader
|
- name: loader
|
||||||
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.004
|
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.005
|
||||||
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ]
|
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ]
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.010
|
image: git.baumann.gr/adebaumann/labhelper:0.011
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
|
|||||||
119
boxes/templates/boxes/box_detail.html
Normal file
119
boxes/templates/boxes/box_detail.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{% load thumbnail %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Box {{ box.id }} - LabHelper</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.box-info {
|
||||||
|
background: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #4a90a4;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.thumbnail {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.no-image {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.empty-message {
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="/" class="back-link">← Back to Home</a>
|
||||||
|
|
||||||
|
<h1>Box {{ box.id }}</h1>
|
||||||
|
|
||||||
|
<div class="box-info">
|
||||||
|
<strong>Type:</strong> {{ box.box_type.name }}
|
||||||
|
({{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if things %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Picture</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for thing in things %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if thing.picture %}
|
||||||
|
{% thumbnail thing.picture "200x200" crop="center" as thumb %}
|
||||||
|
<img src="{{ thumb.url }}" alt="{{ thing.name }}" class="thumbnail">
|
||||||
|
{% endthumbnail %}
|
||||||
|
{% else %}
|
||||||
|
<div class="no-image">No image</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ thing.name }}</td>
|
||||||
|
<td>{{ thing.thing_type.name }}</td>
|
||||||
|
<td>{{ thing.description|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-message">
|
||||||
|
This box is empty.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
152
boxes/tests.py
152
boxes/tests.py
@@ -1,7 +1,8 @@
|
|||||||
from django.contrib.admin.sites import AdminSite
|
from django.contrib.admin.sites import AdminSite
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin
|
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin
|
||||||
from .models import Box, BoxType, Thing, ThingType
|
from .models import Box, BoxType, Thing, ThingType
|
||||||
@@ -337,3 +338,152 @@ class ThingAdminTests(TestCase):
|
|||||||
def test_search_fields(self):
|
def test_search_fields(self):
|
||||||
"""ThingAdmin should search by name and description."""
|
"""ThingAdmin should search by name and description."""
|
||||||
self.assertEqual(self.admin.search_fields, ('name', 'description'))
|
self.assertEqual(self.admin.search_fields, ('name', 'description'))
|
||||||
|
|
||||||
|
|
||||||
|
class IndexViewTests(TestCase):
|
||||||
|
"""Tests for the index view."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test client."""
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
def test_index_returns_200(self):
|
||||||
|
"""Index page should return 200 status."""
|
||||||
|
response = self.client.get('/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_index_contains_labhelper(self):
|
||||||
|
"""Index page should contain LabHelper title."""
|
||||||
|
response = self.client.get('/')
|
||||||
|
self.assertContains(response, 'LabHelper')
|
||||||
|
|
||||||
|
def test_index_contains_admin_link(self):
|
||||||
|
"""Index page should contain link to admin."""
|
||||||
|
response = self.client.get('/')
|
||||||
|
self.assertContains(response, '/admin/')
|
||||||
|
|
||||||
|
|
||||||
|
class BoxDetailViewTests(TestCase):
|
||||||
|
"""Tests for the box 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')
|
||||||
|
|
||||||
|
def test_box_detail_returns_200(self):
|
||||||
|
"""Box detail page should return 200 status."""
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_box_detail_returns_404_for_invalid_box(self):
|
||||||
|
"""Box detail page should return 404 for non-existent box."""
|
||||||
|
response = self.client.get('/box/INVALID/')
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_box_detail_shows_box_id(self):
|
||||||
|
"""Box detail page should show box ID."""
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, 'BOX001')
|
||||||
|
|
||||||
|
def test_box_detail_shows_box_type(self):
|
||||||
|
"""Box detail page should show box type name."""
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, 'Standard Box')
|
||||||
|
|
||||||
|
def test_box_detail_shows_dimensions(self):
|
||||||
|
"""Box detail page should show box dimensions."""
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, '200')
|
||||||
|
self.assertContains(response, '100')
|
||||||
|
self.assertContains(response, '300')
|
||||||
|
|
||||||
|
def test_box_detail_shows_empty_message(self):
|
||||||
|
"""Box detail page should show empty message when no things."""
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, 'This box is empty')
|
||||||
|
|
||||||
|
def test_box_detail_shows_thing(self):
|
||||||
|
"""Box detail page should show things in the box."""
|
||||||
|
Thing.objects.create(
|
||||||
|
name='Arduino Uno',
|
||||||
|
thing_type=self.thing_type,
|
||||||
|
box=self.box,
|
||||||
|
description='A microcontroller board'
|
||||||
|
)
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, 'Arduino Uno')
|
||||||
|
self.assertContains(response, 'Electronics')
|
||||||
|
self.assertContains(response, 'A microcontroller board')
|
||||||
|
|
||||||
|
def test_box_detail_shows_no_image_placeholder(self):
|
||||||
|
"""Box detail page should show placeholder for things without images."""
|
||||||
|
Thing.objects.create(
|
||||||
|
name='Test Item',
|
||||||
|
thing_type=self.thing_type,
|
||||||
|
box=self.box
|
||||||
|
)
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, 'No image')
|
||||||
|
|
||||||
|
def test_box_detail_shows_multiple_things(self):
|
||||||
|
"""Box detail page should show multiple things."""
|
||||||
|
Thing.objects.create(
|
||||||
|
name='Item One',
|
||||||
|
thing_type=self.thing_type,
|
||||||
|
box=self.box
|
||||||
|
)
|
||||||
|
Thing.objects.create(
|
||||||
|
name='Item Two',
|
||||||
|
thing_type=self.thing_type,
|
||||||
|
box=self.box
|
||||||
|
)
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, 'Item One')
|
||||||
|
self.assertContains(response, 'Item Two')
|
||||||
|
|
||||||
|
def test_box_detail_uses_correct_template(self):
|
||||||
|
"""Box detail page should use the correct template."""
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertTemplateUsed(response, 'boxes/box_detail.html')
|
||||||
|
|
||||||
|
def test_box_detail_with_image(self):
|
||||||
|
"""Box detail page should show thumbnail for things with images."""
|
||||||
|
# Create a simple 1x1 pixel PNG
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
image = SimpleUploadedFile(
|
||||||
|
name='test.png',
|
||||||
|
content=image_data,
|
||||||
|
content_type='image/png'
|
||||||
|
)
|
||||||
|
thing = Thing.objects.create(
|
||||||
|
name='Item With Image',
|
||||||
|
thing_type=self.thing_type,
|
||||||
|
box=self.box,
|
||||||
|
picture=image
|
||||||
|
)
|
||||||
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
|
self.assertContains(response, 'Item With Image')
|
||||||
|
self.assertContains(response, '<img')
|
||||||
|
# Clean up
|
||||||
|
thing.picture.delete()
|
||||||
|
|
||||||
|
def test_box_detail_url_name(self):
|
||||||
|
"""Box detail URL should be reversible by name."""
|
||||||
|
url = reverse('box_detail', kwargs={'box_id': 'BOX001'})
|
||||||
|
self.assertEqual(url, '/box/BOX001/')
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
|
||||||
|
from .models import Box
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
"""Simple index page."""
|
"""Simple index page."""
|
||||||
html = '<h1>LabHelper</h1><p><a href="/admin/">Admin</a></p>'
|
html = '<h1>LabHelper</h1><p><a href="/admin/">Admin</a></p>'
|
||||||
return HttpResponse(html)
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
def box_detail(request, box_id):
|
||||||
|
"""Display contents of a box."""
|
||||||
|
box = get_object_or_404(Box, pk=box_id)
|
||||||
|
things = box.things.select_related('thing_type').all()
|
||||||
|
return render(request, 'boxes/box_detail.html', {
|
||||||
|
'box': box,
|
||||||
|
'things': things,
|
||||||
|
})
|
||||||
|
|||||||
Binary file not shown.
@@ -39,6 +39,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django_mptt_admin',
|
'django_mptt_admin',
|
||||||
|
'sorl.thumbnail',
|
||||||
'boxes',
|
'boxes',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ from django.conf.urls.static import static
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from boxes.views import index
|
from boxes.views import box_detail, index
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', index, name='index'),
|
path('', index, name='index'),
|
||||||
|
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -33,5 +33,6 @@ sqlparse==0.5.3
|
|||||||
urllib3==2.6.0
|
urllib3==2.6.0
|
||||||
wcwidth==0.2.13
|
wcwidth==0.2.13
|
||||||
Pillow==11.1.0
|
Pillow==11.1.0
|
||||||
|
sorl-thumbnail==12.11.0
|
||||||
bleach==6.1.0
|
bleach==6.1.0
|
||||||
coverage==7.6.1
|
coverage==7.6.1
|
||||||
|
|||||||
Reference in New Issue
Block a user