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
commit 731ec25b24
70 changed files with 3491 additions and 3071 deletions

46
.github/workflows/coverage.yaml vendored Normal file
View File

@ -0,0 +1,46 @@
# Perform CI checks, and calculate code coverage
name: SQLite
on: ["push", "pull_request"]
jobs:
# Run tests on SQLite database
# These tests are used for code coverage analysis
coverage:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get update
pip3 install invoke
invoke install
- name: Coverage Tests
run: |
invoke coverage
- name: Data Import Export
run: |
invoke migrate
invoke import-fixtures
invoke export-records -f data.json
rm test_db.sqlite
invoke migrate
invoke import-records -f data.json
- name: Check Migration Files
run: python3 ci/check_migration_files.py
- name: Upload Coverage Report
run: coveralls

46
.github/workflows/mariadb.yaml vendored Normal file
View File

@ -0,0 +1,46 @@
name: MariaDB
on: ["push", "pull_request"]
jobs:
test:
runs-on: ubuntu-latest
env:
# Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.mysql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: root
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: info
services:
mariadb:
image: mariadb:latest
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: inventree
MYSQL_USER: inventree
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
ports:
- 3306:3306
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get install mysql-server libmysqlclient-dev
pip3 install invoke
pip3 install mysqlclient
invoke install
- name: Run Tests
run: invoke test

49
.github/workflows/mysql.yaml vendored Normal file
View File

@ -0,0 +1,49 @@
# MySQL Unit Testing
name: MySQL
on: ["push", "pull_request"]
jobs:
test:
runs-on: ubuntu-latest
env:
# Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.mysql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: root
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: info
services:
mysql:
image: mysql:latest
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: inventree
MYSQL_USER: inventree
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
ports:
- 3306:3306
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get install mysql-server libmysqlclient-dev
pip3 install invoke
pip3 install mysqlclient
invoke install
- name: Run Tests
run: invoke test

45
.github/workflows/postgresql.yaml vendored Normal file
View File

@ -0,0 +1,45 @@
# PostgreSQL Unit Testing
name: PostgreSQL
on: ["push", "pull_request"]
jobs:
test:
runs-on: ubuntu-latest
env:
# Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info
services:
postgres:
image: postgres
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get install libpq-dev
pip3 install invoke
pip3 install psycopg2
invoke install
- name: Run Tests
run: invoke test

View File

@ -1,11 +1,27 @@
name: Style Checks name: Style Checks
on: push on: ["push", "pull_request"]
jobs: jobs:
pep: style:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.7]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install deps
run: |
pip install flake8==3.8.3
pip install pep8-naming==0.11.1
- name: flake8
run: |
flake8 InvenTree

View File

@ -1,55 +0,0 @@
dist: xenial
services:
- mysql
- postgresql
language: python
python:
- 3.6
- 3.7
addons:
apt-packages:
- sqlite3
before_install:
- sudo useradd --create-home inventree
- sudo mkdir /home/inventree/media /home/media/static /home/media/backup
- sudo apt-get update
- sudo apt-get install gettext
- sudo apt-get install mysql-server libmysqlclient-dev
- sudo apt-get install libpq-dev
- pip3 install invoke
- pip3 install mysqlclient
- pip3 install psycopg2
- invoke install
- invoke migrate
- cd InvenTree && python3 manage.py createsuperuser --username InvenTreeAdmin --email admin@inventree.com --noinput && cd ..
- psql -c 'create database inventree_test_db;' -U postgres
- mysql -e 'CREATE DATABASE inventree_test_db;'
script:
- cd InvenTree && python3 manage.py makemigrations && cd ..
- python3 ci/check_migration_files.py
# Run unit testing / code coverage tests
- invoke coverage
# Run unit test for SQL database backend
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_mysql && cd ..
# Run unit test for PostgreSQL database backend
- cd InvenTree && python3 manage.py test --settings=InvenTree.ci_postgresql && cd ..
- invoke translate
- invoke style
# Create an empty database and fill it with test data
- rm inventree_default_db.sqlite3
- invoke migrate
- invoke import-fixtures
# Export database records
- invoke export-records -f data.json
# Create a new empty database and import the saved data
- rm inventree_default_db.sqlite3
- invoke migrate
- invoke import-records -f data.json
after_success:
- coveralls

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 = {} DATABASES = {}
""" """
When running unit tests, enforce usage of sqlite3 database, Configure the database backend based on the user-specified values.
so that the tests can be run in RAM without any setup requirements
- 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 logger.info("Configuring database backend:")
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:") # Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {})
# Extract database configuration from the config.yaml file if not db_config:
db_config = CONFIG.get('database', {}) db_config = {}
# Default action if db_config not specified in yaml file # Environment variables take preference over config file!
if not db_config:
db_config = {}
# If a particular database option is not specified in the config file, db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
# look for it in the environmental variables
# e.g. INVENTREE_DB_NAME / INVENTREE_DB_USER / etc
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 env_var:
if key not in db_config: logger.info(f"{env_key}={env_var}")
logger.debug(f" - Missing {key} value: Looking for environment variable INVENTREE_DB_{key}") # Override configuration value
env_key = f'INVENTREE_DB_{key}' db_config[key] = env_var
env_var = os.environ.get(env_key, None)
if env_var is not None: # Check that required database configuration options are specified
logger.info(f'Using environment variable INVENTREE_DB_{key}') reqiured_keys = ['ENGINE', 'NAME']
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 for key in reqiured_keys:
reqiured_keys = ['ENGINE', 'NAME'] 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: print('Error: ' + error_msg)
if key not in db_config: sys.exit(-1)
error_msg = f'Missing required database configuration value {key} in config.yaml'
logger.error(error_msg)
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
"""
""" db_engine = db_config['ENGINE']
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'] 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']: db_name = db_config['NAME']
# Prepend the required python module string db_host = db_config.get('HOST', "''")
db_engine = f'django.db.backends.{db_engine.lower()}'
db_config['ENGINE'] = db_engine
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}'") DATABASES['default'] = db_config
logger.info(f"Database NAME: '{db_name}'")
DATABASES['default'] = db_config
CACHES = { CACHES = {
'default': { 'default': {

View File

@ -586,6 +586,8 @@
.breadcrump { .breadcrump {
margin-bottom: 5px; margin-bottom: 5px;
margin-left: 5px;
margin-right: 10px;
} }
.inventree-body { .inventree-body {
@ -624,6 +626,53 @@
z-index: 11000; 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 { .js-modal-form .checkbox {
margin-left: 0px; margin-left: 0px;
} }

View File

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

View File

@ -10,6 +10,9 @@
{% block heading %} {% block heading %}
{% trans "Build Notes" %} {% 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 %} {% endblock %}
{% block details %} {% block details %}
@ -20,14 +23,13 @@
{{ form }} {{ form }}
<hr> <hr>
<input type="submit" value='{% trans "Save" %}'/> <button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form> </form>
{{ form.media }} {{ form.media }}
{% else %} {% 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 }} {{ build.notes | markdownify }}
{% endif %} {% endif %}

View File

@ -78,6 +78,13 @@ class InvenTreeSetting(models.Model):
'choices': djmoney.settings.CURRENCY_CHOICES, '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': { 'BARCODE_ENABLE': {
'name': _('Barcode Support'), 'name': _('Barcode Support'),
'description': _('Enable barcode scanner support'), 'description': _('Enable barcode scanner support'),
@ -97,6 +104,13 @@ class InvenTreeSetting(models.Model):
'validator': bool, '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': { 'PART_COPY_BOM': {
'name': _('Copy Part BOM Data'), 'name': _('Copy Part BOM Data'),
'description': _('Copy BOM data by default when duplicating a part'), '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.db.utils import OperationalError, ProgrammingError
from django.conf import settings from django.conf import settings
from PIL import UnidentifiedImageError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -38,9 +39,11 @@ class CompanyConfig(AppConfig):
try: try:
company.image.render_variations(replace=False) company.image.render_variations(replace=False)
except FileNotFoundError: except FileNotFoundError:
logger.warning("Image file missing") logger.warning(f"Image file '{company.image}' missing")
company.image = None company.image = None
company.save() company.save()
except UnidentifiedImageError:
logger.warning(f"Image file '{company.image}' is invalid")
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
# Getting here probably meant the database was in test mode # Getting here probably meant the database was in test mode
pass 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): class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """ """ Form for editing a SupplierPart object """

View File

@ -2,19 +2,32 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Company" %} - {{ company.name }} InvenTree | {% trans "Company" %} - {{ company.name }}
{% endblock %} {% endblock %}
{% block thumbnail %} {% block thumbnail %}
<div class='dropzone' id='company-thumb'> {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
<img class="part-thumb"
<div class='dropzone part-thumb-container' id='company-thumb'>
<img class="part-thumb" id='company-image'
{% if company.image %} {% if company.image %}
src="{{ company.image.url }}" src="{{ company.image.url }}"
{% else %} {% else %}
src="{% static 'img/blank_image.png' %}" src="{% static 'img/blank_image.png' %}"
{% endif %}/> {% 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> </div>
{% endblock %} {% 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( launchModalForm(
"{% url 'company-image' company.id %}", "{% 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 %} {% endblock %}

View File

@ -9,6 +9,9 @@
{% block heading %} {% block heading %}
{% trans "Company Notes" %} {% 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 %} {% endblock %}
{% block details %} {% block details %}
@ -18,7 +21,7 @@
{{ form }} {{ form }}
<hr> <hr>
<input type="submit" value='{% trans "Save" %}'/> <button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form> </form>
@ -26,7 +29,6 @@
{% else %} {% 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 }} {{ company.notes | markdownify }}
{% endif %} {% endif %}

View File

@ -21,6 +21,7 @@ company_detail_urls = [
url(r'^notes/', views.CompanyNotes.as_view(), name='company-notes'), url(r'^notes/', views.CompanyNotes.as_view(), name='company-notes'),
url(r'^thumbnail/', views.CompanyImage.as_view(), name='company-image'), 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 # Any other URL
url(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), 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.urls import reverse
from django.forms import HiddenInput from django.forms import HiddenInput
from django.core.files.base import ContentFile
from moneyed import CURRENCIES from moneyed import CURRENCIES
from PIL import Image
import requests
import io
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
@ -28,6 +33,7 @@ from .forms import EditCompanyForm
from .forms import CompanyImageForm from .forms import CompanyImageForm
from .forms import EditSupplierPartForm from .forms import EditSupplierPartForm
from .forms import EditPriceBreakForm from .forms import EditPriceBreakForm
from .forms import CompanyImageDownloadForm
import common.models import common.models
import common.settings import common.settings
@ -150,6 +156,84 @@ class CompanyDetail(DetailView):
return ctx 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): class CompanyImage(AjaxUpdateView):
""" View for uploading an image for the Company """ """ View for uploading an image for the Company """
model = 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 RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField from InvenTree.fields import DatePickerFormField
import part.models
from stock.models import StockLocation from stock.models import StockLocation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment 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): class EditSalesOrderAllocationForm(HelperForm):
"""
Form for editing a SalesOrderAllocation item
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) 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: class Meta:
unique_together = [ unique_together = [
('order', 'part'),
] ]
def fulfilled_quantity(self): def fulfilled_quantity(self):
@ -732,6 +731,12 @@ class SalesOrderAllocation(models.Model):
errors = {} 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: try:
if not self.line.part == self.item.part: if not self.line.part == self.item.part:
errors['item'] = _('Cannot allocate stock item to a line with a different part') errors['item'] = _('Cannot allocate stock item to a line with a different part')

View File

@ -11,6 +11,9 @@
{% block heading %} {% block heading %}
{% trans "Order Notes" %} {% 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 %} {% endblock %}
{% block details %} {% block details %}
@ -21,21 +24,19 @@
{{ form }} {{ form }}
<hr> <hr>
<input type='submit' value='{% trans "Save" %}'/> <button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form> </form>
{{ form.media }} {{ form.media }}
{% else %} {% 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 panel-default'>
<div class='panel-content'> <div class='panel-content'>
{{ order.notes | markdownify }} {{ order.notes | markdownify }}
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -275,15 +275,20 @@ $("#so-lines-table").inventreeTable({
if (row.part) { if (row.part) {
var part = row.part_detail; 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) { 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) { 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" %}'); html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
@ -316,10 +321,28 @@ function setupCallbacks() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/line/${pk}/delete/`, { 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() { table.find(".button-add").click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');

View File

@ -12,6 +12,9 @@
{% block heading %} {% block heading %}
{% trans "Sales Order Notes" %} {% 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 %} {% endblock %}
{% block details %} {% block details %}
@ -23,13 +26,12 @@
{{ form }} {{ form }}
<hr> <hr>
<input type='submit' value='{% trans "Save" %}'/> <button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form> </form>
{{ form.media }} {{ form.media }}
{% else %} {% 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 panel-default'>
<div class='panel-content'> <div class='panel-content'>
{{ order.notes | markdownify }} {{ 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.test import TestCase
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -73,10 +72,10 @@ class SalesOrderTest(TestCase):
self.assertFalse(self.order.is_fully_allocated()) self.assertFalse(self.order.is_fully_allocated())
def test_add_duplicate_line_item(self): 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): for ii in range(1, 5):
SalesOrderLineItem.objects.create(order=self.order, part=self.part) SalesOrderLineItem.objects.create(order=self.order, part=self.part, quantity=ii)
def allocate_stock(self, full=True): def allocate_stock(self, full=True):

View File

@ -81,6 +81,7 @@ sales_order_urls = [
# URLs for sales order allocations # URLs for sales order allocations
url(r'^allocation/', include([ url(r'^allocation/', include([
url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'), 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'(?P<pk>\d+)/', include([
url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'), url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'), 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.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic import DetailView, ListView, UpdateView from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic.edit import FormMixin
from django.forms import HiddenInput from django.forms import HiddenInput
import logging import logging
@ -30,6 +32,7 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus 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): class SalesOrderAllocationCreate(AjaxCreateView):
""" View for creating a new SalesOrderAllocation """ """ View for creating a new SalesOrderAllocation """
model = SalesOrderAllocation model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm form_class = order_forms.CreateSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order') ajax_form_title = _('Allocate Stock to Order')
def get_initial(self): def get_initial(self):

View File

@ -7,6 +7,7 @@ from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
from PIL import UnidentifiedImageError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -44,9 +45,11 @@ class PartConfig(AppConfig):
try: try:
part.image.render_variations(replace=False) part.image.render_variations(replace=False)
except FileNotFoundError: except FileNotFoundError:
logger.warning("Image file missing") logger.warning(f"Image file '{part.image}' missing")
part.image = None part.image = None
part.save() part.save()
except UnidentifiedImageError:
logger.warning(f"Image file '{part.image}' is invalid")
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
# Exception if the database has not been migrated yet # Exception if the database has not been migrated yet
pass pass

View File

@ -232,14 +232,18 @@ class BomUploadManager:
# Fields which are absolutely necessary for valid upload # Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [ REQUIRED_HEADERS = [
'Part_Name',
'Quantity' '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 # Fields which would be helpful but are not required
OPTIONAL_HEADERS = [ OPTIONAL_HEADERS = [
'Part_IPN',
'Part_ID',
'Reference', 'Reference',
'Note', 'Note',
'Overage', 'Overage',
@ -251,7 +255,7 @@ class BomUploadManager:
'Overage' 'Overage'
] ]
HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS
def __init__(self, bom_file): def __init__(self, bom_file):
""" Initialize the BomUpload class with a user-uploaded file object """ """ Initialize the BomUpload class with a user-uploaded file object """

View File

@ -37,6 +37,24 @@ class PartModelChoiceField(forms.ModelChoiceField):
return label 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): class PartImageForm(HelperForm):
""" Form for uploading a Part image """ """ 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 """ 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 @transaction.atomic
def validate_bom(self, user): def validate_bom(self, user):

View File

@ -10,35 +10,34 @@
{% block heading %} {% block heading %}
{% trans "Part Notes" %} {% 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 %} {% endblock %}
{% block details %} {% block details %}
{% if editing %} {% if editing %}
<form method='POST'> <form method='POST'>
{% csrf_token %} {% csrf_token %}
{{ form }} {{ form }}
<hr> <hr>
<input type="submit" value='{% trans "Save" %}'/>
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form> </form>
{{ form.media }} {{ form.media }}
{% else %} {% 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'> <div class='panel panel-default'>
{% if part.notes %}
<div class='panel-content'> <div class='panel-content'>
{% if part.notes %}
{{ part.notes | markdownify }} {{ part.notes | markdownify }}
{% endif %}
</div> </div>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -206,6 +206,12 @@
toggleId: '#part-menu-toggle', toggleId: '#part-menu-toggle',
}); });
{% if part.image %}
$('#part-thumb').click(function() {
showModalImage('{{ part.image.url }}');
});
{% endif %}
enableDragAndDrop( enableDragAndDrop(
'#part-thumb', '#part-thumb',
"{% url 'part-image-upload' part.id %}", "{% url 'part-image-upload' part.id %}",
@ -241,6 +247,7 @@
"{% url 'part-pricing' part.id %}", "{% url 'part-pricing' part.id %}",
{ {
submit_text: 'Calculate', 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() { $("#part-image-select").click(function() {
launchModalForm("{% url 'part-image-select' part.id %}", launchModalForm("{% url 'part-image-select' part.id %}",
@ -302,7 +323,6 @@
}); });
}); });
{% if roles.part.change %}
$("#part-edit").click(function() { $("#part-edit").click(function() {
launchModalForm( launchModalForm(
"{% url 'part-edit' part.id %}", "{% url 'part-edit' part.id %}",

View File

@ -1,20 +1,28 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
<div class="media"> <div class="media">
<div class="media-left part-thumb-container"> <div class="media-left part-thumb-container">
<div class='dropzone' id='part-thumb'> <div class='dropzone' id='part-thumb'>
<img class="part-thumb" <img class="part-thumb" id='part-image'
{% if part.image %} {% if part.image %}
src="{{ part.image.url }}" src="{{ part.image.url }}"
{% else %} {% else %}
src="{% static 'img/blank_image.png' %}" src="{% static 'img/blank_image.png' %}"
{% endif %}/> {% endif %}/>
</div> </div>
{% if roles.part.change %}
<div class='btn-row part-thumb-overlay'> <div class='btn-row part-thumb-overlay'>
<div class='btn-group'> <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 '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>
</div> </div>
{% endif %}
</div> </div>

View File

@ -75,6 +75,7 @@ part_detail_urls = [
# Normal thumbnail with form # Normal thumbnail with form
url(r'^thumbnail/?', views.PartImageUpload.as_view(), name='part-image-upload'), 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-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 # Any other URLs go to the part detail page
url(r'^.*$', views.PartDetail.as_view(), name='part-detail'), 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 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.core.files.base import ContentFile
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -19,7 +20,11 @@ from django.conf import settings
from moneyed import CURRENCIES from moneyed import CURRENCIES
from PIL import Image
import requests
import os import os
import io
from rapidfuzz import fuzz from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
@ -831,6 +836,89 @@ class PartQRCode(QRCodeView):
return None 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): class PartImageUpload(AjaxUpdateView):
""" View for uploading a new Part image """ """ View for uploading a new Part image """
@ -910,6 +998,12 @@ class PartEdit(AjaxUpdateView):
form.fields['default_supplier'].queryset = SupplierPart.objects.filter(part=part) 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 return form
@ -1425,10 +1519,23 @@ class BomUpload(InvenTreeRoleMixin, FormView):
# Are there any missing columns? # Are there any missing columns?
self.missing_columns = [] self.missing_columns = []
# Check that all required fields are present
for col in BomUploadManager.REQUIRED_HEADERS: for col in BomUploadManager.REQUIRED_HEADERS:
if col not in self.column_selections.values(): if col not in self.column_selections.values():
self.missing_columns.append(col) 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): def handleFieldSelection(self):
""" Handle the output of the field selection form. """ Handle the output of the field selection form.
Here the user is presented with the raw data and must select the 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) active = str2bool(active)
queryset = queryset.filter(part__active=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 # Filter by 'depleted' status
depleted = params.get('depleted', None) depleted = params.get('depleted', None)

View File

@ -155,18 +155,24 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<div class='btn-group'> <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> <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'> <ul class='dropdown-menu' role='menu'>
{% if item.can_adjust_location %}
{% if not item.serialized %} {% 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> <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> <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> <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 %} {% 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> <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> <li><a href='#' id='stock-serialize' title='{% trans "Serialize stock" %}'><span class='fas fa-hashtag'></span> {% trans "Serialize stock" %}</a> </li>
{% endif %} {% endif %}
{% 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> <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 %} {% endif %}
{% if item.customer %} {% if item.customer %}
@ -469,16 +475,6 @@ $("#barcode-scan-into-location").click(function() {
scanItemsIntoLocation([{{ item.id }}]); 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) { function itemAdjust(action) {
launchModalForm("/stock/adjust/", 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 %} {% if item.part.has_variants %}
$("#stock-convert").click(function() { $("#stock-convert").click(function() {
launchModalForm("{% url 'stock-item-convert' item.id %}", launchModalForm("{% url 'stock-item-convert' item.id %}",
@ -514,10 +533,6 @@ $('#stock-remove').click(function() {
itemAdjust('take'); itemAdjust('take');
}); });
$('#stock-add').click(function() {
itemAdjust('add');
});
{% else %} {% else %}
$("#stock-return-from-customer").click(function() { $("#stock-return-from-customer").click(function() {
@ -530,13 +545,4 @@ $("#stock-return-from-customer").click(function() {
{% endif %} {% endif %}
$("#stock-delete").click(function () {
launchModalForm(
"{% url 'stock-item-delete' item.id %}",
{
redirect: "{% url 'part-stock' item.part.id %}"
}
);
});
{% endblock %} {% endblock %}

View File

@ -11,6 +11,9 @@
{% block heading %} {% block heading %}
{% trans "Stock Item Notes" %} {% 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 %} {% endblock %}
{% block details %} {% block details %}
@ -20,13 +23,12 @@
{{ form }} {{ form }}
<hr> <hr>
<input type='submit' value='{% trans "Save" %}'/> <button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
</form> </form>
{{ form.media }} {{ form.media }}
{% else %} {% 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 %} {% if item.notes %}
{{ item.notes | markdownify }} {{ item.notes | markdownify }}
{% endif %} {% 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_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_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_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
</tbody> </tbody>
</table> </table>

View File

@ -18,6 +18,7 @@
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %} {% 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_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_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
<tr><td colspan='5 '></td></tr> <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! // Form was returned, invalid!
else { else {
var warningDiv = $(modal).find('#form-validation-warning'); if (!options.hideErrorMessage) {
var warningDiv = $(modal).find('#form-validation-warning');
warningDiv.css('display', 'block'); warningDiv.css('display', 'block');
}
if (response.html_form) { if (response.html_form) {
injectModalForm(modal, response.html_form); injectModalForm(modal, response.html_form);
@ -908,3 +909,42 @@ function launchModalForm(url, options = {}) {
// Send the AJAX request // Send the AJAX request
$.ajax(ajax_data); $.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" %}', title: '{% trans "Active parts" %}',
description: '{% trans "Show stock for active parts" %}', description: '{% trans "Show stock for active parts" %}',
}, },
assembly: {
type: 'bool',
title: '{% trans "Assembly" %}',
description: '{% trans "Part is an assembly" %}',
},
allocated: { allocated: {
type: 'bool', type: 'bool',
title: '{% trans "Is allocated" %}', title: '{% trans "Is allocated" %}',
description: '{% trans "Item has been alloacted" %}', description: '{% trans "Item has been allocated" %}',
}, },
cascade: { cascade: {
type: 'bool', type: 'bool',

View File

@ -1,5 +1,12 @@
{% load i18n %} {% 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 fade modal-fixed-footer modal-primary' tabindex='-1' role='dialog' id='modal-form'>
<div class='modal-dialog'> <div class='modal-dialog'>
<div class='modal-content'> <div class='modal-content'>
@ -78,7 +85,9 @@
</button> </button>
<h3 id='modal-title'>Alert Information</h3> <h3 id='modal-title'>Alert Information</h3>
</div> </div>
<div class='modal-form-content'> <div class='modal-form-content-wrapper'>
<div class='modal-form-content'>
</div>
</div> </div>
<div class='modal-footer'> <div class='modal-footer'>
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button> <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.dispatch import receiver
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
import logging
logger = logging.getLogger(__name__)
class RuleSet(models.Model): 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) content_type = ContentType.objects.get(app_label=app, model=model)
permission = Permission.objects.get(content_type=content_type, codename=perm) permission = Permission.objects.get(content_type=content_type, codename=perm)
except ContentType.DoesNotExist: 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 permission = None
return permission 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)

View File

@ -1,4 +1,10 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Build Status](https://travis-ci.org/inventree/InvenTree.svg?branch=master)](https://travis-ci.org/inventree/InvenTree) [![Coverage Status](https://coveralls.io/repos/github/inventree/InvenTree/badge.svg)](https://coveralls.io/github/inventree/InvenTree) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Coverage Status](https://coveralls.io/repos/github/inventree/InvenTree/badge.svg)](https://coveralls.io/github/inventree/InvenTree)
![PEP](https://github.com/inventree/inventree/actions/workflows/style.yaml/badge.svg)
![SQLite](https://github.com/inventree/inventree/actions/workflows/coverage.yaml/badge.svg)
![MySQL](https://github.com/inventree/inventree/actions/workflows/mysql.yaml/badge.svg)
![MariaDB](https://github.com/inventree/inventree/actions/workflows/mariadb.yaml/badge.svg)
![PostgreSQL](https://github.com/inventree/inventree/actions/workflows/postgresql.yaml/badge.svg)
<img src="images/logo/inventree.png" alt="InvenTree" width="128"/> <img src="images/logo/inventree.png" alt="InvenTree" width="128"/>

View File

@ -1,19 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

View File

@ -1,100 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('../'))
sys.path.append(os.path.abspath('../InvenTree'))
# -- Project information -----------------------------------------------------
project = 'InvenTree'
copyright = '2019, InvenTree'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'autoapi.extension',
]
napoleon_google_docstring = True
napoleon_numpy_docstring = False
autoapi_dirs = [
'../InvenTree',
]
autoapi_options = [
'members',
'private-members',
'special-members',
]
autoapi_type = 'python'
autoapi_ignore = [
'*migrations*',
'**/test*.py',
'**/manage.py',
'**/apps.py',
'**/admin.py',
'**/middleware.py',
'**/utils.py',
'**/wsgi.py',
'**/templates/',
]
# Add any paths that contain templates here, relative to this directory.
autoapi_template_dir = 'templates'
autoapi_root = 'docs'
autoapi_add_toctree_entry = False
templates_path = ['templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = [
'_build',
'Thumbs.db',
'.DS_Store',
'manage.rst', # Ignore django management file
'**/*.migrations*.rst', # Ignore migration files
]
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Table of contents in sidebar
html_sidebars = {'**': [
'globaltoc.html',
'relations.html',
'sourcelink.html',
'searchbox.html'
]}

View File

@ -1,57 +0,0 @@
InvenTree Modal Forms
=====================
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: Modal Forms
:hidden:
The InvenTree web interface uses modal forms for user input. InvenTree defines a wrapper layer around the Django form classes to provide a mechanism for retrieving and rendering forms via jQuery and AJAX.
.. image:: _static/img/modal_form.png
Forms are rendered to a Bootstrap modal window, allowing in-page data input and live page updating.
Crispy Forms
------------
Django provides native form rendering tools which are very powerful, allowing form rendering, input validation, and display of error messages for each field.
InvenTree makes use of the `django-crispy-forms <https://github.com/django-crispy-forms/django-crispy-forms>`_ extension to reduce the amount of boilerplate required to convert a Django model to a HTML form.
Form Rendering
--------------
The InvenTree front-end web interface is implemented using jQuery and Bootstrap. Forms are rendered using Django `class-based forms <https://docs.djangoproject.com/en/2.2/topics/class-based-views/generic-editing/>`_ using standard Django methods.
The main point of difference is that instead of rendering a HTTP response (and displaying a static form page) form data are requested via AJAX, and the form contents are injected into the modal window.
A set of javascript/jQuery functions handle the client/server interactions, and manage GET and POST requests.
Sequence of Events
------------------
#. User presses a button or other element which initiates form loading
#. jQuery function sends AJAX GET request to InvenTree server, requesting form at a specified URL
#. Django renders form (according to specific model/view rules)
#. Django returns rendered form as a JSON object
#. Client displays the modal window and injects the form contents into the modal
#. User fills in form data, presses the 'Submit' button
#. Client sends the completed form to server via POST
#. Django backend handles POST request, specifically determines if the form is valid
#. Return a JSON object containing status of form validity
* If the form is valid, return (at minimum) ``{form_valid: true}``. Client will close the modal.
* If the form is invalid, re-render the form and send back to the client. Process repeats
At the end of this process (i.e. after successful processing of the form) the client closes the modal and runs any optional post-processes (depending on the implementation).
Further Reading
---------------
For a better understanding of the modal form architecture, refer to the relevant source files:
**Server Side:** Refer to ``./InvenTree/InvenTree/views.py`` for AJAXified Django Views
**Client Side:** Refer to ``./InvenTree/static/script/inventree/modals.js`` for client-side javascript

View File

@ -1,38 +0,0 @@
InvenTree Source Documentation
================================
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: Index
:hidden:
Translations<translate>
Modal Forms<forms>
Tables<tables>
REST API<rest>
InvenTree Modules <modules>
Module Reference<reference>
The documentation found here is provided to be useful for developers working on the InvenTree codebase. User documentation can be found on the `InvenTree website <https://inventree.github.io>`_.
Documentation for the Python modules is auto-generated from the `InvenTree codebase <https://github.com/InvenTree/InvenTree>`_.
Code Structure
--------------
**Backend**
InvenTree is developed using the `django web framework <https://www.djangoproject.com/>`_, a powerful toolkit for making web applications in Python.
The database management code and business logic is written in Python 3. Core functionality is separated into individual modules (or *apps* using the django nomenclature).
Each *app* is located in a separate directory under InvenTree. Each *app* contains python modules named according to the standard django configuration.
**Frontend**
The web frontend rendered using a mixture of technologies.
Base HTML code is rendered using the `django templating language <https://docs.djangoproject.com/en/2.2/topics/templates/>`_ which provides low-level access to the underlying database models.
jQuery is also used to implement front-end logic, and desponse to user input. A REST API is provided to facilitate client-server communication.

View File

@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
:end
popd

View File

@ -1,26 +0,0 @@
InvenTree Modules
=================
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: App Modules
:hidden:
docs/InvenTree/index
docs/build/index
docs/common/index
docs/company/index
docs/part/index
docs/order/index
docs/stock/index
The InvenTree Django ecosystem provides the following 'apps' for core functionality:
* `InvenTree <docs/InvenTree/index.html>`_ - High level management functions
* `Build <docs/build/index.html>`_ - Part build projects
* `Common <docs/common/index.html>`_ - Common modules used by various apps
* `Company <docs/company/index.html>`_ - Company management (suppliers / customers)
* `Part <docs/part/index.html>`_ - Part management
* `Order <docs/order/index.html>`_ - Order management
* `Stock <docs/stock/index.html>`_ - Stock management

View File

@ -1,14 +0,0 @@
Module Reference
===================
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: Module Reference
:hidden:
The complete reference indexes are found below:
* :ref:`modindex`
* :ref:`genindex`

View File

@ -1,3 +0,0 @@
Sphinx>=2.0.1
sphinx-autoapi==1.0.0
sphinx-rtd-theme==0.4.3

View File

@ -1,42 +0,0 @@
REST API
========
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: REST API
:hidden:
InvenTree provides a REST API which serves data to the web client and also provides data access to third-party applications. The REST API is implemented using the `Django REST framework (DRF) <https://www.django-rest-framework.org/>`_ which provides the following features out of the box:
* AJAX REST API
* Web-browseable REST
* User authentication
* Database model serialization and validation
API Access
----------
The API is accessible from the root URL ``/api/``. It requires user authentication.
* Requesting data via AJAX query will return regular JSON objects.
* Directing a browser to the API endpoints provides a web-browsable interface
.. image:: _static/img/api_http.png
API Documentation
-----------------
API documentation is provided by DRF autodoc tools, and is available for browsing at ``/api-doc/``
.. image:: _static/img/api_doc.png
API Code
--------
Javascript/jQuery code for interacting with the server via the REST API can be found under ``InvenTree/static/script/InvenTree``.
Python interface
----------------
A Python library for interacting with the InvenTree API is provided on `GitHub <https://github.com/inventree/inventree-python>`_

View File

@ -1,14 +0,0 @@
Table Management
================
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: Tables
:hidden:
InvenTree uses `Bootstrap Table <https://bootstrap-table.com/>`_ to manage tabulated data in the web front-end. The ability to tabulate data from read via an AJAX request allows tables to be updated on-the-fly (without a full page reload).
Bootstrap Table also provides integrated tools for table searching, filtering, and advanced rendering.
Frontend code for table functionality can be found at ``InvenTree/static/script/inventree/tables.js``.

View File

@ -1,97 +0,0 @@
{% if not obj.display %}
:orphan:
{% endif %}
:mod:`{{ obj.name }}`
======={{ "=" * obj.name|length }}
.. py:module:: {{ obj.name }}
{% if obj.docstring %}
.. autoapi-nested-parse::
{{ obj.docstring|prepare_docstring|indent(3) }}
{% endif %}
{% block subpackages %}
{% set visible_subpackages = obj.subpackages|selectattr("display")|list %}
{% if visible_subpackages %}
Subpackages
-----------
.. toctree::
:titlesonly:
:maxdepth: 3
{% for subpackage in visible_subpackages %}
{{ subpackage.short_name }}/index.rst
{% endfor %}
{% endif %}
{% endblock %}
{% block submodules %}
{% set visible_submodules = obj.submodules|selectattr("display")|list %}
{% if visible_submodules %}
Submodules
----------
The {{ obj.name }} module contains the following submodules
.. toctree::
:titlesonly:
:maxdepth: 1
{% for submodule in visible_submodules %}
{{ submodule.short_name }}/index.rst
{% endfor %}
{% endif %}
{% endblock %}
{% block content %}
{% set visible_children = obj.children|selectattr("display")|list %}
{% if visible_children %}
{{ obj.type|title }} Contents
{{ "-" * obj.type|length }}---------
{% set visible_classes = visible_children|selectattr("type", "equalto", "class")|list %}
{% set visible_functions = visible_children|selectattr("type", "equalto", "function")|list %}
{% if include_summaries and (visible_classes or visible_functions) %}
{% block classes %}
{% if visible_classes %}
Classes
~~~~~~~
.. autoapisummary::
{% for klass in visible_classes %}
{{ klass.id }}
{% endfor %}
{% endif %}
{% endblock %}
{% block functions %}
{% if visible_functions %}
Functions
~~~~~~~~~
.. autoapisummary::
{% for function in visible_functions %}
{{ function.id }}
{% endfor %}
{% endif %}
{% endblock %}
{% endif %}
{% for obj_item in visible_children %}
{% if obj.all is none or obj_item.short_name in obj.all %}
{{ obj_item.rendered|indent(0) }}
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}

View File

@ -1,16 +0,0 @@
Translations
============
.. toctree::
:titlesonly:
:maxdepth: 2
:caption: Language Translation
:hidden:
InvenTree supports multi-language translation using the `Django Translation Framework <https://docs.djangoproject.com/en/2.2/topics/i18n/translation/>`_
Translation strings are located in the `InvenTree/locales/` directory, and translation files can be easily added here.
To set the default language, change the `language` setting in the `config.yaml` settings file.
To recompile the translation files (after adding new translation strings), run the command ``make translate`` from the root directory.

View File

@ -1,8 +1,8 @@
invoke>=1.4.0 # Invoke build tool invoke>=1.4.0 # Invoke build tool
wheel>=0.34.2 # Wheel wheel>=0.34.2 # Wheel
Django==3.0.7 # Django package Django==3.0.7 # Django package
pillow==7.1.0 # Image manipulation pillow==8.1.1 # Image manipulation
djangorestframework==3.10.3 # DRF framework djangorestframework==3.11.2 # DRF framework
django-dbbackup==3.3.0 # Database backup / restore functionality django-dbbackup==3.3.0 # Database backup / restore functionality
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF
django_filter==2.2.0 # Extended filtering options django_filter==2.2.0 # Extended filtering options
@ -11,7 +11,7 @@ django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-markdownx==3.0.1 # Markdown form fields django-markdownx==3.0.1 # Markdown form fields
django-markdownify==0.8.0 # Markdown rendering django-markdownify==0.8.0 # Markdown rendering
coreapi==2.3.0 # API documentation coreapi==2.3.0 # API documentation
pygments==2.2.0 # Syntax highlighting pygments==2.7.4 # Syntax highlighting
tablib==0.13.0 # Import / export data files tablib==0.13.0 # Import / export data files
django-crispy-forms==1.8.1 # Form helpers django-crispy-forms==1.8.1 # Form helpers
django-import-export==2.0.0 # Data import / export for admin interface django-import-export==2.0.0 # Data import / export for admin interface