2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 03:25:42 +00:00

Merged master and company migrations

This commit is contained in:
eeintech
2021-04-12 11:10:35 -04:00
59 changed files with 1152 additions and 189 deletions

View File

@ -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,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):
"""

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):

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__)))
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')
@ -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
@ -449,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",

View File

@ -1,13 +1,46 @@
"""
Provides system status functionality checks.
"""
# -*- coding: utf-8 -*-
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

View File

@ -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
)

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

@ -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

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

@ -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

@ -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

@ -1,4 +1,4 @@
# Generated by Django 3.0.7 on 2021-04-08 17:18
# Generated by Django 3.0.7 on 2021-04-10 05:28
from django.db import migrations, models
@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0034_supplierpart_update'),
('company', '0032_auto_20210403_1837'),
]
operations = [

View File

@ -6,7 +6,7 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0032_auto_20210403_1837'),
('company', '0033_auto_20210410_1528'),
]
operations = [

View File

@ -71,8 +71,8 @@ def supplierpart_make_manufacturer_parts(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('company', '0032_auto_20210403_1837'),
('company', '0033_manufacturerpart'),
('company', '0033_auto_20210410_1528'),
('company', '0034_manufacturerpart'),
]
operations = [

View File

@ -97,7 +97,12 @@ class Company(models.Model):
help_text=_('Company name'),
verbose_name=_('Company name'))
description = models.CharField(max_length=500, blank=True, 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'))

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

@ -109,11 +109,11 @@ class TestManufacturerField(MigratorTestCase):
class TestManufacturerPart(MigratorTestCase):
"""
Tests for migration 0033 and 0034 which added and transitioned to the ManufacturerPart model
Tests for migration 0034 and 0035 which added and transitioned to the ManufacturerPart model
"""
migrate_from = ('company', '0032_auto_20210403_1837')
migrate_to = ('company', '0034_supplierpart_update')
migrate_from = ('company', '0033_auto_20210410_1528')
migrate_to = ('company', '0035_supplierpart_update')
def prepare(self):
"""

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'
# For more complex database installations, further parameters are required
# Uncomment (and edit) one of the database configurations below,
# or specify database options using environment variables
# 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.
@ -57,6 +64,7 @@ currencies:
- USD
# 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 +73,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 +95,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 +115,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

@ -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):

View File

@ -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):

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

@ -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

@ -52,7 +52,7 @@ import common.models
import part.settings as part_settings
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree):

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

@ -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

@ -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

@ -38,7 +38,7 @@ except OSError as err:
sys.exit(1)
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
class ReportFileUpload(FileSystemStorage):

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 %}

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

@ -19,11 +19,20 @@
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "InvenTree Version" %}</td><td><a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a></td>
<td>{% trans "InvenTree Version" %}</td>
<td>
<a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>
{% if up_to_date %}
<span class='label label-green float-right'>{% trans "Up to Date" %}</span>
{% else %}
<span class='label label-red float-right'>{% trans "Update Available" %}</span>
{% endif %}
</td>
</tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Django Version" %}</td><td><a href="https://www.djangoproject.com/">{% django_version %}</a></td>
<td>{% trans "Django Version" %}</td>
<td><a href="https://www.djangoproject.com/">{% django_version %}</a></td>
</tr>
{% inventree_commit_hash as hash %}
{% if hash %}
@ -69,4 +78,4 @@
</div>
</div>
</div>
</div>
</div>

View File

@ -241,7 +241,6 @@ function loadStockTable(table, options) {
// List of user-params which override the default filters
options.params['part_detail'] = true;
options.params['location_detail'] = true;
var params = options.params || {};
@ -524,7 +523,8 @@ function loadStockTable(table, options) {
title: '{% trans "Part" %}',
sortName: 'part__name',
sortable: true,
switchable: false,
visible: params['part_detail'],
switchable: params['part_detail'],
formatter: function(value, row, index, field) {
var url = `/stock/item/${row.pk}/`;
@ -543,6 +543,8 @@ function loadStockTable(table, options) {
title: 'IPN',
sortName: 'part__IPN',
sortable: true,
visible: params['part_detail'],
switchable: params['part_detail'],
formatter: function(value, row, index, field) {
return row.part_detail.IPN;
},
@ -550,6 +552,8 @@ function loadStockTable(table, options) {
{
field: 'part_detail.description',
title: '{% trans "Description" %}',
visible: params['part_detail'],
switchable: params['part_detail'],
formatter: function(value, row, index, field) {
return row.part_detail.description;
}

View File

@ -60,7 +60,9 @@
<li class='dropdown'>
<a class='dropdown-toggle' data-toggle='dropdown' href="#">
{% if not system_healthy %}
<span title='{% trans "InvenTree server issues detected" %}' class='fas fa-exclamation-triangle icon-red'></span>
<span class='fas fa-exclamation-triangle icon-red'></span>
{% elif not up_to_date %}
<span class='fas fa-info-circle icon-green'></span>
{% endif %}
<span class="fas fa-user"></span> <b>{{ user.get_username }}</b></a>
<ul class='dropdown-menu'>
@ -78,11 +80,20 @@
{% if system_healthy %}
<span class='fas fa-server'>
{% else %}
<span class='fas fa-exclamation-triangle icon-red'>
<span class='fas fa-server icon-red'>
{% endif %}
</span> {% trans "System Information" %}
</a></li>
<li id='launch-about'><a href='#'><span class="fas fa-info-circle"></span> {% trans "About InvenTree" %}</a></li>
<li id='launch-about'>
<a href='#'>
{% if up_to_date %}
<span class="fas fa-info-circle">
{% else %}
<span class='fas fa-info-circle icon-red'>
{% endif %}
</span> {% trans "About InvenTree" %}
</a>
</li>
</ul>
</li>
</ul>

View File

@ -13,8 +13,9 @@
<td>{% trans "Instance Name" %}</td>
<td>{% inventree_instance_name %}</td>
</tr>
{% if user.is_staff %}
<tr>
<td><span class='fas fa-exclamation-triangle'></span></td>
<td><span class='fas fa-server'></span></td>
<td>{% trans "Server status" %}</td>
<td>
{% if system_healthy %}
@ -24,6 +25,18 @@
{% endif %}
</td>
</tr>
<tr>
<td><span class='fas fa-tasks'></span></td>
<td>{% trans "Background Worker" %}</td>
<td>
{% if django_q_running %}
<span class='label label-green'>{% trans "Operational" %}</span>
{% else %}
<span class='label label-red'>{% trans "Not running" %}</span>
{% endif %}
</td>
</tr>
{% endif %}
{% if not system_healthy %}
{% for issue in system_issues %}

View File

@ -15,7 +15,7 @@ from django.db.models.signals import post_save, post_delete
import logging
logger = logging.getLogger(__name__)
logger = logging.getLogger("inventree")
class RuleSet(models.Model):
@ -138,6 +138,13 @@ class RuleSet(models.Model):
'error_report_error',
'exchange_rate',
'exchange_exchangebackend',
# Django-q
'django_q_ormq',
'django_q_failure',
'django_q_task',
'django_q_schedule',
'django_q_success',
]
RULE_OPTIONS = [