2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 03:25:42 +00:00

Merge remote-tracking branch 'inventree/master' into django-q

# Conflicts:
#	.github/workflows/style.yaml
#	.travis.yml
#	InvenTree/InvenTree/settings.py
This commit is contained in:
Oliver Walters
2021-03-31 22:17:38 +11:00
70 changed files with 3491 additions and 3071 deletions

View File

@ -1,18 +0,0 @@
"""
Configuration file for running tests against a MySQL database.
"""
from InvenTree.settings import *
# Override the 'test' database
if 'test' in sys.argv:
print('InvenTree: Running tests - Using MySQL test database')
DATABASES['default'] = {
# Ensure mysql backend is being used
'ENGINE': 'django.db.backends.mysql',
'NAME': 'inventree_test_db',
'USER': 'travis',
'PASSWORD': '',
'HOST': '127.0.0.1'
}

View File

@ -1,17 +0,0 @@
"""
Configuration file for running tests against a MySQL database.
"""
from InvenTree.settings import *
# Override the 'test' database
if 'test' in sys.argv:
print('InvenTree: Running tests - Using PostGreSQL test database')
DATABASES['default'] = {
# Ensure postgresql backend is being used
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'inventree_test_db',
'USER': 'postgres',
'PASSWORD': '',
}

View File

@ -335,87 +335,71 @@ MARKDOWNIFY_BLEACH = False
DATABASES = {}
"""
When running unit tests, enforce usage of sqlite3 database,
so that the tests can be run in RAM without any setup requirements
Configure the database backend based on the user-specified values.
- Primarily this configuration happens in the config.yaml file
- However there may be reason to configure the DB via environmental variables
- The following code lets the user "mix and match" database configuration
"""
if TESTING:
logger.info('InvenTree: Running tests - Using sqlite3 memory database')
DATABASES['default'] = {
# Ensure sqlite3 backend is being used
'ENGINE': 'django.db.backends.sqlite3',
# Doesn't matter what the database is called, it is executed in RAM
'NAME': 'ram_test_db.sqlite3',
}
# Database backend selection
else:
"""
Configure the database backend based on the user-specified values.
- Primarily this configuration happens in the config.yaml file
- However there may be reason to configure the DB via environmental variables
- The following code lets the user "mix and match" database configuration
"""
logger.info("Configuring database backend:")
logger.info("Configuring database backend:")
# Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {})
# Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {})
if not db_config:
db_config = {}
# Default action if db_config not specified in yaml file
if not db_config:
db_config = {}
# Environment variables take preference over config file!
# If a particular database option is not specified in the config file,
# look for it in the environmental variables
# e.g. INVENTREE_DB_NAME / INVENTREE_DB_USER / etc
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
for key in db_keys:
# First, check the environment variables
env_key = f"INVENTREE_DB_{key}"
env_var = os.environ.get(env_key, None)
for key in db_keys:
if key not in db_config:
logger.debug(f" - Missing {key} value: Looking for environment variable INVENTREE_DB_{key}")
env_key = f'INVENTREE_DB_{key}'
env_var = os.environ.get(env_key, None)
if env_var:
logger.info(f"{env_key}={env_var}")
# Override configuration value
db_config[key] = env_var
if env_var is not None:
logger.info(f'Using environment variable INVENTREE_DB_{key}')
db_config[key] = env_var
else:
logger.debug(f' INVENTREE_DB_{key} not found in environment variables')
# Check that required database configuration options are specified
reqiured_keys = ['ENGINE', 'NAME']
# Check that required database configuration options are specified
reqiured_keys = ['ENGINE', 'NAME']
for key in reqiured_keys:
if key not in db_config:
error_msg = f'Missing required database configuration value {key} in config.yaml'
logger.error(error_msg)
for key in reqiured_keys:
if key not in db_config:
error_msg = f'Missing required database configuration value {key} in config.yaml'
logger.error(error_msg)
print('Error: ' + error_msg)
sys.exit(-1)
print('Error: ' + error_msg)
sys.exit(-1)
"""
Special considerations for the database 'ENGINE' setting.
It can be specified in config.yaml (or envvar) as either (for example):
- sqlite3
- django.db.backends.sqlite3
- django.db.backends.postgresql
"""
"""
Special considerations for the database 'ENGINE' setting.
It can be specified in config.yaml (or envvar) as either (for example):
- sqlite3
- django.db.backends.sqlite3
- django.db.backends.postgresql
"""
db_engine = db_config['ENGINE']
db_engine = db_config['ENGINE']
if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']:
# Prepend the required python module string
db_engine = f'django.db.backends.{db_engine.lower()}'
db_config['ENGINE'] = db_engine
if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']:
# Prepend the required python module string
db_engine = f'django.db.backends.{db_engine.lower()}'
db_config['ENGINE'] = db_engine
db_name = db_config['NAME']
db_host = db_config.get('HOST', "''")
db_name = db_config['NAME']
print("InvenTree Database Configuration")
print("================================")
print(f"ENGINE: {db_engine}")
print(f"NAME: {db_name}")
print(f"HOST: {db_host}")
logger.info(f"Database ENGINE: '{db_engine}'")
logger.info(f"Database NAME: '{db_name}'")
DATABASES['default'] = db_config
DATABASES['default'] = db_config
CACHES = {
'default': {

View File

@ -586,6 +586,8 @@
.breadcrump {
margin-bottom: 5px;
margin-left: 5px;
margin-right: 10px;
}
.inventree-body {
@ -624,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;
}

View File

@ -95,7 +95,7 @@ class BuildOutputCreateForm(HelperForm):
confirm = forms.BooleanField(
required=True,
label=_('Confirm'),
help_text=_('Confirm creation of build outut'),
help_text=_('Confirm creation of build output'),
)
class Meta:

View File

@ -10,6 +10,9 @@
{% block heading %}
{% trans "Build Notes" %}
{% if roles.build.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -20,14 +23,13 @@
{{ form }}
<hr>
<input type="submit" value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{{ build.notes | markdownify }}
{% endif %}

View File

@ -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'),

View File

@ -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

View File

@ -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 """

View File

@ -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 %}

View File

@ -9,6 +9,9 @@
{% block heading %}
{% trans "Company Notes" %}
{% if not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -18,7 +21,7 @@
{{ form }}
<hr>
<input type="submit" value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
@ -26,7 +29,6 @@
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{{ company.notes | markdownify }}
{% endif %}

View File

@ -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'),

View File

@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,8 @@ from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField
import part.models
from stock.models import StockLocation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
@ -211,7 +213,65 @@ class EditSalesOrderLineItemForm(HelperForm):
]
class AllocateSerialsToSalesOrderForm(forms.Form):
"""
Form for assigning stock to a sales order,
by serial number lookup
"""
line = forms.ModelChoiceField(
queryset=SalesOrderLineItem.objects.all(),
)
part = forms.ModelChoiceField(
queryset=part.models.Part.objects.all(),
)
serials = forms.CharField(
label=_("Serial Numbers"),
required=True,
help_text=_('Enter stock item serial numbers'),
)
quantity = forms.IntegerField(
label=_('Quantity'),
required=True,
help_text=_('Enter quantity of stock items'),
initial=1,
min_value=1
)
class Meta:
fields = [
'line',
'part',
'serials',
'quantity',
]
class CreateSalesOrderAllocationForm(HelperForm):
"""
Form for creating a SalesOrderAllocation item.
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta:
model = SalesOrderAllocation
fields = [
'line',
'item',
'quantity',
]
class EditSalesOrderAllocationForm(HelperForm):
"""
Form for editing a SalesOrderAllocation item
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2021-03-29 13:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('order', '0042_auto_20210310_1619'),
]
operations = [
migrations.AlterUniqueTogether(
name='salesorderlineitem',
unique_together=set(),
),
]

View File

@ -663,7 +663,6 @@ class SalesOrderLineItem(OrderLineItem):
class Meta:
unique_together = [
('order', 'part'),
]
def fulfilled_quantity(self):
@ -732,6 +731,12 @@ class SalesOrderAllocation(models.Model):
errors = {}
try:
if not self.item:
raise ValidationError({'item': _('Stock item has not been assigned')})
except stock_models.StockItem.DoesNotExist:
raise ValidationError({'item': _('Stock item has not been assigned')})
try:
if not self.line.part == self.item.part:
errors['item'] = _('Cannot allocate stock item to a line with a different part')

View File

@ -11,6 +11,9 @@
{% block heading %}
{% trans "Order Notes" %}
{% if roles.purchase_order.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -21,21 +24,19 @@
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
{% if roles.purchase_order.change %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endif %}
<div class='panel panel-default'>
<div class='panel-content'>
{{ order.notes | markdownify }}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -275,15 +275,20 @@ $("#so-lines-table").inventreeTable({
if (row.part) {
var part = row.part_detail;
if (part.trackable) {
html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}');
}
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Buy parts" %}');
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
}
if (part.assembly) {
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
}
html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate parts" %}');
}
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
@ -316,10 +321,28 @@ function setupCallbacks() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/line/${pk}/delete/`, {
reload: true,
success: reloadTable,
});
});
table.find(".button-add-by-sn").click(function() {
var pk = $(this).attr('pk');
inventreeGet(`/api/order/so-line/${pk}/`, {},
{
success: function(response) {
launchModalForm('{% url "so-assign-serials" %}', {
success: reloadTable,
data: {
line: pk,
part: response.part,
}
});
}
}
);
});
table.find(".button-add").click(function() {
var pk = $(this).attr('pk');

View File

@ -12,6 +12,9 @@
{% block heading %}
{% trans "Sales Order Notes" %}
{% if roles.sales_order.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -23,13 +26,12 @@
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
<div class='panel panel-default'>
<div class='panel-content'>
{{ order.notes | markdownify }}

View File

@ -0,0 +1,12 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
{% include "hover_image.html" with image=part.image hover=true %}{{ part }}
<hr>
{% trans "Allocate stock items by serial number" %}
</div>
{% endblock %}

View File

@ -3,7 +3,6 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from datetime import datetime, timedelta
@ -73,10 +72,10 @@ class SalesOrderTest(TestCase):
self.assertFalse(self.order.is_fully_allocated())
def test_add_duplicate_line_item(self):
# Adding a duplicate line item to a SalesOrder must throw an error
# Adding a duplicate line item to a SalesOrder is accepted
with self.assertRaises(IntegrityError):
SalesOrderLineItem.objects.create(order=self.order, part=self.part)
for ii in range(1, 5):
SalesOrderLineItem.objects.create(order=self.order, part=self.part, quantity=ii)
def allocate_stock(self, full=True):

View File

@ -81,6 +81,7 @@ sales_order_urls = [
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'),
url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
url(r'(?P<pk>\d+)/', include([
url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'),

View File

@ -7,9 +7,11 @@ from __future__ import unicode_literals
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic.edit import FormMixin
from django.forms import HiddenInput
import logging
@ -30,6 +32,7 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
@ -1291,11 +1294,179 @@ class SOLineItemDelete(AjaxDeleteView):
}
class SalesOrderAssignSerials(AjaxView, FormMixin):
"""
View for assigning stock items to a sales order,
by serial number lookup.
"""
model = SalesOrderAllocation
role_required = 'sales_order.change'
ajax_template_name = 'order/so_allocate_by_serial.html'
ajax_form_title = _('Allocate Serial Numbers')
form_class = order_forms.AllocateSerialsToSalesOrderForm
# Keep track of SalesOrderLineItem and Part references
line = None
part = None
def get_initial(self):
"""
Initial values are passed as query params
"""
initials = super().get_initial()
try:
self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None))
initials['line'] = self.line
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
try:
self.part = Part.objects.get(pk=self.request.GET.get('part', None))
initials['part'] = self.part
except (ValueError, Part.DoesNotExist):
pass
return initials
def post(self, request, *args, **kwargs):
self.form = self.get_form()
# Validate the form
self.form.is_valid()
self.validate()
valid = self.form.is_valid()
if valid:
self.allocate_items()
data = {
'form_valid': valid,
'form_errors': self.form.errors.as_json(),
'non_field_errors': self.form.non_field_errors().as_json(),
'success': _("Allocated") + f" {len(self.stock_items)} " + _("items")
}
return self.renderJsonResponse(request, self.form, data)
def validate(self):
data = self.form.cleaned_data
# Extract hidden fields from posted data
self.line = data.get('line', None)
self.part = data.get('part', None)
if self.line:
self.form.fields['line'].widget = HiddenInput()
else:
self.form.add_error('line', _('Select line item'))
if self.part:
self.form.fields['part'].widget = HiddenInput()
else:
self.form.add_error('part', _('Select part'))
if not self.form.is_valid():
return
# Form is otherwise valid - check serial numbers
serials = data.get('serials', '')
quantity = data.get('quantity', 1)
# Save a list of serial_numbers
self.serial_numbers = None
self.stock_items = []
try:
self.serial_numbers = extract_serial_numbers(serials, quantity)
for serial in self.serial_numbers:
try:
# Find matching stock item
stock_item = StockItem.objects.get(
part=self.part,
serial=serial
)
except StockItem.DoesNotExist:
self.form.add_error(
'serials',
_('No matching item for serial') + f" '{serial}'"
)
continue
# Now we have a valid stock item - but can it be added to the sales order?
# If not in stock, cannot be added to the order
if not stock_item.in_stock:
self.form.add_error(
'serials',
f"'{serial}' " + _("is not in stock")
)
continue
# Already allocated to an order
if stock_item.is_allocated():
self.form.add_error(
'serials',
f"'{serial}' " + _("already allocated to an order")
)
continue
# Add it to the list!
self.stock_items.append(stock_item)
except ValidationError as e:
self.form.add_error('serials', e.messages)
def allocate_items(self):
"""
Create stock item allocations for each selected serial number
"""
for stock_item in self.stock_items:
SalesOrderAllocation.objects.create(
item=stock_item,
line=self.line,
quantity=1,
)
def get_form(self):
form = super().get_form()
if self.line:
form.fields['line'].widget = HiddenInput()
if self.part:
form.fields['part'].widget = HiddenInput()
return form
def get_context_data(self):
return {
'line': self.line,
'part': self.part,
}
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(
request,
self.get_form(),
context=self.get_context_data(),
)
class SalesOrderAllocationCreate(AjaxCreateView):
""" View for creating a new SalesOrderAllocation """
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
form_class = order_forms.CreateSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order')
def get_initial(self):

View File

@ -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

View File

@ -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 """

View File

@ -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 """

View File

@ -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):

View File

@ -10,35 +10,34 @@
{% block heading %}
{% trans "Part Notes" %}
{% if roles.part.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
{% if editing %}
<form method='POST'>
{% csrf_token %}
{{ form }}
<hr>
<input type="submit" value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
{% if roles.part.change %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
<div class='panel panel-default'>
{% if part.notes %}
<div class='panel-content'>
{% if part.notes %}
{{ part.notes | markdownify }}
{% endif %}
</div>
{% endif %}
</div>
{% endif %}

View File

@ -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 %}",
@ -241,6 +247,7 @@
"{% url 'part-pricing' part.id %}",
{
submit_text: 'Calculate',
hideErrorMessage: true,
}
);
});
@ -293,6 +300,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 %}",
@ -302,7 +323,6 @@
});
});
{% if roles.part.change %}
$("#part-edit").click(function() {
launchModalForm(
"{% url 'part-edit' part.id %}",

View File

@ -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>

View File

@ -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'),

View File

@ -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

View File

@ -665,6 +665,13 @@ class StockList(generics.ListCreateAPIView):
active = str2bool(active)
queryset = queryset.filter(part__active=active)
# Do we wish to filter by "assembly parts"
assembly = params.get('assembly', None)
if assembly is not None:
assembly = str2bool(assembly)
queryset = queryset.filter(part__assembly=assembly)
# Filter by 'depleted' status
depleted = params.get('depleted', None)

View File

@ -155,18 +155,24 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock adjustment actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if item.can_adjust_location %}
{% if not item.serialized %}
{% if item.in_stock %}
<li><a href='#' id='stock-count' title='{% trans "Count stock" %}'><span class='fas fa-clipboard-list'></span> {% trans "Count stock" %}</a></li>
{% endif %}
{% if not item.customer %}
<li><a href='#' id='stock-add' title='{% trans "Add stock" %}'><span class='fas fa-plus-circle icon-green'></span> {% trans "Add stock" %}</a></li>
{% endif %}
{% if item.in_stock %}
<li><a href='#' id='stock-remove' title='{% trans "Remove stock" %}'><span class='fas fa-minus-circle icon-red'></span> {% trans "Remove stock" %}</a></li>
{% endif %}
{% if item.in_stock and item.can_adjust_location %}
<li><a href='#' id='stock-move' title='{% trans "Transfer stock" %}'><span class='fas fa-exchange-alt icon-blue'></span> {% trans "Transfer stock" %}</a></li>
{% if item.part.trackable and not item.serialized %}
{% endif %}
{% if item.in_stock and item.part.trackable %}
<li><a href='#' id='stock-serialize' title='{% trans "Serialize stock" %}'><span class='fas fa-hashtag'></span> {% trans "Serialize stock" %}</a> </li>
{% endif %}
{% endif %}
{% if item.part.salable and not item.customer %}
{% if item.in_stock and item.can_adjust_location and item.part.salable and not item.customer %}
<li><a href='#' id='stock-assign-to-customer' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
{% endif %}
{% if item.customer %}
@ -469,16 +475,6 @@ $("#barcode-scan-into-location").click(function() {
scanItemsIntoLocation([{{ item.id }}]);
});
{% if item.in_stock %}
$("#stock-assign-to-customer").click(function() {
launchModalForm("{% url 'stock-item-assign' item.id %}",
{
reload: true,
}
);
});
function itemAdjust(action) {
launchModalForm("/stock/adjust/",
{
@ -492,6 +488,29 @@ function itemAdjust(action) {
);
}
$('#stock-add').click(function() {
itemAdjust('add');
});
$("#stock-delete").click(function () {
launchModalForm(
"{% url 'stock-item-delete' item.id %}",
{
redirect: "{% url 'part-stock' item.part.id %}"
}
);
});
{% if item.in_stock %}
$("#stock-assign-to-customer").click(function() {
launchModalForm("{% url 'stock-item-assign' item.id %}",
{
reload: true,
}
);
});
{% if item.part.has_variants %}
$("#stock-convert").click(function() {
launchModalForm("{% url 'stock-item-convert' item.id %}",
@ -514,10 +533,6 @@ $('#stock-remove').click(function() {
itemAdjust('take');
});
$('#stock-add').click(function() {
itemAdjust('add');
});
{% else %}
$("#stock-return-from-customer").click(function() {
@ -530,13 +545,4 @@ $("#stock-return-from-customer").click(function() {
{% endif %}
$("#stock-delete").click(function () {
launchModalForm(
"{% url 'stock-item-delete' item.id %}",
{
redirect: "{% url 'part-stock' item.part.id %}"
}
);
});
{% endblock %}

View File

@ -11,6 +11,9 @@
{% block heading %}
{% trans "Stock Item Notes" %}
{% if roles.stock.change and not editing %}
<button title='{% trans "Edit notes" %}' class='btn btn-default' id='edit-notes'><span class='fas fa-edit'></span></button>
{% endif %}
{% endblock %}
{% block details %}
@ -20,13 +23,12 @@
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form>
{{ form.media }}
{% else %}
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button' id='edit-notes'><span class='fas fa-edit'></span></button>
{% if item.notes %}
{{ item.notes | markdownify }}
{% endif %}

View File

@ -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>

View File

@ -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>

View 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 %}

View File

@ -739,9 +739,10 @@ function handleModalForm(url, options) {
// Form was returned, invalid!
else {
var warningDiv = $(modal).find('#form-validation-warning');
warningDiv.css('display', 'block');
if (!options.hideErrorMessage) {
var warningDiv = $(modal).find('#form-validation-warning');
warningDiv.css('display', 'block');
}
if (response.html_form) {
injectModalForm(modal, response.html_form);
@ -908,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();
});
}

View File

@ -96,10 +96,15 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Active parts" %}',
description: '{% trans "Show stock for active parts" %}',
},
assembly: {
type: 'bool',
title: '{% trans "Assembly" %}',
description: '{% trans "Part is an assembly" %}',
},
allocated: {
type: 'bool',
title: '{% trans "Is allocated" %}',
description: '{% trans "Item has been alloacted" %}',
description: '{% trans "Item has been allocated" %}',
},
cascade: {
type: 'bool',

View File

@ -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'>&times;</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,7 +85,9 @@
</button>
<h3 id='modal-title'>Alert Information</h3>
</div>
<div class='modal-form-content'>
<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>

View File

@ -12,6 +12,11 @@ from django.utils.translation import gettext_lazy as _
from django.dispatch import receiver
from django.db.models.signals import post_save, post_delete
import logging
logger = logging.getLogger(__name__)
class RuleSet(models.Model):
"""
@ -352,7 +357,7 @@ def update_group_roles(group, debug=False):
content_type = ContentType.objects.get(app_label=app, model=model)
permission = Permission.objects.get(content_type=content_type, codename=perm)
except ContentType.DoesNotExist:
raise ValueError(f"Error: Could not find permission matching '{permission_string}'")
logger.warning(f"Error: Could not find permission matching '{permission_string}'")
permission = None
return permission

View File

@ -0,0 +1,38 @@
"""
Unit tests for the user model database migrations
"""
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import helpers
class TestForwardMigrations(MigratorTestCase):
"""
Test entire schema migration sequence for the users app
"""
migrate_from = ('users', helpers.getOldestMigrationFile('users'))
migrate_to = ('users', helpers.getNewestMigrationFile('users'))
def prepare(self):
User = self.old_state.apps.get_model('auth', 'user')
User.objects.create(
username='fred',
email='fred@fred.com',
password='password'
)
User.objects.create(
username='brad',
email='brad@fred.com',
password='password'
)
def test_users_exist(self):
User = self.new_state.apps.get_model('auth', 'user')
self.assertEqual(User.objects.count(), 2)