mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
commit
731ec25b24
46
.github/workflows/coverage.yaml
vendored
Normal file
46
.github/workflows/coverage.yaml
vendored
Normal 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
46
.github/workflows/mariadb.yaml
vendored
Normal 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
49
.github/workflows/mysql.yaml
vendored
Normal 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
45
.github/workflows/postgresql.yaml
vendored
Normal 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
|
20
.github/workflows/style.yaml
vendored
20
.github/workflows/style.yaml
vendored
@ -1,11 +1,27 @@
|
||||
name: Style Checks
|
||||
|
||||
on: push
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
jobs:
|
||||
pep:
|
||||
style:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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
|
||||
|
55
.travis.yml
55
.travis.yml
@ -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
|
@ -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.
|
||||
logger.info("Configuring database backend:")
|
||||
|
||||
- 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
|
||||
"""
|
||||
# Extract database configuration from the config.yaml file
|
||||
db_config = CONFIG.get('database', {})
|
||||
|
||||
logger.info("Configuring database backend:")
|
||||
if not db_config:
|
||||
db_config = {}
|
||||
|
||||
# Extract database configuration from the config.yaml file
|
||||
db_config = CONFIG.get('database', {})
|
||||
# Environment variables take preference over config file!
|
||||
|
||||
# Default action if db_config not specified in yaml file
|
||||
if not db_config:
|
||||
db_config = {}
|
||||
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
|
||||
|
||||
# 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
|
||||
for key in db_keys:
|
||||
# First, check the environment variables
|
||||
env_key = f"INVENTREE_DB_{key}"
|
||||
env_var = os.environ.get(env_key, None)
|
||||
|
||||
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
|
||||
if env_var:
|
||||
logger.info(f"{env_key}={env_var}")
|
||||
# Override configuration value
|
||||
db_config[key] = env_var
|
||||
|
||||
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)
|
||||
# Check that required database configuration options are specified
|
||||
reqiured_keys = ['ENGINE', 'NAME']
|
||||
|
||||
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')
|
||||
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)
|
||||
|
||||
# Check that required database configuration options are specified
|
||||
reqiured_keys = ['ENGINE', 'NAME']
|
||||
print('Error: ' + error_msg)
|
||||
sys.exit(-1)
|
||||
|
||||
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)
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
print('Error: ' + error_msg)
|
||||
sys.exit(-1)
|
||||
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
|
||||
"""
|
||||
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_engine = db_config['ENGINE']
|
||||
db_name = db_config['NAME']
|
||||
db_host = db_config.get('HOST', "''")
|
||||
|
||||
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
|
||||
print("InvenTree Database Configuration")
|
||||
print("================================")
|
||||
print(f"ENGINE: {db_engine}")
|
||||
print(f"NAME: {db_name}")
|
||||
print(f"HOST: {db_host}")
|
||||
|
||||
db_name = db_config['NAME']
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -294,6 +301,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
{% if roles.part.change %}
|
||||
|
||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
|
||||
{% if allow_download %}
|
||||
$("#part-image-url").click(function() {
|
||||
launchModalForm(
|
||||
'{% url "part-image-download" part.id %}',
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#part-image-select").click(function() {
|
||||
launchModalForm("{% url 'part-image-select' part.id %}",
|
||||
{
|
||||
@ -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)
|
@ -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"/>
|
||||
|
||||
|
@ -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)
|
BIN
docs/_static/img/api_doc.png
vendored
BIN
docs/_static/img/api_doc.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 73 KiB |
BIN
docs/_static/img/api_http.png
vendored
BIN
docs/_static/img/api_http.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 49 KiB |
BIN
docs/_static/img/modal_form.png
vendored
BIN
docs/_static/img/modal_form.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 65 KiB |
100
docs/conf.py
100
docs/conf.py
@ -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'
|
||||
]}
|
@ -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
|
@ -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.
|
@ -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
|
@ -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
|
@ -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`
|
@ -1,3 +0,0 @@
|
||||
Sphinx>=2.0.1
|
||||
sphinx-autoapi==1.0.0
|
||||
sphinx-rtd-theme==0.4.3
|
@ -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>`_
|
@ -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``.
|
97
docs/templates/python/module.rst
vendored
97
docs/templates/python/module.rst
vendored
@ -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 %}
|
@ -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.
|
@ -1,8 +1,8 @@
|
||||
invoke>=1.4.0 # Invoke build tool
|
||||
wheel>=0.34.2 # Wheel
|
||||
Django==3.0.7 # Django package
|
||||
pillow==7.1.0 # Image manipulation
|
||||
djangorestframework==3.10.3 # DRF framework
|
||||
pillow==8.1.1 # Image manipulation
|
||||
djangorestframework==3.11.2 # DRF framework
|
||||
django-dbbackup==3.3.0 # Database backup / restore functionality
|
||||
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
||||
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-markdownify==0.8.0 # Markdown rendering
|
||||
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
|
||||
django-crispy-forms==1.8.1 # Form helpers
|
||||
django-import-export==2.0.0 # Data import / export for admin interface
|
||||
|
Loading…
Reference in New Issue
Block a user