diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index cd4cb0c..9a232fb 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.050 + image: git.baumann.gr/adebaumann/labhelper:0.051 imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/boxes/forms.py b/boxes/forms.py index a9cde86..765e71b 100644 --- a/boxes/forms.py +++ b/boxes/forms.py @@ -4,15 +4,14 @@ from .models import Box, BoxType, Thing, ThingFile, ThingLink class ThingForm(forms.ModelForm): - """Form for adding a Thing.""" + """Form for adding/editing a Thing.""" class Meta: model = Thing - fields = ('name', 'description', 'picture', 'tags') + fields = ('name', 'description', 'picture') widgets = { 'name': forms.TextInput(attrs={'class': 'form-control'}), 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), - 'tags': forms.CheckboxSelectMultiple(attrs={'class': 'tags-checkboxes'}), } diff --git a/boxes/templates/boxes/edit_thing.html b/boxes/templates/boxes/edit_thing.html new file mode 100644 index 0000000..659216b --- /dev/null +++ b/boxes/templates/boxes/edit_thing.html @@ -0,0 +1,324 @@ +{% extends "base.html" %} +{% load thumbnail %} +{% load dict_extras %} + +{% block title %}Edit {{ thing.name }} - LabHelper{% endblock %} + +{% block page_header %} + +{% endblock %} + +{% block content %} +
+

+ Basic Information +

+
+ {% csrf_token %} + +
+
+ + {{ thing_form.name }} +
+
+ + {{ thing_form.description }} +
+
+
+ + + Cancel + +
+
+
+ +
+
+
+ {% if thing.picture %} + {% thumbnail thing.picture "400x400" crop="center" as thumb %} + {{ thing.name }} + {% endthumbnail %} + {% else %} +
+
+ + No image +
+
+ {% endif %} + +
+ {% csrf_token %} + +
+ + {% if thing.picture %} + + {% endif %} +
+
+
+ +
+
+
+ Tags +
+
+ {% regroup thing.tags.all by facet as facet_list %} + {% for facet in facet_list %} +
+
+ {{ facet.grouper.name }} +
+
+ {% for tag in facet.list %} +
+ {% csrf_token %} + + + +
+ {% endfor %} +
+
+ {% endfor %} +
+
+ +
+
+ Location +
+
+ {% csrf_token %} + +
+ + +
+
+
+ + {% if thing.files.all %} +
+
+ Files +
+
+ {% for file in thing.files.all %} +
+
+ + {{ file.title }} + ({{ file.filename }}) +
+
+ {% csrf_token %} + + + +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if thing.links.all %} +
+
+ Links +
+
+ {% for link in thing.links.all %} +
+ +
+ {% csrf_token %} + + + +
+
+ {% endfor %} +
+
+ {% endif %} +
+
+
+ + +
+

+ Add Tags +

+
+
+

+ Add Tag +

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

+ Add Attachments +

+
+
+

+ Upload File +

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

+ Add Link +

+
+ {% csrf_token %} + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/boxes/templates/boxes/thing_detail.html b/boxes/templates/boxes/thing_detail.html index f8f8a0f..9a09398 100644 --- a/boxes/templates/boxes/thing_detail.html +++ b/boxes/templates/boxes/thing_detail.html @@ -5,13 +5,18 @@ {% block title %}{{ thing.name }} - LabHelper{% endblock %} {% block page_header %} -
{% endif %} - -
- {% csrf_token %} - -
- - {% if thing.picture %} - - {% endif %} -
-
+ {% if thing.tags.all %}
Tags @@ -65,21 +53,16 @@
{% for tag in facet.list %} -
- {% csrf_token %} - - - -
+ + {{ tag.name }} + {% endfor %}
{% endfor %}
+ {% endif %}
@@ -109,20 +92,10 @@
{% for file in thing.files.all %} -
-
- - {{ file.title }} - ({{ file.filename }}) -
-
- {% csrf_token %} - - - -
+
+ + {{ file.title }} + ({{ file.filename }})
{% endfor %}
@@ -136,142 +109,17 @@
{% for link in thing.links.all %} -
-
- - {{ link.title }} -
-
- {% csrf_token %} - - - -
+
+ + {{ link.title }}
{% endfor %}
{% endif %} -
- -
-

- Add Tags -

-
-
-

- Add Tag -

-
- {% csrf_token %} - -
-
- - -
- -
-
-
-
-
- -
-

- Add Attachments -

-
-
-

- Upload File -

-
- {% csrf_token %} - -
-
- - -
-
- - -
- -
-
-
-
-

- Add Link -

-
- {% csrf_token %} - -
-
- - -
-
- - -
- -
-
-
-
- -
- {% csrf_token %} - -
-
- - -
- -
-
- {% endblock %} {% block extra_css %} @@ -352,15 +200,3 @@ } {% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/boxes/tests.py b/boxes/tests.py index bc9db0d..8730eec 100644 --- a/boxes/tests.py +++ b/boxes/tests.py @@ -441,9 +441,16 @@ class ThingDetailViewTests(AuthTestCase): self.assertContains(response, 'Arduino Uno') def test_thing_detail_shows_tags_section(self): - """Thing detail page should show tags section.""" + """Thing detail page should show tags section when tags exist.""" + facet = Facet.objects.create( + name='Electronics', + color='#FF5733', + cardinality=Facet.Cardinality.MULTIPLE + ) + tag = Tag.objects.create(facet=facet, name='Arduino') + self.thing.tags.add(tag) response = self.client.get(f'/thing/{self.thing.id}/') - self.assertContains(response, 'Tags') + self.assertContains(response, 'Electronics') def test_thing_detail_shows_description(self): """Thing detail page should show thing description.""" @@ -472,25 +479,11 @@ class ThingDetailViewTests(AuthTestCase): response = self.client.get(f'/thing/{self.thing.id}/') self.assertRedirects(response, f'/login/?next=/thing/{self.thing.id}/') - def test_thing_detail_move_to_box(self): - """Thing can be moved to another box via POST.""" - new_box = Box.objects.create(id='BOX002', box_type=self.box_type) - response = self.client.post( - f'/thing/{self.thing.id}/', - {'action': 'move', 'new_box': 'BOX002'} - ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') - self.thing.refresh_from_db() - self.assertEqual(self.thing.box, new_box) - - def test_thing_detail_move_shows_all_boxes(self): - """Thing detail page should show all available boxes in dropdown.""" - Box.objects.create(id='BOX002', box_type=self.box_type) - Box.objects.create(id='BOX003', box_type=self.box_type) + def test_thing_detail_has_edit_button(self): + """Thing detail page should have an edit button.""" response = self.client.get(f'/thing/{self.thing.id}/') - self.assertContains(response, 'BOX001') - self.assertContains(response, 'BOX002') - self.assertContains(response, 'BOX003') + self.assertContains(response, 'Edit') + self.assertContains(response, f'/thing/{self.thing.id}/edit/') class SearchViewTests(AuthTestCase): @@ -1024,14 +1017,14 @@ class ThingPictureUploadTests(AuthTestCase): 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}/') + def test_edit_thing_shows_add_picture_button(self): + """Edit thing page should show 'Add picture' button when thing has no picture.""" + response = self.client.get(f'/thing/{self.thing.id}/edit/') 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.""" + def test_edit_thing_shows_change_picture_button(self): + """Edit thing page should show 'Change picture' button when thing has a picture.""" image = SimpleUploadedFile( name='test.png', content=self.image_data, @@ -1039,14 +1032,14 @@ class ThingPictureUploadTests(AuthTestCase): ) self.thing.picture = image self.thing.save() - response = self.client.get(f'/thing/{self.thing.id}/') + response = self.client.get(f'/thing/{self.thing.id}/edit/') 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.""" + def test_edit_thing_shows_remove_button(self): + """Edit thing page should show 'Remove' button when thing has a picture.""" image = SimpleUploadedFile( name='test.png', content=self.image_data, @@ -1054,14 +1047,14 @@ class ThingPictureUploadTests(AuthTestCase): ) self.thing.picture = image self.thing.save() - response = self.client.get(f'/thing/{self.thing.id}/') + response = self.client.get(f'/thing/{self.thing.id}/edit/') 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.""" + """Deleting a picture should remove it from thing.""" image = SimpleUploadedFile( name='test.png', content=self.image_data, @@ -1070,19 +1063,28 @@ class ThingPictureUploadTests(AuthTestCase): self.thing.picture = image self.thing.save() - response = self.client.post(f'/thing/{self.thing.id}/', { + response = self.client.post(f'/thing/{self.thing.id}/edit/', { 'action': 'delete_picture' }) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') 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}/', { + response = self.client.post(f'/thing/{self.thing.id}/edit/', { 'action': 'delete_picture' }) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') + 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}/edit/', { + 'action': 'delete_picture' + }) + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.thing.refresh_from_db() self.assertFalse(self.thing.picture.name) @@ -1257,24 +1259,24 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): content_type='application/pdf' ) response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'add_file', 'title': 'Datasheet', 'file': uploaded_file} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') 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}/', + f'/thing/{self.thing.id}/edit/', { 'action': 'add_link', 'title': 'Manufacturer', 'url': 'https://www.arduino.cc' } ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') 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') @@ -1288,10 +1290,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): ) file_id = thing_file.id response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'delete_file', 'file_id': str(file_id)} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.assertFalse(ThingFile.objects.filter(id=file_id).exists()) def test_delete_link_from_thing(self): @@ -1303,10 +1305,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): ) link_id = thing_link.id response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'delete_link', 'link_id': str(link_id)} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.assertFalse(ThingLink.objects.filter(id=link_id).exists()) def test_cannot_delete_file_from_other_thing(self): @@ -1322,10 +1324,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): ) file_id = thing_file.id response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'delete_file', 'file_id': str(file_id)} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.assertTrue(ThingFile.objects.filter(id=file_id).exists()) def test_cannot_delete_link_from_other_thing(self): @@ -1341,10 +1343,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): ) link_id = thing_link.id response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'delete_link', 'link_id': str(link_id)} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.assertTrue(ThingLink.objects.filter(id=link_id).exists()) def test_thing_detail_shows_files_section(self): @@ -1371,9 +1373,9 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): 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}/') + def test_edit_thing_shows_upload_forms(self): + """Edit thing page should show upload forms.""" + response = self.client.get(f'/thing/{self.thing.id}/edit/') self.assertEqual(response.status_code, 200) self.assertContains(response, 'Upload File') self.assertContains(response, 'Add Link') @@ -1630,29 +1632,29 @@ class ThingTagTests(AuthTestCase): self.assertNotIn(self.tag, self.thing.tags.all()) def test_thing_detail_add_tag(self): - """Thing detail page can add a tag via POST.""" + """Edit thing page can add a tag via POST.""" response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'add_tag', 'tag_id': str(self.tag.id)} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.thing.refresh_from_db() self.assertIn(self.tag, self.thing.tags.all()) def test_thing_detail_remove_tag(self): - """Thing detail page can remove a tag via POST.""" + """Edit thing page can remove a tag via POST.""" self.thing.tags.add(self.tag) response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'remove_tag', 'tag_id': str(self.tag.id)} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.thing.refresh_from_db() self.assertNotIn(self.tag, self.thing.tags.all()) def test_thing_detail_shows_available_tags(self): - """Thing detail page should show available tags to add.""" - response = self.client.get(f'/thing/{self.thing.id}/') + """Edit thing page should show available tags to add.""" + response = self.client.get(f'/thing/{self.thing.id}/edit/') self.assertEqual(response.status_code, 200) self.assertContains(response, 'Electronics') @@ -1674,10 +1676,10 @@ class ThingTagTests(AuthTestCase): self.thing.tags.add(tag_low) response = self.client.post( - f'/thing/{self.thing.id}/', + f'/thing/{self.thing.id}/edit/', {'action': 'add_tag', 'tag_id': str(tag_high.id)} ) - self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.assertRedirects(response, f'/thing/{self.thing.id}/edit/') self.thing.refresh_from_db() self.assertNotIn(tag_low, self.thing.tags.all()) self.assertIn(tag_high, self.thing.tags.all()) diff --git a/boxes/views.py b/boxes/views.py index 89a5976..f239dbd 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -10,6 +10,7 @@ from .forms import ( BoxForm, BoxTypeForm, ThingFileForm, + ThingForm, ThingFormSet, ThingLinkForm, ThingPictureForm, @@ -64,7 +65,17 @@ def box_detail(request, box_id): @login_required def thing_detail(request, thing_id): - """Display details of a thing.""" + """Display details of a thing (read-only).""" + thing = get_object_or_404( + Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), + pk=thing_id + ) + return render(request, 'boxes/thing_detail.html', {'thing': thing}) + + +@login_required +def edit_thing(request, thing_id): + """Edit a thing's details.""" thing = get_object_or_404( Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), pk=thing_id @@ -79,26 +90,32 @@ def thing_detail(request, thing_id): if request.method == 'POST': action = request.POST.get('action') - if action == 'move': + if action == 'save_details': + form = ThingForm(request.POST, request.FILES, instance=thing) + if form.is_valid(): + form.save() + return redirect('thing_detail', thing_id=thing.id) + + elif action == 'move': new_box_id = request.POST.get('new_box') if new_box_id: new_box = get_object_or_404(Box, pk=new_box_id) thing.box = new_box thing.save() - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'upload_picture': picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing) if picture_form.is_valid(): picture_form.save() - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'delete_picture': if thing.picture: thing.picture.delete() thing.picture = None thing.save() - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'add_file': file_form = ThingFileForm(request.POST, request.FILES) @@ -106,7 +123,7 @@ def thing_detail(request, thing_id): thing_file = file_form.save(commit=False) thing_file.thing = thing thing_file.save() - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'add_link': link_form = ThingLinkForm(request.POST) @@ -114,7 +131,7 @@ def thing_detail(request, thing_id): thing_link = link_form.save(commit=False) thing_link.thing = thing thing_link.save() - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'delete_file': file_id = request.POST.get('file_id') @@ -125,7 +142,7 @@ def thing_detail(request, thing_id): thing_file.delete() except ThingFile.DoesNotExist: pass - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'delete_link': link_id = request.POST.get('link_id') @@ -135,7 +152,7 @@ def thing_detail(request, thing_id): thing_link.delete() except ThingLink.DoesNotExist: pass - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'add_tag': tag_id = request.POST.get('tag_id') @@ -149,7 +166,7 @@ def thing_detail(request, thing_id): thing.tags.add(tag) except Tag.DoesNotExist: pass - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) elif action == 'remove_tag': tag_id = request.POST.get('tag_id') @@ -159,15 +176,18 @@ def thing_detail(request, thing_id): thing.tags.remove(tag) except Tag.DoesNotExist: pass - return redirect('thing_detail', thing_id=thing.id) + return redirect('edit_thing', thing_id=thing.id) - return render(request, 'boxes/thing_detail.html', { + thing_form = ThingForm(instance=thing) + + return render(request, 'boxes/edit_thing.html', { 'thing': thing, 'boxes': boxes, 'facets': facets, 'picture_form': picture_form, 'file_form': file_form, 'link_form': link_form, + 'thing_form': thing_form, }) diff --git a/labhelper/urls.py b/labhelper/urls.py index 09838e3..29c0c3c 100644 --- a/labhelper/urls.py +++ b/labhelper/urls.py @@ -30,6 +30,7 @@ from boxes.views import ( delete_box_type, edit_box, edit_box_type, + edit_thing, index, search, search_api, @@ -49,6 +50,7 @@ urlpatterns = [ path('box//delete/', delete_box, name='delete_box'), path('box//', box_detail, name='box_detail'), path('thing//', thing_detail, name='thing_detail'), + path('thing//edit/', edit_thing, name='edit_thing'), path('box//add/', add_things, name='add_things'), path('search/', search, name='search'), path('search/api/', search_api, name='search_api'),