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