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:
@ -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'
|
||||
}
|
@ -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': '',
|
||||
}
|
@ -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': {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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 %}
|
||||
|
||||
|
@ -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
|
||||
|
Binary file not shown.
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
@ -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)
|
||||
|
||||
|
17
InvenTree/order/migrations/0043_auto_20210330_0013.py
Normal file
17
InvenTree/order/migrations/0043_auto_20210330_0013.py
Normal 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(),
|
||||
),
|
||||
]
|
@ -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')
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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 }}
|
||||
|
12
InvenTree/order/templates/order/so_allocate_by_serial.html
Normal file
12
InvenTree/order/templates/order/so_allocate_by_serial.html
Normal 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 %}
|
@ -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):
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}",
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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();
|
||||
});
|
||||
}
|
@ -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',
|
||||
|
@ -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,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>
|
||||
|
@ -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
|
||||
|
38
InvenTree/users/test_migrations.py
Normal file
38
InvenTree/users/test_migrations.py
Normal 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)
|
Reference in New Issue
Block a user