Added arbitrary files and links to boxes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
79
boxes/management/commands/clean_orphaned_files.py
Normal file
79
boxes/management/commands/clean_orphaned_files.py
Normal file
@@ -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')
|
||||
41
boxes/migrations/0005_thingfile_thinglink.py
Normal file
41
boxes/migrations/0005_thingfile_thinglink.py
Normal file
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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/<thing_id>/<filename>"""
|
||||
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}'
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<label class="btn" style="cursor: pointer; display: inline-flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-camera"></i>
|
||||
<span>{% if thing.picture %}Change picture{% else %}Add picture{% endif %}</span>
|
||||
<input type="file" name="picture" accept="image/*" style="display: none;" onchange="this.form.submit();">
|
||||
<input type="file" id="picture_upload" name="picture" accept="image/*" style="display: none;" onchange="this.form.submit();">
|
||||
</label>
|
||||
{% if thing.picture %}
|
||||
<button type="submit" name="action" value="delete_picture" class="btn" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border: none;" onclick="return confirm('Are you sure you want to delete this picture?');">
|
||||
@@ -80,10 +80,114 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if thing.files.all %}
|
||||
<div class="detail-row" style="margin-bottom: 25px;">
|
||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||
<i class="fas fa-file-alt"></i> Files
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
{% for file in thing.files.all %}
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
|
||||
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
||||
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
||||
</div>
|
||||
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this file?');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_file">
|
||||
<input type="hidden" name="file_id" value="{{ file.id }}">
|
||||
<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-times" style="font-size: 14px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if thing.links.all %}
|
||||
<div class="detail-row" style="margin-bottom: 25px;">
|
||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||
<i class="fas fa-link"></i> Links
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
{% for link in thing.links.all %}
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fas fa-external-link-alt" style="color: #667eea; font-size: 16px;"></i>
|
||||
<a href="{{ link.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ link.title }}</a>
|
||||
</div>
|
||||
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_link">
|
||||
<input type="hidden" name="link_id" value="{{ link.id }}">
|
||||
<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-times" style="font-size: 14px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fas fa-plus-circle"></i> Add Attachments
|
||||
</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
|
||||
<div>
|
||||
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
||||
<i class="fas fa-file-upload"></i> Upload File
|
||||
</h3>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_file">
|
||||
<div style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<div>
|
||||
<label for="file_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
|
||||
<input type="text" id="file_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="file_upload" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">File</label>
|
||||
<input type="file" id="file_upload" name="file" style="width: 100%; padding: 10px 15px; border: 2px dashed #e0e0e0; border-radius: 8px; font-size: 14px; background: #f8f9fa;">
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<i class="fas fa-upload"></i> Upload File
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
||||
<i class="fas fa-link"></i> Add Link
|
||||
</h3>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_link">
|
||||
<div style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<div>
|
||||
<label for="link_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
|
||||
<input type="text" id="link_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="link_url" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">URL</label>
|
||||
<input type="url" id="link_url" name="url" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<i class="fas fa-plus"></i> Add Link
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="section">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move">
|
||||
@@ -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('<input type="hidden" name="action" value="upload_picture">');
|
||||
form[0].submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
302
boxes/tests.py
302
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')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user