mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	Merge remote-tracking branch 'inventree/master'
This commit is contained in:
		@@ -626,6 +626,53 @@
 | 
			
		||||
    z-index: 11000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-close {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 15px;
 | 
			
		||||
    right: 35px;
 | 
			
		||||
    color: #f1f1f1;
 | 
			
		||||
    font-size: 40px;
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
    transition: 0.25s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-close:hover,
 | 
			
		||||
.modal-close:focus {
 | 
			
		||||
    color: #bbb;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-image-content {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: 80%;
 | 
			
		||||
    max-width: 700px;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    color: #ccc;
 | 
			
		||||
    padding: 10px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media only screen and (max-width: 700px){
 | 
			
		||||
    .modal-image-content {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-image {
 | 
			
		||||
    display: none;
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    z-index: 10000;
 | 
			
		||||
    padding-top: 100px;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    overflow: auto;
 | 
			
		||||
    background-color: rgb(0,0,0); /* Fallback color */
 | 
			
		||||
    background-color: rgba(0,0,0,0.85); /* Black w/ opacity */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.js-modal-form .checkbox {
 | 
			
		||||
    margin-left: 0px;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -78,6 +78,13 @@ class InvenTreeSetting(models.Model):
 | 
			
		||||
            'choices': djmoney.settings.CURRENCY_CHOICES,
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'INVENTREE_DOWNLOAD_FROM_URL': {
 | 
			
		||||
            'name': _('Download from URL'),
 | 
			
		||||
            'description': _('Allow download of remote images and files from external URL'),
 | 
			
		||||
            'validator': bool,
 | 
			
		||||
            'default': False,
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'BARCODE_ENABLE': {
 | 
			
		||||
            'name': _('Barcode Support'),
 | 
			
		||||
            'description': _('Enable barcode scanner support'),
 | 
			
		||||
@@ -97,6 +104,13 @@ class InvenTreeSetting(models.Model):
 | 
			
		||||
            'validator': bool,
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'PART_ALLOW_EDIT_IPN': {
 | 
			
		||||
            'name': _('Allow Editing IPN'),
 | 
			
		||||
            'description': _('Allow changing the IPN value while editing a part'),
 | 
			
		||||
            'default': True,
 | 
			
		||||
            'validator': bool,
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'PART_COPY_BOM': {
 | 
			
		||||
            'name': _('Copy Part BOM Data'),
 | 
			
		||||
            'description': _('Copy BOM data by default when duplicating a part'),
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ from django.apps import AppConfig
 | 
			
		||||
from django.db.utils import OperationalError, ProgrammingError
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from PIL import UnidentifiedImageError
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -38,9 +39,11 @@ class CompanyConfig(AppConfig):
 | 
			
		||||
                        try:
 | 
			
		||||
                            company.image.render_variations(replace=False)
 | 
			
		||||
                        except FileNotFoundError:
 | 
			
		||||
                            logger.warning("Image file missing")
 | 
			
		||||
                            logger.warning(f"Image file '{company.image}' missing")
 | 
			
		||||
                            company.image = None
 | 
			
		||||
                            company.save()
 | 
			
		||||
                        except UnidentifiedImageError:
 | 
			
		||||
                            logger.warning(f"Image file '{company.image}' is invalid")
 | 
			
		||||
        except (OperationalError, ProgrammingError):
 | 
			
		||||
            # Getting here probably meant the database was in test mode
 | 
			
		||||
            pass
 | 
			
		||||
 
 | 
			
		||||
@@ -66,6 +66,24 @@ class CompanyImageForm(HelperForm):
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CompanyImageDownloadForm(HelperForm):
 | 
			
		||||
    """
 | 
			
		||||
    Form for downloading an image from a URL
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    url = django.forms.URLField(
 | 
			
		||||
        label=_('URL'),
 | 
			
		||||
        help_text=_('Image URL'),
 | 
			
		||||
        required=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Company
 | 
			
		||||
        fields = [
 | 
			
		||||
            'url',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EditSupplierPartForm(HelperForm):
 | 
			
		||||
    """ Form for editing a SupplierPart object """
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,19 +2,32 @@
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% load inventree_extras %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block page_title %}
 | 
			
		||||
InvenTree | {% trans "Company" %} - {{ company.name }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block thumbnail %}
 | 
			
		||||
<div class='dropzone' id='company-thumb'>
 | 
			
		||||
    <img class="part-thumb"
 | 
			
		||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
 | 
			
		||||
 | 
			
		||||
<div class='dropzone part-thumb-container' id='company-thumb'>
 | 
			
		||||
    <img class="part-thumb" id='company-image'
 | 
			
		||||
    {% if company.image %}
 | 
			
		||||
    src="{{ company.image.url }}"
 | 
			
		||||
    {% else %}
 | 
			
		||||
    src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
    {% endif %}/>
 | 
			
		||||
    <div class='btn-row part-thumb-overlay'>
 | 
			
		||||
        <div class='btn-group'>
 | 
			
		||||
            <button type='button' class='btn btn-default btn-glyph' title='{% trans "Upload new image" %}' id='company-image-upload'><span class='fas fa-file-upload'></span></button>
 | 
			
		||||
            {% if allow_download %}
 | 
			
		||||
            <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Download image from URL' %}" id='company-image-url'><span class='fas fa-cloud-download-alt'></span></button>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@@ -135,7 +148,13 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
 | 
			
		||||
        }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    $("#company-thumb").click(function() {
 | 
			
		||||
    {% if company.image %}
 | 
			
		||||
    $('#company-image').click(function() {
 | 
			
		||||
        showModalImage('{{ company.image.url }}');
 | 
			
		||||
    });
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    $("#company-image-upload").click(function() {
 | 
			
		||||
        launchModalForm(
 | 
			
		||||
            "{% url 'company-image' company.id %}",
 | 
			
		||||
            {
 | 
			
		||||
@@ -144,4 +163,17 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
 | 
			
		||||
 | 
			
		||||
    {% if allow_download %}
 | 
			
		||||
    $('#company-image-url').click(function() {
 | 
			
		||||
        launchModalForm(
 | 
			
		||||
            '{% url "company-image-download" company.id %}',
 | 
			
		||||
            {
 | 
			
		||||
                reload: true,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
    });
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -21,6 +21,7 @@ company_detail_urls = [
 | 
			
		||||
    url(r'^notes/', views.CompanyNotes.as_view(), name='company-notes'),
 | 
			
		||||
 | 
			
		||||
    url(r'^thumbnail/', views.CompanyImage.as_view(), name='company-image'),
 | 
			
		||||
    url(r'^thumb-download/', views.CompanyImageDownloadFromURL.as_view(), name='company-image-download'),
 | 
			
		||||
 | 
			
		||||
    # Any other URL
 | 
			
		||||
    url(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
 | 
			
		||||
 
 | 
			
		||||
@@ -11,9 +11,14 @@ from django.views.generic import DetailView, ListView, UpdateView
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.forms import HiddenInput
 | 
			
		||||
from django.core.files.base import ContentFile
 | 
			
		||||
 | 
			
		||||
from moneyed import CURRENCIES
 | 
			
		||||
 | 
			
		||||
from PIL import Image
 | 
			
		||||
import requests
 | 
			
		||||
import io
 | 
			
		||||
 | 
			
		||||
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
 | 
			
		||||
from InvenTree.helpers import str2bool
 | 
			
		||||
from InvenTree.views import InvenTreeRoleMixin
 | 
			
		||||
@@ -28,6 +33,7 @@ from .forms import EditCompanyForm
 | 
			
		||||
from .forms import CompanyImageForm
 | 
			
		||||
from .forms import EditSupplierPartForm
 | 
			
		||||
from .forms import EditPriceBreakForm
 | 
			
		||||
from .forms import CompanyImageDownloadForm
 | 
			
		||||
 | 
			
		||||
import common.models
 | 
			
		||||
import common.settings
 | 
			
		||||
@@ -150,6 +156,84 @@ class CompanyDetail(DetailView):
 | 
			
		||||
        return ctx
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CompanyImageDownloadFromURL(AjaxUpdateView):
 | 
			
		||||
    """
 | 
			
		||||
    View for downloading an image from a provided URL
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    model = Company
 | 
			
		||||
    ajax_template_name = 'image_download.html'
 | 
			
		||||
    form_class = CompanyImageDownloadForm
 | 
			
		||||
    ajax_form_title = _('Download Image')
 | 
			
		||||
 | 
			
		||||
    def validate(self, company, form):
 | 
			
		||||
        """
 | 
			
		||||
        Validate that the image data are correct
 | 
			
		||||
        """
 | 
			
		||||
        # First ensure that the normal validation routines pass
 | 
			
		||||
        if not form.is_valid():
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # We can now extract a valid URL from the form data
 | 
			
		||||
        url = form.cleaned_data.get('url', None)
 | 
			
		||||
 | 
			
		||||
        # Download the file
 | 
			
		||||
        response = requests.get(url, stream=True)
 | 
			
		||||
 | 
			
		||||
        # Look at response header, reject if too large
 | 
			
		||||
        content_length = response.headers.get('Content-Length', '0')
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            content_length = int(content_length)
 | 
			
		||||
        except (ValueError):
 | 
			
		||||
            # If we cannot extract meaningful length, just assume it's "small enough"
 | 
			
		||||
            content_length = 0
 | 
			
		||||
 | 
			
		||||
        # TODO: Factor this out into a configurable setting
 | 
			
		||||
        MAX_IMG_LENGTH = 10 * 1024 * 1024
 | 
			
		||||
 | 
			
		||||
        if content_length > MAX_IMG_LENGTH:
 | 
			
		||||
            form.add_error('url', _('Image size exceeds maximum allowable size for download'))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.response = response
 | 
			
		||||
 | 
			
		||||
        # Check for valid response code
 | 
			
		||||
        if not response.status_code == 200:
 | 
			
		||||
            form.add_error('url', f"{_('Invalid response')}: {response.status_code}")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        response.raw.decode_content = True
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self.image = Image.open(response.raw).convert()
 | 
			
		||||
            self.image.verify()
 | 
			
		||||
        except:
 | 
			
		||||
            form.add_error('url', _("Supplied URL is not a valid image file"))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
    def save(self, company, form, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Save the downloaded image to the company
 | 
			
		||||
        """
 | 
			
		||||
        fmt = self.image.format
 | 
			
		||||
 | 
			
		||||
        if not fmt:
 | 
			
		||||
            fmt = 'PNG'
 | 
			
		||||
 | 
			
		||||
        buffer = io.BytesIO()
 | 
			
		||||
 | 
			
		||||
        self.image.save(buffer, format=fmt)
 | 
			
		||||
 | 
			
		||||
        # Construct a simplified name for the image
 | 
			
		||||
        filename = f"company_{company.pk}_image.{fmt.lower()}"
 | 
			
		||||
 | 
			
		||||
        company.image.save(
 | 
			
		||||
            filename,
 | 
			
		||||
            ContentFile(buffer.getvalue()),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CompanyImage(AjaxUpdateView):
 | 
			
		||||
    """ View for uploading an image for the Company """
 | 
			
		||||
    model = Company
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ from django.db.utils import OperationalError, ProgrammingError
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from PIL import UnidentifiedImageError
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -44,9 +45,11 @@ class PartConfig(AppConfig):
 | 
			
		||||
                        try:
 | 
			
		||||
                            part.image.render_variations(replace=False)
 | 
			
		||||
                        except FileNotFoundError:
 | 
			
		||||
                            logger.warning("Image file missing")
 | 
			
		||||
                            logger.warning(f"Image file '{part.image}' missing")
 | 
			
		||||
                            part.image = None
 | 
			
		||||
                            part.save()
 | 
			
		||||
                        except UnidentifiedImageError:
 | 
			
		||||
                            logger.warning(f"Image file '{part.image}' is invalid")
 | 
			
		||||
        except (OperationalError, ProgrammingError):
 | 
			
		||||
            # Exception if the database has not been migrated yet
 | 
			
		||||
            pass
 | 
			
		||||
 
 | 
			
		||||
@@ -232,14 +232,18 @@ class BomUploadManager:
 | 
			
		||||
 | 
			
		||||
    # Fields which are absolutely necessary for valid upload
 | 
			
		||||
    REQUIRED_HEADERS = [
 | 
			
		||||
        'Part_Name',
 | 
			
		||||
        'Quantity'
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Fields which are used for part matching (only one of them is needed)
 | 
			
		||||
    PART_MATCH_HEADERS = [
 | 
			
		||||
        'Part_Name',
 | 
			
		||||
        'Part_IPN',
 | 
			
		||||
        'Part_ID',
 | 
			
		||||
    ]
 | 
			
		||||
    
 | 
			
		||||
    # Fields which would be helpful but are not required
 | 
			
		||||
    OPTIONAL_HEADERS = [
 | 
			
		||||
        'Part_IPN',
 | 
			
		||||
        'Part_ID',
 | 
			
		||||
        'Reference',
 | 
			
		||||
        'Note',
 | 
			
		||||
        'Overage',
 | 
			
		||||
@@ -251,7 +255,7 @@ class BomUploadManager:
 | 
			
		||||
        'Overage'
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
 | 
			
		||||
    HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bom_file):
 | 
			
		||||
        """ Initialize the BomUpload class with a user-uploaded file object """
 | 
			
		||||
 
 | 
			
		||||
@@ -37,6 +37,24 @@ class PartModelChoiceField(forms.ModelChoiceField):
 | 
			
		||||
        return label
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartImageDownloadForm(HelperForm):
 | 
			
		||||
    """
 | 
			
		||||
    Form for downloading an image from a URL
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    url = forms.URLField(
 | 
			
		||||
        label=_('URL'),
 | 
			
		||||
        help_text=_('Image URL'),
 | 
			
		||||
        required=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Part
 | 
			
		||||
        fields = [
 | 
			
		||||
            'url',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartImageForm(HelperForm):
 | 
			
		||||
    """ Form for uploading a Part image """
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1372,7 +1372,7 @@ class Part(MPTTModel):
 | 
			
		||||
        """ Check if the BOM is 'valid' - if the calculated checksum matches the stored value
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        return self.get_bom_hash() == self.bom_checksum
 | 
			
		||||
        return self.get_bom_hash() == self.bom_checksum or not self.has_bom
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def validate_bom(self, user):
 | 
			
		||||
 
 | 
			
		||||
@@ -206,6 +206,12 @@
 | 
			
		||||
        toggleId: '#part-menu-toggle',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    {% if part.image %}
 | 
			
		||||
    $('#part-thumb').click(function() {
 | 
			
		||||
        showModalImage('{{ part.image.url }}');
 | 
			
		||||
    });
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    enableDragAndDrop(
 | 
			
		||||
        '#part-thumb',
 | 
			
		||||
        "{% url 'part-image-upload' part.id %}",
 | 
			
		||||
@@ -295,6 +301,20 @@
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    {% if roles.part.change %}
 | 
			
		||||
    
 | 
			
		||||
    {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
 | 
			
		||||
    {% if allow_download %}
 | 
			
		||||
    $("#part-image-url").click(function() {
 | 
			
		||||
        launchModalForm(
 | 
			
		||||
            '{% url "part-image-download" part.id %}',
 | 
			
		||||
            {
 | 
			
		||||
                reload: true,
 | 
			
		||||
            }
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    $("#part-image-select").click(function() {
 | 
			
		||||
        launchModalForm("{% url 'part-image-select' part.id %}",
 | 
			
		||||
                        {
 | 
			
		||||
@@ -303,7 +323,6 @@
 | 
			
		||||
                        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    {% if roles.part.change %}
 | 
			
		||||
    $("#part-edit").click(function() {
 | 
			
		||||
        launchModalForm(
 | 
			
		||||
            "{% url 'part-edit' part.id %}",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,28 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% load inventree_extras %}
 | 
			
		||||
 | 
			
		||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
 | 
			
		||||
 | 
			
		||||
<div class="media">
 | 
			
		||||
    <div class="media-left part-thumb-container">
 | 
			
		||||
      <div class='dropzone' id='part-thumb'>
 | 
			
		||||
          <img class="part-thumb"
 | 
			
		||||
          <img class="part-thumb" id='part-image'
 | 
			
		||||
              {% if part.image %}
 | 
			
		||||
              src="{{ part.image.url }}"
 | 
			
		||||
              {% else %}
 | 
			
		||||
              src="{% static 'img/blank_image.png' %}"
 | 
			
		||||
              {% endif %}/>
 | 
			
		||||
      </div>
 | 
			
		||||
      {% if roles.part.change %}
 | 
			
		||||
      <div class='btn-row part-thumb-overlay'>
 | 
			
		||||
          <div class='btn-group'>
 | 
			
		||||
              <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Select from existing images' %}" id='part-image-select'><span class='fas fa-th'></span></button>
 | 
			
		||||
              <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Upload new image' %}" id='part-image-upload'><span class='fas fa-file-image'></span></button>
 | 
			
		||||
              <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Upload new image' %}" id='part-image-upload'><span class='fas fa-file-upload'></span></button>
 | 
			
		||||
              {% if allow_download %}
 | 
			
		||||
              <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Download image from URL' %}" id='part-image-url'><span class='fas fa-cloud-download-alt'></span></button>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
          </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -75,6 +75,7 @@ part_detail_urls = [
 | 
			
		||||
    # Normal thumbnail with form
 | 
			
		||||
    url(r'^thumbnail/?', views.PartImageUpload.as_view(), name='part-image-upload'),
 | 
			
		||||
    url(r'^thumb-select/?', views.PartImageSelect.as_view(), name='part-image-select'),
 | 
			
		||||
    url(r'^thumb-download/', views.PartImageDownloadFromURL.as_view(), name='part-image-download'),
 | 
			
		||||
 | 
			
		||||
    # Any other URLs go to the part detail page
 | 
			
		||||
    url(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ Django views for interacting with Part app
 | 
			
		||||
# -*- coding: utf-8 -*-
 | 
			
		||||
from __future__ import unicode_literals
 | 
			
		||||
 | 
			
		||||
from django.core.files.base import ContentFile
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.db.utils import IntegrityError
 | 
			
		||||
@@ -19,7 +20,11 @@ from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from moneyed import CURRENCIES
 | 
			
		||||
 | 
			
		||||
from PIL import Image
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
import os
 | 
			
		||||
import io
 | 
			
		||||
 | 
			
		||||
from rapidfuzz import fuzz
 | 
			
		||||
from decimal import Decimal, InvalidOperation
 | 
			
		||||
@@ -831,6 +836,89 @@ class PartQRCode(QRCodeView):
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartImageDownloadFromURL(AjaxUpdateView):
 | 
			
		||||
    """
 | 
			
		||||
    View for downloading an image from a provided URL
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    model = Part
 | 
			
		||||
 | 
			
		||||
    ajax_template_name = 'image_download.html'
 | 
			
		||||
    form_class = part_forms.PartImageDownloadForm
 | 
			
		||||
    ajax_form_title = _('Download Image')
 | 
			
		||||
 | 
			
		||||
    def validate(self, part, form):
 | 
			
		||||
        """
 | 
			
		||||
        Validate that the image data are correct.
 | 
			
		||||
 | 
			
		||||
        - Try to download the image!
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        # First ensure that the normal validation routines pass
 | 
			
		||||
        if not form.is_valid():
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # We can now extract a valid URL from the form data
 | 
			
		||||
        url = form.cleaned_data.get('url', None)
 | 
			
		||||
 | 
			
		||||
        # Download the file
 | 
			
		||||
        response = requests.get(url, stream=True)
 | 
			
		||||
 | 
			
		||||
        # Look at response header, reject if too large
 | 
			
		||||
        content_length = response.headers.get('Content-Length', '0')
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            content_length = int(content_length)
 | 
			
		||||
        except (ValueError):
 | 
			
		||||
            # If we cannot extract meaningful length, just assume it's "small enough"
 | 
			
		||||
            content_length = 0
 | 
			
		||||
 | 
			
		||||
        # TODO: Factor this out into a configurable setting
 | 
			
		||||
        MAX_IMG_LENGTH = 10 * 1024 * 1024
 | 
			
		||||
 | 
			
		||||
        if content_length > MAX_IMG_LENGTH:
 | 
			
		||||
            form.add_error('url', _('Image size exceeds maximum allowable size for download'))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.response = response
 | 
			
		||||
 | 
			
		||||
        # Check for valid response code
 | 
			
		||||
        if not response.status_code == 200:
 | 
			
		||||
            form.add_error('url', f"{_('Invalid response')}: {response.status_code}")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        response.raw.decode_content = True
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self.image = Image.open(response.raw).convert()
 | 
			
		||||
            self.image.verify()
 | 
			
		||||
        except:
 | 
			
		||||
            form.add_error('url', _("Supplied URL is not a valid image file"))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
    def save(self, part, form, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Save the downloaded image to the part
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        fmt = self.image.format
 | 
			
		||||
 | 
			
		||||
        if not fmt:
 | 
			
		||||
            fmt = 'PNG'
 | 
			
		||||
 | 
			
		||||
        buffer = io.BytesIO()
 | 
			
		||||
 | 
			
		||||
        self.image.save(buffer, format=fmt)
 | 
			
		||||
 | 
			
		||||
        # Construct a simplified name for the image
 | 
			
		||||
        filename = f"part_{part.pk}_image.{fmt.lower()}"
 | 
			
		||||
 | 
			
		||||
        part.image.save(
 | 
			
		||||
            filename,
 | 
			
		||||
            ContentFile(buffer.getvalue()),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartImageUpload(AjaxUpdateView):
 | 
			
		||||
    """ View for uploading a new Part image """
 | 
			
		||||
 | 
			
		||||
@@ -910,6 +998,12 @@ class PartEdit(AjaxUpdateView):
 | 
			
		||||
 | 
			
		||||
        form.fields['default_supplier'].queryset = SupplierPart.objects.filter(part=part)
 | 
			
		||||
 | 
			
		||||
        # Check if IPN can be edited
 | 
			
		||||
        ipn_edit_enable = InvenTreeSetting.get_setting('PART_ALLOW_EDIT_IPN')
 | 
			
		||||
        if not ipn_edit_enable and not self.request.user.is_superuser:
 | 
			
		||||
            # Admin can still change IPN
 | 
			
		||||
            form.fields['IPN'].disabled = True
 | 
			
		||||
 | 
			
		||||
        return form
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -1425,10 +1519,23 @@ class BomUpload(InvenTreeRoleMixin, FormView):
 | 
			
		||||
        # Are there any missing columns?
 | 
			
		||||
        self.missing_columns = []
 | 
			
		||||
 | 
			
		||||
        # Check that all required fields are present
 | 
			
		||||
        for col in BomUploadManager.REQUIRED_HEADERS:
 | 
			
		||||
            if col not in self.column_selections.values():
 | 
			
		||||
                self.missing_columns.append(col)
 | 
			
		||||
 | 
			
		||||
        # Check that at least one of the part match field is present
 | 
			
		||||
        part_match_found = False
 | 
			
		||||
        for col in BomUploadManager.PART_MATCH_HEADERS:
 | 
			
		||||
            if col in self.column_selections.values():
 | 
			
		||||
                part_match_found = True
 | 
			
		||||
                break
 | 
			
		||||
        
 | 
			
		||||
        # If not, notify user
 | 
			
		||||
        if not part_match_found:
 | 
			
		||||
            for col in BomUploadManager.PART_MATCH_HEADERS:
 | 
			
		||||
                self.missing_columns.append(col)
 | 
			
		||||
 | 
			
		||||
    def handleFieldSelection(self):
 | 
			
		||||
        """ Handle the output of the field selection form.
 | 
			
		||||
        Here the user is presented with the raw data and must select the
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
 | 
			
		||||
    </tbody>
 | 
			
		||||
</table>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@
 | 
			
		||||
    <tbody>
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
 | 
			
		||||
        <tr><td colspan='5  '></td></tr>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								InvenTree/templates/image_download.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								InvenTree/templates/image_download.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
{% extends "modal_form.html" %}
 | 
			
		||||
 | 
			
		||||
{% load inventree_extras %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block pre_form_content %}
 | 
			
		||||
<div class='alert alert-block alert-info'>
 | 
			
		||||
    {% trans "Specify URL for downloading image" %}:
 | 
			
		||||
 | 
			
		||||
    <ul>
 | 
			
		||||
        <li>{% trans "Must be a valid image URL" %}</li>
 | 
			
		||||
        <li>{% trans "Remote server must be accessible" %}</li>
 | 
			
		||||
        <li>{% trans "Remote image must not exceed maximum allowable file size" %}</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -909,3 +909,42 @@ function launchModalForm(url, options = {}) {
 | 
			
		||||
    // Send the AJAX request
 | 
			
		||||
    $.ajax(ajax_data);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function hideModalImage() {
 | 
			
		||||
 | 
			
		||||
    var modal = $('#modal-image-dialog');
 | 
			
		||||
 | 
			
		||||
    modal.animate({
 | 
			
		||||
        opacity: 0.0,
 | 
			
		||||
    }, 250, function() {
 | 
			
		||||
        modal.hide();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function showModalImage(image_url) {
 | 
			
		||||
    // Display full-screen modal image
 | 
			
		||||
 | 
			
		||||
    console.log('showing modal image: ' + image_url);
 | 
			
		||||
 | 
			
		||||
    var modal = $('#modal-image-dialog');
 | 
			
		||||
 | 
			
		||||
    // Set image content
 | 
			
		||||
    $('#modal-image').attr('src', image_url);
 | 
			
		||||
 | 
			
		||||
    modal.show();
 | 
			
		||||
 | 
			
		||||
    modal.animate({
 | 
			
		||||
        opacity: 1.0,
 | 
			
		||||
    }, 250);
 | 
			
		||||
 | 
			
		||||
    $('#modal-image-close').click(function() {
 | 
			
		||||
        hideModalImage();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    modal.click(function() {
 | 
			
		||||
        hideModalImage();
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,12 @@
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
<div class='modal fade modal-image' role='dialog' id='modal-image-dialog'>
 | 
			
		||||
  <span class='modal-close' id='modal-image-close'>×</span>
 | 
			
		||||
 | 
			
		||||
  <img class='modal-image-content' id='modal-image'>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class='modal fade modal-fixed-footer modal-primary' tabindex='-1' role='dialog' id='modal-form'>
 | 
			
		||||
    <div class='modal-dialog'>
 | 
			
		||||
        <div class='modal-content'>
 | 
			
		||||
@@ -78,8 +85,10 @@
 | 
			
		||||
              </button>
 | 
			
		||||
              <h3 id='modal-title'>Alert Information</h3>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class='modal-form-content-wrapper'>
 | 
			
		||||
              <div class='modal-form-content'>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class='modal-footer'>
 | 
			
		||||
                <button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
 | 
			
		||||
            </div>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user