Merge pull request #1398 from SchrodingersGat/django-q

Django q
This commit is contained in:
Oliver 2021-04-11 15:38:20 +10:00 committed by GitHub
commit a2ff3e3474
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 989 additions and 169 deletions

View File

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

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

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

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

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

View File

@ -1,46 +0,0 @@
name: MariaDB
on: ["push", "pull_request"]
jobs:
test:
runs-on: ubuntu-latest
env:
# Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.mysql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: root
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: info
services:
mariadb:
image: mariadb:latest
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: inventree
MYSQL_USER: inventree
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
ports:
- 3306:3306
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get install mysql-server libmysqlclient-dev
pip3 install invoke
pip3 install mysqlclient
invoke install
- name: Run Tests
run: invoke test

View File

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

View File

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

View File

@ -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,6 +4,7 @@ Provides information on the current InvenTree version
import subprocess
import django
import re
import common.models
@ -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

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

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

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

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

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

@ -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):
@ -137,6 +137,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 = [

View File

@ -1,9 +1,10 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/inventree/inventree)
[![Coverage Status](https://coveralls.io/repos/github/inventree/InvenTree/badge.svg)](https://coveralls.io/github/inventree/InvenTree)
![PEP](https://github.com/inventree/inventree/actions/workflows/style.yaml/badge.svg)
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker.yaml/badge.svg)
![SQLite](https://github.com/inventree/inventree/actions/workflows/coverage.yaml/badge.svg)
![MySQL](https://github.com/inventree/inventree/actions/workflows/mysql.yaml/badge.svg)
![MariaDB](https://github.com/inventree/inventree/actions/workflows/mariadb.yaml/badge.svg)
![PostgreSQL](https://github.com/inventree/inventree/actions/workflows/postgresql.yaml/badge.svg)
<img src="images/logo/inventree.png" alt="InvenTree" width="128"/>

46
deploy/supervisord.conf Normal file
View File

@ -0,0 +1,46 @@
; # Supervisor Config File
; Example configuration file for running InvenTree using supervisor
; There are two separate processes which must be managed:
;
; ## Web Server
; The InvenTree server must be launched and managed as a process
; The recommended way to handle the web server is to use gunicorn
;
; ## Background Tasks
; A background task manager processes long-running and periodic tasks
; InvenTree uses django-q for this purpose
[supervisord]
; Change this path if log files are stored elsewhere
logfile=/home/inventree/log/supervisor.log
user=inventree
[supervisorctl]
[inet_http_server]
port = 127.0.0.1:9001
; InvenTree Web Server Process
[program:inventree-server]
user=inventree
directory=/home/inventree/src/InvenTree
command=/home/inventree/env/bin/gunicorn -c gunicorn.conf.py InvenTree.wsgi
startsecs=10
autostart=true
autorestart=true
startretries=3
; Change these paths if log files are stored elsewhere
stderr_logfile=/home/inventree/log/server.err.log
stdout_logfile=/home/inventree/log/server.out.log
; InvenTree Background Worker Process
[program:inventree-cluster]
user=inventree
directory=/home/inventree/src/InvenTree
command=/home/inventree/env/bin/python manage.py qcluster
startsecs=10
autostart=true
autorestart=true
; Change these paths if log files are stored elsewhere
stderr_logfile=/home/inventree/log/cluster.err.log
stdout_logfile=/home/inventree/log/cluster.out.log

100
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,100 @@
version: "3.8"
# Docker compose recipe for InvenTree
# - Runs PostgreSQL as the database backend
# - Runs Gunicorn as the web server
# - Runs nginx as a reverse proxy
# - Runs the background worker process
# ---------------------------------
# IMPORTANT - READ BEFORE STARTING!
# ---------------------------------
# Before running, ensure that you change the "/path/to/data" directory,
# specified in the "volumes" section at the end of this file.
# This path determines where the InvenTree data will be stored!
services:
# Database service
# Use PostgreSQL as the database backend
# Note: this can be changed to a different backend,
# just make sure that you change the INVENTREE_DB_xxx vars below
db:
container_name: db
image: postgres
ports:
- 5432/tcp
environment:
- PGDATA=/var/lib/postgresql/data/pgdb
- POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpassword
volumes:
- data:/var/lib/postgresql/data/
restart: unless-stopped
# InvenTree web server services
# Uses gunicorn as the web server
inventree:
container_name: server
image: inventree/inventree:latest
expose:
- 8080
depends_on:
- db
volumes:
- data:/home/inventree/data
- static:/home/inventree/static
environment:
- INVENTREE_DB_ENGINE=postgresql
- INVENTREE_DB_NAME=inventree
- INVENTREE_DB_USER=pguser
- INVENTREE_DB_PASSWORD=pgpassword
- INVENTREE_DB_PORT=5432
- INVENTREE_DB_HOST=db
restart: unless-stopped
# nginx acts as a reverse proxy
# static files are served by nginx
# web requests are redirected to gunicorn
nginx:
container_name: nginx
image: inventree/nginx:latest
depends_on:
- inventree
ports:
# Change "1337" to the port where you want InvenTree web server to be available
- 1337:80
volumes:
- static:/home/inventree/static
# background worker process handles long-running or periodic tasks
worker:
container_name: worker
image: inventree/inventree:latest
entrypoint: ./start_worker.sh
depends_on:
- db
- inventree
volumes:
- data:/home/inventree/data
- static:/home/inventree/static
environment:
- INVENTREE_DB_ENGINE=postgresql
- INVENTREE_DB_NAME=inventree
- INVENTREE_DB_USER=pguser
- INVENTREE_DB_PASSWORD=pgpassword
- INVENTREE_DB_PORT=5432
- INVENTREE_DB_HOST=db
restart: unless-stopped
volumes:
# Static files, shared between containers
static:
# Persistent data, stored externally
data:
driver: local
driver_opts:
type: none
o: bind
# This directory specified where InvenTree data are stored "outside" the docker containers
# Change this path to a local system path where you want InvenTree data stored
device: /path/to/data

View File

@ -0,0 +1,95 @@
FROM python:alpine as production
# GitHub source
ARG repository="https://github.com/inventree/InvenTree.git"
ARG branch="master"
ENV PYTHONUNBUFFERED 1
# InvenTree key settings
ENV INVENTREE_HOME="/home/inventree"
# GitHub settings
ENV INVENTREE_REPO="${repository}"
ENV INVENTREE_BRANCH="${branch}"
ENV INVENTREE_LOG_LEVEL="INFO"
# InvenTree paths
ENV INVENTREE_SRC_DIR="${INVENTREE_HOME}/src"
ENV INVENTREE_MNG_DIR="${INVENTREE_SRC_DIR}/InvenTree"
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
ENV INVENTREE_STATIC_ROOT="${INVENTREE_HOME}/static"
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
# Pass DB configuration through as environment variables
ENV INVENTREE_DB_ENGINE="${INVENTREE_DB_ENGINE}"
ENV INVENTREE_DB_NAME="${INVENTREE_DB_NAME}"
ENV INVENTREE_DB_HOST="${INVENTREE_DB_HOST}"
ENV INVENTREE_DB_PORT="${INVENTREE_DB_PORT}"
ENV INVENTREE_DB_USER="${INVENTREE_DB_USER}"
ENV INVENTREE_DB_PASSWORD="${INVENTREE_DB_PASSWORD}"
LABEL org.label-schema.schema-version="1.0" \
org.label-schema.build-date=${DATE} \
org.label-schema.vendor="inventree" \
org.label-schema.name="inventree/inventree" \
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
org.label-schema.version=${INVENTREE_VERSION} \
org.label-schema.vcs-url=${INVENTREE_REPO} \
org.label-schema.vcs-branch=${BRANCH} \
org.label-schema.vcs-ref=${COMMIT}
# Create user account
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
WORKDIR ${INVENTREE_HOME}
RUN mkdir ${INVENTREE_STATIC_ROOT}
# Install required system packages
RUN apk add --no-cache git make bash \
gcc libgcc g++ libstdc++ \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
libffi libffi-dev \
zlib zlib-dev
RUN apk add --no-cache cairo cairo-dev pango pango-dev
RUN apk add --no-cache fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto
RUN apk add --no-cache python3
RUN apk add --no-cache postgresql-contrib postgresql-dev libpq
RUN apk add --no-cache mariadb-connector-c mariadb-dev
# Create required directories
#RUN mkdir ${INVENTREE_DATA_DIR}}/media ${INVENTREE_HOME}/static ${INVENTREE_HOME}/backup
# Install required python packages
RUN pip install --upgrade pip setuptools wheel
RUN pip install --no-cache-dir -U invoke
RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb
RUN pip install --no-cache-dir -U gunicorn
# Clone source code
RUN echo "Downloading InvenTree from ${INVENTREE_REPO}"
RUN git clone --branch ${INVENTREE_BRANCH} --depth 1 ${INVENTREE_REPO} ${INVENTREE_SRC_DIR}
# Install InvenTree packages
RUN pip install --no-cache-dir -U -r ${INVENTREE_SRC_DIR}/requirements.txt
# Copy gunicorn config file
COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
# Copy startup scripts
COPY start_server.sh ${INVENTREE_SRC_DIR}/start_server.sh
COPY start_worker.sh ${INVENTREE_SRC_DIR}/start_worker.sh
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_server.sh
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_worker.sh
# exec commands should be executed from the "src" directory
WORKDIR ${INVENTREE_SRC_DIR}
# Let us begin
CMD ["bash", "./start_server.sh"]

View File

@ -0,0 +1,6 @@
import multiprocessing
workers = multiprocessing.cpu_count() * 2 + 1
max_requests = 1000
max_requests_jitter = 50

View File

@ -0,0 +1,46 @@
#!/bin/sh
# Create required directory structure (if it does not already exist)
if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
echo "Creating directory $INVENTREE_STATIC_ROOT"
mkdir $INVENTREE_STATIC_ROOT
fi
if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
echo "Creating directory $INVENTREE_MEDIA_ROOT"
mkdir $INVENTREE_MEDIA_ROOT
fi
if [[ ! -d "$INVENTREE_BACKUP_DIR" ]]; then
echo "Creating directory $INVENTREE_BACKUP_DIR"
mkdir $INVENTREE_BACKUP_DIR
fi
# Check if "config.yaml" has been copied into the correct location
if test -f "$INVENTREE_CONFIG_FILE"; then
echo "$INVENTREE_CONFIG_FILE exists - skipping"
else
echo "Copying config file to $INVENTREE_CONFIG_FILE"
cp $INVENTREE_SRC_DIR/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
fi
echo "Starting InvenTree server..."
# Wait for the database to be ready
cd $INVENTREE_MNG_DIR
python manage.py wait_for_db
sleep 10
echo "Running InvenTree database migrations and collecting static files..."
# We assume at this stage that the database is up and running
# Ensure that the database schema are up to date
python manage.py check || exit 1
python manage.py migrate --noinput || exit 1
python manage.py migrate --run-syncdb || exit 1
python manage.py collectstatic --noinput || exit 1
python manage.py clearsessions || exit 1
# Now we can launch the server
gunicorn -c $INVENTREE_HOME/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8080

View File

@ -0,0 +1,14 @@
#!/bin/sh
echo "Starting InvenTree worker..."
sleep 5
# Wait for the database to be ready
cd $INVENTREE_MNG_DIR
python manage.py wait_for_db
sleep 10
# Now we can launch the background worker process
python manage.py qcluster

14
docker/nginx/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM nginx:1.19.0-alpine
# Create user account
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
ENV HOME=/home/inventree
WORKDIR $HOME
# Create the "static" volume directory
RUN mkdir $HOME/static
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d

21
docker/nginx/nginx.conf Normal file
View File

@ -0,0 +1,21 @@
upstream inventree {
server inventree:8080;
}
server {
listen 80;
location / {
proxy_pass http://inventree;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
client_max_body_size 100M;
}
location /static/ {
alias /home/inventree/static/;
}
}

View File

@ -1,3 +1,4 @@
invoke>=1.4.0 # Invoke build tool
wheel>=0.34.2 # Wheel
Django==3.0.7 # Django package
pillow==8.1.1 # Image manipulation
@ -30,5 +31,7 @@ django-error-report==0.2.0 # Error report viewer for the admin interface
django-test-migrations==1.1.0 # Unit testing for database migrations
python-barcode[images]==0.13.1 # Barcode generator
qrcode[pil]==6.1 # QR code generator
django-q==1.3.4 # Background task scheduling
gunicorn>=20.0.4 # Gunicorn web server
inventree # Install the latest version of the InvenTree API python library

View File

@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
from invoke import task
from shutil import copyfile
import random
import string
import os
import sys
try:
from invoke import ctask as task
except:
from invoke import task
def apps():
"""
Returns a list of installed apps
@ -27,6 +29,7 @@ def apps():
'users',
]
def localDir():
"""
Returns the directory of *THIS* file.
@ -35,6 +38,7 @@ def localDir():
"""
return os.path.dirname(os.path.abspath(__file__))
def managePyDir():
"""
Returns the directory of the manage.py file
@ -42,6 +46,7 @@ def managePyDir():
return os.path.join(localDir(), 'InvenTree')
def managePyPath():
"""
Return the path of the manage.py file
@ -49,6 +54,7 @@ def managePyPath():
return os.path.join(managePyDir(), 'manage.py')
def manage(c, cmd, pty=False):
"""
Runs a given command against django's "manage.py" script.
@ -63,32 +69,11 @@ def manage(c, cmd, pty=False):
cmd=cmd
), pty=pty)
@task(help={'length': 'Length of secret key (default=50)'})
def key(c, length=50, force=False):
"""
Generates a SECRET_KEY file which InvenTree uses for generating security hashes
"""
SECRET_KEY_FILE = os.path.join(localDir(), 'InvenTree', 'secret_key.txt')
# If a SECRET_KEY file does not exist, generate a new one!
if force or not os.path.exists(SECRET_KEY_FILE):
print("Generating SECRET_KEY file - " + SECRET_KEY_FILE)
with open(SECRET_KEY_FILE, 'w') as key_file:
options = string.digits + string.ascii_letters + string.punctuation
key = ''.join([random.choice(options) for i in range(length)])
key_file.write(key)
else:
print("SECRET_KEY file already exists - skipping")
@task(post=[key])
@task
def install(c):
"""
Installs required python packages, and runs initial setup functions.
Installs required python packages
"""
# Install required Python packages with PIP
@ -111,6 +96,13 @@ def shell(c):
manage(c, 'shell', pty=True)
@task
def worker(c):
"""
Run the InvenTree background worker process
"""
manage(c, 'qcluster', pty=True)
@task
def superuser(c):
@ -128,6 +120,14 @@ def check(c):
manage(c, "check")
@task
def wait(c):
"""
Wait until the database connection is ready
"""
manage(c, "wait_for_db")
@task
def migrate(c):
"""
@ -154,7 +154,7 @@ def static(c):
as per Django requirements.
"""
manage(c, "collectstatic")
manage(c, "collectstatic --no-input")
@task(pre=[install, migrate, static])
@ -231,28 +231,6 @@ def coverage(c):
# Generate coverage report
c.run('coverage html')
@task
def mysql(c):
"""
Install packages required for using InvenTree with a MySQL database.
"""
print('Installing packages required for MySQL')
c.run('sudo apt-get install mysql-server libmysqlclient-dev')
c.run('pip3 install mysqlclient')
@task
def postgresql(c):
"""
Install packages required for using InvenTree with a PostgreSQL database
"""
print("Installing packages required for PostgreSQL")
c.run('sudo apt-get install postgresql postgresql-contrib libpq-dev')
c.run('pip3 install psycopg2')
@task(help={'filename': "Output filename (default = 'data.json')"})
def export_records(c, filename='data.json'):
"""