From a4f9274da459a81ab4d4dd6f389d528b83924679 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Thu, 1 Jan 2026 14:50:07 +0100 Subject: [PATCH] Added arbitrary files and links to boxes --- boxes/admin.py | 31 +- boxes/forms.py | 26 +- .../commands/clean_orphaned_files.py | 79 +++++ boxes/migrations/0005_thingfile_thinglink.py | 41 +++ boxes/models.py | 47 +++ boxes/templates/boxes/thing_detail.html | 115 ++++++- boxes/tests.py | 302 +++++++++++++++++- boxes/views.py | 47 ++- 8 files changed, 673 insertions(+), 15 deletions(-) create mode 100644 boxes/management/commands/clean_orphaned_files.py create mode 100644 boxes/migrations/0005_thingfile_thinglink.py diff --git a/boxes/admin.py b/boxes/admin.py index 9577da1..78e249d 100644 --- a/boxes/admin.py +++ b/boxes/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django_mptt_admin.admin import DjangoMpttAdmin -from .models import Box, BoxType, Thing, ThingType +from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType @admin.register(BoxType) @@ -35,3 +35,32 @@ class ThingAdmin(admin.ModelAdmin): list_display = ('name', 'thing_type', 'box') list_filter = ('thing_type', 'box') search_fields = ('name', 'description') + + +class ThingFileInline(admin.TabularInline): + """Inline admin for Thing files.""" + + model = ThingFile + extra = 1 + fields = ('title', 'file') + + +class ThingLinkInline(admin.TabularInline): + """Inline admin for Thing links.""" + + model = ThingLink + extra = 1 + fields = ('title', 'url') + + +class ThingAdminWithFiles(admin.ModelAdmin): + """Admin configuration for Thing model with files and links.""" + + list_display = ('name', 'thing_type', 'box') + list_filter = ('thing_type', 'box') + search_fields = ('name', 'description') + inlines = [ThingFileInline, ThingLinkInline] + + +admin.site.unregister(Thing) +admin.register(Thing, ThingAdminWithFiles) diff --git a/boxes/forms.py b/boxes/forms.py index 9e30c62..ee78a37 100644 --- a/boxes/forms.py +++ b/boxes/forms.py @@ -1,6 +1,6 @@ from django import forms -from .models import Box, BoxType, Thing +from .models import Box, BoxType, Thing, ThingFile, ThingLink class ThingForm(forms.ModelForm): @@ -24,6 +24,30 @@ class ThingPictureForm(forms.ModelForm): fields = ('picture',) +class ThingFileForm(forms.ModelForm): + """Form for adding a file to a Thing.""" + + class Meta: + model = ThingFile + fields = ('title', 'file') + widgets = { + 'title': forms.TextInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + 'file': forms.FileInput(attrs={'style': 'width: 100%;'}), + } + + +class ThingLinkForm(forms.ModelForm): + """Form for adding a link to a Thing.""" + + class Meta: + model = ThingLink + fields = ('title', 'url') + widgets = { + 'title': forms.TextInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + 'url': forms.URLInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}), + } + + class BoxTypeForm(forms.ModelForm): """Form for adding/editing a BoxType.""" diff --git a/boxes/management/commands/clean_orphaned_files.py b/boxes/management/commands/clean_orphaned_files.py new file mode 100644 index 0000000..01ed03c --- /dev/null +++ b/boxes/management/commands/clean_orphaned_files.py @@ -0,0 +1,79 @@ +import os +from django.conf import settings +from django.core.management.base import BaseCommand +from boxes.models import ThingFile + + +class Command(BaseCommand): + help = 'Clean up orphaned files from deleted things' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + dest='dry_run', + help='Show what would be deleted without actually deleting', + ) + + def handle(self, *args, **options): + dry_run = options.get('dry_run', False) + + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN - No files will be deleted')) + + self.stdout.write('Finding orphaned files...') + + media_root = settings.MEDIA_ROOT + things_files_root = os.path.join(media_root, 'things', 'files') + + if not os.path.exists(things_files_root): + self.stdout.write(self.style.WARNING('No things/files directory found')) + return + + valid_paths = set() + for thing_file in ThingFile.objects.all(): + if thing_file.file: + file_path = thing_file.file.path + if os.path.exists(file_path): + valid_paths.add(os.path.relpath(file_path, things_files_root)) + + self.stdout.write(f'Found {len(valid_paths)} valid files in database') + + deleted_count = 0 + empty_dirs_removed = 0 + + for root, dirs, files in os.walk(things_files_root, topdown=False): + for filename in files: + file_path = os.path.join(root, filename) + relative_path = os.path.relpath(file_path, things_files_root) + + if relative_path not in valid_paths: + deleted_count += 1 + if dry_run: + self.stdout.write(f'Would delete: {file_path}') + else: + try: + os.remove(file_path) + self.stdout.write(f'Deleted: {file_path}') + except OSError as e: + self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}')) + + for dirname in dirs: + dir_path = os.path.join(root, dirname) + if not os.listdir(dir_path): + if dry_run: + self.stdout.write(f'Would remove empty directory: {dir_path}') + else: + try: + os.rmdir(dir_path) + self.stdout.write(f'Removed empty directory: {dir_path}') + empty_dirs_removed += 1 + except OSError as e: + self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}')) + + if dry_run: + self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} files')) + self.stdout.write(f'Would remove {empty_dirs_removed} empty directories') + else: + self.stdout.write(self.style.SUCCESS(f'\nCleanup complete! Deleted {deleted_count} orphaned files')) + self.stdout.write(f'Removed {empty_dirs_removed} empty directories') diff --git a/boxes/migrations/0005_thingfile_thinglink.py b/boxes/migrations/0005_thingfile_thinglink.py new file mode 100644 index 0000000..d0c919e --- /dev/null +++ b/boxes/migrations/0005_thingfile_thinglink.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.9 on 2026-01-01 13:15 + +import boxes.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('boxes', '0004_alter_thing_picture'), + ] + + operations = [ + migrations.CreateModel( + name='ThingFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=boxes.models.thing_file_upload_path)), + ('title', models.CharField(help_text='Descriptive name for the file', max_length=255)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('thing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='boxes.thing')), + ], + options={ + 'ordering': ['-uploaded_at'], + }, + ), + migrations.CreateModel( + name='ThingLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(max_length=2048)), + ('title', models.CharField(help_text='Descriptive title for the link', max_length=255)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('thing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='boxes.thing')), + ], + options={ + 'ordering': ['-uploaded_at'], + }, + ), + ] diff --git a/boxes/models.py b/boxes/models.py index df95989..c3ee035 100644 --- a/boxes/models.py +++ b/boxes/models.py @@ -110,3 +110,50 @@ class Thing(models.Model): def __str__(self): return self.name + + +def thing_file_upload_path(instance, filename): + """Generate a custom path for thing files in format: things/files//""" + return f'things/files/{instance.thing.id}/{filename}' + + +class ThingFile(models.Model): + """A file attachment for a Thing.""" + + thing = models.ForeignKey( + Thing, + on_delete=models.CASCADE, + related_name='files' + ) + file = models.FileField(upload_to=thing_file_upload_path) + title = models.CharField(max_length=255, help_text='Descriptive name for the file') + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-uploaded_at'] + + def __str__(self): + return f'{self.thing.name} - {self.title}' + + def filename(self): + """Return the original filename.""" + return os.path.basename(self.file.name) + + +class ThingLink(models.Model): + """A hyperlink for a Thing.""" + + thing = models.ForeignKey( + Thing, + on_delete=models.CASCADE, + related_name='links' + ) + url = models.URLField(max_length=2048) + title = models.CharField(max_length=255, help_text='Descriptive title for the link') + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-uploaded_at'] + + def __str__(self): + return f'{self.thing.name} - {self.title}' diff --git a/boxes/templates/boxes/thing_detail.html b/boxes/templates/boxes/thing_detail.html index 4953dcf..34e4e41 100644 --- a/boxes/templates/boxes/thing_detail.html +++ b/boxes/templates/boxes/thing_detail.html @@ -38,7 +38,7 @@ {% if thing.picture %} + + + {% endfor %} + + + {% endif %} + + {% if thing.links.all %} +
+
+ Links +
+
+ {% for link in thing.links.all %} +
+ +
+ {% csrf_token %} + + + +
+
+ {% endfor %} +
+
+ {% endif %} +
+

+ Add Attachments +

+
+
+

+ Upload File +

+
+ {% csrf_token %} + +
+
+ + +
+
+ + +
+ +
+
+
+
+

+ Add Link +

+
+ {% csrf_token %} + +
+
+ + +
+
+ + +
+ +
+
+
+
+
{% csrf_token %} @@ -118,14 +222,5 @@ $('#new_box').on('focus', function() { $(this).css('border-color', '#e0e0e0'); $(this).css('box-shadow', 'none'); }); - -$('input[type="file"]').on('change', function() { - var fileName = $(this).val().split('\\').pop(); - if (fileName) { - var form = $(this).closest('form'); - form.append(''); - form[0].submit(); - } -}); {% endblock %} \ No newline at end of file diff --git a/boxes/tests.py b/boxes/tests.py index 5f1dc94..bfd9543 100644 --- a/boxes/tests.py +++ b/boxes/tests.py @@ -6,7 +6,7 @@ from django.test import Client, TestCase from django.urls import reverse from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin -from .models import Box, BoxType, Thing, ThingType +from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType class AuthTestCase(TestCase): @@ -1310,3 +1310,303 @@ class ThingPictureUploadTests(AuthTestCase): self.assertRedirects(response, f'/thing/{self.thing.id}/') self.thing.refresh_from_db() self.assertFalse(self.thing.picture.name) + + +class ThingFileModelTests(AuthTestCase): + """Tests for the ThingFile model.""" + + 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 + ) + + def test_thing_file_creation(self): + """ThingFile should be created with correct attributes.""" + thing_file = ThingFile.objects.create( + thing=self.thing, + title='Datasheet', + file='datasheets/test.pdf' + ) + self.assertEqual(thing_file.thing, self.thing) + self.assertEqual(thing_file.title, 'Datasheet') + self.assertEqual(thing_file.file.name, 'datasheets/test.pdf') + + def test_thing_file_str(self): + """ThingFile __str__ should return thing name and title.""" + thing_file = ThingFile.objects.create( + thing=self.thing, + title='Manual', + file='manuals/test.pdf' + ) + expected = f'{self.thing.name} - Manual' + self.assertEqual(str(thing_file), expected) + + def test_thing_file_ordering(self): + """ThingFiles should be ordered by uploaded_at descending.""" + file1 = ThingFile.objects.create( + thing=self.thing, + title='First File', + file='test1.pdf' + ) + file2 = ThingFile.objects.create( + thing=self.thing, + title='Second File', + file='test2.pdf' + ) + files = list(ThingFile.objects.all()) + self.assertEqual(files[0], file2) + self.assertEqual(files[1], file1) + + def test_thing_file_deletion_on_thing_delete(self): + """Deleting a Thing should delete its files.""" + thing_file = ThingFile.objects.create( + thing=self.thing, + title='Test File', + file='test.pdf' + ) + file_id = thing_file.id + self.thing.delete() + self.assertFalse(ThingFile.objects.filter(id=file_id).exists()) + + +class ThingLinkModelTests(AuthTestCase): + """Tests for the ThingLink model.""" + + 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 + ) + + def test_thing_link_creation(self): + """ThingLink should be created with correct attributes.""" + thing_link = ThingLink.objects.create( + thing=self.thing, + title='Manufacturer Website', + url='https://www.arduino.cc' + ) + self.assertEqual(thing_link.thing, self.thing) + self.assertEqual(thing_link.title, 'Manufacturer Website') + self.assertEqual(thing_link.url, 'https://www.arduino.cc') + + def test_thing_link_str(self): + """ThingLink __str__ should return thing name and title.""" + thing_link = ThingLink.objects.create( + thing=self.thing, + title='Documentation', + url='https://docs.arduino.cc' + ) + expected = f'{self.thing.name} - Documentation' + self.assertEqual(str(thing_link), expected) + + def test_thing_link_ordering(self): + """ThingLinks should be ordered by uploaded_at descending.""" + link1 = ThingLink.objects.create( + thing=self.thing, + title='First Link', + url='https://example1.com' + ) + link2 = ThingLink.objects.create( + thing=self.thing, + title='Second Link', + url='https://example2.com' + ) + links = list(ThingLink.objects.all()) + self.assertEqual(links[0], link2) + self.assertEqual(links[1], link1) + + def test_thing_link_deletion_on_thing_delete(self): + """Deleting a Thing should delete its links.""" + thing_link = ThingLink.objects.create( + thing=self.thing, + title='Test Link', + url='https://example.com' + ) + link_id = thing_link.id + self.thing.delete() + self.assertFalse(ThingLink.objects.filter(id=link_id).exists()) + + +class ThingFileAndLinkCRUDTests(AuthTestCase): + """Tests for Thing file and link 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 + ) + 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_add_file_to_thing(self): + """Adding a file should create ThingFile.""" + file_content = b'Sample PDF content' + uploaded_file = SimpleUploadedFile( + name='datasheet.pdf', + content=file_content, + content_type='application/pdf' + ) + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'add_file', 'title': 'Datasheet', 'file': uploaded_file} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertEqual(self.thing.files.count(), 1) + self.assertEqual(self.thing.files.first().title, 'Datasheet') + + def test_add_link_to_thing(self): + """Adding a link should create ThingLink.""" + response = self.client.post( + f'/thing/{self.thing.id}/', + { + 'action': 'add_link', + 'title': 'Manufacturer', + 'url': 'https://www.arduino.cc' + } + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertEqual(self.thing.links.count(), 1) + self.assertEqual(self.thing.links.first().title, 'Manufacturer') + self.assertEqual(self.thing.links.first().url, 'https://www.arduino.cc') + + def test_delete_file_from_thing(self): + """Deleting a file should remove it from database.""" + thing_file = ThingFile.objects.create( + thing=self.thing, + title='Test File', + file='test.pdf' + ) + file_id = thing_file.id + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'delete_file', 'file_id': str(file_id)} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertFalse(ThingFile.objects.filter(id=file_id).exists()) + + def test_delete_link_from_thing(self): + """Deleting a link should remove it from database.""" + thing_link = ThingLink.objects.create( + thing=self.thing, + title='Test Link', + url='https://example.com' + ) + link_id = thing_link.id + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'delete_link', 'link_id': str(link_id)} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertFalse(ThingLink.objects.filter(id=link_id).exists()) + + def test_cannot_delete_file_from_other_thing(self): + """Cannot delete file from another thing.""" + other_thing = Thing.objects.create( + name='Other Item', + thing_type=self.thing_type, + box=self.box + ) + thing_file = ThingFile.objects.create( + thing=other_thing, + title='Other File', + file='other.pdf' + ) + file_id = thing_file.id + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'delete_file', 'file_id': str(file_id)} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertTrue(ThingFile.objects.filter(id=file_id).exists()) + + def test_cannot_delete_link_from_other_thing(self): + """Cannot delete link from another thing.""" + other_thing = Thing.objects.create( + name='Other Item', + thing_type=self.thing_type, + box=self.box + ) + thing_link = ThingLink.objects.create( + thing=other_thing, + title='Other Link', + url='https://other.com' + ) + link_id = thing_link.id + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'delete_link', 'link_id': str(link_id)} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertTrue(ThingLink.objects.filter(id=link_id).exists()) + + def test_thing_detail_shows_files_section(self): + """Thing detail page should show files when they exist.""" + ThingFile.objects.create( + thing=self.thing, + title='Datasheet', + file='test.pdf' + ) + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Files') + self.assertContains(response, 'Datasheet') + + def test_thing_detail_shows_links_section(self): + """Thing detail page should show links when they exist.""" + ThingLink.objects.create( + thing=self.thing, + title='Documentation', + url='https://docs.example.com' + ) + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Links') + self.assertContains(response, 'Documentation') + + def test_thing_detail_shows_upload_forms(self): + """Thing detail page should show upload forms.""" + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Upload File') + self.assertContains(response, 'Add Link') diff --git a/boxes/views.py b/boxes/views.py index 06b32b2..8e6a225 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -6,10 +6,12 @@ from django.shortcuts import get_object_or_404, redirect, render from .forms import ( BoxForm, BoxTypeForm, + ThingFileForm, ThingFormSet, + ThingLinkForm, ThingPictureForm, ) -from .models import Box, BoxType, Thing, ThingType +from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType @login_required @@ -46,12 +48,14 @@ def box_detail(request, box_id): def thing_detail(request, thing_id): """Display details of a thing.""" thing = get_object_or_404( - Thing.objects.select_related('thing_type', 'box', 'box__box_type'), + Thing.objects.select_related('thing_type', 'box', 'box__box_type').prefetch_related('files', 'links'), pk=thing_id ) boxes = Box.objects.select_related('box_type').all().order_by('id') picture_form = ThingPictureForm(instance=thing) + file_form = ThingFileForm() + link_form = ThingLinkForm() if request.method == 'POST': action = request.POST.get('action') @@ -77,10 +81,49 @@ def thing_detail(request, thing_id): thing.save() return redirect('thing_detail', thing_id=thing.id) + elif action == 'add_file': + file_form = ThingFileForm(request.POST, request.FILES) + if file_form.is_valid(): + thing_file = file_form.save(commit=False) + thing_file.thing = thing + thing_file.save() + return redirect('thing_detail', thing_id=thing.id) + + elif action == 'add_link': + link_form = ThingLinkForm(request.POST) + if link_form.is_valid(): + thing_link = link_form.save(commit=False) + thing_link.thing = thing + thing_link.save() + return redirect('thing_detail', thing_id=thing.id) + + elif action == 'delete_file': + file_id = request.POST.get('file_id') + if file_id: + try: + thing_file = ThingFile.objects.get(pk=file_id, thing=thing) + thing_file.file.delete() + thing_file.delete() + except ThingFile.DoesNotExist: + pass + return redirect('thing_detail', thing_id=thing.id) + + elif action == 'delete_link': + link_id = request.POST.get('link_id') + if link_id: + try: + thing_link = ThingLink.objects.get(pk=link_id, thing=thing) + thing_link.delete() + except ThingLink.DoesNotExist: + pass + return redirect('thing_detail', thing_id=thing.id) + return render(request, 'boxes/thing_detail.html', { 'thing': thing, 'boxes': boxes, 'picture_form': picture_form, + 'file_form': file_form, + 'link_form': link_form, })