Merge branch 'master' of https://github.com/inventree/InvenTree into price-history

This commit is contained in:
Matthias 2021-04-14 21:18:21 +02:00
commit 75381cbb7b
127 changed files with 6565 additions and 2780 deletions

View File

@ -16,6 +16,8 @@ jobs:
INVENTREE_DB_NAME: './test_db.sqlite' INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: django.db.backends.sqlite3 INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps: steps:
- name: Checkout Code - name: Checkout Code

18
.github/workflows/docker_build.yaml vendored Normal file
View File

@ -0,0 +1,18 @@
# Test that the docker file builds correctly
name: Docker
on: ["push", "pull_request"]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build Server Image
run: cd docker/inventree && docker build . --tag inventree:$(date +%s)
- name: Build nginx Image
run: cd docker/nginx && docker build . --tag nxinx:$(date +%s)

38
.github/workflows/docker_publish.yaml vendored Normal file
View File

@ -0,0 +1,38 @@
# Publish docker images to dockerhub
name: Docker Publish
on:
release:
types: [published]
jobs:
server_image:
name: Push InvenTree web server image to dockerhub
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Push to Docker Hub
uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: inventree/inventree
tag_with_ref: true
dockerfile: docker/inventree/Dockerfile
nginx_image:
name: Push InvenTree nginx image to dockerhub
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Push to Docker Hub
uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: inventree/nginx
tag_with_ref: true
dockerfile: docker/nginx/Dockerfile

View File

@ -1,46 +0,0 @@
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

View File

@ -18,6 +18,8 @@ jobs:
INVENTREE_DB_HOST: '127.0.0.1' INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 3306 INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: info INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
services: services:
mysql: mysql:

View File

@ -18,6 +18,8 @@ jobs:
INVENTREE_DB_HOST: '127.0.0.1' INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 5432 INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
services: services:
postgres: postgres:

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
import logging import logging
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.http import JsonResponse from django.http import JsonResponse
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -19,11 +19,12 @@ from rest_framework.views import APIView
from .views import AjaxView from .views import AjaxView
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
from .status import is_worker_running
from plugins import plugins as inventree_plugins from plugins import plugins as inventree_plugins
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
logger.info("Loading action plugins...") logger.info("Loading action plugins...")
@ -44,6 +45,7 @@ class InfoView(AjaxView):
'version': inventreeVersion(), 'version': inventreeVersion(),
'instance': inventreeInstanceName(), 'instance': inventreeInstanceName(),
'apiVersion': inventreeApiVersion(), 'apiVersion': inventreeApiVersion(),
'worker_running': is_worker_running(),
} }
return JsonResponse(data) return JsonResponse(data)

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
import logging
from django.apps import AppConfig
from django.core.exceptions import AppRegistryNotReady
import InvenTree.tasks
logger = logging.getLogger("inventree")
class InvenTreeConfig(AppConfig):
name = 'InvenTree'
def ready(self):
self.start_background_tasks()
def start_background_tasks(self):
try:
from django_q.models import Schedule
except (AppRegistryNotReady):
return
logger.info("Starting background tasks...")
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_successful_tasks',
schedule_type=Schedule.DAILY,
)
InvenTree.tasks.schedule_task(
'InvenTree.tasks.check_for_updates',
schedule_type=Schedule.DAILY
)
InvenTree.tasks.schedule_task(
'InvenTree.tasks.heartbeat',
schedule_type=Schedule.MINUTES,
minutes=15
)

View File

@ -30,10 +30,23 @@ def health_status(request):
request._inventree_health_status = True request._inventree_health_status = True
return { status = {
"system_healthy": InvenTree.status.check_system_health(), 'django_q_running': InvenTree.status.is_worker_running(),
'email_configured': InvenTree.status.is_email_configured(),
} }
all_healthy = True
for k in status.keys():
if status[k] is not True:
all_healthy = False
status['system_healthy'] = all_healthy
status['up_to_date'] = InvenTree.version.isInvenTreeUpToDate()
return status
def status_codes(request): def status_codes(request):
""" """

View File

@ -5,7 +5,7 @@ from __future__ import unicode_literals
from .validators import allowable_url_schemes from .validators import allowable_url_schemes
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.forms.fields import URLField as FormURLField from django.forms.fields import URLField as FormURLField
from django.db import models as models from django.db import models as models
@ -42,6 +42,7 @@ class DatePickerFormField(forms.DateField):
def __init__(self, **kwargs): def __init__(self, **kwargs):
help_text = kwargs.get('help_text', _('Enter date')) help_text = kwargs.get('help_text', _('Enter date'))
label = kwargs.get('label', None)
required = kwargs.get('required', False) required = kwargs.get('required', False)
initial = kwargs.get('initial', None) initial = kwargs.get('initial', None)
@ -56,7 +57,8 @@ class DatePickerFormField(forms.DateField):
required=required, required=required,
initial=initial, initial=initial,
help_text=help_text, help_text=help_text,
widget=widget widget=widget,
label=label
) )

View File

@ -5,7 +5,7 @@ Helper forms which subclass Django forms to provide additional functionality
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django import forms from django import forms
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field from crispy_forms.layout import Layout, Field
@ -123,6 +123,7 @@ class DeleteForm(forms.Form):
confirm_delete = forms.BooleanField( confirm_delete = forms.BooleanField(
required=False, required=False,
initial=False, initial=False,
label=_('Confirm delete'),
help_text=_('Confirm item deletion') help_text=_('Confirm item deletion')
) )
@ -155,6 +156,7 @@ class SetPasswordForm(HelperForm):
required=True, required=True,
initial='', initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
label=_('Enter password'),
help_text=_('Enter new password')) help_text=_('Enter new password'))
confirm_password = forms.CharField(max_length=100, confirm_password = forms.CharField(max_length=100,
@ -162,6 +164,7 @@ class SetPasswordForm(HelperForm):
required=True, required=True,
initial='', initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
label=_('Confirm password'),
help_text=_('Confirm new password')) help_text=_('Confirm new password'))
class Meta: class Meta:

View File

@ -13,7 +13,7 @@ from decimal import Decimal
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@ -382,17 +382,17 @@ def extract_serial_numbers(serials, expected_quantity):
if a < b: if a < b:
for n in range(a, b + 1): for n in range(a, b + 1):
if n in numbers: if n in numbers:
errors.append(_('Duplicate serial: {n}'.format(n=n))) errors.append(_('Duplicate serial: {n}').format(n=n))
else: else:
numbers.append(n) numbers.append(n)
else: else:
errors.append(_("Invalid group: {g}".format(g=group))) errors.append(_("Invalid group: {g}").format(g=group))
except ValueError: except ValueError:
errors.append(_("Invalid group: {g}".format(g=group))) errors.append(_("Invalid group: {g}").format(g=group))
continue continue
else: else:
errors.append(_("Invalid group: {g}".format(g=group))) errors.append(_("Invalid group: {g}").format(g=group))
continue continue
else: else:
@ -409,7 +409,7 @@ def extract_serial_numbers(serials, expected_quantity):
# The number of extracted serial numbers must match the expected quantity # The number of extracted serial numbers must match the expected quantity
if not expected_quantity == len(numbers): if not expected_quantity == len(numbers):
raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})".format(s=len(numbers), q=expected_quantity))]) raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
return numbers return numbers

View File

@ -0,0 +1,42 @@
"""
Custom management command, wait for the database to be ready!
"""
from django.core.management.base import BaseCommand
from django.db import connection
from django.db.utils import OperationalError, ImproperlyConfigured
import time
class Command(BaseCommand):
"""
django command to pause execution until the database is ready
"""
def handle(self, *args, **kwargs):
self.stdout.write("Waiting for database...")
connected = False
while not connected:
time.sleep(5)
try:
connection.ensure_connection()
connected = True
except OperationalError as e:
self.stdout.write(f"Could not connect to database: {e}")
except ImproperlyConfigured as e:
self.stdout.write(f"Improperly configured: {e}")
else:
if not connection.is_usable():
self.stdout.write("Database configuration is not usable")
if connected:
self.stdout.write("Database connection sucessful!")

View File

@ -8,7 +8,7 @@ import operator
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class AuthRequiredMiddleware(object): class AuthRequiredMiddleware(object):
@ -52,6 +52,10 @@ class AuthRequiredMiddleware(object):
if request.path_info.startswith('/static/'): if request.path_info.startswith('/static/'):
authorized = True authorized = True
# Unauthorized users can access the login page
elif request.path_info.startswith('/accounts/'):
authorized = True
elif 'Authorization' in request.headers.keys(): elif 'Authorization' in request.headers.keys():
auth = request.headers['Authorization'].strip() auth = request.headers['Authorization'].strip()

View File

@ -56,19 +56,20 @@ class InvenTreeAttachment(models.Model):
def __str__(self): def __str__(self):
return os.path.basename(self.attachment.name) return os.path.basename(self.attachment.name)
attachment = models.FileField(upload_to=rename_attachment, attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'),
help_text=_('Select file to attach')) help_text=_('Select file to attach'))
comment = models.CharField(blank=True, max_length=100, help_text=_('File comment')) comment = models.CharField(blank=True, max_length=100, verbose_name=_('Comment'), help_text=_('File comment'))
user = models.ForeignKey( user = models.ForeignKey(
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
verbose_name=_('User'),
help_text=_('User'), help_text=_('User'),
) )
upload_date = models.DateField(auto_now_add=True, null=True, blank=True) upload_date = models.DateField(auto_now_add=True, null=True, blank=True, verbose_name=_('upload date'))
@property @property
def basename(self): def basename(self):
@ -103,12 +104,14 @@ class InvenTreeTree(MPTTModel):
blank=False, blank=False,
max_length=100, max_length=100,
validators=[validate_tree_name], validators=[validate_tree_name],
verbose_name=_("Name"),
help_text=_("Name"), help_text=_("Name"),
) )
description = models.CharField( description = models.CharField(
blank=True, blank=True,
max_length=250, max_length=250,
verbose_name=_("Description"),
help_text=_("Description (optional)") help_text=_("Description (optional)")
) )
@ -117,6 +120,7 @@ class InvenTreeTree(MPTTModel):
on_delete=models.DO_NOTHING, on_delete=models.DO_NOTHING,
blank=True, blank=True,
null=True, null=True,
verbose_name=_("parent"),
related_name='children') related_name='children')
@property @property

View File

@ -13,6 +13,9 @@ database setup in this file.
import logging import logging
import os import os
import random
import string
import shutil
import sys import sys
import tempfile import tempfile
from datetime import datetime from datetime import datetime
@ -46,14 +49,31 @@ def get_setting(environment_var, backup_val, default_value=None):
return default_value return default_value
# Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Specify where the "config file" is located.
# By default, this is 'config.yaml'
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
if cfg_filename:
cfg_filename = cfg_filename.strip()
cfg_filename = os.path.abspath(cfg_filename)
else:
# Config file is *not* specified - use the default
cfg_filename = os.path.join(BASE_DIR, 'config.yaml') cfg_filename = os.path.join(BASE_DIR, 'config.yaml')
if not os.path.exists(cfg_filename): if not os.path.exists(cfg_filename):
print("Error: config.yaml not found") print("InvenTree configuration file 'config.yaml' not found - creating default file")
sys.exit(-1)
cfg_template = os.path.join(BASE_DIR, "config_template.yaml")
shutil.copyfile(cfg_template, cfg_filename)
print(f"Created config file {cfg_filename}")
with open(cfg_filename, 'r') as cfg: with open(cfg_filename, 'r') as cfg:
CONFIG = yaml.safe_load(cfg) CONFIG = yaml.safe_load(cfg)
@ -94,7 +114,18 @@ LOGGING = {
} }
# Get a logger instance for this setup file # Get a logger instance for this setup file
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
"""
Specify a secret key to be used by django.
Following options are tested, in descending order of preference:
A) Check for environment variable INVENTREE_SECRET_KEY => Use raw key data
B) Check for environment variable INVENTREE_SECRET_KEY_FILE => Load key data from file
C) Look for default key file "secret_key.txt"
d) Create "secret_key.txt" if it does not exist
"""
if os.getenv("INVENTREE_SECRET_KEY"): if os.getenv("INVENTREE_SECRET_KEY"):
# Secret key passed in directly # Secret key passed in directly
@ -105,15 +136,22 @@ else:
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE") key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
if key_file: if key_file:
if os.path.isfile(key_file): key_file = os.path.abspath(key_file)
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY_FILE")
else:
logger.error(f"Secret key file {key_file} not found")
exit(-1)
else: else:
# default secret key location # default secret key location
key_file = os.path.join(BASE_DIR, "secret_key.txt") key_file = os.path.join(BASE_DIR, "secret_key.txt")
logger.info(f"SECRET_KEY loaded from {key_file}") key_file = os.path.abspath(key_file)
if not os.path.exists(key_file):
logger.info(f"Generating random key file at '{key_file}'")
# Create a random key file
with open(key_file, 'w') as f:
options = string.digits + string.ascii_letters + string.punctuation
key = ''.join([random.choice(options) for i in range(100)])
f.write(key)
logger.info(f"Loading SECRET_KEY from '{key_file}'")
try: try:
SECRET_KEY = open(key_file, "r").read().strip() SECRET_KEY = open(key_file, "r").read().strip()
except Exception: except Exception:
@ -144,7 +182,7 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.abspath( STATIC_ROOT = os.path.abspath(
get_setting( get_setting(
'INVENTREE_STATIC_ROOT', 'INVENTREE_STATIC_ROOT',
CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')) CONFIG.get('static_root', '/home/inventree/static')
) )
) )
@ -162,7 +200,7 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.abspath( MEDIA_ROOT = os.path.abspath(
get_setting( get_setting(
'INVENTREE_MEDIA_ROOT', 'INVENTREE_MEDIA_ROOT',
CONFIG.get('media_root', os.path.join(BASE_DIR, 'media')) CONFIG.get('media_root', '/home/inventree/data/media')
) )
) )
@ -194,6 +232,7 @@ INSTALLED_APPS = [
'report.apps.ReportConfig', 'report.apps.ReportConfig',
'stock.apps.StockConfig', 'stock.apps.StockConfig',
'users.apps.UsersConfig', 'users.apps.UsersConfig',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
# Third part add-ons # Third part add-ons
'django_filters', # Extended filter functionality 'django_filters', # Extended filter functionality
@ -211,6 +250,7 @@ INSTALLED_APPS = [
'djmoney', # django-money integration 'djmoney', # django-money integration
'djmoney.contrib.exchange', # django-money exchange rates 'djmoney.contrib.exchange', # django-money exchange rates
'error_report', # Error reporting in the admin interface 'error_report', # Error reporting in the admin interface
'django_q',
] ]
MIDDLEWARE = CONFIG.get('middleware', [ MIDDLEWARE = CONFIG.get('middleware', [
@ -285,6 +325,18 @@ REST_FRAMEWORK = {
WSGI_APPLICATION = 'InvenTree.wsgi.application' WSGI_APPLICATION = 'InvenTree.wsgi.application'
# django-q configuration
Q_CLUSTER = {
'name': 'InvenTree',
'workers': 4,
'timeout': 90,
'retry': 120,
'queue_limit': 50,
'bulk': 10,
'orm': 'default',
'sync': False,
}
# Markdownx configuration # Markdownx configuration
# Ref: https://neutronx.github.io/django-markdownx/customization/ # Ref: https://neutronx.github.io/django-markdownx/customization/
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d') MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
@ -331,6 +383,9 @@ logger.info("Configuring database backend:")
# Extract database configuration from the config.yaml file # Extract database configuration from the config.yaml file
db_config = CONFIG.get('database', {}) db_config = CONFIG.get('database', {})
if not db_config:
db_config = {}
# Environment variables take preference over config file! # Environment variables take preference over config file!
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT'] db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
@ -350,7 +405,7 @@ reqiured_keys = ['ENGINE', 'NAME']
for key in reqiured_keys: for key in reqiured_keys:
if key not in db_config: if key not in db_config:
error_msg = f'Missing required database configuration value {key} in config.yaml' error_msg = f'Missing required database configuration value {key}'
logger.error(error_msg) logger.error(error_msg)
print('Error: ' + error_msg) print('Error: ' + error_msg)
@ -386,11 +441,6 @@ CACHES = {
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}, },
'qr-code': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'qr-code-cache',
'TIMEOUT': 3600
}
} }
# Password validation # Password validation
@ -445,16 +495,67 @@ CURRENCIES = CONFIG.get(
# TODO - Allow live web-based backends in the future # TODO - Allow live web-based backends in the future
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend' EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend'
# Extract email settings from the config file
email_config = CONFIG.get('email', {})
EMAIL_BACKEND = get_setting(
'django.core.mail.backends.smtp.EmailBackend',
email_config.get('backend', '')
)
# Email backend settings
EMAIL_HOST = get_setting(
'INVENTREE_EMAIL_HOST',
email_config.get('host', '')
)
EMAIL_PORT = get_setting(
'INVENTREE_EMAIL_PORT',
email_config.get('port', 25)
)
EMAIL_HOST_USER = get_setting(
'INVENTREE_EMAIL_USERNAME',
email_config.get('username', ''),
)
EMAIL_HOST_PASSWORD = get_setting(
'INVENTREE_EMAIL_PASSWORD',
email_config.get('password', ''),
)
EMAIL_SUBJECT_PREFIX = '[InvenTree] '
EMAIL_USE_LOCALTIME = False
EMAIL_USE_TLS = get_setting(
'INVENTREE_EMAIL_TLS',
email_config.get('tls', False),
)
EMAIL_USE_SSL = get_setting(
'INVENTREE_EMAIL_SSL',
email_config.get('ssl', False),
)
EMAIL_TIMEOUT = 60
LOCALE_PATHS = ( LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale/'), os.path.join(BASE_DIR, 'locale/'),
) )
TIME_ZONE = CONFIG.get('timezone', 'UTC') TIME_ZONE = get_setting(
'INVENTREE_TIMEZONE',
CONFIG.get('timezone', 'UTC')
)
USE_I18N = True USE_I18N = True
USE_L10N = True USE_L10N = True
# Do not use native timezone support in "test" mode
# It generates a *lot* of cruft in the logs
if not TESTING:
USE_TZ = True USE_TZ = True
DATE_INPUT_FORMATS = [ DATE_INPUT_FORMATS = [

View File

@ -1,13 +1,73 @@
""" """
Provides system status functionality checks. Provides system status functionality checks.
""" """
# -*- coding: utf-8 -*-
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
import logging import logging
from datetime import datetime, timedelta
from django_q.models import Success
from django_q.monitor import Stat
from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
def is_worker_running(**kwargs):
"""
Return True if the background worker process is oprational
"""
clusters = Stat.get_all()
if len(clusters) > 0:
# TODO - Introspect on any cluster information
return True
"""
Sometimes Stat.get_all() returns [].
In this case we have the 'heartbeat' task running every 15 minutes.
Check to see if we have a result within the last 20 minutes
"""
now = datetime.now()
past = now - timedelta(minutes=20)
results = Success.objects.filter(
func='InvenTree.tasks.heartbeat',
started__gte=past
)
# If any results are returned, then the background worker is running!
return results.exists()
def is_email_configured():
"""
Check if email backend is configured.
NOTE: This does not check if the configuration is valid!
"""
configured = True
if not settings.EMAIL_HOST:
logger.warning("EMAIL_HOST is not configured")
configured = False
if not settings.EMAIL_HOST_USER:
logger.warning("EMAIL_HOST_USER is not configured")
configured = False
if not settings.EMAIL_HOST_PASSWORD:
logger.warning("EMAIL_HOST_PASSWORD is not configured")
configured = False
return configured
def check_system_health(**kwargs): def check_system_health(**kwargs):
@ -19,21 +79,15 @@ def check_system_health(**kwargs):
result = True result = True
if not check_celery_worker(**kwargs): if not is_worker_running(**kwargs):
result = False result = False
logger.warning(_("Celery worker check failed")) logger.warning(_("Background worker check failed"))
if not is_email_configured():
result = False
logger.warning(_("Email backend not configured"))
if not result: if not result:
logger.warning(_("InvenTree system health checks failed")) logger.warning(_("InvenTree system health checks failed"))
return result return result
def check_celery_worker(**kwargs):
"""
Check that a celery worker is running.
"""
# TODO - Checks that the configured celery worker thing is running
return True

View File

@ -1,4 +1,4 @@
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
class StatusCode: class StatusCode:

View File

@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import json
import requests
import logging
from datetime import datetime, timedelta
from django.core.exceptions import AppRegistryNotReady
from django.db.utils import OperationalError, ProgrammingError
logger = logging.getLogger("inventree")
def schedule_task(taskname, **kwargs):
"""
Create a scheduled task.
If the task has already been scheduled, ignore!
"""
# If unspecified, repeat indefinitely
repeats = kwargs.pop('repeats', -1)
kwargs['repeats'] = repeats
try:
from django_q.models import Schedule
except (AppRegistryNotReady):
logger.warning("Could not start background tasks - App registry not ready")
return
try:
# If this task is already scheduled, don't schedule it again
# Instead, update the scheduling parameters
if Schedule.objects.filter(func=taskname).exists():
logger.info(f"Scheduled task '{taskname}' already exists - updating!")
Schedule.objects.filter(func=taskname).update(**kwargs)
else:
logger.info(f"Creating scheduled task '{taskname}'")
Schedule.objects.create(
name=taskname,
func=taskname,
**kwargs
)
except (OperationalError, ProgrammingError):
# Required if the DB is not ready yet
pass
def offload_task(taskname, *args, **kwargs):
"""
Create an AsyncTask.
This is different to a 'scheduled' task,
in that it only runs once!
"""
try:
from django_q.tasks import AsyncTask
except (AppRegistryNotReady):
logger.warning("Could not offload task - app registry not ready")
return
task = AsyncTask(taskname, *args, **kwargs)
task.run()
def heartbeat():
"""
Simple task which runs at 5 minute intervals,
so we can determine that the background worker
is actually running.
(There is probably a less "hacky" way of achieving this)?
"""
try:
from django_q.models import Success
logger.warning("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady:
return
threshold = datetime.now() - timedelta(minutes=30)
# Delete heartbeat results more than half an hour old,
# otherwise they just create extra noise
heartbeats = Success.objects.filter(
func='InvenTree.tasks.heartbeat',
started__lte=threshold
)
heartbeats.delete()
def delete_successful_tasks():
"""
Delete successful task logs
which are more than a month old.
"""
try:
from django_q.models import Success
except AppRegistryNotReady:
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready")
return
threshold = datetime.now() - timedelta(days=30)
results = Success.objects.filter(
started__lte=threshold
)
results.delete()
def check_for_updates():
"""
Check if there is an update for InvenTree
"""
try:
import common.models
except AppRegistryNotReady:
# Apps not yet loaded!
return
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
if not response.status_code == 200:
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
data = json.loads(response.text)
tag = data.get('tag_name', None)
if not tag:
raise ValueError("'tag_name' missing from GitHub response")
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
if not len(match.groups()) == 3:
logger.warning(f"Version '{tag}' did not match expected pattern")
return
latest_version = [int(x) for x in match.groups()]
if not len(latest_version) == 3:
raise ValueError(f"Version '{tag}' is not correct format")
logger.info(f"Latest InvenTree version: '{tag}'")
# Save the version to the database
common.models.InvenTreeSetting.set_setting(
'INVENTREE_LATEST_VERSION',
tag,
None
)
def send_email(subject, body, recipients, from_email=None):
"""
Send an email with the specified subject and body,
to the specified recipients list.
"""
if type(recipients) == str:
recipients = [recipients]
offload_task(
'django.core.mail.send_mail',
subject, body,
from_email,
recipients,
)

View File

@ -0,0 +1,43 @@
"""
Unit tests for task management
"""
from django.test import TestCase
from django_q.models import Schedule
import InvenTree.tasks
class ScheduledTaskTests(TestCase):
"""
Unit tests for scheduled tasks
"""
def get_tasks(self, name):
return Schedule.objects.filter(func=name)
def test_add_task(self):
"""
Ensure that duplicate tasks cannot be added.
"""
task = 'InvenTree.tasks.heartbeat'
self.assertEqual(self.get_tasks(task).count(), 0)
InvenTree.tasks.schedule_task(task, schedule_type=Schedule.MINUTES, minutes=10)
self.assertEqual(self.get_tasks(task).count(), 1)
t = Schedule.objects.get(func=task)
self.assertEqual(t.minutes, 10)
# Attempt to schedule the same task again
InvenTree.tasks.schedule_task(task, schedule_type=Schedule.MINUTES, minutes=5)
self.assertEqual(self.get_tasks(task).count(), 1)
# But the 'minutes' should have been updated
t = Schedule.objects.get(func=task)
self.assertEqual(t.minutes, 5)

View File

@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
from .validators import validate_overage, validate_part_name from .validators import validate_overage, validate_part_name
from . import helpers from . import helpers
from . import version
from mptt.exceptions import InvalidMove from mptt.exceptions import InvalidMove
@ -269,3 +270,33 @@ class TestSerialNumberExtraction(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
e("10, a, 7-70j", 4) e("10, a, 7-70j", 4)
class TestVersionNumber(TestCase):
"""
Unit tests for version number functions
"""
def test_tuple(self):
v = version.inventreeVersionTuple()
self.assertEqual(len(v), 3)
s = '.'.join([str(i) for i in v])
self.assertTrue(s in version.inventreeVersion())
def test_comparison(self):
"""
Test direct comparison of version numbers
"""
v_a = version.inventreeVersionTuple('1.2.0')
v_b = version.inventreeVersionTuple('1.2.3')
v_c = version.inventreeVersionTuple('1.2.4')
v_d = version.inventreeVersionTuple('2.0.0')
self.assertTrue(v_b > v_a)
self.assertTrue(v_c > v_b)
self.assertTrue(v_d > v_c)
self.assertTrue(v_d > v_a)

View File

@ -110,6 +110,7 @@ dynamic_javascript_urls = [
url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'), url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'),
url(r'^tables.js', DynamicJsView.as_view(template_name='js/tables.js'), name='tables.js'), url(r'^tables.js', DynamicJsView.as_view(template_name='js/tables.js'), name='tables.js'),
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'), url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
url(r'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'),
] ]
urlpatterns = [ urlpatterns = [
@ -132,7 +133,7 @@ urlpatterns = [
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
url(r'^login/?', auth_views.LoginView.as_view(), name='login'), url(r'^login/?', auth_views.LoginView.as_view(), name='login'),
url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logout.html'), name='logout'), url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logged_out.html'), name='logout'),
url(r'^settings/', include(settings_urls)), url(r'^settings/', include(settings_urls)),
@ -142,6 +143,7 @@ urlpatterns = [
url(r'^admin/error_log/', include('error_report.urls')), url(r'^admin/error_log/', include('error_report.urls')),
url(r'^admin/shell/', include('django_admin_shell.urls')), url(r'^admin/shell/', include('django_admin_shell.urls')),
url(r'^admin/', admin.site.urls, name='inventree-admin'), url(r'^admin/', admin.site.urls, name='inventree-admin'),
url(r'accounts/', include('django.contrib.auth.urls')),
url(r'^index/', IndexView.as_view(), name='index'), url(r'^index/', IndexView.as_view(), name='index'),
url(r'^search/', SearchView.as_view(), name='search'), url(r'^search/', SearchView.as_view(), name='search'),

View File

@ -60,7 +60,7 @@ def validate_part_ipn(value):
match = re.search(pattern, value) match = re.search(pattern, value)
if match is None: if match is None:
raise ValidationError(_('IPN must match regex pattern') + " '{pat}'".format(pat=pattern)) raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
def validate_build_order_reference(value): def validate_build_order_reference(value):

View File

@ -4,10 +4,11 @@ Provides information on the current InvenTree version
import subprocess import subprocess
import django import django
import re
import common.models import common.models
INVENTREE_SW_VERSION = "0.1.8 pre" INVENTREE_SW_VERSION = "0.2.1 pre"
# Increment this number whenever there is a significant change to the API that any clients need to know about # Increment this number whenever there is a significant change to the API that any clients need to know about
INVENTREE_API_VERSION = 2 INVENTREE_API_VERSION = 2
@ -23,6 +24,38 @@ def inventreeVersion():
return INVENTREE_SW_VERSION return INVENTREE_SW_VERSION
def inventreeVersionTuple(version=None):
""" Return the InvenTree version string as (maj, min, sub) tuple """
if version is None:
version = INVENTREE_SW_VERSION
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", str(version))
return [int(g) for g in match.groups()]
def isInvenTreeUpToDate():
"""
Test if the InvenTree instance is "up to date" with the latest version.
A background task periodically queries GitHub for latest version,
and stores it to the database as INVENTREE_LATEST_VERSION
"""
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', None)
# No record for "latest" version - we must assume we are up to date!
if not latest:
return True
# Extract "tuple" version (Python can directly compare version tuples)
latest_version = inventreeVersionTuple(latest)
inventree_version = inventreeVersionTuple()
return inventree_version >= latest_version
def inventreeApiVersion(): def inventreeApiVersion():
return INVENTREE_API_VERSION return INVENTREE_API_VERSION
@ -37,7 +70,7 @@ def inventreeCommitHash():
try: try:
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
except FileNotFoundError: except:
return None return None
@ -47,5 +80,5 @@ def inventreeCommitDate():
try: try:
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip() d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
return d.split(' ')[0] return d.split(' ')[0]
except FileNotFoundError: except:
return None return None

View File

@ -2,7 +2,7 @@
from django.urls import reverse from django.urls import reverse
from django.conf.urls import url from django.conf.urls import url
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework import permissions from rest_framework import permissions

View File

@ -12,7 +12,7 @@ from stock.serializers import StockItemSerializer, LocationSerializer
from part.serializers import PartSerializer from part.serializers import PartSerializer
logger = logging.getLogger(__name__) logger = logging.getLogger('inventree')
def hash_barcode(barcode_data): def hash_barcode(barcode_data):

View File

@ -5,7 +5,7 @@ Django Forms for interacting with Build objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django import forms from django import forms
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
@ -36,11 +36,13 @@ class EditBuildForm(HelperForm):
} }
target_date = DatePickerFormField( target_date = DatePickerFormField(
label=_('Target Date'),
help_text=_('Target date for build completion. Build will be overdue after this date.') help_text=_('Target date for build completion. Build will be overdue after this date.')
) )
quantity = RoundingDecimalFormField( quantity = RoundingDecimalFormField(
max_digits=10, decimal_places=5, max_digits=10, decimal_places=5,
label=_('Quantity'),
help_text=_('Number of items to build') help_text=_('Number of items to build')
) )
@ -87,7 +89,7 @@ class BuildOutputCreateForm(HelperForm):
) )
serial_numbers = forms.CharField( serial_numbers = forms.CharField(
label=_('Serial numbers'), label=_('Serial Numbers'),
required=False, required=False,
help_text=_('Enter serial numbers for build outputs'), help_text=_('Enter serial numbers for build outputs'),
) )
@ -115,6 +117,7 @@ class BuildOutputDeleteForm(HelperForm):
confirm = forms.BooleanField( confirm = forms.BooleanField(
required=False, required=False,
label=_('Confirm'),
help_text=_('Confirm deletion of build output') help_text=_('Confirm deletion of build output')
) )
@ -136,7 +139,7 @@ class UnallocateBuildForm(HelperForm):
Form for auto-de-allocation of stock from a build Form for auto-de-allocation of stock from a build
""" """
confirm = forms.BooleanField(required=False, help_text=_('Confirm unallocation of stock')) confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock'))
output_id = forms.IntegerField( output_id = forms.IntegerField(
required=False, required=False,
@ -160,7 +163,7 @@ class UnallocateBuildForm(HelperForm):
class AutoAllocateForm(HelperForm): class AutoAllocateForm(HelperForm):
""" Form for auto-allocation of stock to a build """ """ Form for auto-allocation of stock to a build """
confirm = forms.BooleanField(required=True, help_text=_('Confirm stock allocation')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
# Keep track of which build output we are interested in # Keep track of which build output we are interested in
output = forms.ModelChoiceField( output = forms.ModelChoiceField(
@ -207,15 +210,17 @@ class CompleteBuildOutputForm(HelperForm):
location = forms.ModelChoiceField( location = forms.ModelChoiceField(
queryset=StockLocation.objects.all(), queryset=StockLocation.objects.all(),
label=_('Location'),
help_text=_('Location of completed parts'), help_text=_('Location of completed parts'),
) )
confirm_incomplete = forms.BooleanField( confirm_incomplete = forms.BooleanField(
required=False, required=False,
label=_('Confirm incomplete'),
help_text=_("Confirm completion with incomplete stock allocation") help_text=_("Confirm completion with incomplete stock allocation")
) )
confirm = forms.BooleanField(required=True, help_text=_('Confirm build completion')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm build completion'))
output = forms.ModelChoiceField( output = forms.ModelChoiceField(
queryset=StockItem.objects.all(), # Queryset is narrowed in the view queryset=StockItem.objects.all(), # Queryset is narrowed in the view
@ -235,7 +240,7 @@ class CompleteBuildOutputForm(HelperForm):
class CancelBuildForm(HelperForm): class CancelBuildForm(HelperForm):
""" Form for cancelling a build """ """ Form for cancelling a build """
confirm_cancel = forms.BooleanField(required=False, help_text=_('Confirm build cancellation')) confirm_cancel = forms.BooleanField(required=False, label=_('Confirm cancel'), help_text=_('Confirm build cancellation'))
class Meta: class Meta:
model = Build model = Build
@ -249,7 +254,7 @@ class EditBuildItemForm(HelperForm):
Form for creating (or editing) a BuildItem object. Form for creating (or editing) a BuildItem object.
""" """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, help_text=_('Select quantity of stock to allocate')) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate'))
part_id = forms.IntegerField(required=False, widget=forms.HiddenInput()) part_id = forms.IntegerField(required=False, widget=forms.HiddenInput())

View File

@ -0,0 +1,85 @@
# Generated by Django 3.0.7 on 2021-04-04 20:16
import InvenTree.models
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0058_stockitem_packaging'),
('users', '0005_owner_model'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('build', '0026_auto_20210216_1539'),
]
operations = [
migrations.AlterField(
model_name='build',
name='completed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_completed', to=settings.AUTH_USER_MODEL, verbose_name='completed by'),
),
migrations.AlterField(
model_name='build',
name='completion_date',
field=models.DateField(blank=True, null=True, verbose_name='Completion Date'),
),
migrations.AlterField(
model_name='build',
name='creation_date',
field=models.DateField(auto_now_add=True, verbose_name='Creation Date'),
),
migrations.AlterField(
model_name='build',
name='issued_by',
field=models.ForeignKey(blank=True, help_text='User who issued this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_issued', to=settings.AUTH_USER_MODEL, verbose_name='Issued by'),
),
migrations.AlterField(
model_name='build',
name='responsible',
field=models.ForeignKey(blank=True, help_text='User responsible for this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_responsible', to='users.Owner', verbose_name='Responsible'),
),
migrations.AlterField(
model_name='builditem',
name='build',
field=models.ForeignKey(help_text='Build to allocate parts', on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build', verbose_name='Build'),
),
migrations.AlterField(
model_name='builditem',
name='install_into',
field=models.ForeignKey(blank=True, help_text='Destination stock item', limit_choices_to={'is_building': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items_to_install', to='stock.StockItem', verbose_name='Install into'),
),
migrations.AlterField(
model_name='builditem',
name='quantity',
field=models.DecimalField(decimal_places=5, default=1, help_text='Stock quantity to allocate to build', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
),
migrations.AlterField(
model_name='builditem',
name='stock_item',
field=models.ForeignKey(help_text='Source stock item', limit_choices_to={'belongs_to': None, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem', verbose_name='Stock Item'),
),
migrations.AlterField(
model_name='buildorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
migrations.AlterField(
model_name='buildorderattachment',
name='comment',
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
),
migrations.AlterField(
model_name='buildorderattachment',
name='upload_date',
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
),
migrations.AlterField(
model_name='buildorderattachment',
name='user',
field=models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
]

View File

@ -9,7 +9,7 @@ import os
from datetime import datetime from datetime import datetime
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
@ -216,7 +216,7 @@ class Build(MPTTModel):
help_text=_('Batch code for this build output') help_text=_('Batch code for this build output')
) )
creation_date = models.DateField(auto_now_add=True, editable=False) creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date'))
target_date = models.DateField( target_date = models.DateField(
null=True, blank=True, null=True, blank=True,
@ -224,12 +224,13 @@ class Build(MPTTModel):
help_text=_('Target date for build completion. Build will be overdue after this date.') help_text=_('Target date for build completion. Build will be overdue after this date.')
) )
completion_date = models.DateField(null=True, blank=True) completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date'))
completed_by = models.ForeignKey( completed_by = models.ForeignKey(
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
verbose_name=_('completed by'),
related_name='builds_completed' related_name='builds_completed'
) )
@ -237,6 +238,7 @@ class Build(MPTTModel):
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
verbose_name=_('Issued by'),
help_text=_('User who issued this build order'), help_text=_('User who issued this build order'),
related_name='builds_issued', related_name='builds_issued',
) )
@ -245,6 +247,7 @@ class Build(MPTTModel):
UserModels.Owner, UserModels.Owner,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
verbose_name=_('Responsible'),
help_text=_('User responsible for this build order'), help_text=_('User responsible for this build order'),
related_name='builds_responsible', related_name='builds_responsible',
) )
@ -1017,14 +1020,14 @@ class BuildItem(models.Model):
try: try:
# Allocated part must be in the BOM for the master part # Allocated part must be in the BOM for the master part
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False): if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'".format(p=self.build.part.full_name))] errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]
# Allocated quantity cannot exceed available stock quantity # Allocated quantity cannot exceed available stock quantity
if self.quantity > self.stock_item.quantity: if self.quantity > self.stock_item.quantity:
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})".format( errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
n=normalize(self.quantity), n=normalize(self.quantity),
q=normalize(self.stock_item.quantity) q=normalize(self.stock_item.quantity)
))] )]
# Allocated quantity cannot cause the stock item to be over-allocated # Allocated quantity cannot cause the stock item to be over-allocated
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity: if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
@ -1076,6 +1079,7 @@ class BuildItem(models.Model):
Build, Build,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='allocated_stock', related_name='allocated_stock',
verbose_name=_('Build'),
help_text=_('Build to allocate parts') help_text=_('Build to allocate parts')
) )
@ -1083,6 +1087,7 @@ class BuildItem(models.Model):
'stock.StockItem', 'stock.StockItem',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='allocations', related_name='allocations',
verbose_name=_('Stock Item'),
help_text=_('Source stock item'), help_text=_('Source stock item'),
limit_choices_to={ limit_choices_to={
'sales_order': None, 'sales_order': None,
@ -1095,6 +1100,7 @@ class BuildItem(models.Model):
max_digits=15, max_digits=15,
default=1, default=1,
validators=[MinValueValidator(0)], validators=[MinValueValidator(0)],
verbose_name=_('Quantity'),
help_text=_('Stock quantity to allocate to build') help_text=_('Stock quantity to allocate to build')
) )
@ -1103,6 +1109,7 @@ class BuildItem(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
related_name='items_to_install', related_name='items_to_install',
verbose_name=_('Install into'),
help_text=_('Destination stock item'), help_text=_('Destination stock item'),
limit_choices_to={ limit_choices_to={
'is_building': True, 'is_building': True,

View File

@ -164,7 +164,7 @@ src="{% static 'img/blank_image.png' %}"
launchModalForm("{% url 'build-cancel' build.id %}", launchModalForm("{% url 'build-cancel' build.id %}",
{ {
reload: true, reload: true,
submit_text: "Cancel Build", submit_text: '{% trans "Cancel Build" %}',
}); });
}); });
@ -173,7 +173,7 @@ src="{% static 'img/blank_image.png' %}"
"{% url 'build-complete' build.id %}", "{% url 'build-complete' build.id %}",
{ {
reload: true, reload: true,
submit_text: "Complete Build", submit_text: '{% trans "Complete Build" %}',
} }
); );
}); });

View File

@ -130,6 +130,7 @@ InvenTree | {% trans "Build Orders" %}
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
nowIndicator: true, nowIndicator: true,
aspectRatio: 2.5, aspectRatio: 2.5,
locale: '{{request.LANGUAGE_CODE}}',
datesSet: function() { datesSet: function() {
loadOrderEvents(calendar); loadOrderEvents(calendar);
} }

View File

@ -5,7 +5,7 @@ Django views for interacting with Build objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.views.generic import DetailView, ListView, UpdateView from django.views.generic import DetailView, ListView, UpdateView
from django.forms import HiddenInput from django.forms import HiddenInput

View File

@ -17,7 +17,7 @@ from djmoney.models.fields import MoneyField
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator, URLValidator from django.core.validators import MinValueValidator, URLValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -500,7 +500,7 @@ class InvenTreeSetting(models.Model):
create: If True, create a new setting if the specified key does not exist. create: If True, create a new setting if the specified key does not exist.
""" """
if not user.is_staff: if user is not None and not user.is_staff:
return return
try: try:

View File

@ -5,7 +5,7 @@ Django views for interacting with common models
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.forms import CheckboxInput, Select from django.forms import CheckboxInput, Select
from InvenTree.views import AjaxUpdateView from InvenTree.views import AjaxUpdateView

View File

@ -9,7 +9,7 @@ from django.conf import settings
from PIL import UnidentifiedImageError from PIL import UnidentifiedImageError
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class CompanyConfig(AppConfig): class CompanyConfig(AppConfig):

View File

@ -8,7 +8,7 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
import django.forms import django.forms
import djmoney.settings import djmoney.settings
@ -34,6 +34,7 @@ class EditCompanyForm(HelperForm):
currency = django.forms.ChoiceField( currency = django.forms.ChoiceField(
required=False, required=False,
label=_('Currency'),
help_text=_('Default currency used for this company'), help_text=_('Default currency used for this company'),
choices=[('', '----------')] + djmoney.settings.CURRENCY_CHOICES, choices=[('', '----------')] + djmoney.settings.CURRENCY_CHOICES,
initial=common.settings.currency_code_default, initial=common.settings.currency_code_default,

View File

@ -0,0 +1,69 @@
# Generated by Django 3.0.7 on 2021-04-03 18:37
import InvenTree.fields
import company.models
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import markdownx.models
import stdimage.models
class Migration(migrations.Migration):
dependencies = [
('company', '0031_auto_20210103_2215'),
]
operations = [
migrations.AlterField(
model_name='company',
name='image',
field=stdimage.models.StdImageField(blank=True, null=True, upload_to=company.models.rename_company_image, verbose_name='Image'),
),
migrations.AlterField(
model_name='company',
name='is_customer',
field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='is customer'),
),
migrations.AlterField(
model_name='company',
name='is_manufacturer',
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='is manufacturer'),
),
migrations.AlterField(
model_name='company',
name='is_supplier',
field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='is supplier'),
),
migrations.AlterField(
model_name='company',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external company information', verbose_name='Link'),
),
migrations.AlterField(
model_name='company',
name='notes',
field=markdownx.models.MarkdownxField(blank=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='supplierpart',
name='base_cost',
field=models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)], verbose_name='base cost'),
),
migrations.AlterField(
model_name='supplierpart',
name='multiple',
field=models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(1)], verbose_name='multiple'),
),
migrations.AlterField(
model_name='supplierpart',
name='packaging',
field=models.CharField(blank=True, help_text='Part packaging', max_length=50, null=True, verbose_name='Packaging'),
),
migrations.AlterField(
model_name='supplierpricebreak',
name='part',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricebreaks', to='company.SupplierPart', verbose_name='Part'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-04-10 05:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0032_auto_20210403_1837'),
]
operations = [
migrations.AlterField(
model_name='company',
name='description',
field=models.CharField(blank=True, help_text='Description of the company', max_length=500, verbose_name='Company description'),
),
]

View File

@ -9,7 +9,7 @@ import os
import math import math
from django.utils.translation import gettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models import Sum, Q, UniqueConstraint from django.db.models import Sum, Q, UniqueConstraint
@ -95,7 +95,12 @@ class Company(models.Model):
help_text=_('Company name'), help_text=_('Company name'),
verbose_name=_('Company name')) verbose_name=_('Company name'))
description = models.CharField(max_length=500, verbose_name=_('Company description'), help_text=_('Description of the company')) description = models.CharField(
max_length=500,
verbose_name=_('Company description'),
help_text=_('Description of the company'),
blank=True,
)
website = models.URLField(blank=True, verbose_name=_('Website'), help_text=_('Company website URL')) website = models.URLField(blank=True, verbose_name=_('Website'), help_text=_('Company website URL'))
@ -114,7 +119,7 @@ class Company(models.Model):
verbose_name=_('Contact'), verbose_name=_('Contact'),
blank=True, help_text=_('Point of contact')) blank=True, help_text=_('Point of contact'))
link = InvenTreeURLField(blank=True, help_text=_('Link to external company information')) link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external company information'))
image = StdImageField( image = StdImageField(
upload_to=rename_company_image, upload_to=rename_company_image,
@ -122,15 +127,16 @@ class Company(models.Model):
blank=True, blank=True,
variations={'thumbnail': (128, 128)}, variations={'thumbnail': (128, 128)},
delete_orphans=True, delete_orphans=True,
verbose_name=_('Image'),
) )
notes = MarkdownxField(blank=True) notes = MarkdownxField(blank=True, verbose_name=_('Notes'))
is_customer = models.BooleanField(default=False, help_text=_('Do you sell items to this company?')) is_customer = models.BooleanField(default=False, verbose_name=_('is customer'), help_text=_('Do you sell items to this company?'))
is_supplier = models.BooleanField(default=True, help_text=_('Do you purchase items from this company?')) is_supplier = models.BooleanField(default=True, verbose_name=_('is supplier'), help_text=_('Do you purchase items from this company?'))
is_manufacturer = models.BooleanField(default=False, help_text=_('Does this company manufacture parts?')) is_manufacturer = models.BooleanField(default=False, verbose_name=_('is manufacturer'), help_text=_('Does this company manufacture parts?'))
currency = models.CharField( currency = models.CharField(
max_length=3, max_length=3,
@ -366,11 +372,11 @@ class SupplierPart(models.Model):
help_text=_('Notes') help_text=_('Notes')
) )
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text=_('Minimum charge (e.g. stocking fee)')) base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
packaging = models.CharField(max_length=50, blank=True, null=True, help_text=_('Part packaging')) packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging'))
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text=('Order multiple')) multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple'))
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching). # TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
# lead_time = models.DurationField(blank=True, null=True) # lead_time = models.DurationField(blank=True, null=True)
@ -530,7 +536,7 @@ class SupplierPriceBreak(common.models.PriceBreak):
currency: Reference to the currency of this pricebreak (leave empty for base currency) currency: Reference to the currency of this pricebreak (leave empty for base currency)
""" """
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks') part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks', verbose_name=_('Part'),)
class Meta: class Meta:
unique_together = ("part", "quantity") unique_together = ("part", "quantity")

View File

@ -43,17 +43,17 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
<p>{{ company.description }}</p> <p>{{ company.description }}</p>
<div class='btn-group action-buttons'> <div class='btn-group action-buttons'>
{% if company.is_supplier and roles.purchase_order.add %} {% if company.is_supplier and roles.purchase_order.add %}
<button type='button' class='btn btn-default' id='company-order-2' title='Create purchase order'> <button type='button' class='btn btn-default' id='company-order-2' title='{% trans "Create Purchase Order" %}'>
<span class='fas fa-shopping-cart'/> <span class='fas fa-shopping-cart'/>
</button> </button>
{% endif %} {% endif %}
{% if perms.company.change_company %} {% if perms.company.change_company %}
<button type='button' class='btn btn-default' id='company-edit' title='Edit company information'> <button type='button' class='btn btn-default' id='company-edit' title='{% trans "Edit company information" %}'>
<span class='fas fa-edit icon-green'/> <span class='fas fa-edit icon-green'/>
</button> </button>
{% endif %} {% endif %}
{% if perms.company.delete_company %} {% if perms.company.delete_company %}
<button type='button' class='btn btn-default' id='company-delete' title='Delete company'> <button type='button' class='btn btn-default' id='company-delete' title='{% trans "Delete Company" %}'>
<span class='fas fa-trash-alt icon-red'/> <span class='fas fa-trash-alt icon-red'/>
</button> </button>
{% endif %} {% endif %}

View File

@ -1,14 +1,16 @@
{% extends "modal_delete_form.html" %} {% extends "modal_delete_form.html" %}
{% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
Are you sure you want to delete company '{{ company.name }}'? {% blocktrans with company.name as name %}Are you sure you want to delete company '{{ name }}'?{% endblocktrans %}
<br> <br>
{% if company.supplied_part_count > 0 %} {% if company.supplied_part_count > 0 %}
<p>There are {{ company.supplied_part_count }} parts sourced from this company.<br> <p>{% blocktrans with company.supplied_part_count as count %}There are {{ count }} parts sourced from this company.<br>
If this supplier is deleted, these supplier part entries will also be deleted.</p> If this supplier is deleted, these supplier part entries will also be deleted.{% endblocktrans %}</p>
<ul class='list-group'> <ul class='list-group'>
{% for part in company.parts.all %} {% for part in company.parts.all %}
<li class='list-group-item'><b>{{ part.SKU }}</b> - <i>{{ part.part.full_name }}</i></li> <li class='list-group-item'><b>{{ part.SKU }}</b> - <i>{{ part.part.full_name }}</i></li>

View File

@ -15,7 +15,7 @@
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid'> <div class='button-toolbar container-fluid'>
<div class='btn-group role='group'> <div class='btn-group' role='group'>
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<button class="btn btn-success" id='part-create' title='{% trans "Create new supplier part" %}'> <button class="btn btn-success" id='part-create' title='{% trans "Create new supplier part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %} <span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}

View File

@ -14,7 +14,7 @@
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<div id='button-bar'> <div id='button-bar'>
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-primary' type='button' id='order-part2' title='Order part'> <button class='btn btn-primary' type='button' id='order-part2' title='{% trans "Order part" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}</button> <span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}</button>
</div> </div>
</div> </div>

View File

@ -22,7 +22,7 @@
params: { params: {
supplier_part: {{ part.id }}, supplier_part: {{ part.id }},
location_detail: true, location_detail: true,
part_detail: true, part_detail: false,
}, },
groupByField: 'location', groupByField: 'location',
buttons: ['#stock-options'], buttons: ['#stock-options'],

View File

@ -6,7 +6,7 @@ Django views for interacting with Company app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView, ListView, UpdateView from django.views.generic import DetailView, ListView, UpdateView
from django.urls import reverse from django.urls import reverse

View File

@ -7,11 +7,9 @@
# with the prefix INVENTREE_DB_ # with the prefix INVENTREE_DB_
# e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD # e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD
database: database:
# Default configuration - sqlite filesystem database # Uncomment (and edit) one of the database configurations below,
ENGINE: sqlite3 # or specify database options using environment variables
NAME: '../inventree_default_db.sqlite3'
# For more complex database installations, further parameters are required
# Refer to the django documentation for full list of options # Refer to the django documentation for full list of options
# --- Available options: --- # --- Available options: ---
@ -27,14 +25,22 @@ database:
# --- Example Configuration - sqlite3 --- # --- Example Configuration - sqlite3 ---
# ENGINE: sqlite3 # ENGINE: sqlite3
# NAME: '/path/to/database.sqlite3' # NAME: '/home/inventree/database.sqlite3'
# --- Example Configuration - MySQL --- # --- Example Configuration - MySQL ---
#ENGINE: django.db.backends.mysql #ENGINE: mysql
#NAME: inventree #NAME: inventree
#USER: inventree_username #USER: inventree
#PASSWORD: inventree_password #PASSWORD: inventree_password
#HOST: '127.0.0.1' #HOST: 'localhost'
#PORT: '3306'
# --- Example Configuration - Postgresql ---
#ENGINE: postgresql
#NAME: inventree
#USER: inventree
#PASSWORD: inventree_password
#HOST: 'localhost'
#PORT: '5432' #PORT: '5432'
# Select default system language (default is 'en-us') # Select default system language (default is 'en-us')
@ -43,6 +49,7 @@ language: en-us
# System time-zone (default is UTC) # System time-zone (default is UTC)
# Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# Select an option from the "TZ database name" column # Select an option from the "TZ database name" column
# Use the environment variable INVENTREE_TIMEZONE
timezone: UTC timezone: UTC
# List of currencies supported by default. # List of currencies supported by default.
@ -56,7 +63,33 @@ currencies:
- NZD - NZD
- USD - USD
# Email backend configuration
# Ref: https://docs.djangoproject.com/en/dev/topics/email/
# Available options:
# host: Email server host address
# port: Email port
# username: Account username
# password: Account password
# prefix: Email subject prefix
# tls: Enable TLS support
# ssl: Enable SSL support
# Alternatively, these options can all be set using environment variables,
# with the INVENTREE_EMAIL_ prefix:
# e.g. INVENTREE_EMAIL_HOST / INVENTREE_EMAIL_PORT / INVENTREE_EMAIL_USERNAME
# Refer to the InvenTree documentation for more information
email:
# backend: 'django.core.mail.backends.smtp.EmailBackend'
host: ''
port: 25
username: ''
password: ''
tls: False
ssl: False
# Set debug to False to run in production mode # Set debug to False to run in production mode
# Use the environment variable INVENTREE_DEBUG
debug: True debug: True
# Set debug_toolbar to True to enable a debugging toolbar for InvenTree # Set debug_toolbar to True to enable a debugging toolbar for InvenTree
@ -65,6 +98,7 @@ debug: True
debug_toolbar: False debug_toolbar: False
# Configure the system logging level # Configure the system logging level
# Use environment variable INVENTREE_LOG_LEVEL
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL # Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
log_level: WARNING log_level: WARNING
@ -86,13 +120,14 @@ cors:
# - https://sub.example.com # - https://sub.example.com
# MEDIA_ROOT is the local filesystem location for storing uploaded files # MEDIA_ROOT is the local filesystem location for storing uploaded files
# By default, it is stored in a directory named 'inventree_media' local to the InvenTree directory # By default, it is stored under /home/inventree/data/media
# This should be changed for a production installation # Use environment variable INVENTREE_MEDIA_ROOT
media_root: '../inventree_media' media_root: '/home/inventree/data/media'
# STATIC_ROOT is the local filesystem location for storing static files # STATIC_ROOT is the local filesystem location for storing static files
# By default it is stored in a directory named 'inventree_static' local to the InvenTree directory # By default, it is stored under /home/inventree
static_root: '../inventree_static' # Use environment variable INVENTREE_STATIC_ROOT
static_root: '/home/inventree/static'
# Optional URL schemes to allow in URL fields # Optional URL schemes to allow in URL fields
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps'] # By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']
@ -105,7 +140,8 @@ static_root: '../inventree_static'
# Backup options # Backup options
# Set the backup_dir parameter to store backup files in a specific location # Set the backup_dir parameter to store backup files in a specific location
# If unspecified, the local user's temp directory will be used # If unspecified, the local user's temp directory will be used
#backup_dir: '/home/inventree/backup/' # Use environment variable INVENTREE_BACKUP_DIR
backup_dir: '/home/inventree/data/backup/'
# Permit custom authentication backends # Permit custom authentication backends
#authentication_backends: #authentication_backends:

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.http import HttpResponse from django.http import HttpResponse

View File

@ -7,7 +7,7 @@ from django.apps import AppConfig
from django.conf import settings from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
def hashFile(filename): def hashFile(filename):

View File

@ -32,7 +32,7 @@ except OSError as err:
sys.exit(1) sys.exit(1)
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
def rename_label(instance, filename): def rename_label(instance, filename):
@ -126,7 +126,7 @@ class LabelTemplate(models.Model):
width = models.FloatField( width = models.FloatField(
default=50, default=50,
verbose_name=('Width [mm]'), verbose_name=_('Width [mm]'),
help_text=_('Label width, specified in mm'), help_text=_('Label width, specified in mm'),
validators=[MinValueValidator(2)] validators=[MinValueValidator(2)]
) )

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

@ -6,7 +6,7 @@ Django Forms for interacting with Order objects
from __future__ import unicode_literals from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
@ -24,7 +24,7 @@ from .models import SalesOrderAllocation
class IssuePurchaseOrderForm(HelperForm): class IssuePurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, initial=False, help_text=_('Place order')) confirm = forms.BooleanField(required=True, initial=False, label=_('Confirm'), help_text=_('Place order'))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -35,7 +35,7 @@ class IssuePurchaseOrderForm(HelperForm):
class CompletePurchaseOrderForm(HelperForm): class CompletePurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, help_text=_("Mark order as complete")) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_("Mark order as complete"))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -46,7 +46,7 @@ class CompletePurchaseOrderForm(HelperForm):
class CancelPurchaseOrderForm(HelperForm): class CancelPurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, help_text=_('Cancel order')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -57,7 +57,7 @@ class CancelPurchaseOrderForm(HelperForm):
class CancelSalesOrderForm(HelperForm): class CancelSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, help_text=_('Cancel order')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
class Meta: class Meta:
model = SalesOrder model = SalesOrder
@ -68,7 +68,7 @@ class CancelSalesOrderForm(HelperForm):
class ShipSalesOrderForm(HelperForm): class ShipSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, help_text=_('Ship order')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Ship order'))
class Meta: class Meta:
model = SalesOrder model = SalesOrder
@ -79,7 +79,7 @@ class ShipSalesOrderForm(HelperForm):
class ReceivePurchaseOrderForm(HelperForm): class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, help_text=_('Receive parts to this location')) location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location'))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -106,6 +106,7 @@ class EditPurchaseOrderForm(HelperForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
target_date = DatePickerFormField( target_date = DatePickerFormField(
label=_('Target Date'),
help_text=_('Target date for order delivery. Order will be overdue after this date.'), help_text=_('Target date for order delivery. Order will be overdue after this date.'),
) )
@ -140,6 +141,7 @@ class EditSalesOrderForm(HelperForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
target_date = DatePickerFormField( target_date = DatePickerFormField(
label=_('Target Date'),
help_text=_('Target date for order completion. Order will be overdue after this date.'), help_text=_('Target date for order completion. Order will be overdue after this date.'),
) )
@ -183,7 +185,7 @@ class EditSalesOrderAttachmentForm(HelperForm):
class EditPurchaseOrderLineItemForm(HelperForm): class EditPurchaseOrderLineItemForm(HelperForm):
""" Form for editing a PurchaseOrderLineItem object """ """ Form for editing a PurchaseOrderLineItem object """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta: class Meta:
model = PurchaseOrderLineItem model = PurchaseOrderLineItem
@ -200,7 +202,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
class EditSalesOrderLineItemForm(HelperForm): class EditSalesOrderLineItemForm(HelperForm):
""" Form for editing a SalesOrderLineItem object """ """ Form for editing a SalesOrderLineItem object """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta: class Meta:
model = SalesOrderLineItem model = SalesOrderLineItem
@ -256,7 +258,7 @@ class CreateSalesOrderAllocationForm(HelperForm):
Form for creating a SalesOrderAllocation item. Form for creating a SalesOrderAllocation item.
""" """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta: class Meta:
model = SalesOrderAllocation model = SalesOrderAllocation
@ -273,7 +275,7 @@ class EditSalesOrderAllocationForm(HelperForm):
Form for editing a SalesOrderAllocation item Form for editing a SalesOrderAllocation item
""" """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta: class Meta:
model = SalesOrderAllocation model = SalesOrderAllocation

View File

@ -0,0 +1,233 @@
# Generated by Django 3.0.7 on 2021-04-04 20:16
import InvenTree.fields
import InvenTree.models
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import markdownx.models
class Migration(migrations.Migration):
dependencies = [
('company', '0032_auto_20210403_1837'),
('part', '0063_bomitem_inherited'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('stock', '0058_stockitem_packaging'),
('order', '0043_auto_20210330_0013'),
]
operations = [
migrations.AlterField(
model_name='purchaseorder',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='purchaseorder',
name='creation_date',
field=models.DateField(blank=True, null=True, verbose_name='Creation Date'),
),
migrations.AlterField(
model_name='purchaseorder',
name='description',
field=models.CharField(help_text='Order description', max_length=250, verbose_name='Description'),
),
migrations.AlterField(
model_name='purchaseorder',
name='link',
field=models.URLField(blank=True, help_text='Link to external page', verbose_name='Link'),
),
migrations.AlterField(
model_name='purchaseorder',
name='notes',
field=markdownx.models.MarkdownxField(blank=True, help_text='Order notes', verbose_name='Notes'),
),
migrations.AlterField(
model_name='purchaseorder',
name='received_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='received by'),
),
migrations.AlterField(
model_name='purchaseorder',
name='reference',
field=models.CharField(help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
),
migrations.AlterField(
model_name='purchaseorder',
name='supplier',
field=models.ForeignKey(help_text='Company from which the items are being ordered', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='company.Company', verbose_name='Supplier'),
),
migrations.AlterField(
model_name='purchaseorder',
name='supplier_reference',
field=models.CharField(blank=True, help_text='Supplier order reference code', max_length=64, verbose_name='Supplier Reference'),
),
migrations.AlterField(
model_name='purchaseorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
migrations.AlterField(
model_name='purchaseorderattachment',
name='comment',
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
),
migrations.AlterField(
model_name='purchaseorderattachment',
name='upload_date',
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
),
migrations.AlterField(
model_name='purchaseorderattachment',
name='user',
field=models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='notes',
field=models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='order',
field=models.ForeignKey(help_text='Purchase Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.PurchaseOrder', verbose_name='Order'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='part',
field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_order_line_items', to='company.SupplierPart', verbose_name='Part'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='quantity',
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='received',
field=models.DecimalField(decimal_places=5, default=0, help_text='Number of items received', max_digits=15, verbose_name='Received'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='reference',
field=models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference'),
),
migrations.AlterField(
model_name='salesorder',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='salesorder',
name='creation_date',
field=models.DateField(blank=True, null=True, verbose_name='Creation Date'),
),
migrations.AlterField(
model_name='salesorder',
name='customer',
field=models.ForeignKey(help_text='Company to which the items are being sold', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company', verbose_name='Customer'),
),
migrations.AlterField(
model_name='salesorder',
name='customer_reference',
field=models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference '),
),
migrations.AlterField(
model_name='salesorder',
name='description',
field=models.CharField(help_text='Order description', max_length=250, verbose_name='Description'),
),
migrations.AlterField(
model_name='salesorder',
name='link',
field=models.URLField(blank=True, help_text='Link to external page', verbose_name='Link'),
),
migrations.AlterField(
model_name='salesorder',
name='notes',
field=markdownx.models.MarkdownxField(blank=True, help_text='Order notes', verbose_name='Notes'),
),
migrations.AlterField(
model_name='salesorder',
name='reference',
field=models.CharField(help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
),
migrations.AlterField(
model_name='salesorder',
name='shipment_date',
field=models.DateField(blank=True, null=True, verbose_name='Shipment Date'),
),
migrations.AlterField(
model_name='salesorder',
name='shipped_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='shipped by'),
),
migrations.AlterField(
model_name='salesorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status', verbose_name='Status'),
),
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'part__salable': True, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem', verbose_name='Item'),
),
migrations.AlterField(
model_name='salesorderallocation',
name='line',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.SalesOrderLineItem', verbose_name='Line'),
),
migrations.AlterField(
model_name='salesorderallocation',
name='quantity',
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Enter stock allocation quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
),
migrations.AlterField(
model_name='salesorderattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
migrations.AlterField(
model_name='salesorderattachment',
name='comment',
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
),
migrations.AlterField(
model_name='salesorderattachment',
name='upload_date',
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
),
migrations.AlterField(
model_name='salesorderattachment',
name='user',
field=models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
migrations.AlterField(
model_name='salesorderlineitem',
name='notes',
field=models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes'),
),
migrations.AlterField(
model_name='salesorderlineitem',
name='order',
field=models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.SalesOrder', verbose_name='Order'),
),
migrations.AlterField(
model_name='salesorderlineitem',
name='part',
field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_order_line_items', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='salesorderlineitem',
name='quantity',
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
),
migrations.AlterField(
model_name='salesorderlineitem',
name='reference',
field=models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference'),
),
]

View File

@ -15,7 +15,7 @@ from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.urls import reverse from django.urls import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
@ -96,18 +96,19 @@ class Order(models.Model):
class Meta: class Meta:
abstract = True abstract = True
reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Order reference')) reference = models.CharField(unique=True, max_length=64, blank=False, verbose_name=_('Reference'), help_text=_('Order reference'))
description = models.CharField(max_length=250, help_text=_('Order description')) description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description'))
link = models.URLField(blank=True, help_text=_('Link to external page')) link = models.URLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
creation_date = models.DateField(blank=True, null=True) creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date'))
created_by = models.ForeignKey(User, created_by = models.ForeignKey(User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
related_name='+' related_name='+',
verbose_name=_('Created By')
) )
responsible = models.ForeignKey( responsible = models.ForeignKey(
@ -119,7 +120,7 @@ class Order(models.Model):
related_name='+', related_name='+',
) )
notes = MarkdownxField(blank=True, help_text=_('Order notes')) notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
class PurchaseOrder(Order): class PurchaseOrder(Order):
@ -186,16 +187,18 @@ class PurchaseOrder(Order):
'is_supplier': True, 'is_supplier': True,
}, },
related_name='purchase_orders', related_name='purchase_orders',
verbose_name=_('Supplier'),
help_text=_('Company from which the items are being ordered') help_text=_('Company from which the items are being ordered')
) )
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference code")) supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code"))
received_by = models.ForeignKey( received_by = models.ForeignKey(
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
related_name='+' related_name='+',
verbose_name=_('received by')
) )
issue_date = models.DateField( issue_date = models.DateField(
@ -435,13 +438,14 @@ class SalesOrder(Order):
null=True, null=True,
limit_choices_to={'is_customer': True}, limit_choices_to={'is_customer': True},
related_name='sales_orders', related_name='sales_orders',
verbose_name=_('Customer'),
help_text=_("Company to which the items are being sold"), help_text=_("Company to which the items are being sold"),
) )
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(), status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(),
help_text=_('Purchase order status')) verbose_name=_('Status'), help_text=_('Purchase order status'))
customer_reference = models.CharField(max_length=64, blank=True, help_text=_("Customer order reference code")) customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code"))
target_date = models.DateField( target_date = models.DateField(
null=True, blank=True, null=True, blank=True,
@ -449,13 +453,14 @@ class SalesOrder(Order):
help_text=_('Target date for order completion. Order will be overdue after this date.') help_text=_('Target date for order completion. Order will be overdue after this date.')
) )
shipment_date = models.DateField(blank=True, null=True) shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date'))
shipped_by = models.ForeignKey( shipped_by = models.ForeignKey(
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
related_name='+' related_name='+',
verbose_name=_('shipped by')
) )
@property @property
@ -586,11 +591,11 @@ class OrderLineItem(models.Model):
class Meta: class Meta:
abstract = True abstract = True
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Item quantity')) quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Item quantity'))
reference = models.CharField(max_length=100, blank=True, help_text=_('Line item reference')) reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference'))
notes = models.CharField(max_length=500, blank=True, help_text=_('Line item notes')) notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes'))
class PurchaseOrderLineItem(OrderLineItem): class PurchaseOrderLineItem(OrderLineItem):
@ -616,6 +621,7 @@ class PurchaseOrderLineItem(OrderLineItem):
order = models.ForeignKey( order = models.ForeignKey(
PurchaseOrder, on_delete=models.CASCADE, PurchaseOrder, on_delete=models.CASCADE,
related_name='lines', related_name='lines',
verbose_name=_('Order'),
help_text=_('Purchase Order') help_text=_('Purchase Order')
) )
@ -629,10 +635,11 @@ class PurchaseOrderLineItem(OrderLineItem):
SupplierPart, on_delete=models.SET_NULL, SupplierPart, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
related_name='purchase_order_line_items', related_name='purchase_order_line_items',
verbose_name=_('Part'),
help_text=_("Supplier part"), help_text=_("Supplier part"),
) )
received = models.DecimalField(decimal_places=5, max_digits=15, default=0, help_text=_('Number of items received')) received = models.DecimalField(decimal_places=5, max_digits=15, default=0, verbose_name=_('Received'), help_text=_('Number of items received'))
purchase_price = MoneyField( purchase_price = MoneyField(
max_digits=19, max_digits=19,
@ -658,9 +665,9 @@ class SalesOrderLineItem(OrderLineItem):
part: Link to a Part object (may be null) part: Link to a Part object (may be null)
""" """
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', help_text=_('Sales Order')) order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order'))
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, help_text=_('Part'), limit_choices_to={'salable': True}) part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
class Meta: class Meta:
unique_together = [ unique_together = [
@ -760,7 +767,7 @@ class SalesOrderAllocation(models.Model):
if len(errors) > 0: if len(errors) > 0:
raise ValidationError(errors) raise ValidationError(errors)
line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, related_name='allocations') line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations')
item = models.ForeignKey( item = models.ForeignKey(
'stock.StockItem', 'stock.StockItem',
@ -771,10 +778,11 @@ class SalesOrderAllocation(models.Model):
'belongs_to': None, 'belongs_to': None,
'sales_order': None, 'sales_order': None,
}, },
verbose_name=_('Item'),
help_text=_('Select stock item to allocate') help_text=_('Select stock item to allocate')
) )
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Enter stock allocation quantity')) quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Enter stock allocation quantity'))
def get_serial(self): def get_serial(self):
return self.item.serial return self.item.serial

View File

@ -1,12 +1,14 @@
{% extends "modal_form.html" %} {% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
Mark this order as complete? {% trans 'Mark this order as complete?' %}
{% if not order.is_complete %} {% if not order.is_complete %}
<div class='alert alert-warning alert-block'> <div class='alert alert-warning alert-block'>
This order has line items which have not been marked as received. {%trans 'This order has line items which have not been marked as received.
Marking this order as complete will remove these line items. Marking this order as complete will remove these line items.' %}
</div> </div>
{% endif %} {% endif %}

View File

@ -1,7 +1,9 @@
{% extends "modal_form.html" %} {% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
After placing this purchase order, line items will no longer be editable. {% trans 'After placing this purchase order, line items will no longer be editable.' %}
{% endblock %} {% endblock %}

View File

@ -39,7 +39,7 @@
{{ part.full_name }} <small><i>{{ part.description }}</i></small> {{ part.full_name }} <small><i>{{ part.description }}</i></small>
</td> </td>
<td> <td>
<button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" $}' type='button'> <button class='btn btn-default btn-create' onClick='newSupplierPartFromOrderWizard()' id='new_supplier_part_{{ part.id }}' part='{{ part.pk }}' title='{% trans "Create new supplier part" %}' type='button'>
<span part='{{ part.pk }}' class='fas fa-plus-circle'></span> <span part='{{ part.pk }}' class='fas fa-plus-circle'></span>
</button> </button>
</td> </td>
@ -66,7 +66,7 @@
</div> </div>
</td> </td>
<td> <td>
<button class='btn btn-default btn-remove' onclick='removeOrderRowFromOrderWizard()' id='del_item_{{ part.id }}' title='Remove part' type='button'> <button class='btn btn-default btn-remove' onclick='removeOrderRowFromOrderWizard()' id='del_item_{{ part.id }}' title='{% trans "Remove part" %}' type='button'>
<span row='part_row_{{ part.id }}' class='fas fa-trash-alt icon-red'></span> <span row='part_row_{{ part.id }}' class='fas fa-trash-alt icon-red'></span>
</button> </button>
</td> </td>

View File

@ -42,7 +42,7 @@
<button <button
class='btn btn-default btn-create' class='btn btn-default btn-create'
id='new_po_{{ supplier.id }}' id='new_po_{{ supplier.id }}'
title='Create new purchase order for {{ supplier.name }}' title='{% trans "Create new purchase order for {{ supplier.name }}" %}'
type='button' type='button'
supplierid='{{ supplier.id }}' supplierid='{{ supplier.id }}'
onclick='newPurchaseOrderFromOrderWizard()'> onclick='newPurchaseOrderFromOrderWizard()'>

View File

@ -116,6 +116,7 @@ InvenTree | {% trans "Purchase Orders" %}
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
nowIndicator: true, nowIndicator: true,
aspectRatio: 2.5, aspectRatio: 2.5,
locale: '{{request.LANGUAGE_CODE}}',
datesSet: function() { datesSet: function() {
loadOrderEvents(calendar); loadOrderEvents(calendar);
} }

View File

@ -54,7 +54,7 @@
</div> </div>
</td> </td>
<td> <td>
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='Remove line' type='button'> <button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span> <span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
</button> </button>
</td> </td>

View File

@ -49,7 +49,7 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-print'></span> <span class='fas fa-print'></span>
</button> </button>
{% if roles.sales_order.change %} {% if roles.sales_order.change %}
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'> <button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
<span class='fas fa-edit icon-green'></span> <span class='fas fa-edit icon-green'></span>
</button> </button>
{% if order.status == SalesOrderStatus.PENDING %} {% if order.status == SalesOrderStatus.PENDING %}

View File

@ -67,7 +67,7 @@ function showAllocationSubTable(index, row, element) {
{ {
width: '50%', width: '50%',
field: 'allocated', field: 'allocated',
title: 'Quantity', title: '{% trans "Quantity" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var text = ''; var text = '';
@ -89,7 +89,7 @@ function showAllocationSubTable(index, row, element) {
}, },
{ {
field: 'buttons', field: 'buttons',
title: 'Actions', title: '{% trans "Actions" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var html = "<div class='btn-group float-right' role='group'>"; var html = "<div class='btn-group float-right' role='group'>";
@ -167,7 +167,7 @@ function showFulfilledSubTable(index, row, element) {
} }
$("#so-lines-table").inventreeTable({ $("#so-lines-table").inventreeTable({
formatNoMatches: function() { return "No matching line items"; }, formatNoMatches: function() { return "{% trans 'No matching line items' %}"; },
queryParams: { queryParams: {
order: {{ order.id }}, order: {{ order.id }},
part_detail: true, part_detail: true,
@ -196,7 +196,7 @@ $("#so-lines-table").inventreeTable({
columns: [ columns: [
{ {
field: 'pk', field: 'pk',
title: 'ID', title: '{% trans "ID" %}',
visible: false, visible: false,
switchable: false, switchable: false,
}, },
@ -204,7 +204,7 @@ $("#so-lines-table").inventreeTable({
sortable: true, sortable: true,
sortName: 'part__name', sortName: 'part__name',
field: 'part', field: 'part',
title: 'Part', title: '{% trans "Part" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (row.part) { if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`); return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
@ -216,12 +216,12 @@ $("#so-lines-table").inventreeTable({
{ {
sortable: true, sortable: true,
field: 'reference', field: 'reference',
title: 'Reference' title: '{% trans "Reference" %}'
}, },
{ {
sortable: true, sortable: true,
field: 'quantity', field: 'quantity',
title: 'Quantity', title: '{% trans "Quantity" %}',
}, },
{ {
field: 'allocated', field: 'allocated',
@ -261,7 +261,7 @@ $("#so-lines-table").inventreeTable({
}, },
{ {
field: 'notes', field: 'notes',
title: 'Notes', title: '{% trans "Notes" %}',
}, },
{% if order.status == SalesOrderStatus.PENDING %} {% if order.status == SalesOrderStatus.PENDING %}
{ {

View File

@ -115,6 +115,7 @@ InvenTree | {% trans "Sales Orders" %}
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
nowIndicator: true, nowIndicator: true,
aspectRatio: 2.5, aspectRatio: 2.5,
locale: '{{request.LANGUAGE_CODE}}',
datesSet: function() { datesSet: function() {
loadOrderEvents(calendar); loadOrderEvents(calendar);
}, },

View File

@ -9,7 +9,7 @@ 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.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_lazy 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.views.generic.edit import FormMixin
from django.forms import HiddenInput from django.forms import HiddenInput
@ -37,7 +37,7 @@ from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class PurchaseOrderIndex(InvenTreeRoleMixin, ListView): class PurchaseOrderIndex(InvenTreeRoleMixin, ListView):
@ -1058,7 +1058,7 @@ class OrderParts(AjaxView):
data = { data = {
'form_valid': valid, 'form_valid': valid,
'success': 'Ordered {n} parts'.format(n=len(self.parts)) 'success': _('Ordered {n} parts').format(n=len(self.parts))
} }
return self.renderJsonResponse(self.request, data=data) return self.renderJsonResponse(self.request, data=data)
@ -1349,7 +1349,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
'form_valid': valid, 'form_valid': valid,
'form_errors': self.form.errors.as_json(), 'form_errors': self.form.errors.as_json(),
'non_field_errors': self.form.non_field_errors().as_json(), 'non_field_errors': self.form.non_field_errors().as_json(),
'success': _("Allocated") + f" {len(self.stock_items)} " + _("items") 'success': _("Allocated {n} items").format(n=len(self.stock_items))
} }
return self.renderJsonResponse(request, self.form, data) return self.renderJsonResponse(request, self.form, data)

View File

@ -16,8 +16,6 @@ from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
from InvenTree.helpers import normalize
from stock.models import StockLocation from stock.models import StockLocation
from company.models import SupplierPart from company.models import SupplierPart
@ -180,7 +178,7 @@ class BomItemResource(ModelResource):
Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export
""" """
return normalize(item.quantity) return float(item.quantity)
def before_export(self, queryset, *args, **kwargs): def before_export(self, queryset, *args, **kwargs):

View File

@ -735,6 +735,15 @@ class PartParameterList(generics.ListCreateAPIView):
] ]
class PartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a single PartParameter object
"""
queryset = PartParameter.objects.all()
serializer_class = part_serializers.PartParameterSerializer
class BomList(generics.ListCreateAPIView): class BomList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of BomItem objects. """ API endpoint for accessing a list of BomItem objects.
@ -942,6 +951,8 @@ part_api_urls = [
# Base URL for PartParameter API endpoints # Base URL for PartParameter API endpoints
url(r'^parameter/', include([ url(r'^parameter/', include([
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'), url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
url(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-param-detail'),
url(r'^.*$', PartParameterList.as_view(), name='api-part-param-list'), url(r'^.*$', PartParameterList.as_view(), name='api-part-param-list'),
])), ])),

View File

@ -9,7 +9,7 @@ from django.conf import settings
from PIL import UnidentifiedImageError from PIL import UnidentifiedImageError
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class PartConfig(AppConfig): class PartConfig(AppConfig):

View File

@ -275,7 +275,7 @@ class BomUploadManager:
elif ext in ['.xls', '.xlsx']: elif ext in ['.xls', '.xlsx']:
raw_data = bom_file.read() raw_data = bom_file.read()
else: else:
raise ValidationError({'bom_file': _('Unsupported file format: {f}'.format(f=ext))}) raise ValidationError({'bom_file': _('Unsupported file format: {f}').format(f=ext)})
try: try:
self.data = tablib.Dataset().load(raw_data) self.data = tablib.Dataset().load(raw_data)

View File

@ -33,6 +33,27 @@
template: 1 template: 1
data: 12 data: 12
- model: part.PartParameter
pk: 3
fields:
part: 3
template: 1
data: 12
- model: part.PartParameter
pk: 4
fields:
part: 3
template: 2
data: 12
- model: part.PartParameter
pk: 5
fields:
part: 3
template: 3
data: 12
# Add some template parameters to categories (requires category.yaml) # Add some template parameters to categories (requires category.yaml)
- model: part.PartCategoryParameterTemplate - model: part.PartCategoryParameterTemplate
pk: 1 pk: 1

View File

@ -11,7 +11,7 @@ from InvenTree.fields import RoundingDecimalFormField
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
from django import forms from django import forms
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
import common.models import common.models
@ -129,6 +129,7 @@ class BomDuplicateForm(HelperForm):
confirm = forms.BooleanField( confirm = forms.BooleanField(
required=False, initial=False, required=False, initial=False,
label=_('Confirm'),
help_text=_('Confirm BOM duplication') help_text=_('Confirm BOM duplication')
) )
@ -147,7 +148,7 @@ class BomValidateForm(HelperForm):
to confirm that the BOM for this part is valid to confirm that the BOM for this part is valid
""" """
validate = forms.BooleanField(required=False, initial=False, help_text=_('Confirm that the BOM is correct')) validate = forms.BooleanField(required=False, initial=False, label=_('validate'), help_text=_('Confirm that the BOM is correct'))
class Meta: class Meta:
model = Part model = Part
@ -159,7 +160,7 @@ class BomValidateForm(HelperForm):
class BomUploadSelectFile(HelperForm): class BomUploadSelectFile(HelperForm):
""" Form for importing a BOM. Provides a file input box for upload """ """ Form for importing a BOM. Provides a file input box for upload """
bom_file = forms.FileField(label='BOM file', required=True, help_text=_("Select BOM file to upload")) bom_file = forms.FileField(label=_('BOM file'), required=True, help_text=_("Select BOM file to upload"))
class Meta: class Meta:
model = Part model = Part
@ -336,9 +337,9 @@ class EditCategoryParameterTemplateForm(HelperForm):
class EditBomItemForm(HelperForm): class EditBomItemForm(HelperForm):
""" Form for editing a BomItem object """ """ Form for editing a BomItem object """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
sub_part = PartModelChoiceField(queryset=Part.objects.all()) sub_part = PartModelChoiceField(queryset=Part.objects.all(), label=_('Sub part'))
class Meta: class Meta:
model = BomItem model = BomItem
@ -365,6 +366,7 @@ class PartPriceForm(forms.Form):
quantity = forms.IntegerField( quantity = forms.IntegerField(
required=True, required=True,
initial=1, initial=1,
label=_('Quantity'),
help_text=_('Input quantity for price calculation') help_text=_('Input quantity for price calculation')
) )
@ -380,7 +382,7 @@ class EditPartSalePriceBreakForm(HelperForm):
Form for creating / editing a sale price for a part Form for creating / editing a sale price for a part
""" """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta: class Meta:
model = PartSellPriceBreak model = PartSellPriceBreak

View File

@ -0,0 +1,218 @@
# Generated by Django 3.0.7 on 2021-04-04 20:16
import InvenTree.models
import InvenTree.validators
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
import part.models
import stdimage.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('stock', '0058_stockitem_packaging'),
('part', '0063_bomitem_inherited'),
]
operations = [
migrations.AlterField(
model_name='bomitem',
name='checksum',
field=models.CharField(blank=True, help_text='BOM line checksum', max_length=128, verbose_name='Checksum'),
),
migrations.AlterField(
model_name='bomitem',
name='note',
field=models.CharField(blank=True, help_text='BOM item notes', max_length=500, verbose_name='Note'),
),
migrations.AlterField(
model_name='bomitem',
name='optional',
field=models.BooleanField(default=False, help_text='This BOM item is optional', verbose_name='Optional'),
),
migrations.AlterField(
model_name='bomitem',
name='overage',
field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage], verbose_name='Overage'),
),
migrations.AlterField(
model_name='bomitem',
name='part',
field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'assembly': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='bomitem',
name='quantity',
field=models.DecimalField(decimal_places=5, default=1.0, help_text='BOM quantity for this BOM item', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
),
migrations.AlterField(
model_name='bomitem',
name='reference',
field=models.CharField(blank=True, help_text='BOM item reference', max_length=500, verbose_name='Reference'),
),
migrations.AlterField(
model_name='bomitem',
name='sub_part',
field=models.ForeignKey(help_text='Select part to be used in BOM', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part', verbose_name='Sub part'),
),
migrations.AlterField(
model_name='part',
name='bom_checked_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='boms_checked', to=settings.AUTH_USER_MODEL, verbose_name='BOM checked by'),
),
migrations.AlterField(
model_name='part',
name='bom_checked_date',
field=models.DateField(blank=True, null=True, verbose_name='BOM checked date'),
),
migrations.AlterField(
model_name='part',
name='bom_checksum',
field=models.CharField(blank=True, help_text='Stored BOM checksum', max_length=128, verbose_name='BOM checksum'),
),
migrations.AlterField(
model_name='part',
name='creation_date',
field=models.DateField(auto_now_add=True, null=True, verbose_name='Creation Date'),
),
migrations.AlterField(
model_name='part',
name='creation_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parts_created', to=settings.AUTH_USER_MODEL, verbose_name='Creation User'),
),
migrations.AlterField(
model_name='part',
name='image',
field=stdimage.models.StdImageField(blank=True, null=True, upload_to=part.models.rename_part_image, verbose_name='Image'),
),
migrations.AlterField(
model_name='part',
name='responsible',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parts_responible', to=settings.AUTH_USER_MODEL, verbose_name='Responsible'),
),
migrations.AlterField(
model_name='partattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
migrations.AlterField(
model_name='partattachment',
name='comment',
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
),
migrations.AlterField(
model_name='partattachment',
name='part',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='partattachment',
name='upload_date',
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
),
migrations.AlterField(
model_name='partattachment',
name='user',
field=models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
migrations.AlterField(
model_name='partcategory',
name='default_keywords',
field=models.CharField(blank=True, help_text='Default keywords for parts in this category', max_length=250, null=True, verbose_name='Default keywords'),
),
migrations.AlterField(
model_name='partcategory',
name='default_location',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Default location for parts in this category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_categories', to='stock.StockLocation', verbose_name='Default Location'),
),
migrations.AlterField(
model_name='partcategory',
name='description',
field=models.CharField(blank=True, help_text='Description (optional)', max_length=250, verbose_name='Description'),
),
migrations.AlterField(
model_name='partcategory',
name='name',
field=models.CharField(help_text='Name', max_length=100, validators=[InvenTree.validators.validate_tree_name], verbose_name='Name'),
),
migrations.AlterField(
model_name='partcategory',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='part.PartCategory', verbose_name='parent'),
),
migrations.AlterField(
model_name='partcategoryparametertemplate',
name='category',
field=models.ForeignKey(help_text='Part Category', on_delete=django.db.models.deletion.CASCADE, related_name='parameter_templates', to='part.PartCategory', verbose_name='Category'),
),
migrations.AlterField(
model_name='partcategoryparametertemplate',
name='default_value',
field=models.CharField(blank=True, help_text='Default Parameter Value', max_length=500, verbose_name='Default Value'),
),
migrations.AlterField(
model_name='partcategoryparametertemplate',
name='parameter_template',
field=models.ForeignKey(help_text='Parameter Template', on_delete=django.db.models.deletion.CASCADE, related_name='part_categories', to='part.PartParameterTemplate', verbose_name='Parameter Template'),
),
migrations.AlterField(
model_name='partparameter',
name='data',
field=models.CharField(help_text='Parameter Value', max_length=500, verbose_name='Data'),
),
migrations.AlterField(
model_name='partparameter',
name='part',
field=models.ForeignKey(help_text='Parent Part', on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='partparameter',
name='template',
field=models.ForeignKey(help_text='Parameter Template', on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='part.PartParameterTemplate', verbose_name='Template'),
),
migrations.AlterField(
model_name='partparametertemplate',
name='name',
field=models.CharField(help_text='Parameter Name', max_length=100, unique=True, verbose_name='Name'),
),
migrations.AlterField(
model_name='partparametertemplate',
name='units',
field=models.CharField(blank=True, help_text='Parameter Units', max_length=25, verbose_name='Units'),
),
migrations.AlterField(
model_name='partrelated',
name='part_1',
field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_1', to='part.Part', verbose_name='Part 1'),
),
migrations.AlterField(
model_name='partrelated',
name='part_2',
field=models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.DO_NOTHING, related_name='related_parts_2', to='part.Part', verbose_name='Part 2'),
),
migrations.AlterField(
model_name='partsellpricebreak',
name='part',
field=models.ForeignKey(limit_choices_to={'salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='salepricebreaks', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='partstar',
name='part',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='partstar',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_parts', to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
migrations.AlterField(
model_name='parttesttemplate',
name='part',
field=models.ForeignKey(limit_choices_to={'trackable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='test_templates', to='part.Part', verbose_name='Part'),
),
]

View File

@ -52,7 +52,7 @@ import common.models
import part.settings as part_settings import part.settings as part_settings
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree): class PartCategory(InvenTreeTree):
@ -69,10 +69,11 @@ class PartCategory(InvenTreeTree):
'stock.StockLocation', related_name="default_categories", 'stock.StockLocation', related_name="default_categories",
null=True, blank=True, null=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
verbose_name=_('Default Location'),
help_text=_('Default location for parts in this category') help_text=_('Default location for parts in this category')
) )
default_keywords = models.CharField(null=True, blank=True, max_length=250, help_text=_('Default keywords for parts in this category')) default_keywords = models.CharField(null=True, blank=True, max_length=250, verbose_name=_('Default keywords'), help_text=_('Default keywords for parts in this category'))
def get_absolute_url(self): def get_absolute_url(self):
return reverse('category-detail', kwargs={'pk': self.id}) return reverse('category-detail', kwargs={'pk': self.id})
@ -442,10 +443,10 @@ class Part(MPTTModel):
return return
if self.pk == parent.pk: if self.pk == parent.pk:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)".format( raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(self), p1=str(self),
p2=str(parent) p2=str(parent)
))}) )})
bom_items = self.get_bom_items() bom_items = self.get_bom_items()
@ -454,10 +455,10 @@ class Part(MPTTModel):
# Check for simple match # Check for simple match
if item.sub_part == parent: if item.sub_part == parent:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)".format( raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(parent), p1=str(parent),
p2=str(self) p2=str(self)
))}) )})
# And recursively check too # And recursively check too
item.sub_part.checkAddToBOM(parent) item.sub_part.checkAddToBOM(parent)
@ -749,6 +750,7 @@ class Part(MPTTModel):
blank=True, blank=True,
variations={'thumbnail': (128, 128)}, variations={'thumbnail': (128, 128)},
delete_orphans=False, delete_orphans=False,
verbose_name=_('Image'),
) )
default_location = TreeForeignKey( default_location = TreeForeignKey(
@ -870,18 +872,18 @@ class Part(MPTTModel):
help_text=_('Part notes - supports Markdown formatting') help_text=_('Part notes - supports Markdown formatting')
) )
bom_checksum = models.CharField(max_length=128, blank=True, help_text=_('Stored BOM checksum')) bom_checksum = models.CharField(max_length=128, blank=True, verbose_name=_('BOM checksum'), help_text=_('Stored BOM checksum'))
bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
related_name='boms_checked') verbose_name=_('BOM checked by'), related_name='boms_checked')
bom_checked_date = models.DateField(blank=True, null=True) bom_checked_date = models.DateField(blank=True, null=True, verbose_name=_('BOM checked date'))
creation_date = models.DateField(auto_now_add=True, editable=False, blank=True, null=True) creation_date = models.DateField(auto_now_add=True, editable=False, blank=True, null=True, verbose_name=_('Creation Date'))
creation_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='parts_created') creation_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Creation User'), related_name='parts_created')
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='parts_responible') responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible')
def format_barcode(self, **kwargs): def format_barcode(self, **kwargs):
""" Return a JSON string for formatting a barcode for this Part object """ """ Return a JSON string for formatting a barcode for this Part object """
@ -1851,7 +1853,7 @@ class PartAttachment(InvenTreeAttachment):
return os.path.join("part_files", str(self.part.id)) return os.path.join("part_files", str(self.part.id))
part = models.ForeignKey(Part, on_delete=models.CASCADE, part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='attachments') verbose_name=_('Part'), related_name='attachments')
class PartSellPriceBreak(common.models.PriceBreak): class PartSellPriceBreak(common.models.PriceBreak):
@ -1862,7 +1864,8 @@ class PartSellPriceBreak(common.models.PriceBreak):
part = models.ForeignKey( part = models.ForeignKey(
Part, on_delete=models.CASCADE, Part, on_delete=models.CASCADE,
related_name='salepricebreaks', related_name='salepricebreaks',
limit_choices_to={'salable': True} limit_choices_to={'salable': True},
verbose_name=_('Part')
) )
class Meta: class Meta:
@ -1880,9 +1883,9 @@ class PartStar(models.Model):
user: Link to a User object user: Link to a User object
""" """
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='starred_users') part = models.ForeignKey(Part, on_delete=models.CASCADE, verbose_name=_('Part'), related_name='starred_users')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='starred_parts') user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
class Meta: class Meta:
unique_together = ['part', 'user'] unique_together = ['part', 'user']
@ -1955,6 +1958,7 @@ class PartTestTemplate(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='test_templates', related_name='test_templates',
limit_choices_to={'trackable': True}, limit_choices_to={'trackable': True},
verbose_name=_('Part'),
) )
test_name = models.CharField( test_name = models.CharField(
@ -2022,9 +2026,9 @@ class PartParameterTemplate(models.Model):
except PartParameterTemplate.DoesNotExist: except PartParameterTemplate.DoesNotExist:
pass pass
name = models.CharField(max_length=100, help_text=_('Parameter Name'), unique=True) name = models.CharField(max_length=100, verbose_name=_('Name'), help_text=_('Parameter Name'), unique=True)
units = models.CharField(max_length=25, help_text=_('Parameter Units'), blank=True) units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True)
class PartParameter(models.Model): class PartParameter(models.Model):
@ -2050,11 +2054,11 @@ class PartParameter(models.Model):
# Prevent multiple instances of a parameter for a single part # Prevent multiple instances of a parameter for a single part
unique_together = ('part', 'template') unique_together = ('part', 'template')
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', help_text=_('Parent Part')) part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', verbose_name=_('Part'), help_text=_('Parent Part'))
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', help_text=_('Parameter Template')) template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', verbose_name=_('Template'), help_text=_('Parameter Template'))
data = models.CharField(max_length=500, help_text=_('Parameter Value')) data = models.CharField(max_length=500, verbose_name=_('Data'), help_text=_('Parameter Value'))
@classmethod @classmethod
def create(cls, part, template, data, save=False): def create(cls, part, template, data, save=False):
@ -2095,15 +2099,18 @@ class PartCategoryParameterTemplate(models.Model):
category = models.ForeignKey(PartCategory, category = models.ForeignKey(PartCategory,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='parameter_templates', related_name='parameter_templates',
verbose_name=_('Category'),
help_text=_('Part Category')) help_text=_('Part Category'))
parameter_template = models.ForeignKey(PartParameterTemplate, parameter_template = models.ForeignKey(PartParameterTemplate,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='part_categories', related_name='part_categories',
verbose_name=_('Parameter Template'),
help_text=_('Parameter Template')) help_text=_('Parameter Template'))
default_value = models.CharField(max_length=500, default_value = models.CharField(max_length=500,
blank=True, blank=True,
verbose_name=_('Default Value'),
help_text=_('Default Parameter Value')) help_text=_('Default Parameter Value'))
@ -2132,6 +2139,7 @@ class BomItem(models.Model):
# A link to the parent part # A link to the parent part
# Each part will get a reverse lookup field 'bom_items' # Each part will get a reverse lookup field 'bom_items'
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items', part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',
verbose_name=_('Part'),
help_text=_('Select parent part'), help_text=_('Select parent part'),
limit_choices_to={ limit_choices_to={
'assembly': True, 'assembly': True,
@ -2140,26 +2148,28 @@ class BomItem(models.Model):
# A link to the child item (sub-part) # A link to the child item (sub-part)
# Each part will get a reverse lookup field 'used_in' # Each part will get a reverse lookup field 'used_in'
sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in', sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in',
verbose_name=_('Sub part'),
help_text=_('Select part to be used in BOM'), help_text=_('Select part to be used in BOM'),
limit_choices_to={ limit_choices_to={
'component': True, 'component': True,
}) })
# Quantity required # Quantity required
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], help_text=_('BOM quantity for this BOM item')) quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item'))
optional = models.BooleanField(default=False, help_text=_("This BOM item is optional")) optional = models.BooleanField(default=False, verbose_name=_('Optional'), help_text=_("This BOM item is optional"))
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage], overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
verbose_name=_('Overage'),
help_text=_('Estimated build wastage quantity (absolute or percentage)') help_text=_('Estimated build wastage quantity (absolute or percentage)')
) )
reference = models.CharField(max_length=500, blank=True, help_text=_('BOM item reference')) reference = models.CharField(max_length=500, blank=True, verbose_name=_('Reference'), help_text=_('BOM item reference'))
# Note attached to this BOM line item # Note attached to this BOM line item
note = models.CharField(max_length=500, blank=True, help_text=_('BOM item notes')) note = models.CharField(max_length=500, blank=True, verbose_name=_('Note'), help_text=_('BOM item notes'))
checksum = models.CharField(max_length=128, blank=True, help_text=_('BOM line checksum')) checksum = models.CharField(max_length=128, blank=True, verbose_name=_('Checksum'), help_text=_('BOM line checksum'))
inherited = models.BooleanField( inherited = models.BooleanField(
default=False, default=False,
@ -2371,11 +2381,11 @@ class PartRelated(models.Model):
""" Store and handle related parts (eg. mating connector, crimps, etc.) """ """ Store and handle related parts (eg. mating connector, crimps, etc.) """
part_1 = models.ForeignKey(Part, related_name='related_parts_1', part_1 = models.ForeignKey(Part, related_name='related_parts_1',
on_delete=models.DO_NOTHING) verbose_name=_('Part 1'), on_delete=models.DO_NOTHING)
part_2 = models.ForeignKey(Part, related_name='related_parts_2', part_2 = models.ForeignKey(Part, related_name='related_parts_2',
on_delete=models.DO_NOTHING, on_delete=models.DO_NOTHING,
help_text=_('Select Related Part')) verbose_name=_('Part 2'), help_text=_('Select Related Part'))
def __str__(self): def __str__(self):
return f'{self.part_1} <--> {self.part_2}' return f'{self.part_1} <--> {self.part_2}'

View File

@ -16,13 +16,13 @@
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
The BOM for <i>{{ part.full_name }}</i> has changed, and must be validated.<br> {% blocktrans with part=part.full_name %}The BOM for <i>{{ part }}</i> has changed, and must be validated.<br>{% endblocktrans %}
{% endif %} {% endif %}
The BOM for <i>{{ part.full_name }}</i> was last checked by {{ part.bom_checked_by }} on {{ part.bom_checked_date }} {% blocktrans with part=part.full_name checker=part.bom_checked_by check_date=part.bom_checked_date %}The BOM for <i>{{ part }}</i> was last checked by {{ checker }} on {{ check_date }}{% endblocktrans %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-danger alert-block'> <div class='alert alert-danger alert-block'>
<b>The BOM for <i>{{ part.full_name }}</i> has not been validated.</b> <b>{% blocktrans with part=part.full_name %}The BOM for <i>{{ part }}</i> has not been validated.{% endblocktrans %}</b>
</div> </div>
{% endif %} {% endif %}

View File

@ -44,7 +44,7 @@
<div> <div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/> <input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
{{ col.name }} {{ col.name }}
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='Remove column'> <button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span> <span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
</button> </button>
</div> </div>
@ -73,7 +73,7 @@
{% for row in bom_rows %} {% for row in bom_rows %}
<tr> <tr>
<td> <td>
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='Remove row'> <button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
<span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></span> <span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></span>
</button> </button>
</td> </td>

View File

@ -24,7 +24,7 @@
</div> </div>
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data"> <form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
<button type="submit" class="save btn btn-default">Upload File</button> <button type="submit" class="save btn btn-default">{% trans 'Upload File' %}</button>
{% csrf_token %} {% csrf_token %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}

View File

@ -1,10 +1,12 @@
{% extends "modal_form.html" %} {% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part.full_name }}</i> {% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part }}</i>{% endblocktrans %}
<div class='alert alert-warning alert-block'> <div class='alert alert-warning alert-block'>
This will validate each line in the BOM. {% trans 'This will validate each line in the BOM.' %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -65,8 +65,8 @@
reload: true, reload: true,
secondary: [{ secondary: [{
field: 'template', field: 'template',
label: 'New Template', label: '{% trans "New Template" %}',
title: 'Create New Parameter Template', title: '{% trans "Create New Parameter Template" %}',
url: "{% url 'part-param-template-create' %}" url: "{% url 'part-param-template-create' %}"
}], }],
}); });

View File

@ -246,7 +246,7 @@
launchModalForm( launchModalForm(
"{% url 'part-pricing' part.id %}", "{% url 'part-pricing' part.id %}",
{ {
submit_text: 'Calculate', submit_text: '{% trans "Calculate" %}',
hideErrorMessage: true, hideErrorMessage: true,
} }
); );

View File

@ -4,33 +4,33 @@
{% block pre_form_content %} {% block pre_form_content %}
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
Pricing information for:<br> {% trans 'Pricing information for:' %}<br>
{{ part }}. {{ part }}.
</div> </div>
<h4>Quantity</h4> <h4>{% trans 'Quantity' %}</h4>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tr> <tr>
<td><b>Part</b></td> <td><b>{% trans 'Part' %}</b></td>
<td colspan='2'>{{ part }}</td> <td colspan='2'>{{ part }}</td>
</tr> </tr>
<tr> <tr>
<td><b>Quantity</b></td> <td><b>{% trans 'Quantity' %}</b></td>
<td colspan='2'>{{ quantity }}</td> <td colspan='2'>{{ quantity }}</td>
</tr> </tr>
</table> </table>
{% if part.supplier_count > 0 %} {% if part.supplier_count > 0 %}
<h4>Supplier Pricing</h4> <h4>{% trans 'Supplier Pricing' %}</h4>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
{% if min_total_buy_price %} {% if min_total_buy_price %}
<tr> <tr>
<td><b>Unit Cost</b></td> <td><b>{% trans 'Unit Cost' %}</b></td>
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td> <td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td> <td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
</tr> </tr>
{% if quantity > 1 %} {% if quantity > 1 %}
<tr> <tr>
<td><b>Total Cost</b></td> <td><b>{% trans 'Total Cost' %}</b></td>
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td> <td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td> <td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
</tr> </tr>
@ -38,7 +38,7 @@ Pricing information for:<br>
{% else %} {% else %}
<tr> <tr>
<td colspan='3'> <td colspan='3'>
<span class='warning-msg'><i>No supplier pricing available</i></span> <span class='warning-msg'><i>{% trans 'No supplier pricing available' %}</i></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@ -46,17 +46,17 @@ Pricing information for:<br>
{% endif %} {% endif %}
{% if part.bom_count > 0 %} {% if part.bom_count > 0 %}
<h4>BOM Pricing</h4> <h4>{% trans 'BOM Pricing' %}</h4>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
{% if min_total_bom_price %} {% if min_total_bom_price %}
<tr> <tr>
<td><b>Unit Cost</b></td> <td><b>{% trans 'Unit Cost' %}</b></td>
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td> <td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td> <td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
</tr> </tr>
{% if quantity > 1 %} {% if quantity > 1 %}
<tr> <tr>
<td><b>Total Cost</b></td> <td><b>{% trans 'Total Cost' %}</b></td>
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td> <td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td> <td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr> </tr>
@ -64,14 +64,14 @@ Pricing information for:<br>
{% if part.has_complete_bom_pricing == False %} {% if part.has_complete_bom_pricing == False %}
<tr> <tr>
<td colspan='3'> <td colspan='3'>
<span class='warning-msg'><i>Note: BOM pricing is incomplete for this part</i></span> <span class='warning-msg'><i>{% trans 'Note: BOM pricing is incomplete for this part' %}</i></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% else %} {% else %}
<tr> <tr>
<td colspan='3'> <td colspan='3'>
<span class='warning-msg'><i>No BOM pricing available</i></span> <span class='warning-msg'><i>{% trans 'No BOM pricing available' %}</i></span>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@ -151,7 +151,7 @@ Pricing information for:<br>
{% if min_unit_buy_price or min_unit_bom_price %} {% if min_unit_buy_price or min_unit_bom_price %}
{% else %} {% else %}
<div class='alert alert-danger alert-block'> <div class='alert alert-danger alert-block'>
No pricing information is available for this part. {% trans 'No pricing information is available for this part.' %}
</div> </div>
{% endif %} {% endif %}

View File

@ -40,7 +40,7 @@
params: { params: {
part: {{ part.id }}, part: {{ part.id }},
location_detail: true, location_detail: true,
part_detail: true, part_detail: false,
}, },
groupByField: 'location', groupByField: 'location',
buttons: [ buttons: [

View File

@ -325,3 +325,106 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['in_stock'], 1100) self.assertEqual(data['in_stock'], 1100)
self.assertEqual(data['stock_item_count'], 105) self.assertEqual(data['stock_item_count'], 105)
class PartParameterTest(InvenTreeAPITestCase):
"""
Tests for the ParParameter API
"""
superuser = True
fixtures = [
'category',
'part',
'location',
'params',
]
def setUp(self):
super().setUp()
def test_list_params(self):
"""
Test for listing part parameters
"""
url = reverse('api-part-param-list')
response = self.client.get(url, format='json')
self.assertEqual(len(response.data), 5)
# Filter by part
response = self.client.get(
url,
{
'part': 3,
},
format='json'
)
self.assertEqual(len(response.data), 3)
# Filter by template
response = self.client.get(
url,
{
'template': 1,
},
format='json',
)
self.assertEqual(len(response.data), 3)
def test_create_param(self):
"""
Test that we can create a param via the API
"""
url = reverse('api-part-param-list')
response = self.client.post(
url,
{
'part': '2',
'template': '3',
'data': 70
}
)
self.assertEqual(response.status_code, 201)
response = self.client.get(url, format='json')
self.assertEqual(len(response.data), 6)
def test_param_detail(self):
"""
Tests for the PartParameter detail endpoint
"""
url = reverse('api-part-param-detail', kwargs={'pk': 5})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 5)
self.assertEqual(data['part'], 3)
self.assertEqual(data['data'], '12')
# PATCH data back in
response = self.client.patch(url, {'data': '15'}, format='json')
self.assertEqual(response.status_code, 200)
# Check that the data changed!
response = self.client.get(url, format='json')
data = response.data
self.assertEqual(data['data'], '15')

View File

@ -342,7 +342,7 @@ class PartSetCategory(AjaxUpdateView):
data = { data = {
'form_valid': valid, 'form_valid': valid,
'success': _('Set category for {n} parts'.format(n=len(self.parts))) 'success': _('Set category for {n} parts').format(n=len(self.parts))
} }
if valid: if valid:

View File

@ -5,7 +5,7 @@ import logging
import plugins.plugin as plugin import plugins.plugin as plugin
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class ActionPlugin(plugin.InvenTreePlugin): class ActionPlugin(plugin.InvenTreePlugin):

View File

@ -10,7 +10,7 @@ import plugins.action as action
from plugins.action.action import ActionPlugin from plugins.action.action import ActionPlugin
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
def iter_namespace(pkg): def iter_namespace(pkg):

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.http import HttpResponse from django.http import HttpResponse

View File

@ -6,7 +6,7 @@ from django.apps import AppConfig
from django.conf import settings from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class ReportConfig(AppConfig): class ReportConfig(AppConfig):

View File

@ -0,0 +1,35 @@
# Generated by Django 3.0.7 on 2021-04-03 18:37
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
dependencies = [
('report', '0014_purchaseorderreport_salesorderreport'),
]
operations = [
migrations.AlterField(
model_name='reportasset',
name='asset',
field=models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset, verbose_name='Asset'),
),
migrations.AlterField(
model_name='reportasset',
name='description',
field=models.CharField(help_text='Asset file description', max_length=250, verbose_name='Description'),
),
migrations.AlterField(
model_name='reportsnippet',
name='description',
field=models.CharField(help_text='Snippet file description', max_length=250, verbose_name='Description'),
),
migrations.AlterField(
model_name='reportsnippet',
name='snippet',
field=models.FileField(help_text='Report snippet file', upload_to=report.models.rename_snippet, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Snippet'),
),
]

View File

@ -38,7 +38,7 @@ except OSError as err:
sys.exit(1) sys.exit(1)
logger = logging.getLogger(__name__) logger = logging.getLogger("inventree")
class ReportFileUpload(FileSystemStorage): class ReportFileUpload(FileSystemStorage):
@ -497,11 +497,12 @@ class ReportSnippet(models.Model):
snippet = models.FileField( snippet = models.FileField(
upload_to=rename_snippet, upload_to=rename_snippet,
verbose_name=_('Snippet'),
help_text=_('Report snippet file'), help_text=_('Report snippet file'),
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
) )
description = models.CharField(max_length=250, help_text=_("Snippet file description")) description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_("Snippet file description"))
def rename_asset(instance, filename): def rename_asset(instance, filename):
@ -536,7 +537,8 @@ class ReportAsset(models.Model):
asset = models.FileField( asset = models.FileField(
upload_to=rename_asset, upload_to=rename_asset,
verbose_name=_('Asset'),
help_text=_("Report asset file"), help_text=_("Report asset file"),
) )
description = models.CharField(max_length=250, help_text=_("Asset file description")) description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_("Asset file description"))

View File

@ -11,6 +11,7 @@ from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from .models import StockLocation, StockItem from .models import StockLocation, StockItem
from .models import StockItemTracking from .models import StockItemTracking
@ -195,7 +196,7 @@ class StockCount(StockAdjust):
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes): if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
n += 1 n += 1
return Response({'success': 'Updated stock for {n} items'.format(n=n)}) return Response({'success': _('Updated stock for {n} items').format(n=n)})
class StockAdd(StockAdjust): class StockAdd(StockAdjust):
@ -264,7 +265,7 @@ class StockTransfer(StockAdjust):
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']): if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
n += 1 n += 1
return Response({'success': 'Moved {n} parts to {loc}'.format( return Response({'success': _('Moved {n} parts to {loc}').format(
n=n, n=n,
loc=str(location), loc=str(location),
)}) )})

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -111,10 +111,11 @@ class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """ """ Form for creating a new StockItem """
expiry_date = DatePickerFormField( expiry_date = DatePickerFormField(
help_text=('Expiration date for this stock item'), label=_('Expiry Date'),
help_text=_('Expiration date for this stock item'),
) )
serial_numbers = forms.CharField(label=_('Serial numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)')) serial_numbers = forms.CharField(label=_('Serial Numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -165,13 +166,13 @@ class CreateStockItemForm(HelperForm):
class SerializeStockForm(HelperForm): class SerializeStockForm(HelperForm):
""" Form for serializing a StockItem. """ """ Form for serializing a StockItem. """
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)') destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)'))
serial_numbers = forms.CharField(label='Serial numbers', required=True, help_text='Unique serial numbers (must match quantity)') serial_numbers = forms.CharField(label=_('Serial numbers'), required=True, help_text=_('Unique serial numbers (must match quantity)'))
note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)') note = forms.CharField(label=_('Notes'), required=False, help_text=_('Add transaction note (optional)'))
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -263,7 +264,7 @@ class ExportOptionsForm(HelperForm):
file_format = forms.ChoiceField(label=_('File Format'), help_text=_('Select output file format')) file_format = forms.ChoiceField(label=_('File Format'), help_text=_('Select output file format'))
include_sublocations = forms.BooleanField(required=False, initial=True, help_text=_("Include stock items in sub locations")) include_sublocations = forms.BooleanField(required=False, initial=True, label=_('Include sublocations'), help_text=_("Include stock items in sub locations"))
class Meta: class Meta:
model = StockLocation model = StockLocation
@ -402,7 +403,8 @@ class EditStockItemForm(HelperForm):
""" """
expiry_date = DatePickerFormField( expiry_date = DatePickerFormField(
help_text=('Expiration date for this stock item'), label=_('Expiry Date'),
help_text=_('Expiration date for this stock item'),
) )
class Meta: class Meta:

View File

@ -0,0 +1,92 @@
# Generated by Django 3.0.7 on 2021-04-04 20:16
import InvenTree.fields
import InvenTree.models
import InvenTree.validators
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('users', '0005_owner_model'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('stock', '0058_stockitem_packaging'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='delete_on_deplete',
field=models.BooleanField(default=True, help_text='Delete this Stock Item when stock is depleted', verbose_name='Delete on deplete'),
),
migrations.AlterField(
model_name='stockitem',
name='owner',
field=models.ForeignKey(blank=True, help_text='Select Owner', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='users.Owner', verbose_name='Owner'),
),
migrations.AlterField(
model_name='stockitemattachment',
name='attachment',
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
),
migrations.AlterField(
model_name='stockitemattachment',
name='comment',
field=models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment'),
),
migrations.AlterField(
model_name='stockitemattachment',
name='upload_date',
field=models.DateField(auto_now_add=True, null=True, verbose_name='upload date'),
),
migrations.AlterField(
model_name='stockitemattachment',
name='user',
field=models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
migrations.AlterField(
model_name='stockitemtracking',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page for further information', verbose_name='Link'),
),
migrations.AlterField(
model_name='stockitemtracking',
name='notes',
field=models.CharField(blank=True, help_text='Entry notes', max_length=512, verbose_name='Notes'),
),
migrations.AlterField(
model_name='stockitemtracking',
name='quantity',
field=models.DecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'),
),
migrations.AlterField(
model_name='stockitemtracking',
name='title',
field=models.CharField(help_text='Tracking entry title', max_length=250, verbose_name='Title'),
),
migrations.AlterField(
model_name='stocklocation',
name='description',
field=models.CharField(blank=True, help_text='Description (optional)', max_length=250, verbose_name='Description'),
),
migrations.AlterField(
model_name='stocklocation',
name='name',
field=models.CharField(help_text='Name', max_length=100, validators=[InvenTree.validators.validate_tree_name], verbose_name='Name'),
),
migrations.AlterField(
model_name='stocklocation',
name='owner',
field=models.ForeignKey(blank=True, help_text='Select Owner', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_locations', to='users.Owner', verbose_name='Owner'),
),
migrations.AlterField(
model_name='stocklocation',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='stock.StockLocation', verbose_name='parent'),
),
]

View File

@ -51,7 +51,8 @@ class StockLocation(InvenTreeTree):
""" """
owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True, owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True,
help_text='Select Owner', verbose_name=_('Owner'),
help_text=_('Select Owner'),
related_name='stock_locations') related_name='stock_locations')
def get_absolute_url(self): def get_absolute_url(self):
@ -483,7 +484,7 @@ class StockItem(MPTTModel):
review_needed = models.BooleanField(default=False) review_needed = models.BooleanField(default=False)
delete_on_deplete = models.BooleanField(default=True, help_text=_('Delete this Stock Item when stock is depleted')) delete_on_deplete = models.BooleanField(default=True, verbose_name=_('Delete on deplete'), help_text=_('Delete this Stock Item when stock is depleted'))
status = models.PositiveIntegerField( status = models.PositiveIntegerField(
default=StockStatus.OK, default=StockStatus.OK,
@ -507,7 +508,8 @@ class StockItem(MPTTModel):
) )
owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True, owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True,
help_text='Select Owner', verbose_name=_('Owner'),
help_text=_('Select Owner'),
related_name='stock_items') related_name='stock_items')
def is_stale(self): def is_stale(self):
@ -948,7 +950,7 @@ class StockItem(MPTTModel):
raise ValidationError({"quantity": _("Quantity must be greater than zero")}) raise ValidationError({"quantity": _("Quantity must be greater than zero")})
if quantity > self.quantity: if quantity > self.quantity:
raise ValidationError({"quantity": _("Quantity must not exceed available stock quantity ({n})".format(n=self.quantity))}) raise ValidationError({"quantity": _("Quantity must not exceed available stock quantity ({n})").format(n=self.quantity)})
if not type(serials) in [list, tuple]: if not type(serials) in [list, tuple]:
raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")}) raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")})
@ -989,7 +991,7 @@ class StockItem(MPTTModel):
new_item.addTransactionNote(_('Add serial number'), user, notes=notes) new_item.addTransactionNote(_('Add serial number'), user, notes=notes)
# Remove the equivalent number of items # Remove the equivalent number of items
self.take_stock(quantity, user, notes=_('Serialized {n} items'.format(n=quantity))) self.take_stock(quantity, user, notes=_('Serialized {n} items').format(n=quantity))
@transaction.atomic @transaction.atomic
def copyHistoryFrom(self, other): def copyHistoryFrom(self, other):
@ -1548,17 +1550,17 @@ class StockItemTracking(models.Model):
date = models.DateTimeField(auto_now_add=True, editable=False) date = models.DateTimeField(auto_now_add=True, editable=False)
title = models.CharField(blank=False, max_length=250, help_text=_('Tracking entry title')) title = models.CharField(blank=False, max_length=250, verbose_name=_('Title'), help_text=_('Tracking entry title'))
notes = models.CharField(blank=True, max_length=512, help_text=_('Entry notes')) notes = models.CharField(blank=True, max_length=512, verbose_name=_('Notes'), help_text=_('Entry notes'))
link = InvenTreeURLField(blank=True, help_text=_('Link to external page for further information')) link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page for further information'))
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
system = models.BooleanField(default=False) system = models.BooleanField(default=False)
quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1) quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'))
# TODO # TODO
# image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True) # image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True)

View File

@ -165,13 +165,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% if item.in_stock %} {% 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>
{% endif %}
{% if item.in_stock and item.part.trackable %} {% 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.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>
{% endif %}
{% if item.in_stock and item.can_adjust_location and 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 %}
@ -451,7 +451,7 @@ $("#stock-edit").click(function () {
"{% url 'stock-item-edit' item.id %}", "{% url 'stock-item-edit' item.id %}",
{ {
reload: true, reload: true,
submit_text: "Save", submit_text: '{% trans "Save" %}',
} }
); );
}); });

View File

@ -160,7 +160,7 @@
$("#stock-export").click(function() { $("#stock-export").click(function() {
launchModalForm("{% url 'stock-export-options' %}", { launchModalForm("{% url 'stock-export-options' %}", {
submit_text: "Export", submit_text: '{% trans "Export" %}',
success: function(response) { success: function(response) {
var url = "{% url 'stock-export' %}"; var url = "{% url 'stock-export' %}";
@ -188,8 +188,8 @@
secondary: [ secondary: [
{ {
field: 'parent', field: 'parent',
label: 'New Location', label: '{% trans "New Location" %}',
title: 'Create new location', title: '{% trans "Create new location" %}',
url: "{% url 'stock-location-create' %}", url: "{% url 'stock-location-create' %}",
}, },
] ]

View File

@ -40,7 +40,7 @@
<input type='hidden' name='stock-id-{{ item.id }}' value='{{ item.new_quantity }}'/> <input type='hidden' name='stock-id-{{ item.id }}' value='{{ item.new_quantity }}'/>
{% endif %} {% endif %}
</td> </td>
<td><button class='btn btn-default btn-remove' onclick='removeStockRow()' id='del-{{ item.id }}' title='Remove item' type='button'><span row='stock-row-{{ item.id }}' class='fas fa-trash-alt icon-red'></span></button></td> <td><button class='btn btn-default btn-remove' onclick='removeStockRow()' id='del-{{ item.id }}' title='{% trans "Remove item" %}' type='button'><span row='stock-row-{{ item.id }}' class='fas fa-trash-alt icon-red'></span></button></td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -14,7 +14,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from moneyed import CURRENCIES from moneyed import CURRENCIES
@ -965,7 +965,7 @@ class StockAdjust(AjaxView, FormMixin):
context['stock_action'] = self.stock_action.strip().lower() context['stock_action'] = self.stock_action.strip().lower()
context['stock_action_title'] = self.stock_action.capitalize() context['stock_action_title'] = self.stock_action_title
# Quantity column will be read-only in some circumstances # Quantity column will be read-only in some circumstances
context['edit_quantity'] = not self.stock_action == 'delete' context['edit_quantity'] = not self.stock_action == 'delete'
@ -993,16 +993,17 @@ class StockAdjust(AjaxView, FormMixin):
if self.stock_action not in ['move', 'count', 'take', 'add', 'delete']: if self.stock_action not in ['move', 'count', 'take', 'add', 'delete']:
self.stock_action = 'count' self.stock_action = 'count'
# Choose the form title based on the action # Choose form title and action column based on the action
titles = { titles = {
'move': _('Move Stock Items'), 'move': [_('Move Stock Items'), _('Move')],
'count': _('Count Stock Items'), 'count': [_('Count Stock Items'), _('Count')],
'take': _('Remove From Stock'), 'take': [_('Remove From Stock'), _('Take')],
'add': _('Add Stock Items'), 'add': [_('Add Stock Items'), _('Add')],
'delete': _('Delete Stock Items') 'delete': [_('Delete Stock Items'), _('Delete')],
} }
self.ajax_form_title = titles[self.stock_action] self.ajax_form_title = titles[self.stock_action][0]
self.stock_action_title = titles[self.stock_action][1]
# Save list of items! # Save list of items!
self.stock_items = self.get_GET_items() self.stock_items = self.get_GET_items()
@ -1039,7 +1040,7 @@ class StockAdjust(AjaxView, FormMixin):
if self.stock_action in ['move', 'take']: if self.stock_action in ['move', 'take']:
if item.new_quantity > item.quantity: if item.new_quantity > item.quantity:
item.error = _('Quantity must not exceed {x}'.format(x=item.quantity)) item.error = _('Quantity must not exceed {x}').format(x=item.quantity)
valid = False valid = False
continue continue
@ -1118,7 +1119,7 @@ class StockAdjust(AjaxView, FormMixin):
count += 1 count += 1
return f"{_('Added stock to ')} {count} {_('items')}" return _('Added stock to {n} items').format(n=count)
def do_take(self): def do_take(self):
@ -1133,7 +1134,7 @@ class StockAdjust(AjaxView, FormMixin):
count += 1 count += 1
return f"{_('Removed stock from ')} {count} {_('items')}" return _('Removed stock from {n} items').format(n=count)
def do_count(self): def do_count(self):
@ -1189,9 +1190,9 @@ class StockAdjust(AjaxView, FormMixin):
return _('No items were moved') return _('No items were moved')
else: else:
return _('Moved {n} items to {dest}'.format( return _('Moved {n} items to {dest}').format(
n=count, n=count,
dest=destination.pathstring)) dest=destination.pathstring)
def do_delete(self): def do_delete(self):
""" Delete multiple stock items """ """ Delete multiple stock items """
@ -1208,7 +1209,7 @@ class StockAdjust(AjaxView, FormMixin):
count += 1 count += 1
return _("Deleted {n} stock items".format(n=count)) return _("Deleted {n} stock items").format(n=count)
class StockItemEdit(AjaxUpdateView): class StockItemEdit(AjaxUpdateView):

View File

@ -131,6 +131,7 @@ addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa
loadStockTable($('#table-recently-updated-stock'), { loadStockTable($('#table-recently-updated-stock'), {
params: { params: {
part_detail: true,
ordering: "-updated", ordering: "-updated",
max_results: {% settings_value "STOCK_RECENT_COUNT" %}, max_results: {% settings_value "STOCK_RECENT_COUNT" %},
}, },

View File

@ -133,14 +133,14 @@ InvenTree | {% trans "Search Results" %}
columns: [ columns: [
{ {
field: 'name', field: 'name',
title: 'Name', title: '{% trans "Name" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(value, '/part/category/' + row.pk + '/'); return renderLink(value, '/part/category/' + row.pk + '/');
}, },
}, },
{ {
field: 'description', field: 'description',
title: 'Description', title: '{% trans "Description" %}',
}, },
], ],
}); });
@ -270,14 +270,14 @@ InvenTree | {% trans "Search Results" %}
columns: [ columns: [
{ {
field: 'name', field: 'name',
title: 'Name', title: '{% trans "Name" %}',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(row.pathstring, '/stock/location/' + row.pk + '/'); return renderLink(row.pathstring, '/stock/location/' + row.pk + '/');
}, },
}, },
{ {
field: 'description', field: 'description',
title: 'Description', title: '{% trans "Description" %}',
}, },
], ],
}); });

View File

@ -8,7 +8,7 @@
<span class='fas {{ icon }}'></span> <span class='fas {{ icon }}'></span>
{% endif %} {% endif %}
</td> </td>
<td><b>{{ setting.name }}</b></td> <td><b>{% trans setting.name %}</b></td>
<td> <td>
{% if setting.is_bool %} {% if setting.is_bool %}
<div> <div>
@ -24,7 +24,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<td> <td>
{{ setting.description }} {% trans setting.description %}
</td> </td>
<td> <td>
<div class='btn-group float-right'> <div class='btn-group float-right'>

Some files were not shown because too many files have changed in this diff Show More