diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 0000000000..2b883490d2 --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,48 @@ +# Perform CI checks, and calculate code coverage + +name: SQLite + +on: ["push", "pull_request"] + +jobs: + + # Run tests on SQLite database + # These tests are used for code coverage analysis + coverage: + runs-on: ubuntu-latest + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INVENTREE_DB_NAME: './test_db.sqlite' + INVENTREE_DB_ENGINE: django.db.backends.sqlite3 + INVENTREE_DEBUG: info + INVENTREE_MEDIA_ROOT: ./media + INVENTREE_STATIC_ROOT: ./static + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Install Dependencies + run: | + sudo apt-get update + pip3 install invoke + invoke install + - name: Coverage Tests + run: | + invoke coverage + - name: Data Import Export + run: | + invoke migrate + invoke import-fixtures + invoke export-records -f data.json + rm test_db.sqlite + invoke migrate + invoke import-records -f data.json + - name: Check Migration Files + run: python3 ci/check_migration_files.py + - name: Upload Coverage Report + run: coveralls diff --git a/.github/workflows/docker_build.yaml b/.github/workflows/docker_build.yaml new file mode 100644 index 0000000000..c9f8a69654 --- /dev/null +++ b/.github/workflows/docker_build.yaml @@ -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) + \ No newline at end of file diff --git a/.github/workflows/docker_publish.yaml b/.github/workflows/docker_publish.yaml new file mode 100644 index 0000000000..6870754ad3 --- /dev/null +++ b/.github/workflows/docker_publish.yaml @@ -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 diff --git a/.github/workflows/mysql.yaml b/.github/workflows/mysql.yaml new file mode 100644 index 0000000000..70acad66a1 --- /dev/null +++ b/.github/workflows/mysql.yaml @@ -0,0 +1,51 @@ +# MySQL Unit Testing + +name: MySQL + +on: ["push", "pull_request"] + +jobs: + + test: + runs-on: ubuntu-latest + + env: + # Database backend configuration + INVENTREE_DB_ENGINE: django.db.backends.mysql + INVENTREE_DB_NAME: inventree + INVENTREE_DB_USER: root + INVENTREE_DB_PASSWORD: password + INVENTREE_DB_HOST: '127.0.0.1' + INVENTREE_DB_PORT: 3306 + INVENTREE_DEBUG: info + INVENTREE_MEDIA_ROOT: ./media + INVENTREE_STATIC_ROOT: ./static + + services: + mysql: + image: mysql:latest + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: inventree + MYSQL_USER: inventree + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: password + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + ports: + - 3306:3306 + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Install Dependencies + run: | + sudo apt-get install mysql-server libmysqlclient-dev + pip3 install invoke + pip3 install mysqlclient + invoke install + - name: Run Tests + run: invoke test \ No newline at end of file diff --git a/.github/workflows/postgresql.yaml b/.github/workflows/postgresql.yaml new file mode 100644 index 0000000000..76981e5a1b --- /dev/null +++ b/.github/workflows/postgresql.yaml @@ -0,0 +1,47 @@ +# PostgreSQL Unit Testing + +name: PostgreSQL + +on: ["push", "pull_request"] + +jobs: + + test: + runs-on: ubuntu-latest + + env: + # Database backend configuration + INVENTREE_DB_ENGINE: django.db.backends.postgresql + INVENTREE_DB_NAME: inventree + INVENTREE_DB_USER: inventree + INVENTREE_DB_PASSWORD: password + INVENTREE_DB_HOST: '127.0.0.1' + INVENTREE_DB_PORT: 5432 + INVENTREE_DEBUG: info + INVENTREE_MEDIA_ROOT: ./media + INVENTREE_STATIC_ROOT: ./static + + services: + postgres: + image: postgres + env: + POSTGRES_USER: inventree + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Install Dependencies + run: | + sudo apt-get install libpq-dev + pip3 install invoke + pip3 install psycopg2 + invoke install + - name: Run Tests + run: invoke test \ No newline at end of file diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml new file mode 100644 index 0000000000..31da3ec61a --- /dev/null +++ b/.github/workflows/style.yaml @@ -0,0 +1,27 @@ +name: Style Checks + +on: ["push", "pull_request"] + +jobs: + style: + runs-on: ubuntu-latest + + strategy: + max-parallel: 4 + matrix: + python-version: [3.7] + + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install deps + run: | + pip install flake8==3.8.3 + pip install pep8-naming==0.11.1 + - name: flake8 + run: | + flake8 InvenTree diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 52d0ef1c5c..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,53 +0,0 @@ -dist: xenial - -services: - - mysql - - postgresql - -language: python -python: - - 3.6 - - 3.7 - -addons: - apt-packages: - - sqlite3 - -before_install: - - sudo apt-get update - - sudo apt-get install gettext - - sudo apt-get install mysql-server libmysqlclient-dev - - sudo apt-get install libpq-dev - - pip3 install invoke - - pip3 install mysqlclient - - pip3 install psycopg2 - - invoke install - - invoke migrate - - cd InvenTree && python3 manage.py createsuperuser --username InvenTreeAdmin --email admin@inventree.com --noinput && cd .. - - psql -c 'create database inventree_test_db;' -U postgres - - mysql -e 'CREATE DATABASE inventree_test_db;' - -script: - - cd InvenTree && python3 manage.py makemigrations && cd .. - - python3 ci/check_migration_files.py - # Run unit testing / code coverage tests - - invoke coverage - # Run unit test for SQL database backend - - cd InvenTree && python3 manage.py test --settings=InvenTree.ci_mysql && cd .. - # Run unit test for PostgreSQL database backend - - cd InvenTree && python3 manage.py test --settings=InvenTree.ci_postgresql && cd .. - - invoke translate - - invoke style - # Create an empty database and fill it with test data - - rm inventree_default_db.sqlite3 - - invoke migrate - - invoke import-fixtures - # Export database records - - invoke export-records -f data.json - # Create a new empty database and import the saved data - - rm inventree_default_db.sqlite3 - - invoke migrate - - invoke import-records -f data.json - -after_success: - - coveralls \ No newline at end of file diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 2fc85ef653..a006050694 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -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) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py new file mode 100644 index 0000000000..03eb2bcb60 --- /dev/null +++ b/InvenTree/InvenTree/apps.py @@ -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 + ) diff --git a/InvenTree/InvenTree/ci_mysql.py b/InvenTree/InvenTree/ci_mysql.py deleted file mode 100644 index 0a61866082..0000000000 --- a/InvenTree/InvenTree/ci_mysql.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Configuration file for running tests against a MySQL database. -""" - -from InvenTree.settings import * - -# Override the 'test' database -if 'test' in sys.argv: - print('InvenTree: Running tests - Using MySQL test database') - - DATABASES['default'] = { - # Ensure mysql backend is being used - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'inventree_test_db', - 'USER': 'travis', - 'PASSWORD': '', - 'HOST': '127.0.0.1' - } diff --git a/InvenTree/InvenTree/ci_postgresql.py b/InvenTree/InvenTree/ci_postgresql.py deleted file mode 100644 index e235658b96..0000000000 --- a/InvenTree/InvenTree/ci_postgresql.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Configuration file for running tests against a MySQL database. -""" - -from InvenTree.settings import * - -# Override the 'test' database -if 'test' in sys.argv: - print('InvenTree: Running tests - Using PostGreSQL test database') - - DATABASES['default'] = { - # Ensure postgresql backend is being used - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'inventree_test_db', - 'USER': 'postgres', - 'PASSWORD': '', - } diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 9fee5deaab..3e9af2f751 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -30,10 +30,22 @@ 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(), } + 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): """ diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py index c0e1633ac7..155f77c639 100644 --- a/InvenTree/InvenTree/fields.py +++ b/InvenTree/InvenTree/fields.py @@ -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 ) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 56a1a116da..52d1c8758f 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -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: diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 62e50bd52f..c2441590f5 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -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 diff --git a/InvenTree/InvenTree/management/commands/wait_for_db.py b/InvenTree/InvenTree/management/commands/wait_for_db.py new file mode 100644 index 0000000000..b9fa4e5025 --- /dev/null +++ b/InvenTree/InvenTree/management/commands/wait_for_db.py @@ -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!") diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index 2f1cf3a157..a34df4b7bd 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -8,7 +8,7 @@ import operator from rest_framework.authtoken.models import Token -logger = logging.getLogger(__name__) +logger = logging.getLogger("inventree") class AuthRequiredMiddleware(object): diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index cffe48cc0b..8494b52a10 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -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 diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 1a298240bc..8b5400e374 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -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__))) -cfg_filename = os.path.join(BASE_DIR, 'config.yaml') +# 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') @@ -319,93 +371,76 @@ MARKDOWNIFY_BLEACH = False DATABASES = {} """ -When running unit tests, enforce usage of sqlite3 database, -so that the tests can be run in RAM without any setup requirements +Configure the database backend based on the user-specified values. + +- Primarily this configuration happens in the config.yaml file +- However there may be reason to configure the DB via environmental variables +- The following code lets the user "mix and match" database configuration """ -if 'test' in sys.argv: - logger.info('InvenTree: Running tests - Using sqlite3 memory database') - DATABASES['default'] = { - # Ensure sqlite3 backend is being used - 'ENGINE': 'django.db.backends.sqlite3', - # Doesn't matter what the database is called, it is executed in RAM - 'NAME': 'ram_test_db.sqlite3', - } -# Database backend selection -else: - """ - Configure the database backend based on the user-specified values. - - - Primarily this configuration happens in the config.yaml file - - However there may be reason to configure the DB via environmental variables - - The following code lets the user "mix and match" database configuration - """ +logger.info("Configuring database backend:") - logger.info("Configuring database backend:") +# Extract database configuration from the config.yaml file +db_config = CONFIG.get('database', {}) - # Extract database configuration from the config.yaml file - db_config = CONFIG.get('database', {}) +if not db_config: + db_config = {} - # If a particular database option is not specified in the config file, - # look for it in the environmental variables - # e.g. INVENTREE_DB_NAME / INVENTREE_DB_USER / etc +# Environment variables take preference over config file! - db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT'] +db_keys = ['ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT'] - for key in db_keys: - if key not in db_config: - logger.debug(f" - Missing {key} value: Looking for environment variable INVENTREE_DB_{key}") - env_key = f'INVENTREE_DB_{key}' - env_var = os.environ.get(env_key, None) +for key in db_keys: + # First, check the environment variables + env_key = f"INVENTREE_DB_{key}" + env_var = os.environ.get(env_key, None) - if env_var is not None: - logger.info(f'Using environment variable INVENTREE_DB_{key}') - db_config[key] = env_var - else: - logger.debug(f' INVENTREE_DB_{key} not found in environment variables') + if env_var: + logger.info(f"{env_key}={env_var}") + # Override configuration value + db_config[key] = env_var - # Check that required database configuration options are specified - reqiured_keys = ['ENGINE', 'NAME'] +# Check that required database configuration options are specified +reqiured_keys = ['ENGINE', 'NAME'] - for key in reqiured_keys: - if key not in db_config: - error_msg = f'Missing required database configuration value {key} in config.yaml' - logger.error(error_msg) +for key in reqiured_keys: + if key not in db_config: + error_msg = f'Missing required database configuration value {key}' + logger.error(error_msg) - print('Error: ' + error_msg) - sys.exit(-1) + print('Error: ' + error_msg) + sys.exit(-1) - """ - Special considerations for the database 'ENGINE' setting. - It can be specified in config.yaml (or envvar) as either (for example): - - sqlite3 - - django.db.backends.sqlite3 - - django.db.backends.postgresql - """ +""" +Special considerations for the database 'ENGINE' setting. +It can be specified in config.yaml (or envvar) as either (for example): +- sqlite3 +- django.db.backends.sqlite3 +- django.db.backends.postgresql +""" - db_engine = db_config['ENGINE'] +db_engine = db_config['ENGINE'] - if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']: - # Prepend the required python module string - db_engine = f'django.db.backends.{db_engine.lower()}' - db_config['ENGINE'] = db_engine +if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']: + # Prepend the required python module string + db_engine = f'django.db.backends.{db_engine.lower()}' + db_config['ENGINE'] = db_engine - db_name = db_config['NAME'] +db_name = db_config['NAME'] +db_host = db_config.get('HOST', "''") - logger.info(f"Database ENGINE: '{db_engine}'") - logger.info(f"Database NAME: '{db_name}'") +print("InvenTree Database Configuration") +print("================================") +print(f"ENGINE: {db_engine}") +print(f"NAME: {db_name}") +print(f"HOST: {db_host}") - DATABASES['default'] = db_config +DATABASES['default'] = db_config 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 @@ -464,13 +499,19 @@ 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 -USE_TZ = 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 = [ "%Y-%m-%d", diff --git a/InvenTree/InvenTree/status.py b/InvenTree/InvenTree/status.py index ec2422a254..88acc69a7a 100644 --- a/InvenTree/InvenTree/status.py +++ b/InvenTree/InvenTree/status.py @@ -1,13 +1,46 @@ """ 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 + +logger = logging.getLogger("inventree") -logger = logging.getLogger(__name__) +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 check_system_health(**kwargs): @@ -19,21 +52,11 @@ 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 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 diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index 916b3341cb..5eb97504c6 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -1,4 +1,4 @@ -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _ class StatusCode: diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py new file mode 100644 index 0000000000..4829514f19 --- /dev/null +++ b/InvenTree/InvenTree/tasks.py @@ -0,0 +1,143 @@ +# -*- 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 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 + ) diff --git a/InvenTree/InvenTree/test_tasks.py b/InvenTree/InvenTree/test_tasks.py new file mode 100644 index 0000000000..02e8d14e5e --- /dev/null +++ b/InvenTree/InvenTree/test_tasks.py @@ -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) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 96f32e7f57..8465473901 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -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) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index a9f53a7014..7c22639e65 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -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 = [ diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 70322df062..f8199ef20b 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -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): diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 4d3d546789..b79323e1e7 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -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 diff --git a/InvenTree/barcodes/api.py b/InvenTree/barcodes/api.py index d727f5a778..e6b3ea84e3 100644 --- a/InvenTree/barcodes/api.py +++ b/InvenTree/barcodes/api.py @@ -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 diff --git a/InvenTree/barcodes/barcode.py b/InvenTree/barcodes/barcode.py index 412065bf75..a00e91d7e4 100644 --- a/InvenTree/barcodes/barcode.py +++ b/InvenTree/barcodes/barcode.py @@ -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): diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 4892fa631f..0726779b87 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -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()) diff --git a/InvenTree/build/migrations/0027_auto_20210404_2016.py b/InvenTree/build/migrations/0027_auto_20210404_2016.py new file mode 100644 index 0000000000..f4a2c1afde --- /dev/null +++ b/InvenTree/build/migrations/0027_auto_20210404_2016.py @@ -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'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index ada9db1e08..4ee8de0d73 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -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, diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 83cef5bd44..a8e5e53377 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -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" %}', } ); }); diff --git a/InvenTree/build/templates/build/index.html b/InvenTree/build/templates/build/index.html index 75dc497b4b..f710928ee7 100644 --- a/InvenTree/build/templates/build/index.html +++ b/InvenTree/build/templates/build/index.html @@ -130,6 +130,7 @@ InvenTree | {% trans "Build Orders" %} initialView: 'dayGridMonth', nowIndicator: true, aspectRatio: 2.5, + locale: '{{request.LANGUAGE_CODE}}', datesSet: function() { loadOrderEvents(calendar); } diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index e8a1d33ddc..15ced77130 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -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 diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index df8e3b2d37..79580aabd9 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -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: diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 1f3c827532..31d11e30cc 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -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 diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 3fa3197183..53e884c50f 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -9,7 +9,7 @@ from django.conf import settings from PIL import UnidentifiedImageError -logger = logging.getLogger(__name__) +logger = logging.getLogger("inventree") class CompanyConfig(AppConfig): diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 67ac402ba7..2677402334 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -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, diff --git a/InvenTree/company/migrations/0032_auto_20210403_1837.py b/InvenTree/company/migrations/0032_auto_20210403_1837.py new file mode 100644 index 0000000000..41b6977d31 --- /dev/null +++ b/InvenTree/company/migrations/0032_auto_20210403_1837.py @@ -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'), + ), + ] diff --git a/InvenTree/company/migrations/0033_auto_20210410_1528.py b/InvenTree/company/migrations/0033_auto_20210410_1528.py new file mode 100644 index 0000000000..12153a9ef6 --- /dev/null +++ b/InvenTree/company/migrations/0033_auto_20210410_1528.py @@ -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'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index e4386712c8..d83748c930 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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") diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 9331e5d895..236cc58981 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -43,17 +43,17 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
{{ company.description }}