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_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- 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_PORT: 3306
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
services:
mysql:

View File

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

View File

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

View File

@ -5,7 +5,7 @@ from __future__ import unicode_literals
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.db import models as models
@ -42,6 +42,7 @@ class DatePickerFormField(forms.DateField):
def __init__(self, **kwargs):
help_text = kwargs.get('help_text', _('Enter date'))
label = kwargs.get('label', None)
required = kwargs.get('required', False)
initial = kwargs.get('initial', None)
@ -56,7 +57,8 @@ class DatePickerFormField(forms.DateField):
required=required,
initial=initial,
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 -*-
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 crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
@ -123,6 +123,7 @@ class DeleteForm(forms.Form):
confirm_delete = forms.BooleanField(
required=False,
initial=False,
label=_('Confirm delete'),
help_text=_('Confirm item deletion')
)
@ -155,6 +156,7 @@ class SetPasswordForm(HelperForm):
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
label=_('Enter password'),
help_text=_('Enter new password'))
confirm_password = forms.CharField(max_length=100,
@ -162,6 +164,7 @@ class SetPasswordForm(HelperForm):
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
label=_('Confirm password'),
help_text=_('Confirm new password'))
class Meta:

View File

@ -13,7 +13,7 @@ from decimal import Decimal
from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse
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
@ -382,17 +382,17 @@ def extract_serial_numbers(serials, expected_quantity):
if a < b:
for n in range(a, b + 1):
if n in numbers:
errors.append(_('Duplicate serial: {n}'.format(n=n)))
errors.append(_('Duplicate serial: {n}').format(n=n))
else:
numbers.append(n)
else:
errors.append(_("Invalid group: {g}".format(g=group)))
errors.append(_("Invalid group: {g}").format(g=group))
except ValueError:
errors.append(_("Invalid group: {g}".format(g=group)))
errors.append(_("Invalid group: {g}").format(g=group))
continue
else:
errors.append(_("Invalid group: {g}".format(g=group)))
errors.append(_("Invalid group: {g}").format(g=group))
continue
else:
@ -409,7 +409,7 @@ def extract_serial_numbers(serials, expected_quantity):
# The number of extracted serial numbers must match the expected quantity
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

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
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
class AuthRequiredMiddleware(object):
@ -52,6 +52,10 @@ class AuthRequiredMiddleware(object):
if request.path_info.startswith('/static/'):
authorized = True
# Unauthorized users can access the login page
elif request.path_info.startswith('/accounts/'):
authorized = True
elif 'Authorization' in request.headers.keys():
auth = request.headers['Authorization'].strip()

View File

@ -56,19 +56,20 @@ class InvenTreeAttachment(models.Model):
def __str__(self):
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'))
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,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('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
def basename(self):
@ -103,12 +104,14 @@ class InvenTreeTree(MPTTModel):
blank=False,
max_length=100,
validators=[validate_tree_name],
verbose_name=_("Name"),
help_text=_("Name"),
)
description = models.CharField(
blank=True,
max_length=250,
verbose_name=_("Description"),
help_text=_("Description (optional)")
)
@ -117,6 +120,7 @@ class InvenTreeTree(MPTTModel):
on_delete=models.DO_NOTHING,
blank=True,
null=True,
verbose_name=_("parent"),
related_name='children')
@property

View File

@ -13,6 +13,9 @@ database setup in this file.
import logging
import os
import random
import string
import shutil
import sys
import tempfile
from datetime import datetime
@ -46,14 +49,31 @@ def get_setting(environment_var, backup_val, default_value=None):
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, ...)
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')
if not os.path.exists(cfg_filename):
print("Error: config.yaml not found")
sys.exit(-1)
print("InvenTree configuration file 'config.yaml' not found - creating default file")
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:
CONFIG = yaml.safe_load(cfg)
@ -94,7 +114,18 @@ LOGGING = {
}
# 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"):
# Secret key passed in directly
@ -105,15 +136,22 @@ else:
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
if key_file:
if os.path.isfile(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)
key_file = os.path.abspath(key_file)
else:
# default secret key location
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:
SECRET_KEY = open(key_file, "r").read().strip()
except Exception:
@ -144,7 +182,7 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.abspath(
get_setting(
'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(
get_setting(
'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',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
# Third part add-ons
'django_filters', # Extended filter functionality
@ -211,6 +250,7 @@ INSTALLED_APPS = [
'djmoney', # django-money integration
'djmoney.contrib.exchange', # django-money exchange rates
'error_report', # Error reporting in the admin interface
'django_q',
]
MIDDLEWARE = CONFIG.get('middleware', [
@ -285,6 +325,18 @@ REST_FRAMEWORK = {
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
# Ref: https://neutronx.github.io/django-markdownx/customization/
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
db_config = CONFIG.get('database', {})
if not db_config:
db_config = {}
# Environment variables take preference over config file!
db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']
@ -350,7 +405,7 @@ reqiured_keys = ['ENGINE', 'NAME']
for key in reqiured_keys:
if key not in db_config:
error_msg = f'Missing required database configuration value {key} in config.yaml'
error_msg = f'Missing required database configuration value {key}'
logger.error(error_msg)
print('Error: ' + error_msg)
@ -386,11 +441,6 @@ CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
},
'qr-code': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'qr-code-cache',
'TIMEOUT': 3600
}
}
# Password validation
@ -445,16 +495,67 @@ CURRENCIES = CONFIG.get(
# TODO - Allow live web-based backends in the future
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 = (
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_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
DATE_INPUT_FORMATS = [

View File

@ -1,13 +1,73 @@
"""
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
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):
@ -19,21 +79,15 @@ def check_system_health(**kwargs):
result = True
if not check_celery_worker(**kwargs):
if not is_worker_running(**kwargs):
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:
logger.warning(_("InvenTree system health checks failed"))
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:

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 . import helpers
from . import version
from mptt.exceptions import InvalidMove
@ -269,3 +270,33 @@ class TestSerialNumberExtraction(TestCase):
with self.assertRaises(ValidationError):
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'^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'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'),
]
urlpatterns = [
@ -132,7 +133,7 @@ urlpatterns = [
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
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)),
@ -142,6 +143,7 @@ urlpatterns = [
url(r'^admin/error_log/', include('error_report.urls')),
url(r'^admin/shell/', include('django_admin_shell.urls')),
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'^search/', SearchView.as_view(), name='search'),

View File

@ -60,7 +60,7 @@ def validate_part_ipn(value):
match = re.search(pattern, value)
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):

View File

@ -4,10 +4,11 @@ Provides information on the current InvenTree version
import subprocess
import django
import re
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
INVENTREE_API_VERSION = 2
@ -23,6 +24,38 @@ def inventreeVersion():
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():
return INVENTREE_API_VERSION
@ -37,7 +70,7 @@ def inventreeCommitHash():
try:
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
except FileNotFoundError:
except:
return None
@ -47,5 +80,5 @@ def inventreeCommitDate():
try:
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
return d.split(' ')[0]
except FileNotFoundError:
except:
return None

View File

@ -2,7 +2,7 @@
from django.urls import reverse
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 import permissions

View File

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

View File

@ -5,7 +5,7 @@ Django Forms for interacting with Build objects
# -*- coding: utf-8 -*-
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 InvenTree.forms import HelperForm
@ -36,11 +36,13 @@ class EditBuildForm(HelperForm):
}
target_date = DatePickerFormField(
label=_('Target Date'),
help_text=_('Target date for build completion. Build will be overdue after this date.')
)
quantity = RoundingDecimalFormField(
max_digits=10, decimal_places=5,
label=_('Quantity'),
help_text=_('Number of items to build')
)
@ -87,7 +89,7 @@ class BuildOutputCreateForm(HelperForm):
)
serial_numbers = forms.CharField(
label=_('Serial numbers'),
label=_('Serial Numbers'),
required=False,
help_text=_('Enter serial numbers for build outputs'),
)
@ -115,6 +117,7 @@ class BuildOutputDeleteForm(HelperForm):
confirm = forms.BooleanField(
required=False,
label=_('Confirm'),
help_text=_('Confirm deletion of build output')
)
@ -136,7 +139,7 @@ class UnallocateBuildForm(HelperForm):
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(
required=False,
@ -160,7 +163,7 @@ class UnallocateBuildForm(HelperForm):
class AutoAllocateForm(HelperForm):
""" 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
output = forms.ModelChoiceField(
@ -207,15 +210,17 @@ class CompleteBuildOutputForm(HelperForm):
location = forms.ModelChoiceField(
queryset=StockLocation.objects.all(),
label=_('Location'),
help_text=_('Location of completed parts'),
)
confirm_incomplete = forms.BooleanField(
required=False,
label=_('Confirm incomplete'),
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(
queryset=StockItem.objects.all(), # Queryset is narrowed in the view
@ -235,7 +240,7 @@ class CompleteBuildOutputForm(HelperForm):
class CancelBuildForm(HelperForm):
""" 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:
model = Build
@ -249,7 +254,7 @@ class EditBuildItemForm(HelperForm):
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())

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 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.urls import reverse
@ -216,7 +216,7 @@ class Build(MPTTModel):
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(
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.')
)
completion_date = models.DateField(null=True, blank=True)
completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date'))
completed_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('completed by'),
related_name='builds_completed'
)
@ -237,6 +238,7 @@ class Build(MPTTModel):
User,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Issued by'),
help_text=_('User who issued this build order'),
related_name='builds_issued',
)
@ -245,6 +247,7 @@ class Build(MPTTModel):
UserModels.Owner,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Responsible'),
help_text=_('User responsible for this build order'),
related_name='builds_responsible',
)
@ -1017,14 +1020,14 @@ class BuildItem(models.Model):
try:
# Allocated part must be in the BOM for the master part
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
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),
q=normalize(self.stock_item.quantity)
))]
)]
# 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:
@ -1076,6 +1079,7 @@ class BuildItem(models.Model):
Build,
on_delete=models.CASCADE,
related_name='allocated_stock',
verbose_name=_('Build'),
help_text=_('Build to allocate parts')
)
@ -1083,6 +1087,7 @@ class BuildItem(models.Model):
'stock.StockItem',
on_delete=models.CASCADE,
related_name='allocations',
verbose_name=_('Stock Item'),
help_text=_('Source stock item'),
limit_choices_to={
'sales_order': None,
@ -1095,6 +1100,7 @@ class BuildItem(models.Model):
max_digits=15,
default=1,
validators=[MinValueValidator(0)],
verbose_name=_('Quantity'),
help_text=_('Stock quantity to allocate to build')
)
@ -1103,6 +1109,7 @@ class BuildItem(models.Model):
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='items_to_install',
verbose_name=_('Install into'),
help_text=_('Destination stock item'),
limit_choices_to={
'is_building': True,

View File

@ -164,7 +164,7 @@ src="{% static 'img/blank_image.png' %}"
launchModalForm("{% url 'build-cancel' build.id %}",
{
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 %}",
{
reload: true,
submit_text: "Complete Build",
submit_text: '{% trans "Complete Build" %}',
}
);
});

View File

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

View File

@ -5,7 +5,7 @@ Django views for interacting with Build objects
# -*- coding: utf-8 -*-
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.views.generic import DetailView, ListView, UpdateView
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.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.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.
"""
if not user.is_staff:
if user is not None and not user.is_staff:
return
try:

View File

@ -5,7 +5,7 @@ Django views for interacting with common models
# -*- coding: utf-8 -*-
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 InvenTree.views import AjaxUpdateView

View File

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

View File

@ -8,7 +8,7 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy as _
import django.forms
import djmoney.settings
@ -34,6 +34,7 @@ class EditCompanyForm(HelperForm):
currency = django.forms.ChoiceField(
required=False,
label=_('Currency'),
help_text=_('Default currency used for this company'),
choices=[('', '----------')] + djmoney.settings.CURRENCY_CHOICES,
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
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Sum, Q, UniqueConstraint
@ -95,7 +95,12 @@ class Company(models.Model):
help_text=_('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'))
@ -114,7 +119,7 @@ class Company(models.Model):
verbose_name=_('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(
upload_to=rename_company_image,
@ -122,15 +127,16 @@ class Company(models.Model):
blank=True,
variations={'thumbnail': (128, 128)},
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(
max_length=3,
@ -366,11 +372,11 @@ class SupplierPart(models.Model):
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).
# 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)
"""
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:
unique_together = ("part", "quantity")

View File

@ -43,17 +43,17 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
<p>{{ company.description }}</p>
<div class='btn-group action-buttons'>
{% 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'/>
</button>
{% endif %}
{% 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'/>
</button>
{% endif %}
{% 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'/>
</button>
{% endif %}

View File

@ -1,14 +1,16 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% 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>
{% if company.supplied_part_count > 0 %}
<p>There are {{ company.supplied_part_count }} parts sourced from this company.<br>
If this supplier is deleted, these supplier part entries will also be deleted.</p>
<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.{% endblocktrans %}</p>
<ul class='list-group'>
{% for part in company.parts.all %}
<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 %}
<div id='button-toolbar'>
<div class='button-toolbar container-fluid'>
<div class='btn-group role='group'>
<div class='btn-group' role='group'>
{% if roles.purchase_order.add %}
<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" %}

View File

@ -14,7 +14,7 @@
{% if roles.purchase_order.add %}
<div id='button-bar'>
<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>
</div>
</div>

View File

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

View File

@ -6,7 +6,7 @@ Django views for interacting with Company app
# -*- coding: utf-8 -*-
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.urls import reverse

View File

@ -7,11 +7,9 @@
# with the prefix INVENTREE_DB_
# e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD
database:
# Default configuration - sqlite filesystem database
ENGINE: sqlite3
NAME: '../inventree_default_db.sqlite3'
# Uncomment (and edit) one of the database configurations below,
# or specify database options using environment variables
# For more complex database installations, further parameters are required
# Refer to the django documentation for full list of options
# --- Available options: ---
@ -27,14 +25,22 @@ database:
# --- Example Configuration - sqlite3 ---
# ENGINE: sqlite3
# NAME: '/path/to/database.sqlite3'
# NAME: '/home/inventree/database.sqlite3'
# --- Example Configuration - MySQL ---
#ENGINE: django.db.backends.mysql
#ENGINE: mysql
#NAME: inventree
#USER: inventree_username
#USER: inventree
#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'
# Select default system language (default is 'en-us')
@ -43,6 +49,7 @@ language: en-us
# System time-zone (default is UTC)
# Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# Select an option from the "TZ database name" column
# Use the environment variable INVENTREE_TIMEZONE
timezone: UTC
# List of currencies supported by default.
@ -56,7 +63,33 @@ currencies:
- NZD
- 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
# Use the environment variable INVENTREE_DEBUG
debug: True
# Set debug_toolbar to True to enable a debugging toolbar for InvenTree
@ -65,6 +98,7 @@ debug: True
debug_toolbar: False
# Configure the system logging level
# Use environment variable INVENTREE_LOG_LEVEL
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
log_level: WARNING
@ -86,13 +120,14 @@ cors:
# - https://sub.example.com
# 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
# This should be changed for a production installation
media_root: '../inventree_media'
# By default, it is stored under /home/inventree/data/media
# Use environment variable INVENTREE_MEDIA_ROOT
media_root: '/home/inventree/data/media'
# 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
static_root: '../inventree_static'
# By default, it is stored under /home/inventree
# Use environment variable INVENTREE_STATIC_ROOT
static_root: '/home/inventree/static'
# Optional URL schemes to allow in URL fields
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']
@ -105,7 +140,8 @@ static_root: '../inventree_static'
# Backup options
# Set the backup_dir parameter to store backup files in a specific location
# 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
#authentication_backends:

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
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.core.exceptions import ValidationError, FieldError
from django.http import HttpResponse

View File

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

View File

@ -32,7 +32,7 @@ except OSError as err:
sys.exit(1)
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
def rename_label(instance, filename):
@ -126,7 +126,7 @@ class LabelTemplate(models.Model):
width = models.FloatField(
default=50,
verbose_name=('Width [mm]'),
verbose_name=_('Width [mm]'),
help_text=_('Label width, specified in mm'),
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 django import forms
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeNodeChoiceField
@ -24,7 +24,7 @@ from .models import SalesOrderAllocation
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:
model = PurchaseOrder
@ -35,7 +35,7 @@ class IssuePurchaseOrderForm(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:
model = PurchaseOrder
@ -46,7 +46,7 @@ class CompletePurchaseOrderForm(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:
model = PurchaseOrder
@ -57,7 +57,7 @@ class CancelPurchaseOrderForm(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:
model = SalesOrder
@ -68,7 +68,7 @@ class CancelSalesOrderForm(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:
model = SalesOrder
@ -79,7 +79,7 @@ class ShipSalesOrderForm(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:
model = PurchaseOrder
@ -106,6 +106,7 @@ class EditPurchaseOrderForm(HelperForm):
super().__init__(*args, **kwargs)
target_date = DatePickerFormField(
label=_('Target 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)
target_date = DatePickerFormField(
label=_('Target 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):
""" 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:
model = PurchaseOrderLineItem
@ -200,7 +202,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
class EditSalesOrderLineItemForm(HelperForm):
""" 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:
model = SalesOrderLineItem
@ -256,7 +258,7 @@ class CreateSalesOrderAllocationForm(HelperForm):
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:
model = SalesOrderAllocation
@ -273,7 +275,7 @@ class EditSalesOrderAllocationForm(HelperForm):
Form for editing a SalesOrderAllocation item
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta:
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.contrib.auth.models import User
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
@ -96,18 +96,19 @@ class Order(models.Model):
class Meta:
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,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='+'
related_name='+',
verbose_name=_('Created By')
)
responsible = models.ForeignKey(
@ -119,7 +120,7 @@ class Order(models.Model):
related_name='+',
)
notes = MarkdownxField(blank=True, help_text=_('Order notes'))
notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
class PurchaseOrder(Order):
@ -186,16 +187,18 @@ class PurchaseOrder(Order):
'is_supplier': True,
},
related_name='purchase_orders',
verbose_name=_('Supplier'),
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(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='+'
related_name='+',
verbose_name=_('received by')
)
issue_date = models.DateField(
@ -435,13 +438,14 @@ class SalesOrder(Order):
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
verbose_name=_('Customer'),
help_text=_("Company to which the items are being sold"),
)
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(
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.')
)
shipment_date = models.DateField(blank=True, null=True)
shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date'))
shipped_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='+'
related_name='+',
verbose_name=_('shipped by')
)
@property
@ -586,11 +591,11 @@ class OrderLineItem(models.Model):
class Meta:
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):
@ -616,6 +621,7 @@ class PurchaseOrderLineItem(OrderLineItem):
order = models.ForeignKey(
PurchaseOrder, on_delete=models.CASCADE,
related_name='lines',
verbose_name=_('Order'),
help_text=_('Purchase Order')
)
@ -629,10 +635,11 @@ class PurchaseOrderLineItem(OrderLineItem):
SupplierPart, on_delete=models.SET_NULL,
blank=True, null=True,
related_name='purchase_order_line_items',
verbose_name=_('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(
max_digits=19,
@ -658,9 +665,9 @@ class SalesOrderLineItem(OrderLineItem):
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:
unique_together = [
@ -760,7 +767,7 @@ class SalesOrderAllocation(models.Model):
if len(errors) > 0:
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(
'stock.StockItem',
@ -771,10 +778,11 @@ class SalesOrderAllocation(models.Model):
'belongs_to': None,
'sales_order': None,
},
verbose_name=_('Item'),
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):
return self.item.serial

View File

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

View File

@ -1,7 +1,9 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% 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 %}

View File

@ -39,7 +39,7 @@
{{ part.full_name }} <small><i>{{ part.description }}</i></small>
</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>
</button>
</td>
@ -66,7 +66,7 @@
</div>
</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>
</button>
</td>

View File

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

View File

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

View File

@ -54,7 +54,7 @@
</div>
</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>
</button>
</td>

View File

@ -49,7 +49,7 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-print'></span>
</button>
{% 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>
</button>
{% if order.status == SalesOrderStatus.PENDING %}

View File

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

View File

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

View File

@ -9,7 +9,7 @@ from django.db import transaction
from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic.edit import FormMixin
from django.forms import HiddenInput
@ -37,7 +37,7 @@ from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
class PurchaseOrderIndex(InvenTreeRoleMixin, ListView):
@ -1058,7 +1058,7 @@ class OrderParts(AjaxView):
data = {
'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)
@ -1349,7 +1349,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
'form_valid': valid,
'form_errors': self.form.errors.as_json(),
'non_field_errors': self.form.non_field_errors().as_json(),
'success': _("Allocated") + f" {len(self.stock_items)} " + _("items")
'success': _("Allocated {n} items").format(n=len(self.stock_items))
}
return self.renderJsonResponse(request, self.form, data)

View File

@ -16,8 +16,6 @@ from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate
from .models import PartSellPriceBreak
from InvenTree.helpers import normalize
from stock.models import StockLocation
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
"""
return normalize(item.quantity)
return float(item.quantity)
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):
""" API endpoint for accessing a list of BomItem objects.
@ -942,6 +951,8 @@ part_api_urls = [
# Base URL for PartParameter API endpoints
url(r'^parameter/', include([
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'),
])),

View File

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

View File

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

View File

@ -33,6 +33,27 @@
template: 1
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)
- model: part.PartCategoryParameterTemplate
pk: 1

View File

@ -11,7 +11,7 @@ from InvenTree.fields import RoundingDecimalFormField
from mptt.fields import TreeNodeChoiceField
from django import forms
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy as _
import common.models
@ -129,6 +129,7 @@ class BomDuplicateForm(HelperForm):
confirm = forms.BooleanField(
required=False, initial=False,
label=_('Confirm'),
help_text=_('Confirm BOM duplication')
)
@ -147,7 +148,7 @@ class BomValidateForm(HelperForm):
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:
model = Part
@ -159,7 +160,7 @@ class BomValidateForm(HelperForm):
class BomUploadSelectFile(HelperForm):
""" 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:
model = Part
@ -336,9 +337,9 @@ class EditCategoryParameterTemplateForm(HelperForm):
class EditBomItemForm(HelperForm):
""" 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:
model = BomItem
@ -365,6 +366,7 @@ class PartPriceForm(forms.Form):
quantity = forms.IntegerField(
required=True,
initial=1,
label=_('Quantity'),
help_text=_('Input quantity for price calculation')
)
@ -380,7 +382,7 @@ class EditPartSalePriceBreakForm(HelperForm):
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:
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
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree):
@ -69,10 +69,11 @@ class PartCategory(InvenTreeTree):
'stock.StockLocation', related_name="default_categories",
null=True, blank=True,
on_delete=models.SET_NULL,
verbose_name=_('Default Location'),
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):
return reverse('category-detail', kwargs={'pk': self.id})
@ -442,10 +443,10 @@ class Part(MPTTModel):
return
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),
p2=str(parent)
))})
)})
bom_items = self.get_bom_items()
@ -454,10 +455,10 @@ class Part(MPTTModel):
# Check for simple match
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),
p2=str(self)
))})
)})
# And recursively check too
item.sub_part.checkAddToBOM(parent)
@ -749,6 +750,7 @@ class Part(MPTTModel):
blank=True,
variations={'thumbnail': (128, 128)},
delete_orphans=False,
verbose_name=_('Image'),
)
default_location = TreeForeignKey(
@ -870,18 +872,18 @@ class Part(MPTTModel):
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,
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):
""" 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))
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='attachments')
verbose_name=_('Part'), related_name='attachments')
class PartSellPriceBreak(common.models.PriceBreak):
@ -1862,7 +1864,8 @@ class PartSellPriceBreak(common.models.PriceBreak):
part = models.ForeignKey(
Part, on_delete=models.CASCADE,
related_name='salepricebreaks',
limit_choices_to={'salable': True}
limit_choices_to={'salable': True},
verbose_name=_('Part')
)
class Meta:
@ -1880,9 +1883,9 @@ class PartStar(models.Model):
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:
unique_together = ['part', 'user']
@ -1955,6 +1958,7 @@ class PartTestTemplate(models.Model):
on_delete=models.CASCADE,
related_name='test_templates',
limit_choices_to={'trackable': True},
verbose_name=_('Part'),
)
test_name = models.CharField(
@ -2022,9 +2026,9 @@ class PartParameterTemplate(models.Model):
except PartParameterTemplate.DoesNotExist:
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):
@ -2050,11 +2054,11 @@ class PartParameter(models.Model):
# Prevent multiple instances of a parameter for a single part
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
def create(cls, part, template, data, save=False):
@ -2095,15 +2099,18 @@ class PartCategoryParameterTemplate(models.Model):
category = models.ForeignKey(PartCategory,
on_delete=models.CASCADE,
related_name='parameter_templates',
verbose_name=_('Category'),
help_text=_('Part Category'))
parameter_template = models.ForeignKey(PartParameterTemplate,
on_delete=models.CASCADE,
related_name='part_categories',
verbose_name=_('Parameter Template'),
help_text=_('Parameter Template'))
default_value = models.CharField(max_length=500,
blank=True,
verbose_name=_('Default Value'),
help_text=_('Default Parameter Value'))
@ -2132,6 +2139,7 @@ class BomItem(models.Model):
# A link to the parent part
# Each part will get a reverse lookup field 'bom_items'
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',
verbose_name=_('Part'),
help_text=_('Select parent part'),
limit_choices_to={
'assembly': True,
@ -2140,26 +2148,28 @@ class BomItem(models.Model):
# A link to the child item (sub-part)
# Each part will get a reverse lookup field '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'),
limit_choices_to={
'component': True,
})
# 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],
verbose_name=_('Overage'),
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 = 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(
default=False,
@ -2371,11 +2381,11 @@ class PartRelated(models.Model):
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
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',
on_delete=models.DO_NOTHING,
help_text=_('Select Related Part'))
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
def __str__(self):
return f'{self.part_1} <--> {self.part_2}'

View File

@ -16,13 +16,13 @@
<div class='alert alert-block alert-info'>
{% else %}
<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 %}
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>
{% else %}
<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>
{% endif %}

View File

@ -44,7 +44,7 @@
<div>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ 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>
</button>
</div>
@ -73,7 +73,7 @@
{% for row in bom_rows %}
<tr>
<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>
</button>
</td>

View File

@ -24,7 +24,7 @@
</div>
<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 %}
{% load crispy_forms_tags %}

View File

@ -1,10 +1,12 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% 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'>
This will validate each line in the BOM.
{% trans 'This will validate each line in the BOM.' %}
</div>
{% endblock %}

View File

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

View File

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

View File

@ -4,33 +4,33 @@
{% block pre_form_content %}
<div class='alert alert-info alert-block'>
Pricing information for:<br>
{% trans 'Pricing information for:' %}<br>
{{ part }}.
</div>
<h4>Quantity</h4>
<h4>{% trans 'Quantity' %}</h4>
<table class='table table-striped table-condensed'>
<tr>
<td><b>Part</b></td>
<td><b>{% trans 'Part' %}</b></td>
<td colspan='2'>{{ part }}</td>
</tr>
<tr>
<td><b>Quantity</b></td>
<td><b>{% trans 'Quantity' %}</b></td>
<td colspan='2'>{{ quantity }}</td>
</tr>
</table>
{% if part.supplier_count > 0 %}
<h4>Supplier Pricing</h4>
<h4>{% trans 'Supplier Pricing' %}</h4>
<table class='table table-striped table-condensed'>
{% if min_total_buy_price %}
<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>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
</tr>
{% if quantity > 1 %}
<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>Max: {% include "price.html" with price=max_total_buy_price %}</td>
</tr>
@ -38,7 +38,7 @@ Pricing information for:<br>
{% else %}
<tr>
<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>
</tr>
{% endif %}
@ -46,17 +46,17 @@ Pricing information for:<br>
{% endif %}
{% if part.bom_count > 0 %}
<h4>BOM Pricing</h4>
<h4>{% trans 'BOM Pricing' %}</h4>
<table class='table table-striped table-condensed'>
{% if min_total_bom_price %}
<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>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
</tr>
{% if quantity > 1 %}
<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>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr>
@ -64,14 +64,14 @@ Pricing information for:<br>
{% if part.has_complete_bom_pricing == False %}
<tr>
<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>
</tr>
{% endif %}
{% else %}
<tr>
<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>
</tr>
{% endif %}
@ -151,7 +151,7 @@ Pricing information for:<br>
{% if min_unit_buy_price or min_unit_bom_price %}
{% else %}
<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>
{% endif %}

View File

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

View File

@ -325,3 +325,106 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['in_stock'], 1100)
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 = {
'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:

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
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.core.exceptions import ValidationError, FieldError
from django.http import HttpResponse

View File

@ -6,7 +6,7 @@ from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
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)
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
class ReportFileUpload(FileSystemStorage):
@ -497,11 +497,12 @@ class ReportSnippet(models.Model):
snippet = models.FileField(
upload_to=rename_snippet,
verbose_name=_('Snippet'),
help_text=_('Report snippet file'),
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):
@ -536,7 +537,8 @@ class ReportAsset(models.Model):
asset = models.FileField(
upload_to=rename_asset,
verbose_name=_('Asset'),
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.http import JsonResponse
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from .models import StockLocation, StockItem
from .models import StockItemTracking
@ -195,7 +196,7 @@ class StockCount(StockAdjust):
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
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):
@ -264,7 +265,7 @@ class StockTransfer(StockAdjust):
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
n += 1
return Response({'success': 'Moved {n} parts to {loc}'.format(
return Response({'success': _('Moved {n} parts to {loc}').format(
n=n,
loc=str(location),
)})

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django import forms
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.exceptions import ValidationError
@ -111,10 +111,11 @@ class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """
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):
@ -165,13 +166,13 @@ class CreateStockItemForm(HelperForm):
class SerializeStockForm(HelperForm):
""" 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):
@ -263,7 +264,7 @@ class ExportOptionsForm(HelperForm):
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:
model = StockLocation
@ -402,7 +403,8 @@ class EditStockItemForm(HelperForm):
"""
expiry_date = DatePickerFormField(
help_text=('Expiration date for this stock item'),
label=_('Expiry Date'),
help_text=_('Expiration date for this stock item'),
)
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,
help_text='Select Owner',
verbose_name=_('Owner'),
help_text=_('Select Owner'),
related_name='stock_locations')
def get_absolute_url(self):
@ -483,7 +484,7 @@ class StockItem(MPTTModel):
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(
default=StockStatus.OK,
@ -507,7 +508,8 @@ class StockItem(MPTTModel):
)
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')
def is_stale(self):
@ -948,7 +950,7 @@ class StockItem(MPTTModel):
raise ValidationError({"quantity": _("Quantity must be greater than zero")})
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]:
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)
# 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
def copyHistoryFrom(self, other):
@ -1548,17 +1550,17 @@ class StockItemTracking(models.Model):
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)
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
# 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 %}
<li><a href='#' id='stock-remove' title='{% trans "Remove stock" %}'><span class='fas fa-minus-circle icon-red'></span> {% trans "Remove stock" %}</a></li>
{% endif %}
{% if item.in_stock and item.can_adjust_location %}
<li><a href='#' id='stock-move' title='{% trans "Transfer stock" %}'><span class='fas fa-exchange-alt icon-blue'></span> {% trans "Transfer stock" %}</a></li>
{% endif %}
{% if item.in_stock and item.part.trackable %}
<li><a href='#' id='stock-serialize' title='{% trans "Serialize stock" %}'><span class='fas fa-hashtag'></span> {% trans "Serialize stock" %}</a> </li>
{% endif %}
{% endif %}
{% if item.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 %}
<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 %}
@ -451,7 +451,7 @@ $("#stock-edit").click(function () {
"{% url 'stock-item-edit' item.id %}",
{
reload: true,
submit_text: "Save",
submit_text: '{% trans "Save" %}',
}
);
});

View File

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

View File

@ -40,7 +40,7 @@
<input type='hidden' name='stock-id-{{ item.id }}' value='{{ item.new_quantity }}'/>
{% endif %}
</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>
{% endfor %}
</table>

View File

@ -14,7 +14,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
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
@ -965,7 +965,7 @@ class StockAdjust(AjaxView, FormMixin):
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
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']:
self.stock_action = 'count'
# Choose the form title based on the action
# Choose form title and action column based on the action
titles = {
'move': _('Move Stock Items'),
'count': _('Count Stock Items'),
'take': _('Remove From Stock'),
'add': _('Add Stock Items'),
'delete': _('Delete Stock Items')
'move': [_('Move Stock Items'), _('Move')],
'count': [_('Count Stock Items'), _('Count')],
'take': [_('Remove From Stock'), _('Take')],
'add': [_('Add Stock Items'), _('Add')],
'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!
self.stock_items = self.get_GET_items()
@ -1039,7 +1040,7 @@ class StockAdjust(AjaxView, FormMixin):
if self.stock_action in ['move', 'take']:
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
continue
@ -1118,7 +1119,7 @@ class StockAdjust(AjaxView, FormMixin):
count += 1
return f"{_('Added stock to ')} {count} {_('items')}"
return _('Added stock to {n} items').format(n=count)
def do_take(self):
@ -1133,7 +1134,7 @@ class StockAdjust(AjaxView, FormMixin):
count += 1
return f"{_('Removed stock from ')} {count} {_('items')}"
return _('Removed stock from {n} items').format(n=count)
def do_count(self):
@ -1189,9 +1190,9 @@ class StockAdjust(AjaxView, FormMixin):
return _('No items were moved')
else:
return _('Moved {n} items to {dest}'.format(
return _('Moved {n} items to {dest}').format(
n=count,
dest=destination.pathstring))
dest=destination.pathstring)
def do_delete(self):
""" Delete multiple stock items """
@ -1208,7 +1209,7 @@ class StockAdjust(AjaxView, FormMixin):
count += 1
return _("Deleted {n} stock items".format(n=count))
return _("Deleted {n} stock items").format(n=count)
class StockItemEdit(AjaxUpdateView):

View File

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

View File

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

View File

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

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