diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py
index d6d40e85b6..8a2c0b17a4 100644
--- a/InvenTree/InvenTree/middleware.py
+++ b/InvenTree/InvenTree/middleware.py
@@ -5,6 +5,8 @@ import logging
import time
import operator
+from rest_framework.authtoken.models import Token
+
logger = logging.getLogger(__name__)
@@ -20,10 +22,49 @@ class AuthRequiredMiddleware(object):
response = self.get_response(request)
- # Redirect any unauthorized HTTP requests to the login page
if not request.user.is_authenticated:
- if not request.path_info == reverse_lazy('login') and not request.path_info.startswith('/api/'):
- return HttpResponseRedirect(reverse_lazy('login'))
+ """
+ Normally, a web-based session would use csrftoken based authentication.
+ However when running an external application (e.g. the InvenTree app),
+ we wish to use token-based auth to grab media files.
+
+ So, we will allow token-based authentication but ONLY for the /media/ directory.
+
+ What problem is this solving?
+ - The InvenTree mobile app does not use csrf token auth
+ - Token auth is used by the Django REST framework, but that is under the /api/ endpoint
+ - Media files (e.g. Part images) are required to be served to the app
+ - We do not want to make /media/ files accessible without login!
+
+ There is PROBABLY a better way of going about this?
+ a) Allow token-based authentication against a user?
+ b) Serve /media/ files in a duplicate location e.g. /api/media/ ?
+ c) Is there a "standard" way of solving this problem?
+
+ My [google|stackoverflow]-fu has failed me. So this hack has been created.
+ """
+
+ authorized = False
+
+ if 'Authorization' in request.headers.keys():
+ auth = request.headers['Authorization'].strip()
+
+ if auth.startswith('Token') and len(auth.split()) == 2:
+ token = auth.split()[1]
+
+ # Does the provided token match a valid user?
+ if Token.objects.filter(key=token).exists():
+
+ allowed = ['/media/', '/static/']
+
+ # Only allow token-auth for /media/ or /static/ dirs!
+ if any([request.path_info.startswith(a) for a in allowed]):
+ authorized = True
+
+ # No authorization was found for the request
+ if not authorized:
+ if not request.path_info == reverse_lazy('login') and not request.path_info.startswith('/api/'):
+ return HttpResponseRedirect(reverse_lazy('login'))
# Code to be executed for each request/response after
# the view is called.
@@ -38,6 +79,8 @@ class QueryCountMiddleware(object):
status code of 200). It does not currently support
multi-db setups.
+ To enable this middleware, set 'log_queries: True' in the local InvenTree config file.
+
Reference: https://www.dabapps.com/blog/logging-sql-queries-django-13/
"""
diff --git a/InvenTree/InvenTree/static/script/inventree/part.js b/InvenTree/InvenTree/static/script/inventree/part.js
index dceb08dd5f..665701defd 100644
--- a/InvenTree/InvenTree/static/script/inventree/part.js
+++ b/InvenTree/InvenTree/static/script/inventree/part.js
@@ -139,7 +139,7 @@ function loadPartTable(table, url, options={}) {
name = '' + name + '';
}
- var display = imageHoverIcon(row.image) + renderLink(name, '/part/' + row.pk + '/');
+ var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
if (row.is_template) {
display = display + "TEMPLATE";
diff --git a/InvenTree/InvenTree/static/script/inventree/stock.js b/InvenTree/InvenTree/static/script/inventree/stock.js
index 2a06808690..60978f8091 100644
--- a/InvenTree/InvenTree/static/script/inventree/stock.js
+++ b/InvenTree/InvenTree/static/script/inventree/stock.js
@@ -70,7 +70,7 @@ function loadStockTable(table, options) {
name += row.part__name;
- return imageHoverIcon(row.part__image) + name + ' (' + data.length + ' items)';
+ return imageHoverIcon(row.part__thumbnail) + name + ' (' + data.length + ' items)';
}
else if (field == 'part__description') {
return row.part__description;
@@ -188,7 +188,7 @@ function loadStockTable(table, options) {
name += row.part__revision;
}
- return imageHoverIcon(row.part__image) + renderLink(name, '/part/' + row.part + '/stock/');
+ return imageHoverIcon(row.part__thumbnail) + renderLink(name, '/part/' + row.part + '/stock/');
}
},
{
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 4b18916828..fd36fa9112 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -109,9 +109,8 @@ urlpatterns = [
# Static file access
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
-if settings.DEBUG:
- # Media file access
- urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+# Media file access
+urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Send any unknown URLs to the parts page
urlpatterns += [url(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')]
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 55e711ff5f..ec599de446 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -221,7 +221,17 @@ class PartList(generics.ListCreateAPIView):
for item in data:
if item['image']:
- item['image'] = os.path.join(settings.MEDIA_URL, item['image'])
+ img = item['image']
+
+ # Use the 'thumbnail' image here instead of the full-size image
+ # Note: The full-size image is used when requesting the /api/part// endpoint
+ fn, ext = os.path.splitext(img)
+
+ thumb = "{fn}.thumbnail{ext}".format(fn=fn, ext=ext)
+
+ item['thumbnail'] = os.path.join(settings.MEDIA_URL, thumb)
+
+ del item['image']
cat_id = item['category']
diff --git a/InvenTree/part/migrations/0033_auto_20200404_0445.py b/InvenTree/part/migrations/0033_auto_20200404_0445.py
new file mode 100644
index 0000000000..4c2b8c1c96
--- /dev/null
+++ b/InvenTree/part/migrations/0033_auto_20200404_0445.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.2.10 on 2020-04-04 04:45
+
+from django.db import migrations
+import part.models
+import stdimage.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('part', '0032_auto_20200322_0453'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='part',
+ name='image',
+ field=stdimage.models.StdImageField(blank=True, null=True, upload_to=part.models.rename_part_image),
+ ),
+ ]
diff --git a/InvenTree/part/migrations/0034_auto_20200404_1238.py b/InvenTree/part/migrations/0034_auto_20200404_1238.py
new file mode 100644
index 0000000000..95b3cd2b96
--- /dev/null
+++ b/InvenTree/part/migrations/0034_auto_20200404_1238.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.2.10 on 2020-04-04 12:38
+
+from django.db import migrations
+
+from part.models import Part
+from stdimage.utils import render_variations
+
+
+def create_thumbnails(apps, schema_editor):
+ """
+ Create thumbnails for all existing Part images.
+ """
+
+ for part in Part.objects.all():
+ # Render thumbnail for each existing Part
+ if part.image:
+ part.image.render_variations()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('part', '0033_auto_20200404_0445'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_thumbnails),
+ ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 58ee8bbd2c..92dd8a0d68 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -27,6 +27,8 @@ from django_cleanup import cleanup
from mptt.models import TreeForeignKey
+from stdimage.models import StdImageField
+
from decimal import Decimal
from datetime import datetime
from rapidfuzz import fuzz
@@ -302,6 +304,16 @@ class Part(models.Model):
else:
return os.path.join(settings.STATIC_URL, 'img/blank_image.png')
+ def get_thumbnail_url(self):
+ """
+ Return the URL of the image thumbnail for this part
+ """
+
+ if self.image:
+ return os.path.join(settings.MEDIA_URL, str(self.image.thumbnail.url))
+ else:
+ return os.path.join(settings.STATIC_URL, 'img/blank_image.thumbnail.png')
+
def validate_unique(self, exclude=None):
""" Validate that a part is 'unique'.
Uniqueness is checked across the following (case insensitive) fields:
@@ -373,7 +385,13 @@ class Part(models.Model):
URL = InvenTreeURLField(blank=True, help_text=_('Link to extenal URL'))
- image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True)
+ image = StdImageField(
+ upload_to=rename_part_image,
+ null=True,
+ blank=True,
+ variations={'thumbnail': (128, 128)},
+ delete_orphans=True,
+ )
default_location = TreeForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
blank=True, null=True,
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 1c7ae6f4ca..a48aaaa54d 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -47,7 +47,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
""" Serializer for Part (brief detail) """
url = serializers.CharField(source='get_absolute_url', read_only=True)
- image_url = serializers.CharField(source='get_image_url', read_only=True)
+ image_url = serializers.CharField(source='get_thumbnail_url', read_only=True)
@staticmethod
def setup_eager_loading(queryset):
@@ -79,7 +79,8 @@ class PartSerializer(InvenTreeModelSerializer):
"""
url = serializers.CharField(source='get_absolute_url', read_only=True)
- image_url = serializers.CharField(source='get_image_url', read_only=True)
+ image = serializers.CharField(source='get_image_url', read_only=True)
+ thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
category_name = serializers.CharField(source='category_path', read_only=True)
allocated_stock = serializers.IntegerField(source='allocation_count', read_only=True)
@@ -100,7 +101,8 @@ class PartSerializer(InvenTreeModelSerializer):
'url', # Link to the part detail page
'category',
'category_name',
- 'image_url',
+ 'image',
+ 'thumbnail',
'full_name',
'name',
'IPN',
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 3eb183ce35..3c3e5acce6 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -337,7 +337,17 @@ class StockList(generics.ListCreateAPIView):
locations = {}
for item in data:
- item['part__image'] = os.path.join(settings.MEDIA_URL, item['part__image'])
+
+ img = item['part__image']
+
+ # Use the thumbnail image instead
+ fn, ext = os.path.splitext(img)
+
+ thumb = "{fn}.thumbnail{ext}".format(fn=fn, ext=ext)
+
+ item['part__thumbnail'] = os.path.join(settings.MEDIA_URL, thumb)
+
+ del item['part__image']
loc_id = item['location']
diff --git a/requirements.txt b/requirements.txt
index 73d05fec4f..ff65664282 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -18,3 +18,4 @@ flake8==3.3.0 # PEP checking
coverage==4.0.3 # Unit test coverage
python-coveralls==2.9.1 # Coveralls linking (for Travis)
rapidfuzz==0.2.1 # Fuzzy string matching
+django-stdimage==5.0.3 # Advanced ImageField management
\ No newline at end of file