diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index c12c8a9..d4b7b71 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -18,14 +18,14 @@ spec: fsGroupChangePolicy: "OnRootMismatch" initContainers: - name: loader - image: git.baumann.gr/adebaumann/labhelper-data-loader:0.003 + image: git.baumann.gr/adebaumann/labhelper-data-loader:0.004 command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; chown -R 999:999 /data; ls -la /data; sleep 10; exit 0" ] volumeMounts: - name: data mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.009 + image: git.baumann.gr/adebaumann/labhelper:0.010 imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/boxes/admin.py b/boxes/admin.py index 64fe900..9577da1 100644 --- a/boxes/admin.py +++ b/boxes/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django_mptt_admin.admin import DjangoMpttAdmin from .models import Box, BoxType, Thing, ThingType @@ -21,10 +22,9 @@ class BoxAdmin(admin.ModelAdmin): @admin.register(ThingType) -class ThingTypeAdmin(admin.ModelAdmin): +class ThingTypeAdmin(DjangoMpttAdmin): """Admin configuration for ThingType model.""" - list_display = ('name',) search_fields = ('name',) diff --git a/boxes/migrations/0003_convert_thingtype_to_mptt.py b/boxes/migrations/0003_convert_thingtype_to_mptt.py new file mode 100644 index 0000000..673d7a3 --- /dev/null +++ b/boxes/migrations/0003_convert_thingtype_to_mptt.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.9 on 2025-12-28 19:39 + +import django.db.models.deletion +import mptt.fields +from django.db import migrations, models + + +def rebuild_tree(apps, schema_editor): + """Rebuild MPTT tree after adding fields.""" + ThingType = apps.get_model('boxes', 'ThingType') + # Import the actual model to use rebuild + from boxes.models import ThingType as RealThingType + RealThingType.objects.rebuild() + + +class Migration(migrations.Migration): + + dependencies = [ + ('boxes', '0002_thingtype_thing'), + ] + + operations = [ + migrations.AddField( + model_name='thingtype', + name='parent', + field=mptt.fields.TreeForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='children', + to='boxes.thingtype' + ), + ), + migrations.AddField( + model_name='thingtype', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='thingtype', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='thingtype', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='thingtype', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + migrations.AlterModelOptions( + name='thingtype', + options={}, + ), + migrations.AlterField( + model_name='thingtype', + name='name', + field=models.CharField(max_length=255), + ), + migrations.RunPython(rebuild_tree, migrations.RunPython.noop), + ] diff --git a/boxes/models.py b/boxes/models.py index f13a03d..59950bb 100644 --- a/boxes/models.py +++ b/boxes/models.py @@ -1,4 +1,5 @@ from django.db import models +from mptt.models import MPTTModel, TreeForeignKey class BoxType(models.Model): @@ -37,13 +38,20 @@ class Box(models.Model): return self.id -class ThingType(models.Model): - """A type/category for things stored in boxes.""" +class ThingType(MPTTModel): + """A hierarchical type/category for things stored in boxes.""" - name = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255) + parent = TreeForeignKey( + 'self', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='children' + ) - class Meta: - ordering = ['name'] + class MPTTMeta: + order_insertion_by = ['name'] def __str__(self): return self.name diff --git a/boxes/tests.py b/boxes/tests.py index 51974df..35a6207 100644 --- a/boxes/tests.py +++ b/boxes/tests.py @@ -1,9 +1,10 @@ from django.contrib.admin.sites import AdminSite +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import IntegrityError from django.test import TestCase -from .admin import BoxAdmin, BoxTypeAdmin -from .models import Box, BoxType +from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin +from .models import Box, BoxType, Thing, ThingType class BoxTypeModelTests(TestCase): @@ -128,3 +129,211 @@ class BoxAdminTests(TestCase): def test_search_fields(self): """BoxAdmin should search by id.""" self.assertEqual(self.admin.search_fields, ('id',)) + + +class ThingTypeModelTests(TestCase): + """Tests for the ThingType model.""" + + def setUp(self): + """Set up test fixtures.""" + self.thing_type = ThingType.objects.create(name='Electronics') + + def test_thing_type_str_returns_name(self): + """ThingType __str__ should return the name.""" + self.assertEqual(str(self.thing_type), 'Electronics') + + def test_thing_type_creation(self): + """ThingType should be created with correct attributes.""" + self.assertEqual(self.thing_type.name, 'Electronics') + + def test_thing_type_hierarchy(self): + """ThingType should support parent-child relationships.""" + child = ThingType.objects.create( + name='Resistors', + parent=self.thing_type + ) + self.assertEqual(child.parent, self.thing_type) + self.assertIn(child, self.thing_type.children.all()) + + def test_thing_type_is_leaf_node(self): + """ThingType without children should be a leaf node.""" + self.assertTrue(self.thing_type.is_leaf_node()) + + def test_thing_type_is_not_leaf_with_children(self): + """ThingType with children should not be a leaf node.""" + ThingType.objects.create(name='Capacitors', parent=self.thing_type) + self.assertFalse(self.thing_type.is_leaf_node()) + + def test_thing_type_ancestors(self): + """ThingType should return correct ancestors.""" + child = ThingType.objects.create( + name='Resistors', + parent=self.thing_type + ) + grandchild = ThingType.objects.create( + name='10k Resistors', + parent=child + ) + ancestors = list(grandchild.get_ancestors()) + self.assertEqual(ancestors, [self.thing_type, child]) + + def test_thing_type_descendants(self): + """ThingType should return correct descendants.""" + child = ThingType.objects.create( + name='Resistors', + parent=self.thing_type + ) + grandchild = ThingType.objects.create( + name='10k Resistors', + parent=child + ) + descendants = list(self.thing_type.get_descendants()) + self.assertEqual(descendants, [child, grandchild]) + + def test_thing_type_level(self): + """ThingType should have correct level in hierarchy.""" + self.assertEqual(self.thing_type.level, 0) + child = ThingType.objects.create( + name='Resistors', + parent=self.thing_type + ) + self.assertEqual(child.level, 1) + + +class ThingModelTests(TestCase): + """Tests for the Thing model.""" + + def setUp(self): + """Set up test fixtures.""" + 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 + ) + + def test_thing_str_returns_name(self): + """Thing __str__ should return the name.""" + self.assertEqual(str(self.thing), 'Arduino Uno') + + def test_thing_creation(self): + """Thing should be created with correct attributes.""" + self.assertEqual(self.thing.name, 'Arduino Uno') + self.assertEqual(self.thing.thing_type, self.thing_type) + self.assertEqual(self.thing.box, self.box) + + def test_thing_optional_description(self): + """Thing description should be optional.""" + self.assertEqual(self.thing.description, '') + self.thing.description = 'A microcontroller board' + self.thing.save() + self.thing.refresh_from_db() + self.assertEqual(self.thing.description, 'A microcontroller board') + + def test_thing_optional_picture(self): + """Thing picture should be optional.""" + self.assertEqual(self.thing.picture.name, '') + + def test_thing_with_picture(self): + """Thing should accept an image upload.""" + # 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='Test Item', + thing_type=self.thing_type, + box=self.box, + picture=image + ) + self.assertTrue(thing.picture.name.startswith('things/')) + # Clean up + thing.picture.delete() + + def test_thing_ordering(self): + """Things should be ordered by name.""" + Thing.objects.create( + name='Zeta Item', + thing_type=self.thing_type, + box=self.box + ) + Thing.objects.create( + name='Alpha Item', + thing_type=self.thing_type, + box=self.box + ) + things = list(Thing.objects.values_list('name', flat=True)) + self.assertEqual(things, ['Alpha Item', 'Arduino Uno', 'Zeta Item']) + + def test_thing_type_relationship(self): + """Thing should be accessible from ThingType via related_name.""" + self.assertIn(self.thing, self.thing_type.things.all()) + + def test_thing_box_relationship(self): + """Thing should be accessible from Box via related_name.""" + self.assertIn(self.thing, self.box.things.all()) + + def test_thing_type_protect_on_delete(self): + """Deleting a ThingType with things should raise IntegrityError.""" + with self.assertRaises(IntegrityError): + self.thing_type.delete() + + def test_box_protect_on_delete_with_things(self): + """Deleting a Box with things should raise IntegrityError.""" + with self.assertRaises(IntegrityError): + self.box.delete() + + +class ThingTypeAdminTests(TestCase): + """Tests for the ThingType admin configuration.""" + + def setUp(self): + """Set up test fixtures.""" + self.site = AdminSite() + self.admin = ThingTypeAdmin(ThingType, self.site) + + def test_search_fields(self): + """ThingTypeAdmin should search by name.""" + self.assertEqual(self.admin.search_fields, ('name',)) + + +class ThingAdminTests(TestCase): + """Tests for the Thing admin configuration.""" + + def setUp(self): + """Set up test fixtures.""" + self.site = AdminSite() + self.admin = ThingAdmin(Thing, self.site) + + def test_list_display(self): + """ThingAdmin should display correct fields.""" + self.assertEqual( + self.admin.list_display, + ('name', 'thing_type', 'box') + ) + + def test_list_filter(self): + """ThingAdmin should filter by thing_type and box.""" + self.assertEqual(self.admin.list_filter, ('thing_type', 'box')) + + def test_search_fields(self): + """ThingAdmin should search by name and description.""" + self.assertEqual(self.admin.search_fields, ('name', 'description')) diff --git a/data-loader/preload.sqlite3 b/data-loader/preload.sqlite3 index 31f0970..c7011e4 100644 Binary files a/data-loader/preload.sqlite3 and b/data-loader/preload.sqlite3 differ diff --git a/labhelper/settings.py b/labhelper/settings.py index 3d5f5dd..140767a 100644 --- a/labhelper/settings.py +++ b/labhelper/settings.py @@ -38,6 +38,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django_mptt_admin', 'boxes', ]