diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index e5f103f2a9..e7f73fcec6 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -308,7 +308,10 @@ STATICFILES_DIRS = [ MEDIA_URL = '/media/' # The filesystem location for served static files -MEDIA_ROOT = CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')) +MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))) + +if DEBUG: + print("MEDIA_ROOT:", MEDIA_ROOT) # crispy forms use the bootstrap templates CRISPY_TEMPLATE_PACK = 'bootstrap' diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index b920205ca3..6c89ff7265 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -183,6 +183,24 @@ -webkit-opacity: 10%; } +/* grid display for part images */ + +.table-img-grid tr { + display: inline; +} + +.table-img-grid td { + padding: 10px; + margin: 10px; +} + +.table-img-grid .grid-image { + + height: 128px; + width: 128px; + object-fit: contain; + background: #eee; +} .btn-glyph { padding-left: 6px; @@ -211,6 +229,20 @@ object-fit: contain; } +.part-thumb-container:hover .part-thumb-overlay { + opacity: 1; +} + +.part-thumb-overlay { + position: absolute; + top: 0; + left: 0; + opacity: 0; + transition: .25s ease; + padding: 15px; + margin: 5px; +} + .checkbox { margin-left: 20px; } diff --git a/InvenTree/build/migrations/0009_auto_20200210_1032.py b/InvenTree/build/migrations/0009_auto_20200210_1032.py new file mode 100644 index 0000000000..45908933b9 --- /dev/null +++ b/InvenTree/build/migrations/0009_auto_20200210_1032.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.9 on 2020-02-10 10:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0008_auto_20200201_1247'), + ] + + operations = [ + migrations.AlterField( + model_name='build', + name='creation_date', + field=models.DateField(auto_now_add=True), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 1ad761a98e..b625dde475 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -82,7 +82,7 @@ class Build(models.Model): batch = models.CharField(max_length=100, blank=True, null=True, help_text=_('Batch code for this build output')) - creation_date = models.DateField(auto_now=True, editable=False) + creation_date = models.DateField(auto_now_add=True, editable=False) completion_date = models.DateField(null=True, blank=True) diff --git a/InvenTree/locale/de/LC_MESSAGES/django.po b/InvenTree/locale/de/LC_MESSAGES/django.po index 151190ca0e..fc53b0aed0 100644 --- a/InvenTree/locale/de/LC_MESSAGES/django.po +++ b/InvenTree/locale/de/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-03 10:28+0000\n" +"POT-Creation-Date: 2020-02-10 11:09+0000\n" "PO-Revision-Date: 2020-02-02 08:07+0100\n" "Last-Translator: Christian Schlüter \n" "Language-Team: C \n" @@ -706,7 +706,7 @@ msgstr "Link auf externe Seite" msgid "Order notes" msgstr "Bestell-Notizen" -#: order/models.py:159 order/models.py:210 part/views.py:1067 +#: order/models.py:159 order/models.py:210 part/views.py:1080 #: stock/models.py:440 msgid "Quantity must be greater than zero" msgstr "Anzahl muss größer Null sein" @@ -1277,56 +1277,208 @@ msgstr "Tracking" msgid "Attachments" msgstr "Anhänge" +#: part/views.py:77 +#, fuzzy +#| msgid "Add Attachment" +msgid "Added attachment" +msgstr "Anhang hinzufügen" + +#: part/views.py:119 +#, fuzzy +#| msgid "Part Attachments" +msgid "Part attachment updated" +msgstr "Anhänge" + #: part/views.py:196 #, python-brace-format msgid "Set category for {n} parts" msgstr "Kategorie für {n} Teile setzen" -#: part/views.py:808 +#: part/views.py:306 +#, fuzzy +#| msgid "Supplier part" +msgid "Copied part" +msgstr "Zulieferer-Teil" + +#: part/views.py:414 +#, fuzzy +#| msgid "Create new Stock Item" +msgid "Create new part" +msgstr "Neues Lagerobjekt hinzufügen" + +#: part/views.py:419 +#, fuzzy +#| msgid "Created new stock item" +msgid "Created new part" +msgstr "Neues Lagerobjekt erstellt" + +#: part/views.py:609 +msgid "Upload Part Image" +msgstr "" + +#: part/views.py:614 +msgid "Updated part image" +msgstr "" + +#: part/views.py:623 +#, fuzzy +#| msgid "Select part" +msgid "Select Part Image" +msgstr "Teil auswählen" + +#: part/views.py:627 +#, fuzzy +#| msgid "Select part" +msgid "Selected part image" +msgstr "Teil auswählen" + +#: part/views.py:637 +#, fuzzy +#| msgid "Edit notes" +msgid "Edit Part Properties" +msgstr "Bermerkungen bearbeiten" + +#: part/views.py:659 +msgid "Validate BOM" +msgstr "" + +#: part/views.py:821 msgid "No BOM file provided" msgstr "Keine Stückliste angegeben" -#: part/views.py:1069 +#: part/views.py:1082 msgid "Enter a valid quantity" msgstr "Bitte eine gültige Anzahl eingeben" -#: part/views.py:1093 part/views.py:1096 +#: part/views.py:1106 part/views.py:1109 msgid "Select valid part" msgstr "Bitte ein gültiges Teil auswählen" -#: part/views.py:1102 +#: part/views.py:1115 msgid "Duplicate part selected" msgstr "Teil doppelt ausgewählt" -#: part/views.py:1130 +#: part/views.py:1143 msgid "Select a part" msgstr "Teil auswählen" -#: part/views.py:1134 +#: part/views.py:1147 msgid "Specify quantity" msgstr "Anzahl angeben" -#: stock/forms.py:92 +#: part/views.py:1324 +#, fuzzy +#| msgid "Confirm part creation" +msgid "Confirm Part Deletion" +msgstr "Erstellen des Teils bestätigen" + +#: part/views.py:1331 +msgid "Part was deleted" +msgstr "" + +#: part/views.py:1340 +#, fuzzy +#| msgid "Part packaging" +msgid "Part Pricing" +msgstr "Teile-Packaging" + +#: part/views.py:1462 +#, fuzzy +#| msgid "Parameter Template" +msgid "Create Part Parameter Template" +msgstr "Parameter Vorlage" + +#: part/views.py:1470 +#, fuzzy +#| msgid "Parameter Template" +msgid "Edit Part Parameter Template" +msgstr "Parameter Vorlage" + +#: part/views.py:1477 +#, fuzzy +#| msgid "Parameter Template" +msgid "Delete Part Parameter Template" +msgstr "Parameter Vorlage" + +#: part/views.py:1485 +msgid "Create Part Parameter" +msgstr "" + +#: part/views.py:1535 +#, fuzzy +#| msgid "Edit attachment" +msgid "Edit Part Parameter" +msgstr "Anhang bearbeiten" + +#: part/views.py:1549 +#, fuzzy +#| msgid "Delete attachment" +msgid "Delete Part Parameter" +msgstr "Anhang löschen" + +#: part/views.py:1565 +#, fuzzy +#| msgid "Part category" +msgid "Edit Part Category" +msgstr "Teile-Kategorie" + +#: part/views.py:1600 +#, fuzzy +#| msgid "Select part category" +msgid "Delete Part Category" +msgstr "Teilekategorie wählen" + +#: part/views.py:1606 +#, fuzzy +#| msgid "Part category" +msgid "Part category was deleted" +msgstr "Teile-Kategorie" + +#: part/views.py:1614 +#, fuzzy +#| msgid "Select part category" +msgid "Create new part category" +msgstr "Teilekategorie wählen" + +#: part/views.py:1665 +#, fuzzy +#| msgid "Created new stock item" +msgid "Create BOM item" +msgstr "Neues Lagerobjekt erstellt" + +#: part/views.py:1731 +#, fuzzy +#| msgid "Edit Stock Item" +msgid "Edit BOM item" +msgstr "Lagerobjekt bearbeiten" + +#: part/views.py:1779 +#, fuzzy +#| msgid "Confirm build completion" +msgid "Confim BOM item deletion" +msgstr "Bau-Fertigstellung bestätigen" + +#: stock/forms.py:91 msgid "File Format" msgstr "Dateiformat" -#: stock/forms.py:92 +#: stock/forms.py:91 msgid "Select output file format" msgstr "Ausgabe-Dateiformat auswählen" -#: stock/forms.py:94 +#: stock/forms.py:93 msgid "Include stock items in sub locations" msgstr "Lagerobjekte in untergeordneten Lagerorten einschließen" -#: stock/forms.py:127 +#: stock/forms.py:126 msgid "Destination stock location" msgstr "Ziel-Lagerbestand" -#: stock/forms.py:133 +#: stock/forms.py:132 msgid "Confirm movement of stock items" msgstr "Bewegung der Lagerobjekte bestätigen" -#: stock/forms.py:135 +#: stock/forms.py:134 msgid "Set the destination as the default location for selected parts" msgstr "Setze das Ziel als Standard-Ziel für ausgewählte Teile" @@ -1652,27 +1804,33 @@ msgstr "Ungültige Menge" msgid "Invalid part selection" msgstr "Ungültige Teileauswahl" -#: stock/views.py:925 +#: stock/views.py:910 +#, fuzzy, python-brace-format +#| msgid "Created new stock item" +msgid "Created {n} new stock items" +msgstr "Neues Lagerobjekt erstellt" + +#: stock/views.py:927 stock/views.py:940 msgid "Created new stock item" msgstr "Neues Lagerobjekt erstellt" -#: stock/views.py:942 +#: stock/views.py:957 msgid "Delete Stock Location" msgstr "Standort löschen" -#: stock/views.py:955 +#: stock/views.py:970 msgid "Delete Stock Item" msgstr "Lagerobjekt löschen" -#: stock/views.py:966 +#: stock/views.py:981 msgid "Delete Stock Tracking Entry" msgstr "Lagerbestands-Tracking-Eintrag löschen" -#: stock/views.py:983 +#: stock/views.py:998 msgid "Edit Stock Tracking Entry" msgstr "Lagerbestands-Tracking-Eintrag bearbeiten" -#: stock/views.py:992 +#: stock/views.py:1007 msgid "Add Stock Tracking Entry" msgstr "Lagerbestands-Tracking-Eintrag hinzufügen" diff --git a/InvenTree/locale/en/LC_MESSAGES/django.po b/InvenTree/locale/en/LC_MESSAGES/django.po index 3b7c7af271..930ff589f8 100644 --- a/InvenTree/locale/en/LC_MESSAGES/django.po +++ b/InvenTree/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-03 10:28+0000\n" +"POT-Creation-Date: 2020-02-10 11:09+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -675,7 +675,7 @@ msgstr "" msgid "Order notes" msgstr "" -#: order/models.py:159 order/models.py:210 part/views.py:1067 +#: order/models.py:159 order/models.py:210 part/views.py:1080 #: stock/models.py:440 msgid "Quantity must be greater than zero" msgstr "" @@ -1242,56 +1242,164 @@ msgstr "" msgid "Attachments" msgstr "" +#: part/views.py:77 +msgid "Added attachment" +msgstr "" + +#: part/views.py:119 +msgid "Part attachment updated" +msgstr "" + #: part/views.py:196 #, python-brace-format msgid "Set category for {n} parts" msgstr "" -#: part/views.py:808 +#: part/views.py:306 +msgid "Copied part" +msgstr "" + +#: part/views.py:414 +msgid "Create new part" +msgstr "" + +#: part/views.py:419 +msgid "Created new part" +msgstr "" + +#: part/views.py:609 +msgid "Upload Part Image" +msgstr "" + +#: part/views.py:614 +msgid "Updated part image" +msgstr "" + +#: part/views.py:623 +msgid "Select Part Image" +msgstr "" + +#: part/views.py:627 +msgid "Selected part image" +msgstr "" + +#: part/views.py:637 +msgid "Edit Part Properties" +msgstr "" + +#: part/views.py:659 +msgid "Validate BOM" +msgstr "" + +#: part/views.py:821 msgid "No BOM file provided" msgstr "" -#: part/views.py:1069 +#: part/views.py:1082 msgid "Enter a valid quantity" msgstr "" -#: part/views.py:1093 part/views.py:1096 +#: part/views.py:1106 part/views.py:1109 msgid "Select valid part" msgstr "" -#: part/views.py:1102 +#: part/views.py:1115 msgid "Duplicate part selected" msgstr "" -#: part/views.py:1130 +#: part/views.py:1143 msgid "Select a part" msgstr "" -#: part/views.py:1134 +#: part/views.py:1147 msgid "Specify quantity" msgstr "" -#: stock/forms.py:92 +#: part/views.py:1324 +msgid "Confirm Part Deletion" +msgstr "" + +#: part/views.py:1331 +msgid "Part was deleted" +msgstr "" + +#: part/views.py:1340 +msgid "Part Pricing" +msgstr "" + +#: part/views.py:1462 +msgid "Create Part Parameter Template" +msgstr "" + +#: part/views.py:1470 +msgid "Edit Part Parameter Template" +msgstr "" + +#: part/views.py:1477 +msgid "Delete Part Parameter Template" +msgstr "" + +#: part/views.py:1485 +msgid "Create Part Parameter" +msgstr "" + +#: part/views.py:1535 +msgid "Edit Part Parameter" +msgstr "" + +#: part/views.py:1549 +msgid "Delete Part Parameter" +msgstr "" + +#: part/views.py:1565 +msgid "Edit Part Category" +msgstr "" + +#: part/views.py:1600 +msgid "Delete Part Category" +msgstr "" + +#: part/views.py:1606 +msgid "Part category was deleted" +msgstr "" + +#: part/views.py:1614 +msgid "Create new part category" +msgstr "" + +#: part/views.py:1665 +msgid "Create BOM item" +msgstr "" + +#: part/views.py:1731 +msgid "Edit BOM item" +msgstr "" + +#: part/views.py:1779 +msgid "Confim BOM item deletion" +msgstr "" + +#: stock/forms.py:91 msgid "File Format" msgstr "" -#: stock/forms.py:92 +#: stock/forms.py:91 msgid "Select output file format" msgstr "" -#: stock/forms.py:94 +#: stock/forms.py:93 msgid "Include stock items in sub locations" msgstr "" -#: stock/forms.py:127 +#: stock/forms.py:126 msgid "Destination stock location" msgstr "" -#: stock/forms.py:133 +#: stock/forms.py:132 msgid "Confirm movement of stock items" msgstr "" -#: stock/forms.py:135 +#: stock/forms.py:134 msgid "Set the destination as the default location for selected parts" msgstr "" @@ -1610,27 +1718,32 @@ msgstr "" msgid "Invalid part selection" msgstr "" -#: stock/views.py:925 +#: stock/views.py:910 +#, python-brace-format +msgid "Created {n} new stock items" +msgstr "" + +#: stock/views.py:927 stock/views.py:940 msgid "Created new stock item" msgstr "" -#: stock/views.py:942 +#: stock/views.py:957 msgid "Delete Stock Location" msgstr "" -#: stock/views.py:955 +#: stock/views.py:970 msgid "Delete Stock Item" msgstr "" -#: stock/views.py:966 +#: stock/views.py:981 msgid "Delete Stock Tracking Entry" msgstr "" -#: stock/views.py:983 +#: stock/views.py:998 msgid "Edit Stock Tracking Entry" msgstr "" -#: stock/views.py:992 +#: stock/views.py:1007 msgid "Add Stock Tracking Entry" msgstr "" diff --git a/InvenTree/locale/es/LC_MESSAGES/django.po b/InvenTree/locale/es/LC_MESSAGES/django.po index 3b7c7af271..930ff589f8 100644 --- a/InvenTree/locale/es/LC_MESSAGES/django.po +++ b/InvenTree/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-03 10:28+0000\n" +"POT-Creation-Date: 2020-02-10 11:09+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -675,7 +675,7 @@ msgstr "" msgid "Order notes" msgstr "" -#: order/models.py:159 order/models.py:210 part/views.py:1067 +#: order/models.py:159 order/models.py:210 part/views.py:1080 #: stock/models.py:440 msgid "Quantity must be greater than zero" msgstr "" @@ -1242,56 +1242,164 @@ msgstr "" msgid "Attachments" msgstr "" +#: part/views.py:77 +msgid "Added attachment" +msgstr "" + +#: part/views.py:119 +msgid "Part attachment updated" +msgstr "" + #: part/views.py:196 #, python-brace-format msgid "Set category for {n} parts" msgstr "" -#: part/views.py:808 +#: part/views.py:306 +msgid "Copied part" +msgstr "" + +#: part/views.py:414 +msgid "Create new part" +msgstr "" + +#: part/views.py:419 +msgid "Created new part" +msgstr "" + +#: part/views.py:609 +msgid "Upload Part Image" +msgstr "" + +#: part/views.py:614 +msgid "Updated part image" +msgstr "" + +#: part/views.py:623 +msgid "Select Part Image" +msgstr "" + +#: part/views.py:627 +msgid "Selected part image" +msgstr "" + +#: part/views.py:637 +msgid "Edit Part Properties" +msgstr "" + +#: part/views.py:659 +msgid "Validate BOM" +msgstr "" + +#: part/views.py:821 msgid "No BOM file provided" msgstr "" -#: part/views.py:1069 +#: part/views.py:1082 msgid "Enter a valid quantity" msgstr "" -#: part/views.py:1093 part/views.py:1096 +#: part/views.py:1106 part/views.py:1109 msgid "Select valid part" msgstr "" -#: part/views.py:1102 +#: part/views.py:1115 msgid "Duplicate part selected" msgstr "" -#: part/views.py:1130 +#: part/views.py:1143 msgid "Select a part" msgstr "" -#: part/views.py:1134 +#: part/views.py:1147 msgid "Specify quantity" msgstr "" -#: stock/forms.py:92 +#: part/views.py:1324 +msgid "Confirm Part Deletion" +msgstr "" + +#: part/views.py:1331 +msgid "Part was deleted" +msgstr "" + +#: part/views.py:1340 +msgid "Part Pricing" +msgstr "" + +#: part/views.py:1462 +msgid "Create Part Parameter Template" +msgstr "" + +#: part/views.py:1470 +msgid "Edit Part Parameter Template" +msgstr "" + +#: part/views.py:1477 +msgid "Delete Part Parameter Template" +msgstr "" + +#: part/views.py:1485 +msgid "Create Part Parameter" +msgstr "" + +#: part/views.py:1535 +msgid "Edit Part Parameter" +msgstr "" + +#: part/views.py:1549 +msgid "Delete Part Parameter" +msgstr "" + +#: part/views.py:1565 +msgid "Edit Part Category" +msgstr "" + +#: part/views.py:1600 +msgid "Delete Part Category" +msgstr "" + +#: part/views.py:1606 +msgid "Part category was deleted" +msgstr "" + +#: part/views.py:1614 +msgid "Create new part category" +msgstr "" + +#: part/views.py:1665 +msgid "Create BOM item" +msgstr "" + +#: part/views.py:1731 +msgid "Edit BOM item" +msgstr "" + +#: part/views.py:1779 +msgid "Confim BOM item deletion" +msgstr "" + +#: stock/forms.py:91 msgid "File Format" msgstr "" -#: stock/forms.py:92 +#: stock/forms.py:91 msgid "Select output file format" msgstr "" -#: stock/forms.py:94 +#: stock/forms.py:93 msgid "Include stock items in sub locations" msgstr "" -#: stock/forms.py:127 +#: stock/forms.py:126 msgid "Destination stock location" msgstr "" -#: stock/forms.py:133 +#: stock/forms.py:132 msgid "Confirm movement of stock items" msgstr "" -#: stock/forms.py:135 +#: stock/forms.py:134 msgid "Set the destination as the default location for selected parts" msgstr "" @@ -1610,27 +1718,32 @@ msgstr "" msgid "Invalid part selection" msgstr "" -#: stock/views.py:925 +#: stock/views.py:910 +#, python-brace-format +msgid "Created {n} new stock items" +msgstr "" + +#: stock/views.py:927 stock/views.py:940 msgid "Created new stock item" msgstr "" -#: stock/views.py:942 +#: stock/views.py:957 msgid "Delete Stock Location" msgstr "" -#: stock/views.py:955 +#: stock/views.py:970 msgid "Delete Stock Item" msgstr "" -#: stock/views.py:966 +#: stock/views.py:981 msgid "Delete Stock Tracking Entry" msgstr "" -#: stock/views.py:983 +#: stock/views.py:998 msgid "Edit Stock Tracking Entry" msgstr "" -#: stock/views.py:992 +#: stock/views.py:1007 msgid "Add Stock Tracking Entry" msgstr "" diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 75c8f3a60f..b55e027152 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -8,7 +8,7 @@ from __future__ import unicode_literals from django_filters.rest_framework import DjangoFilterBackend from django.conf import settings -from django.db.models import Sum +from django.db.models import Sum, Count from rest_framework import status from rest_framework.response import Response @@ -23,10 +23,7 @@ import os from .models import Part, PartCategory, BomItem, PartStar from .models import PartParameter, PartParameterTemplate -from .serializers import PartSerializer, BomItemSerializer -from .serializers import CategorySerializer -from .serializers import PartStarSerializer -from .serializers import PartParameterSerializer, PartParameterTemplateSerializer +from . import serializers as part_serializers from InvenTree.views import TreeSerializer from InvenTree.helpers import str2bool @@ -53,7 +50,7 @@ class CategoryList(generics.ListCreateAPIView): """ queryset = PartCategory.objects.all() - serializer_class = CategorySerializer + serializer_class = part_serializers.CategorySerializer permission_classes = [ permissions.IsAuthenticated, @@ -83,14 +80,37 @@ class CategoryList(generics.ListCreateAPIView): class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single PartCategory object """ - serializer_class = CategorySerializer + serializer_class = part_serializers.CategorySerializer queryset = PartCategory.objects.all() +class PartThumbs(generics.ListAPIView): + """ API endpoint for retrieving information on available Part thumbnails """ + + serializer_class = part_serializers.PartThumbSerializer + + def list(self, reguest, *args, **kwargs): + """ + Serialize the available Part images. + - Images may be used for multiple parts! + """ + + # Get all Parts which have an associated image + queryset = Part.objects.all().exclude(image='') + + # Return the most popular parts first + data = queryset.values( + 'image', + ).annotate(count=Count('image')).order_by('-count') + + return Response(data) + + class PartDetail(generics.RetrieveUpdateAPIView): """ API endpoint for detail view of a single Part object """ + queryset = Part.objects.all() - serializer_class = PartSerializer + serializer_class = part_serializers.PartSerializer permission_classes = [ permissions.IsAuthenticated, @@ -104,12 +124,12 @@ class PartList(generics.ListCreateAPIView): - POST: Create a new Part object """ - serializer_class = PartSerializer + serializer_class = part_serializers.PartSerializer def list(self, request, *args, **kwargs): """ Instead of using the DRF serialiser to LIST, - we serialize the objects manuually. + we serialize the objects manually. This turns out to be significantly faster. """ @@ -218,7 +238,7 @@ class PartStarDetail(generics.RetrieveDestroyAPIView): """ API endpoint for viewing or removing a PartStar object """ queryset = PartStar.objects.all() - serializer_class = PartStarSerializer + serializer_class = part_serializers.PartStarSerializer class PartStarList(generics.ListCreateAPIView): @@ -229,7 +249,7 @@ class PartStarList(generics.ListCreateAPIView): """ queryset = PartStar.objects.all() - serializer_class = PartStarSerializer + serializer_class = part_serializers.PartStarSerializer def create(self, request, *args, **kwargs): @@ -271,7 +291,7 @@ class PartParameterTemplateList(generics.ListCreateAPIView): """ queryset = PartParameterTemplate.objects.all() - serializer_class = PartParameterTemplateSerializer + serializer_class = part_serializers.PartParameterTemplateSerializer permission_classes = [ permissions.IsAuthenticated, @@ -294,7 +314,7 @@ class PartParameterList(generics.ListCreateAPIView): """ queryset = PartParameter.objects.all() - serializer_class = PartParameterSerializer + serializer_class = part_serializers.PartParameterSerializer permission_classes = [ permissions.IsAuthenticated, @@ -317,7 +337,7 @@ class BomList(generics.ListCreateAPIView): - POST: Create a new BomItem object """ - serializer_class = BomItemSerializer + serializer_class = part_serializers.BomItemSerializer def get_serializer(self, *args, **kwargs): @@ -360,7 +380,7 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ queryset = BomItem.objects.all() - serializer_class = BomItemSerializer + serializer_class = part_serializers.BomItemSerializer permission_classes = [ permissions.IsAuthenticated, @@ -424,6 +444,8 @@ part_api_urls = [ url(r'^star/', include(part_star_api_urls)), url(r'^parameter/', include(part_param_api_urls)), + url(r'^thumbs/', PartThumbs.as_view(), name='api-part-thumbs'), + url(r'^(?P\d+)/?', PartDetail.as_view(), name='api-part-detail'), url(r'^.*$', PartList.as_view(), name='api-part-list'), diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index fb270f06f0..3a59db0cc6 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -12,7 +12,6 @@ from django.core.exceptions import ValidationError from django.urls import reverse from django.conf import settings -from django.core.files.base import ContentFile from django.db import models, transaction from django.db.models import Sum from django.db.models import prefetch_related_objects @@ -24,6 +23,8 @@ from django.dispatch import receiver from markdownx.models import MarkdownxField +from django_cleanup import cleanup + from mptt.models import TreeForeignKey from datetime import datetime @@ -136,18 +137,9 @@ def rename_part_image(instance, filename): """ base = 'part_images' + fname = os.path.basename(filename) - if filename.count('.') > 0: - ext = filename.split('.')[-1] - else: - ext = '' - - fn = 'part_{pk}_img'.format(pk=instance.pk) - - if ext: - fn += '.' + ext - - return os.path.join(base, fn) + return os.path.join(base, fname) def match_part_names(match, threshold=80, reverse=True, compare_length=False): @@ -201,6 +193,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False): return matches +@cleanup.ignore class Part(models.Model): """ The Part object represents an abstract part, the 'concept' of an actual entity. @@ -237,6 +230,26 @@ class Part(models.Model): verbose_name = "Part" verbose_name_plural = "Parts" + def save(self, *args, **kwargs): + """ + Overrides the save() function for the Part model. + If the part image has been updated, + then check if the "old" (previous) image is still used by another part. + If not, it is considered "orphaned" and will be deleted. + """ + + if self.pk: + previous = Part.objects.get(pk=self.pk) + + if previous.image and not self.image == previous.image: + # Are there any (other) parts which reference the image? + n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count() + + if n_refs == 0: + previous.image.delete(save=False) + + super().save(*args, **kwargs) + def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) @@ -832,10 +845,8 @@ class Part(models.Model): # Copy the part image if kwargs.get('image', True): if other.image: - image_file = ContentFile(other.image.read()) - image_file.name = rename_part_image(self, other.image.url) - - self.image = image_file + # Reference the other image from this Part + self.image = other.image # Copy the BOM data if kwargs.get('bom', False): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index bdeead670d..3e1ed6949a 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -30,6 +30,16 @@ class CategorySerializer(InvenTreeModelSerializer): ] +class PartThumbSerializer(serializers.Serializer): + """ + Serializer for the 'image' field of the Part model. + Used to serve and display existing Part images. + """ + + image = serializers.URLField(read_only=True) + count = serializers.IntegerField(read_only=True) + + class PartBriefSerializer(InvenTreeModelSerializer): """ Serializer for Part (brief detail) """ diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 70640027ce..27e5aedb83 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -25,17 +25,7 @@
-
-
-
- -
-
+ {% include "part/part_thumb.html" %}

{{ part.full_name }} @@ -163,7 +153,7 @@ enableDragAndDrop( '#part-thumb', - "{% url 'part-image' part.id %}", + "{% url 'part-image-upload' part.id %}", { label: 'image', success: function(data, status, xhr) { @@ -208,13 +198,54 @@ }); }); - $("#part-thumb").click(function() { - launchModalForm( - "{% url 'part-image' part.id %}", + $("#part-image-upload").click(function() { + launchModalForm("{% url 'part-image-upload' part.id %}", + { + reload: true + } + ); + }); + + + function onSelectImage(response) { + // Callback when the image-selection modal form is displayed + // Populate the form with image data (requested via AJAX) + + $("#modal-form").find("#image-select-table").bootstrapTable({ + pagination: true, + pageSize: 25, + url: "{% url 'api-part-thumbs' %}", + showHeader: false, + clickToSelect: true, + singleSelect: true, + columns: [ + { + checkbox: true, + }, + { + field: 'image', + title: 'Image', + formatter: function(value, row, index, field) { + return "" + } + } + ], + onCheck: function(row, element) { + + // Update the selected image in the form + var ipt = $("#modal-form").find("#image-input"); + ipt.val(row.image); + + } + }); + } + + $("#part-image-select").click(function() { + launchModalForm("{% url 'part-image-select' part.id %}", { - reload: true - } - ); + reload: true, + after_render: onSelectImage + }); }); $("#part-edit").click(function() { diff --git a/InvenTree/part/templates/part/part_thumb.html b/InvenTree/part/templates/part/part_thumb.html new file mode 100644 index 0000000000..04efdd383f --- /dev/null +++ b/InvenTree/part/templates/part/part_thumb.html @@ -0,0 +1,20 @@ +{% load static %} +{% load i18n %} + +
+
+
+ +
+
+
+ + +
+
+
\ No newline at end of file diff --git a/InvenTree/part/templates/part/select_image.html b/InvenTree/part/templates/part/select_image.html new file mode 100644 index 0000000000..5e7667824d --- /dev/null +++ b/InvenTree/part/templates/part/select_image.html @@ -0,0 +1,21 @@ +{% extends "modal_form.html" %} + +{% block pre_form_content %} + +{{ block.super }} + + +{% endblock %} + +{% block form %} +
+ {% csrf_token %} + {% load crispy_forms_tags %} + + + + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 28ee93d976..2d3e5408bd 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -23,7 +23,7 @@ class TemplateTagTest(TestCase): def test_hash(self): hash = inventree_extras.inventree_commit_hash() - self.assertEqual(len(hash), 7) + self.assertGreater(len(hash), 5) def test_date(self): d = inventree_extras.inventree_commit_date() @@ -68,11 +68,8 @@ class PartTest(TestCase): def test_rename_img(self): img = rename_part_image(self.R1, 'hello.png') - self.assertEqual(img, os.path.join('part_images', 'part_3_img.png')) - - img = rename_part_image(self.R2, 'test') - self.assertEqual(img, os.path.join('part_images', 'part_4_img')) - + self.assertEqual(img, os.path.join('part_images', 'hello.png')) + def test_stock(self): # No stock of any resistors res = Part.objects.filter(description__contains='resistor') diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 0a9adefe8f..48e647302a 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -57,7 +57,8 @@ part_detail_urls = [ url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), # Normal thumbnail with form - url(r'^thumbnail/?', views.PartImage.as_view(), name='part-image'), + url(r'^thumbnail/?', views.PartImageUpload.as_view(), name='part-image-upload'), + url(r'^thumb-select/?', views.PartImageSelect.as_view(), name='part-image-select'), # Any other URLs go to the part detail page url(r'^.*$', views.PartDetail.as_view(), name='part-detail'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index f36cedf48e..24717d55bb 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -14,6 +14,9 @@ from django.urls import reverse, reverse_lazy from django.views.generic import DetailView, ListView, FormView, UpdateView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput +from django.conf import settings + +import os from fuzzywuzzy import fuzz from decimal import Decimal @@ -74,7 +77,7 @@ class PartAttachmentCreate(AjaxCreateView): def get_data(self): return { - 'success': 'Added attachment' + 'success': _('Added attachment') } def get_initial(self): @@ -116,7 +119,7 @@ class PartAttachmentEdit(AjaxUpdateView): def get_data(self): return { - 'success': 'Part attachment updated' + 'success': _('Part attachment updated') } def get_form(self): @@ -303,7 +306,7 @@ class PartDuplicate(AjaxCreateView): def get_data(self): return { - 'success': 'Copied part' + 'success': _('Copied part') } def get_part_to_copy(self): @@ -411,12 +414,12 @@ class PartCreate(AjaxCreateView): model = Part form_class = part_forms.EditPartForm - ajax_form_title = 'Create new part' + ajax_form_title = _('Create new part') ajax_template_name = 'part/create_part.html' def get_data(self): return { - 'success': "Created new part", + 'success': _("Created new part"), } def get_category_id(self): @@ -601,27 +604,66 @@ class PartQRCode(QRCodeView): return None -class PartImage(AjaxUpdateView): - """ View for uploading Part image """ +class PartImageUpload(AjaxUpdateView): + """ View for uploading a new Part image """ model = Part ajax_template_name = 'modal_form.html' - ajax_form_title = 'Upload Part Image' + ajax_form_title = _('Upload Part Image') form_class = part_forms.PartImageForm def get_data(self): return { - 'success': 'Updated part image', + 'success': _('Updated part image'), } +class PartImageSelect(AjaxUpdateView): + """ View for selecting Part image from existing images. """ + + model = Part + ajax_template_name = 'part/select_image.html' + ajax_form_title = _('Select Part Image') + + fields = [ + 'image', + ] + + def post(self, request, *args, **kwargs): + + part = self.get_object() + form = self.get_form() + + img = request.POST.get('image', '') + + img = os.path.basename(img) + + data = {} + + if img: + img_path = os.path.join(settings.MEDIA_ROOT, 'part_images', img) + + # Ensure that the image already exists + if os.path.exists(img_path): + + part.image = os.path.join('part_images', img) + part.save() + + data['success'] = _('Updated part image') + + if 'success' not in data: + data['error'] = _('Part image not found') + + return self.renderJsonResponse(request, form, data) + + class PartEdit(AjaxUpdateView): """ View for editing Part object """ model = Part form_class = part_forms.EditPartForm ajax_template_name = 'modal_form.html' - ajax_form_title = 'Edit Part Properties' + ajax_form_title = _('Edit Part Properties') context_object_name = 'part' def get_form(self): @@ -643,7 +685,7 @@ class BomValidate(AjaxUpdateView): """ Modal form view for validating a part BOM """ model = Part - ajax_form_title = "Validate BOM" + ajax_form_title = _("Validate BOM") ajax_template_name = 'part/bom_validate.html' context_object_name = 'part' form_class = part_forms.BomValidateForm @@ -1308,14 +1350,14 @@ class PartDelete(AjaxDeleteView): model = Part ajax_template_name = 'part/partial_delete.html' - ajax_form_title = 'Confirm Part Deletion' + ajax_form_title = _('Confirm Part Deletion') context_object_name = 'part' success_url = '/part/' def get_data(self): return { - 'danger': 'Part was deleted', + 'danger': _('Part was deleted'), } @@ -1324,7 +1366,7 @@ class PartPricing(AjaxView): model = Part ajax_template_name = "part/part_pricing.html" - ajax_form_title = "Part Pricing" + ajax_form_title = _("Part Pricing") form_class = part_forms.PartPriceForm def get_part(self): @@ -1446,7 +1488,7 @@ class PartParameterTemplateCreate(AjaxCreateView): model = PartParameterTemplate form_class = part_forms.EditPartParameterTemplateForm - ajax_form_title = 'Create Part Parameter Template' + ajax_form_title = _('Create Part Parameter Template') class PartParameterTemplateEdit(AjaxUpdateView): @@ -1454,14 +1496,14 @@ class PartParameterTemplateEdit(AjaxUpdateView): model = PartParameterTemplate form_class = part_forms.EditPartParameterTemplateForm - ajax_form_title = 'Edit Part Parameter Template' + ajax_form_title = _('Edit Part Parameter Template') class PartParameterTemplateDelete(AjaxDeleteView): """ View for deleting an existing PartParameterTemplate """ model = PartParameterTemplate - ajax_form_title = "Delete Part Parameter Template" + ajax_form_title = _("Delete Part Parameter Template") class PartParameterCreate(AjaxCreateView): @@ -1469,7 +1511,7 @@ class PartParameterCreate(AjaxCreateView): model = PartParameter form_class = part_forms.EditPartParameterForm - ajax_form_title = 'Create Part Parameter' + ajax_form_title = _('Create Part Parameter') def get_initial(self): @@ -1519,7 +1561,7 @@ class PartParameterEdit(AjaxUpdateView): model = PartParameter form_class = part_forms.EditPartParameterForm - ajax_form_title = 'Edit Part Parameter' + ajax_form_title = _('Edit Part Parameter') def get_form(self): @@ -1533,7 +1575,7 @@ class PartParameterDelete(AjaxDeleteView): model = PartParameter ajax_template_name = 'part/param_delete.html' - ajax_form_title = 'Delete Part Parameter' + ajax_form_title = _('Delete Part Parameter') class CategoryDetail(DetailView): @@ -1549,7 +1591,7 @@ class CategoryEdit(AjaxUpdateView): model = PartCategory form_class = part_forms.EditCategoryForm ajax_template_name = 'modal_form.html' - ajax_form_title = 'Edit Part Category' + ajax_form_title = _('Edit Part Category') def get_context_data(self, **kwargs): context = super(CategoryEdit, self).get_context_data(**kwargs).copy() @@ -1584,13 +1626,13 @@ class CategoryDelete(AjaxDeleteView): """ Delete view to delete a PartCategory """ model = PartCategory ajax_template_name = 'part/category_delete.html' - ajax_form_title = 'Delete Part Category' + ajax_form_title = _('Delete Part Category') context_object_name = 'category' success_url = '/part/' def get_data(self): return { - 'danger': 'Part category was deleted', + 'danger': _('Part category was deleted'), } @@ -1598,7 +1640,7 @@ class CategoryCreate(AjaxCreateView): """ Create view to make a new PartCategory """ model = PartCategory ajax_form_action = reverse_lazy('category-create') - ajax_form_title = 'Create new part category' + ajax_form_title = _('Create new part category') ajax_template_name = 'modal_form.html' form_class = part_forms.EditCategoryForm @@ -1649,7 +1691,7 @@ class BomItemCreate(AjaxCreateView): model = BomItem form_class = part_forms.EditBomItemForm ajax_template_name = 'modal_form.html' - ajax_form_title = 'Create BOM item' + ajax_form_title = _('Create BOM item') def get_form(self): """ Override get_form() method to reduce Part selection options. @@ -1715,7 +1757,7 @@ class BomItemEdit(AjaxUpdateView): model = BomItem form_class = part_forms.EditBomItemForm ajax_template_name = 'modal_form.html' - ajax_form_title = 'Edit BOM item' + ajax_form_title = _('Edit BOM item') def get_form(self): """ Override get_form() method to filter part selection options @@ -1763,4 +1805,4 @@ class BomItemDelete(AjaxDeleteView): model = BomItem ajax_template_name = 'part/bom-delete.html' context_object_name = 'item' - ajax_form_title = 'Confim BOM item deletion' + ajax_form_title = _('Confim BOM item deletion') diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 5c84f3b4df..ca206ca49d 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -44,7 +44,6 @@ class CreateStockItemForm(HelperForm): 'serial_numbers', 'delete_on_deplete', 'status', - 'notes', 'URL', ] diff --git a/InvenTree/stock/migrations/0020_auto_20200206_1213.py b/InvenTree/stock/migrations/0020_auto_20200206_1213.py new file mode 100644 index 0000000000..34be29fd39 --- /dev/null +++ b/InvenTree/stock/migrations/0020_auto_20200206_1213.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.9 on 2020-02-06 12:13 + +from django.db import migrations +import markdownx.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0019_auto_20200202_1024'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='notes', + field=markdownx.models.MarkdownxField(blank=True, help_text='Stock Item Notes', null=True), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index d0bc23f14d..57f390415f 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -360,7 +360,7 @@ class StockItem(models.Model): choices=StockStatus.items(), validators=[MinValueValidator(0)]) - notes = MarkdownxField(blank=True, help_text=_('Stock Item Notes')) + notes = MarkdownxField(blank=True, null=True, help_text=_('Stock Item Notes')) # If stock item is incoming, an (optional) ETA field # expected_arrival = models.DateField(null=True, blank=True) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index cb478e42f3..9d5e2b5dc4 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -884,34 +884,49 @@ class StockItemCreate(AjaxCreateView): form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))] valid = False - # At this point we have a list of serial numbers which we know are valid, - # and do not currently exist - form.clean() + else: + # At this point we have a list of serial numbers which we know are valid, + # and do not currently exist + form.clean() - data = form.cleaned_data + form_data = form.cleaned_data - for serial in serials: - # Create a new stock item for each serial number - item = StockItem( - part=part, - quantity=1, - serial=serial, - supplier_part=data.get('supplier_part'), - location=data.get('location'), - batch=data.get('batch'), - delete_on_deplete=False, - status=data.get('status'), - notes=data.get('notes'), - URL=data.get('URL'), - ) + for serial in serials: + # Create a new stock item for each serial number + item = StockItem( + part=part, + quantity=1, + serial=serial, + supplier_part=form_data.get('supplier_part'), + location=form_data.get('location'), + batch=form_data.get('batch'), + delete_on_deplete=False, + status=form_data.get('status'), + URL=form_data.get('URL'), + ) - item.save(user=request.user) + item.save(user=request.user) + + data['success'] = _('Created {n} new stock items'.format(n=len(serials))) + valid = True except ValidationError as e: form.errors['serial_numbers'] = e.messages valid = False - else: + else: + # We have a serialized part, but no serial numbers specified... + form.clean() + form._post_clean() + + item = form.save(commit=False) + item.save(user=request.user) + + data['pk'] = item.pk + data['url'] = item.get_absolute_url() + data['success'] = _("Created new stock item") + + else: # Referenced Part object is not marked as "trackable" # For non-serialized items, simply save the form. # We need to call _post_clean() here because it is prevented in the form implementation form.clean()