Merge branch 'master' of https://github.com/inventree/InvenTree into pr/ChristianSchindler/6305

This commit is contained in:
Matthias Mair 2024-02-20 00:20:37 +01:00
commit 65f6a51260
No known key found for this signature in database
GPG Key ID: A593429DDA23B66A
420 changed files with 197967 additions and 134909 deletions

View File

@ -58,9 +58,6 @@
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"containerUser": "vscode",
"features": {
"git": "os-provided"
},
"remoteEnv": {
// InvenTree config

View File

@ -46,6 +46,7 @@ jobs:
- docker.dev.env
- Dockerfile
- requirements.txt
- tasks.py
# Build the docker image

View File

@ -30,6 +30,7 @@ jobs:
server: ${{ steps.filter.outputs.server }}
migrations: ${{ steps.filter.outputs.migrations }}
frontend: ${{ steps.filter.outputs.frontend }}
api: ${{ steps.filter.outputs.api }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
@ -44,6 +45,8 @@ jobs:
migrations:
- '**/migrations/**'
- '.github/workflows**'
api:
- 'InvenTree/InvenTree/api_version.py'
frontend:
- 'src/frontend/**'
@ -105,12 +108,98 @@ jobs:
- name: Check Config
run: |
pip install pyyaml
pip install -r docs/requirements.txt
python docs/ci/check_mkdocs_config.py
- name: Check Links
run: |
pip install linkcheckmd requests
python -m linkcheckmd docs --recurse
schema:
name: Tests - API Schema Documentation
runs-on: ubuntu-20.04
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
- name: Export API Documentation
run: invoke schema --ignore-warnings
- name: Upload schema
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3
with:
name: schema.yml
path: InvenTree/schema.yml
- name: Download public schema
if: needs.paths-filter.outputs.api == 'false'
run: |
pip install requests >/dev/null 2>&1
version="$(python3 ci/version_check.py only_version 2>&1)"
echo "Version: $version"
url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml"
echo "URL: $url"
curl -s -o api.yaml $url
echo "Downloaded api.yaml"
- name: Check for differences in schemas
if: needs.paths-filter.outputs.api == 'false'
run: |
diff --color -u InvenTree/schema.yml api.yaml
diff -u InvenTree/schema.yml api.yaml && echo "no difference in API schema " || exit 2
- name: Check schema - including warnings
run: invoke schema
continue-on-error: true
- name: Extract version for publishing
id: version
if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
run: |
pip install requests >/dev/null 2>&1
version="$(python3 ci/version_check.py only_version 2>&1)"
echo "Version: $version"
echo "version=$version" >> "$GITHUB_OUTPUT"
schema-push:
name: Push new schema
runs-on: ubuntu-20.04
needs: [paths-filter, schema]
if: needs.schema.result == 'success' && github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
env:
version: ${{ needs.schema.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
repository: inventree/schema
token: ${{ secrets.SCHEMA_PAT }}
- name: Download schema artifact
uses: actions/download-artifact@v3
with:
name: schema.yml
- name: Move schema to correct location
run: |
echo "Version: $version"
mkdir export/${version}
mv schema.yml export/${version}/api.yaml
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Update API schema for ${version}"
python:
name: Tests - inventree-python
runs-on: ubuntu-20.04
@ -357,6 +446,13 @@ jobs:
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
- name: 0.13.5 Database
run: |
rm /home/runner/work/InvenTree/db.sqlite3
cp test-db/stable_0.13.5.sqlite3 /home/runner/work/InvenTree/db.sqlite3
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
platform_ui:
name: Tests - Platform UI
runs-on: ubuntu-20.04

5
.gitignore vendored
View File

@ -48,6 +48,7 @@ label.pdf
label.png
InvenTree/my_special*
_tests*.txt
schema.yml
# Local static and media file storage (only when running in development mode)
inventree_media
@ -70,6 +71,7 @@ secret_key.txt
.idea/
*.code-workspace
.bash_history
.DS_Store
# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
.vscode/*
@ -107,5 +109,8 @@ InvenTree/plugins/
*.mo
messages.ts
# Generated API schema file
api.yaml
# web frontend (static files)
InvenTree/web/static

View File

@ -16,7 +16,7 @@ repos:
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.11
rev: v0.2.1
hooks:
- id: ruff-format
args: [--preview]
@ -37,7 +37,7 @@ repos:
args: [requirements.in, -o, requirements.txt]
files: ^requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
rev: v1.34.1
hooks:
- id: djlint-django
- repo: https://github.com/codespell-project/codespell
@ -52,7 +52,7 @@ repos:
src/frontend/src/locales/.* |
)$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
rev: "v4.0.0-alpha.8"
hooks:
- id: prettier
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
@ -60,7 +60,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v8.51.0"
rev: "v9.0.0-alpha.2"
hooks:
- id: eslint
additional_dependencies:

7
.vscode/launch.json vendored
View File

@ -21,6 +21,13 @@
"args": ["runserver"],
"django": true,
"justMyCode": false
},
{
"name": "InvenTree Frontend - Vite",
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src/frontend"
}
]
}

View File

@ -79,6 +79,10 @@ The HEAD of the "stable" branch represents the latest stable release code.
- When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2)
- The bugfix *must* also be cherry picked into the *master* branch.
## API versioning
The [API version](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed.
## Environment
### Target version
We are currently targeting:

View File

@ -13,9 +13,9 @@ ARG base_image=python:3.10-alpine3.18
FROM ${base_image} as inventree_base
# Build arguments for this image
ARG commit_tag=""
ARG commit_hash=""
ARG commit_date=""
ARG commit_tag=""
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
@ -60,13 +60,7 @@ RUN apk add --no-cache \
# Image format support
libjpeg libwebp zlib \
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#alpine-3-12
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap \
# SQLite support
sqlite \
# PostgreSQL support
postgresql-libs postgresql-client \
# MySQL / MariaDB support
mariadb-connector-c-dev mariadb-client && \
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap && \
# fonts
apk --update --upgrade --no-cache add fontconfig ttf-freefont font-noto terminus-font && fc-cache -f
@ -90,7 +84,7 @@ RUN if [ `apk --print-arch` = "armv7" ]; then \
COPY tasks.py docker/gunicorn.conf.py docker/init.sh ./
RUN chmod +x init.sh
ENTRYPOINT ["/bin/sh", "./init.sh"]
ENTRYPOINT ["/bin/ash", "./init.sh"]
FROM inventree_base as prebuild

View File

@ -1,5 +1,7 @@
"""Main JSON interface views."""
import sys
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
@ -8,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
@ -18,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission
from InvenTree.templatetags.inventree_extras import plugins_info
from part.models import Part
from plugin.serializers import MetadataSerializer
from users.models import ApiToken
@ -28,11 +32,41 @@ from .version import inventreeApiText
from .views import AjaxView
class VersionViewSerializer(serializers.Serializer):
"""Serializer for a single version."""
class VersionSerializer(serializers.Serializer):
"""Serializer for server version."""
server = serializers.CharField()
api = serializers.IntegerField()
commit_hash = serializers.CharField()
commit_date = serializers.CharField()
commit_branch = serializers.CharField()
python = serializers.CharField()
django = serializers.CharField()
class LinkSerializer(serializers.Serializer):
"""Serializer for all possible links."""
doc = serializers.URLField()
code = serializers.URLField()
credit = serializers.URLField()
app = serializers.URLField()
bug = serializers.URLField()
dev = serializers.BooleanField()
up_to_date = serializers.BooleanField()
version = VersionSerializer()
links = LinkSerializer()
class VersionView(APIView):
"""Simple JSON endpoint for InvenTree version information."""
permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
def get(self, request, *args, **kwargs):
"""Return information about the InvenTree server."""
return JsonResponse({
@ -81,6 +115,8 @@ class VersionApiSerializer(serializers.Serializer):
class VersionTextView(ListAPI):
"""Simple JSON endpoint for InvenTree version text."""
serializer_class = VersionSerializer
permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)})
@ -120,36 +156,32 @@ class InfoView(AjaxView):
'email_configured': is_email_configured(),
'debug_mode': settings.DEBUG,
'docker_mode': settings.DOCKER,
'default_locale': settings.LANGUAGE_CODE,
# Following fields are only available to staff users
'system_health': check_system_health() if is_staff else None,
'database': InvenTree.version.inventreeDatabase() if is_staff else None,
'platform': InvenTree.version.inventreePlatform() if is_staff else None,
'installer': InvenTree.version.inventreeInstaller() if is_staff else None,
'target': InvenTree.version.inventreeTarget() if is_staff else None,
'default_locale': settings.LANGUAGE_CODE,
}
return JsonResponse(data)
def check_auth_header(self, request):
"""Check if user is authenticated via a token in the header."""
# TODO @matmair: remove after refacgtor of Token check is done
headers = request.headers.get(
'Authorization', request.headers.get('authorization')
)
if not headers:
return False
from InvenTree.middleware import get_token_from_request
auth = headers.strip()
if not (auth.lower().startswith('token') and len(auth.split()) == 2):
return False
if token := get_token_from_request(request):
# Does the provided token match a valid user?
try:
token = ApiToken.objects.get(key=token)
# Check if the token is active and the user is a staff member
if token.active and token.user and token.user.is_staff:
return True
except ApiToken.DoesNotExist:
pass
token_key = auth.split()[1]
try:
token = ApiToken.objects.get(key=token_key)
if token.active and token.user and token.user.is_staff:
return True
except ApiToken.DoesNotExist:
pass
return False
@ -328,7 +360,17 @@ class AttachmentMixin:
attachment.save()
class APISearchView(APIView):
class APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView."""
search = serializers.CharField()
search_regex = serializers.BooleanField(default=False, required=False)
search_whole = serializers.BooleanField(default=False, required=False)
limit = serializers.IntegerField(default=1, required=False)
offset = serializers.IntegerField(default=0, required=False)
class APISearchView(GenericAPIView):
"""A general-purpose 'search' API endpoint.
Returns hits against a number of different models simultaneously,
@ -338,6 +380,7 @@ class APISearchView(APIView):
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = APISearchViewSerializer
def get_result_types(self):
"""Construct a list of search types we can return."""
@ -450,4 +493,7 @@ class MetadataView(RetrieveUpdateAPI):
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance."""
# Detect if we are currently generating the OpenAPI schema
if 'spectacular' in sys.argv:
return MetadataSerializer(Part, *args, **kwargs)
return MetadataSerializer(self.get_model_type(), *args, **kwargs)

View File

@ -1,11 +1,47 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 162
INVENTREE_API_VERSION = 171
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v171 - 2024-02-19 : https://github.com/inventree/InvenTree/pull/6516
- Adds "key" as a filterable parameter to PartTestTemplate list endpoint
v170 -> 2024-02-19 : https://github.com/inventree/InvenTree/pull/6514
- Adds "has_results" filter to the PartTestTemplate list endpoint
v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430
- Adds 'key' field to PartTestTemplate API endpoint
- Adds annotated 'results' field to PartTestTemplate API endpoint
- Adds 'template' field to StockItemTestResult API endpoint
v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824
- Adds machine CRUD API endpoints
- Adds machine settings API endpoints
- Adds machine restart API endpoint
- Adds machine types/drivers list API endpoints
- Adds machine registry status API endpoint
- Adds 'required' field to the global Settings API
- Discover sub-sub classes of the StatusCode API
v167 -> 2024-02-07: https://github.com/inventree/InvenTree/pull/6440
- Fixes for OpenAPI schema generation
v166 -> 2024-02-04 : https://github.com/inventree/InvenTree/pull/6400
- Adds package_name to plugin API
- Adds mechanism for uninstalling plugins via the API
v165 -> 2024-01-28 : https://github.com/inventree/InvenTree/pull/6040
- Adds supplier_part.name, part.creation_user, part.required_for_sales_order
v164 -> 2024-01-24 : https://github.com/inventree/InvenTree/pull/6343
- Adds "building" quantity to BuildLine API serializer
v163 -> 2024-01-22 : https://github.com/inventree/InvenTree/pull/6314
- Extends API endpoint to expose auth configuration information for signin pages
v162 -> 2024-01-14 : https://github.com/inventree/InvenTree/pull/6230
- Adds API endpoints to provide information on background tasks

View File

@ -58,6 +58,7 @@ class InvenTreeConfig(AppConfig):
# Let the background worker check for migrations
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
self.update_site_url()
self.collect_notification_methods()
self.collect_state_transition_methods()
@ -223,6 +224,46 @@ class InvenTreeConfig(AppConfig):
except Exception as e:
logger.exception('Error updating exchange rates: %s (%s)', e, type(e))
def update_site_url(self):
"""Update the site URL setting.
- If a fixed SITE_URL is specified (via configuration), it should override the INVENTREE_BASE_URL setting
- If multi-site support is enabled, update the site URL for the current site
"""
import common.models
if not InvenTree.ready.canAppAccessDatabase():
return
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
if settings.SITE_URL:
try:
if (
common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
!= settings.SITE_URL
):
common.models.InvenTreeSetting.set_setting(
'INVENTREE_BASE_URL', settings.SITE_URL
)
logger.info('Updated INVENTREE_SITE_URL to %s', settings.SITE_URL)
except Exception:
pass
# If multi-site support is enabled, update the site URL for the current site
try:
from django.contrib.sites.models import Site
site = Site.objects.get_current()
site.domain = settings.SITE_URL
site.save()
logger.info('Updated current site URL to %s', settings.SITE_URL)
except Exception:
pass
def add_user_on_startup(self):
"""Add a user on startup."""
# stop if checks were already created

View File

@ -0,0 +1,85 @@
"""Custom backend implementations."""
import datetime
import logging
import time
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from maintenance_mode.backends import AbstractStateBackend
import common.models
logger = logging.getLogger('inventree')
class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
"""Custom backend for managing state of maintenance mode.
Stores a timestamp in the database to determine when maintenance mode will elapse.
"""
SETTING_KEY = '_MAINTENANCE_MODE'
def get_value(self) -> bool:
"""Get the current state of the maintenance mode.
Returns:
bool: True if maintenance mode is active, False otherwise.
"""
try:
setting = common.models.InvenTreeSetting.objects.get(key=self.SETTING_KEY)
value = str(setting.value).strip()
except common.models.InvenTreeSetting.DoesNotExist:
# Database is accessible, but setting is not available - assume False
return False
except (IntegrityError, OperationalError, ProgrammingError):
# Database is inaccessible - assume we are not in maintenance mode
logger.debug('Failed to read maintenance mode state - assuming True')
return True
# Extract timestamp from string
try:
# If the timestamp is in the past, we are now *out* of maintenance mode
timestamp = datetime.datetime.fromisoformat(value)
return timestamp > datetime.datetime.now()
except ValueError:
# If the value is not a valid timestamp, assume maintenance mode is not active
return False
def set_value(self, value: bool, retries: int = 5, minutes: int = 5):
"""Set the state of the maintenance mode.
Instead of simply writing "true" or "false" to the setting,
we write a timestamp to the setting, which is used to determine
when maintenance mode will elapse.
This ensures that we will always *exit* maintenance mode after a certain time period.
"""
logger.debug('Setting maintenance mode state: %s', value)
if value:
# Save as isoformat
timestamp = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
timestamp = timestamp.isoformat()
else:
# Blank timestamp means maintenance mode is not active
timestamp = ''
while retries > 0:
try:
common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, timestamp)
# Read the value back to confirm
if self.get_value() == value:
break
except (IntegrityError, OperationalError, ProgrammingError):
# In the database is locked, then
logger.debug(
'Failed to set maintenance mode state (%s retries left)', retries
)
time.sleep(0.1)
retries -= 1
if retries == 0:
logger.warning('Failed to set maintenance mode state')

View File

@ -72,7 +72,7 @@ def user_roles(request):
roles = {}
for role in RuleSet.RULESET_MODELS.keys():
for role in RuleSet.get_ruleset_models().keys():
permissions = {}
for perm in ['view', 'add', 'change', 'delete']:

View File

@ -53,7 +53,10 @@ def log_error(path, error_name=None, error_info=None, error_data=None):
if error_data:
data = error_data
else:
data = '\n'.join(traceback.format_exception(kind, info, data))
try:
data = '\n'.join(traceback.format_exception(kind, info, data))
except AttributeError:
data = 'No traceback information available'
# Log error to stderr
logger.error(info)

View File

@ -6,7 +6,6 @@ from urllib.parse import urlencode
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group, User
from django.contrib.sites.models import Site
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -23,6 +22,7 @@ from crispy_forms.layout import Field, Layout
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
import InvenTree.helpers_model
import InvenTree.sso
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error
@ -293,7 +293,8 @@ class CustomUrlMixin:
def get_email_confirmation_url(self, request, emailconfirmation):
"""Custom email confirmation (activation) url."""
url = reverse('account_confirm_email', args=[emailconfirmation.key])
return Site.objects.get_current().domain + url
return InvenTree.helpers_model.construct_absolute_url(url)
class CustomAccountAdapter(

View File

@ -8,6 +8,7 @@ import os
import os.path
import re
from decimal import Decimal, InvalidOperation
from typing import TypeVar
from wsgiref.util import FileWrapper
from django.conf import settings
@ -30,16 +31,68 @@ from .settings import MEDIA_URL, STATIC_URL
logger = logging.getLogger('inventree')
def generateTestKey(test_name):
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
reference = str(reference).strip()
# Ignore empty string
if len(reference) == 0:
return 0
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
else:
# Look at the "end" of the string
result = re.search(r'(\d+)$', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
# Ensure that the returned values are within the range that can be stored in an IntegerField
# Note: This will result in large values being "clipped"
if clip is not None:
if ref_int > clip:
ref_int = clip
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int
def generateTestKey(test_name: str) -> str:
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
Tests must be named such that they will have unique keys.
"""
if test_name is None:
test_name = ''
key = test_name.strip().lower()
key = key.replace(' ', '')
# Remove any characters that cannot be used to represent a variable
key = re.sub(r'[^a-zA-Z0-9]', '', key)
key = re.sub(r'[^a-zA-Z0-9_]', '', key)
# If the key starts with a digit, prefix with an underscore
if key[0].isdigit():
key = '_' + key
return key
@ -840,7 +893,10 @@ def get_objectreference(
return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret}
def inheritors(cls):
Inheritors_T = TypeVar('Inheritors_T')
def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
"""Return all classes that are subclasses from the supplied cls."""
subcls = set()
work = [cls]

View File

@ -0,0 +1,106 @@
"""Provides helper mixins that are used throughout the InvenTree project."""
import inspect
from pathlib import Path
from django.conf import settings
from plugin import registry as plg_registry
class ClassValidationMixin:
"""Mixin to validate class attributes and overrides.
Class attributes:
required_attributes: List of class attributes that need to be defined
required_overrides: List of functions that need override, a nested list mean either one of them needs an override
Example:
```py
class Parent(ClassValidationMixin):
NAME: str
def test(self):
pass
required_attributes = ["NAME"]
required_overrides = [test]
class MyClass(Parent):
pass
myClass = MyClass()
myClass.validate() # raises NotImplementedError
```
"""
required_attributes = []
required_overrides = []
@classmethod
def validate(cls):
"""Validate the class against the required attributes/overrides."""
def attribute_missing(key):
"""Check if attribute is missing."""
return not hasattr(cls, key) or getattr(cls, key) == ''
def override_missing(base_implementation):
"""Check if override is missing."""
if isinstance(base_implementation, list):
return all(override_missing(x) for x in base_implementation)
return base_implementation == getattr(
cls, base_implementation.__name__, None
)
missing_attributes = list(filter(attribute_missing, cls.required_attributes))
missing_overrides = list(filter(override_missing, cls.required_overrides))
errors = []
if len(missing_attributes) > 0:
errors.append(
f"did not provide the following attributes: {', '.join(missing_attributes)}"
)
if len(missing_overrides) > 0:
missing_overrides_list = []
for base_implementation in missing_overrides:
if isinstance(base_implementation, list):
missing_overrides_list.append(
'one of '
+ ' or '.join(attr.__name__ for attr in base_implementation)
)
else:
missing_overrides_list.append(base_implementation.__name__)
errors.append(
f"did not override the required attributes: {', '.join(missing_overrides_list)}"
)
if len(errors) > 0:
raise NotImplementedError(f"'{cls}' " + ' and '.join(errors))
class ClassProviderMixin:
"""Mixin to get metadata about a class itself, e.g. the plugin that provided that class."""
@classmethod
def get_provider_file(cls):
"""File that contains the Class definition."""
return inspect.getfile(cls)
@classmethod
def get_provider_plugin(cls):
"""Plugin that contains the Class definition, otherwise None."""
for plg in plg_registry.plugins.values():
if plg.package_path == cls.__module__:
return plg
@classmethod
def get_is_builtin(cls):
"""Is this Class build in the Inventree source code?"""
try:
Path(cls.get_provider_file()).relative_to(settings.BASE_DIR)
return True
except ValueError:
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
return False

View File

@ -34,47 +34,59 @@ def getSetting(key, backup_value=None):
return common.models.InvenTreeSetting.get_setting(key, backup_value=backup_value)
def construct_absolute_url(*arg, **kwargs):
def get_base_url(request=None):
"""Return the base URL for the InvenTree server.
The base URL is determined in the following order of decreasing priority:
1. If a request object is provided, use the request URL
2. Multi-site is enabled, and the current site has a valid URL
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
"""
# Check if a request is provided
if request:
return request.build_absolute_uri('/')
# Check if multi-site is enabled
try:
from django.contrib.sites.models import Site
return Site.objects.get_current().domain
except (ImportError, RuntimeError):
pass
# Check if a global site URL is provided
if site_url := getattr(settings, 'SITE_URL', None):
return site_url
# Check if a global InvenTree setting is provided
try:
if site_url := common.models.InvenTreeSetting.get_setting(
'INVENTREE_BASE_URL', create=False, cache=False
):
return site_url
except (ProgrammingError, OperationalError):
pass
# No base URL available
return ''
def construct_absolute_url(*arg, base_url=None, request=None):
"""Construct (or attempt to construct) an absolute URL from a relative URL.
This is useful when (for example) sending an email to a user with a link
to something in the InvenTree web framework.
A URL is constructed in the following order:
1. If settings.SITE_URL is set (e.g. in the Django settings), use that
2. If the InvenTree setting INVENTREE_BASE_URL is set, use that
3. Otherwise, use the current request URL (if available)
Args:
*arg: The relative URL to construct
base_url: The base URL to use for the construction (if not provided, will attempt to determine from settings)
request: The request object to use for the construction (optional)
"""
relative_url = '/'.join(arg)
# If a site URL is provided, use that
site_url = getattr(settings, 'SITE_URL', None)
if not base_url:
base_url = get_base_url(request=request)
if not site_url:
# Otherwise, try to use the InvenTree setting
try:
site_url = common.models.InvenTreeSetting.get_setting(
'INVENTREE_BASE_URL', create=False, cache=False
)
except (ProgrammingError, OperationalError):
pass
if not site_url:
# Otherwise, try to use the current request
request = kwargs.get('request', None)
if request:
site_url = request.build_absolute_uri('/')
if not site_url:
# No site URL available, return the relative URL
return relative_url
return urljoin(site_url, relative_url)
def get_base_url(**kwargs):
"""Return the base URL for the InvenTree server."""
return construct_absolute_url('', **kwargs)
return urljoin(base_url, relative_url)
def download_image_from_url(remote_url, timeout=2.5):
@ -287,6 +299,11 @@ def notify_responsible(
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None.
"""
import InvenTree.ready
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
notify_users(
[instance.responsible], instance, sender, content=content, exclude=exclude
)

View File

@ -36,6 +36,7 @@ LOCALES = [
('pt', _('Portuguese')),
('pt-br', _('Portuguese (Brazilian)')),
('ru', _('Russian')),
('sk', _('Slovak')),
('sl', _('Slovenian')),
('sr', _('Serbian')),
('sv', _('Swedish')),

View File

@ -2,7 +2,6 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
@ -10,21 +9,23 @@ from django.utils.translation import gettext_lazy as _
import sesame.utils
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.version
def send_simple_login_email(user, link):
"""Send an email with the login link to this user."""
site = Site.objects.get_current()
site_name = InvenTree.version.inventreeInstanceName()
context = {'username': user.username, 'site_name': site.name, 'link': link}
context = {'username': user.username, 'site_name': site_name, 'link': link}
email_plaintext_message = render_to_string(
'InvenTree/user_simple_login.txt', context
)
send_mail(
_(f'[{site.name}] Log in to the app'),
_(f'[{site_name}] Log in to the app'),
email_plaintext_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
@ -37,7 +38,7 @@ class GetSimpleLoginSerializer(serializers.Serializer):
email = serializers.CharField(label=_('Email'))
class GetSimpleLoginView(APIView):
class GetSimpleLoginView(GenericAPIView):
"""View to send a simple login link."""
permission_classes = ()

View File

@ -0,0 +1,19 @@
"""Check if there are any pending database migrations, and run them."""
import logging
from django.core.management.base import BaseCommand
from InvenTree.tasks import check_for_migrations
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Check if there are any pending database migrations, and run them."""
def handle(self, *args, **kwargs):
"""Check for any pending database migrations."""
logger.info('Checking for pending database migrations')
check_for_migrations(force=True, reload_registry=False)
logger.info('Database migrations complete')

View File

@ -3,60 +3,73 @@
- This is crucial after importing any fixtures, etc
"""
import logging
from django.core.management.base import BaseCommand
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Rebuild all database models which leverage the MPTT structure."""
def handle(self, *args, **kwargs):
"""Rebuild all database models which leverage the MPTT structure."""
with maintenance_mode_on():
self.rebuild_models()
set_maintenance_mode(False)
def rebuild_models(self):
"""Rebuild all MPTT models in the database."""
# Part model
try:
print('Rebuilding Part objects')
logger.info('Rebuilding Part objects')
from part.models import Part
Part.objects.rebuild()
except Exception:
print('Error rebuilding Part objects')
logger.info('Error rebuilding Part objects')
# Part category
try:
print('Rebuilding PartCategory objects')
logger.info('Rebuilding PartCategory objects')
from part.models import PartCategory
PartCategory.objects.rebuild()
except Exception:
print('Error rebuilding PartCategory objects')
logger.info('Error rebuilding PartCategory objects')
# StockItem model
try:
print('Rebuilding StockItem objects')
logger.info('Rebuilding StockItem objects')
from stock.models import StockItem
StockItem.objects.rebuild()
except Exception:
print('Error rebuilding StockItem objects')
logger.info('Error rebuilding StockItem objects')
# StockLocation model
try:
print('Rebuilding StockLocation objects')
logger.info('Rebuilding StockLocation objects')
from stock.models import StockLocation
StockLocation.objects.rebuild()
except Exception:
print('Error rebuilding StockLocation objects')
logger.info('Error rebuilding StockLocation objects')
# Build model
try:
print('Rebuilding Build objects')
logger.info('Rebuilding Build objects')
from build.models import Build
Build.objects.rebuild()
except Exception:
print('Error rebuilding Build objects')
logger.info('Error rebuilding Build objects')

View File

@ -0,0 +1,19 @@
"""Check if there are any pending database migrations, and run them."""
import logging
from django.core.management.base import BaseCommand
from InvenTree.tasks import check_for_migrations
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Check if there are any pending database migrations, and run them."""
def handle(self, *args, **kwargs):
"""Check for any pending database migrations."""
logger.info('Checking for pending database migrations')
check_for_migrations(force=True, reload_registry=False)
logger.info('Database migrations complete')

View File

@ -7,6 +7,7 @@ from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata
from rest_framework.utils import model_meta
import common.models
import InvenTree.permissions
import users.models
from InvenTree.helpers import str2bool
@ -208,7 +209,10 @@ class InvenTreeMetadata(SimpleMetadata):
pk = kwargs[field]
break
if pk is not None:
if issubclass(model_class, common.models.BaseInvenTreeSetting):
instance = model_class.get_setting_object(**kwargs, create=False)
elif pk is not None:
try:
instance = model_class.objects.get(pk=pk)
except (ValueError, model_class.DoesNotExist):

View File

@ -18,6 +18,23 @@ from users.models import ApiToken
logger = logging.getLogger('inventree')
def get_token_from_request(request):
"""Extract token information from a request object."""
auth_keys = ['Authorization', 'authorization']
token_keys = ['token', 'bearer']
for k in auth_keys:
if auth_header := request.headers.get(k, None):
auth_header = auth_header.strip().lower().split()
if len(auth_header) > 1:
if auth_header[0].strip().lower().replace(':', '') in token_keys:
token = auth_header[1]
return token
return None
class AuthRequiredMiddleware(object):
"""Check for user to be authenticated."""
@ -25,6 +42,22 @@ class AuthRequiredMiddleware(object):
"""Save response object."""
self.get_response = get_response
def check_token(self, request) -> bool:
"""Check if the user is authenticated via token."""
if token := get_token_from_request(request):
# Does the provided token match a valid user?
try:
token = ApiToken.objects.get(key=token)
if token.active and token.user:
# Provide the user information to the request
request.user = token.user
return True
except ApiToken.DoesNotExist:
logger.warning('Access denied for unknown token %s', token)
return False
def __call__(self, request):
"""Check if user needs to be authenticated and is.
@ -70,28 +103,8 @@ class AuthRequiredMiddleware(object):
):
authorized = True
elif (
'Authorization' in request.headers.keys()
or 'authorization' in request.headers.keys()
):
auth = request.headers.get(
'Authorization', request.headers.get('authorization')
).strip()
if auth.lower().startswith('token') and len(auth.split()) == 2:
token_key = auth.split()[1]
# Does the provided token match a valid user?
try:
token = ApiToken.objects.get(key=token_key)
if token.active and token.user:
# Provide the user information to the request
request.user = token.user
authorized = True
except ApiToken.DoesNotExist:
logger.warning('Access denied for unknown token %s', token_key)
elif self.check_token(request):
authorized = True
# No authorization was found for the request
if not authorized:

View File

@ -9,56 +9,6 @@ from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
class DiffMixin:
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
def get_db_instance(self):
"""Return the instance of the object saved in the database.
Returns:
object: Instance of the object saved in the database
"""
if self.pk:
try:
return self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
pass
return None
def get_field_deltas(self):
"""Return a dict of field deltas.
Compares the current instance with the instance saved in the database,
and returns a dict of fields which have changed.
Returns:
dict: Dict of field deltas
"""
db_instance = self.get_db_instance()
if db_instance is None:
return {}
deltas = {}
for field in self._meta.fields:
if field.name == 'id':
continue
if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = {
'old': getattr(db_instance, field.name),
'new': getattr(self, field.name),
}
return deltas
def has_field_changed(self, field_name):
"""Determine if a particular field has changed."""
return field_name in self.get_field_deltas()
class CleanMixin:
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""

View File

@ -2,7 +2,6 @@
import logging
import os
import re
from datetime import datetime
from io import BytesIO
@ -30,18 +29,98 @@ from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
class DiffMixin:
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
def get_db_instance(self):
"""Return the instance of the object saved in the database.
Returns:
path to store file, format: '<subdir>/<id>/filename'
Returns:
object: Instance of the object saved in the database
"""
if self.pk:
try:
return self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
pass
return None
def get_field_deltas(self):
"""Return a dict of field deltas.
Compares the current instance with the instance saved in the database,
and returns a dict of fields which have changed.
Returns:
dict: Dict of field deltas
"""
db_instance = self.get_db_instance()
if db_instance is None:
return {}
deltas = {}
for field in self._meta.fields:
if field.name == 'id':
continue
if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = {
'old': getattr(db_instance, field.name),
'new': getattr(self, field.name),
}
return deltas
def has_field_changed(self, field_name):
"""Determine if a particular field has changed."""
return field_name in self.get_field_deltas()
class PluginValidationMixin(DiffMixin):
"""Mixin class which exposes the model instance to plugin validation.
Any model class which inherits from this mixin will be exposed to the plugin validation system.
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
def run_plugin_validation(self):
"""Throw this model against the plugin validation interface."""
from plugin.registry import registry
deltas = self.get_field_deltas()
for plugin in registry.with_mixin('validation'):
try:
if plugin.validate_model_instance(self, deltas=deltas) is True:
return
except ValidationError as exc:
raise exc
except Exception as exc:
# Log the exception to the database
import InvenTree.exceptions
InvenTree.exceptions.log_error(
f'plugins.{plugin.slug}.validate_model_instance'
)
raise ValidationError(_('Error running plugin validation'))
def full_clean(self, *args, **kwargs):
"""Run plugin validation on full model clean.
Note that plugin validation is performed *after* super.full_clean()
"""
super().full_clean(*args, **kwargs)
self.run_plugin_validation()
def save(self, *args, **kwargs):
"""Run plugin validation on model save.
Note that plugin validation is performed *before* super.save()
"""
self.run_plugin_validation()
super().save(*args, **kwargs)
class MetadataMixin(models.Model):
@ -377,7 +456,7 @@ class ReferenceIndexingMixin(models.Model):
except Exception:
pass
reference_int = extract_int(reference)
reference_int = InvenTree.helpers.extract_int(reference)
if validate:
if reference_int > models.BigIntegerField.MAX_BIGINT:
@ -388,52 +467,44 @@ class ReferenceIndexingMixin(models.Model):
reference_int = models.BigIntegerField(default=0)
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
class InvenTreeModel(PluginValidationMixin, models.Model):
"""Base class for InvenTree models, which provides some common functionality.
reference = str(reference).strip()
Includes the following mixins by default:
# Ignore empty string
if len(reference) == 0:
return 0
- PluginValidationMixin: Provides a hook for plugins to validate model instances
"""
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', reference)
class Meta:
"""Metaclass options."""
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
else:
# Look at the "end" of the string
result = re.search(r'(\d+)$', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
# Ensure that the returned values are within the range that can be stored in an IntegerField
# Note: This will result in large values being "clipped"
if clip is not None:
if ref_int > clip:
ref_int = clip
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int
abstract = True
class InvenTreeAttachment(models.Model):
class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
"""Base class for an InvenTree model which includes a metadata field."""
class Meta:
"""Metaclass options."""
abstract = True
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: '<subdir>/<id>/filename'
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
class InvenTreeAttachment(InvenTreeModel):
"""Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL
@ -615,7 +686,7 @@ class InvenTreeAttachment(models.Model):
return ''
class InvenTreeTree(MPTTModel):
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories.
- Each Category has one parent Category, which can be blank (for a top-level Category).

View File

@ -69,6 +69,10 @@ class RolePermission(permissions.BasePermission):
# The required role may be defined for the view class
if role := getattr(view, 'role_required', None):
# If the role is specified as "role.permission", split it
if '.' in role:
role, permission = role.split('.')
return users.models.check_user_role(user, role, permission)
try:

View File

@ -10,13 +10,64 @@ def isInTestMode():
def isImportingData():
"""Returns True if the database is currently importing data, e.g. 'loaddata' command is performed."""
return 'loaddata' in sys.argv
"""Returns True if the database is currently importing (or exporting) data, e.g. 'loaddata' command is performed."""
return any((x in sys.argv for x in ['flush', 'loaddata', 'dumpdata']))
def isRunningMigrations():
"""Return True if the database is currently running migrations."""
return any((x in sys.argv for x in ['migrate', 'makemigrations', 'showmigrations']))
return any(
(
x in sys.argv
for x in ['migrate', 'makemigrations', 'showmigrations', 'runmigrations']
)
)
def isRebuildingData():
"""Return true if any of the rebuilding commands are being executed."""
return any(
(
x in sys.argv
for x in ['prerender', 'rebuild_models', 'rebuild_thumbnails', 'rebuild']
)
)
def isRunningBackup():
"""Return true if any of the backup commands are being executed."""
return any(
(
x in sys.argv
for x in [
'backup',
'restore',
'dbbackup',
'dbresotore',
'mediabackup',
'mediarestore',
]
)
)
def isInWorkerThread():
"""Returns True if the current thread is a background worker thread."""
return 'qcluster' in sys.argv
def isInServerThread():
"""Returns True if the current thread is a server thread."""
if isInWorkerThread():
return False
if 'runserver' in sys.argv:
return True
if 'gunicorn' in sys.argv[0]:
return True
return False
def isInMainThread():
@ -28,7 +79,7 @@ def isInMainThread():
if 'runserver' in sys.argv and '--noreload' not in sys.argv:
return os.environ.get('RUN_MAIN', None) == 'true'
return True
return not isInWorkerThread()
def canAppAccessDatabase(
@ -39,26 +90,30 @@ def canAppAccessDatabase(
There are some circumstances where we don't want the ready function in apps.py
to touch the database
"""
# Prevent database access if we are running backups
if isRunningBackup():
return False
# Prevent database access if we are importing data
if isImportingData():
return False
# Prevent database access if we are rebuilding data
if isRebuildingData():
return False
# Prevent database access if we are running migrations
if not allow_plugins and isRunningMigrations():
return False
# If any of the following management commands are being executed,
# prevent custom "on load" code from running!
excluded_commands = [
'flush',
'loaddata',
'dumpdata',
'check',
'createsuperuser',
'wait_for_db',
'prerender',
'rebuild_models',
'rebuild_thumbnails',
'makemessages',
'compilemessages',
'backup',
'dbbackup',
'mediabackup',
'restore',
'dbrestore',
'mediarestore',
]
if not allow_shell:
@ -69,12 +124,7 @@ def canAppAccessDatabase(
excluded_commands.append('test')
if not allow_plugins:
excluded_commands.extend([
'makemigrations',
'showmigrations',
'migrate',
'collectstatic',
])
excluded_commands.extend(['collectstatic'])
for cmd in excluded_commands:
if cmd in sys.argv:

View File

@ -7,7 +7,6 @@ from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -26,7 +25,10 @@ from taggit.serializers import TaggitSerializer
import common.models as common_models
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url
class EmptySerializer(serializers.Serializer):
"""Empty serializer for use in testing."""
class InvenTreeMoneySerializer(MoneyField):
@ -155,8 +157,15 @@ class DependentField(serializers.Field):
# check if the request data contains the dependent fields, otherwise skip getting the child
for f in self.depends_on:
if not data.get(f, None):
return
if data.get(f, None) is None:
if (
self.parent
and (v := getattr(self.parent.fields[f], 'default', None))
is not None
):
data[f] = v
else:
return
# partially validate the data for options requests that set raise_exception while calling .get_child(...)
if raise_exception:
@ -445,19 +454,27 @@ class UserCreateSerializer(ExendedUserSerializer):
def create(self, validated_data):
"""Send an e email to the user after creation."""
from InvenTree.helpers_model import get_base_url
base_url = get_base_url()
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
# Send the user an onboarding email (from current site)
current_site = Site.objects.get_current()
domain = current_site.domain
instance.email_user(
subject=_(f'Welcome to {current_site.name}'),
message=_(
f'Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain}).'
),
message = (
_('Your account has been created.')
+ '\n\n'
+ _('Please use the password reset function to login')
)
if base_url:
message += f'\n\nURL: {base_url}'
# Send the user an onboarding email (from current site)
instance.email_user(subject=_('Welcome to InvenTree'), message=message)
return instance
@ -844,6 +861,8 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
- Attempt to download the image and store it against this object instance
- Catches and re-throws any errors
"""
from InvenTree.helpers_model import download_image_from_url
if not url:
return

View File

@ -26,7 +26,6 @@ from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.tracing import setup_instruments, setup_tracing
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
from . import config, locales
@ -121,31 +120,6 @@ STATIC_ROOT = config.get_static_dir()
# The filesystem location for uploaded meadia files
MEDIA_ROOT = config.get_media_dir()
# List of allowed hosts (default = allow all)
ALLOWED_HOSTS = get_setting(
'INVENTREE_ALLOWED_HOSTS',
config_key='allowed_hosts',
default_value=['*'],
typecast=list,
)
# Cross Origin Resource Sharing (CORS) options
# Only allow CORS access to API and media endpoints
CORS_URLS_REGEX = r'^/(api|media|static)/.*$'
# Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=False
)
CORS_ORIGIN_WHITELIST = get_setting(
'INVENTREE_CORS_ORIGIN_WHITELIST',
config_key='cors.whitelist',
default_value=[],
typecast=list,
)
# Needed for the parts importer, directly impacts the maximum parts that can be uploaded
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000
@ -214,6 +188,7 @@ INSTALLED_APPS = [
'report.apps.ReportConfig',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'machine.apps.MachineConfig',
'web',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
@ -263,9 +238,9 @@ MIDDLEWARE = CONFIG.get(
'x_forwarded_for.middleware.XForwardedForMiddleware',
'user_sessions.middleware.SessionMiddleware', # db user sessions
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
'django_otp.middleware.OTPMiddleware', # MFA support
@ -444,9 +419,9 @@ REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
'DEFAULT_AUTHENTICATION_CLASSES': (
'users.authentication.ApiTokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'users.authentication.ApiTokenAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
@ -488,12 +463,15 @@ if USE_JWT:
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'},
'EXTERNAL_DOCS': {
'docs': 'https://docs.inventree.org',
'web': 'https://inventree.org',
'LICENSE': {
'name': 'MIT',
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
},
'VERSION': inventreeApiVersion(),
'EXTERNAL_DOCS': {
'description': 'More information about InvenTree in the official docs',
'url': 'https://docs.inventree.org',
},
'VERSION': str(inventreeApiVersion()),
'SERVE_INCLUDE_SCHEMA': False,
}
@ -590,11 +568,6 @@ db_options = db_config.get('OPTIONS', db_config.get('options', {}))
# Specific options for postgres backend
if 'postgres' in db_engine: # pragma: no cover
from psycopg2.extensions import (
ISOLATION_LEVEL_READ_COMMITTED,
ISOLATION_LEVEL_SERIALIZABLE,
)
# Connection timeout
if 'connect_timeout' not in db_options:
# The DB server is in the same data center, it should not take very
@ -658,11 +631,7 @@ if 'postgres' in db_engine: # pragma: no cover
serializable = get_boolean_setting(
'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False
)
db_options['isolation_level'] = (
ISOLATION_LEVEL_SERIALIZABLE
if serializable
else ISOLATION_LEVEL_READ_COMMITTED
)
db_options['isolation_level'] = 4 if serializable else 2
# Specific options for MySql / MariaDB backend
elif 'mysql' in db_engine: # pragma: no cover
@ -738,7 +707,10 @@ if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
TRACING_ENABLED = get_boolean_setting(
'INVENTREE_TRACING_ENABLED', 'tracing.enabled', False
)
if TRACING_ENABLED: # pragma: no cover
from InvenTree.tracing import setup_instruments, setup_tracing
_t_endpoint = get_setting('INVENTREE_TRACING_ENDPOINT', 'tracing.endpoint', None)
_t_headers = get_setting('INVENTREE_TRACING_HEADERS', 'tracing.headers', None, dict)
@ -820,7 +792,7 @@ Q_CLUSTER = {
get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)
),
'timeout': _q_worker_timeout,
'retry': min(120, _q_worker_timeout + 30),
'retry': max(120, _q_worker_timeout + 30),
'max_attempts': int(
get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)
),
@ -959,7 +931,6 @@ TIME_ZONE = get_setting('INVENTREE_TIMEZONE', 'timezone', 'UTC')
USE_I18N = True
USE_L10N = True
# Do not use native timezone support in "test" mode
# It generates a *lot* of cruft in the logs
@ -974,13 +945,93 @@ CRISPY_TEMPLATE_PACK = 'bootstrap4'
# Use database transactions when importing / exporting data
IMPORT_EXPORT_USE_TRANSACTIONS = True
SITE_ID = 1
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info('Using Site URL: %s', SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
# Enable or disable multi-site framework
SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
# If a SITE_ID is specified
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
# Load the allauth social backends
SOCIAL_BACKENDS = get_setting(
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
)
if not SITE_MULTI:
INSTALLED_APPS.remove('django.contrib.sites')
# List of allowed hosts (default = allow all)
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#allowed-hosts
ALLOWED_HOSTS = get_setting(
'INVENTREE_ALLOWED_HOSTS',
config_key='allowed_hosts',
default_value=['*'],
typecast=list,
)
# List of trusted origins for unsafe requests
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
CSRF_TRUSTED_ORIGINS = get_setting(
'INVENTREE_TRUSTED_ORIGINS',
config_key='trusted_origins',
default_value=[],
typecast=list,
)
# If a list of trusted is not specified, but a site URL has been specified, use that
if SITE_URL and len(CSRF_TRUSTED_ORIGINS) == 0:
CSRF_TRUSTED_ORIGINS.append(SITE_URL)
USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host',
default_value=False,
)
USE_X_FORWARDED_PORT = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_PORT',
config_key='use_x_forwarded_port',
default_value=False,
)
# Cross Origin Resource Sharing (CORS) options
# Refer to the django-cors-headers documentation for more information
# Ref: https://github.com/adamchainz/django-cors-headers
# Extract CORS options from configuration file
CORS_ALLOW_ALL_ORIGINS = get_boolean_setting(
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG
)
CORS_ALLOW_CREDENTIALS = get_boolean_setting(
'INVENTREE_CORS_ALLOW_CREDENTIALS',
config_key='cors.allow_credentials',
default_value=True,
)
# Only allow CORS access to API and media endpoints
CORS_URLS_REGEX = r'^/(api|media|static)/.*$'
CORS_ALLOWED_ORIGINS = get_setting(
'INVENTREE_CORS_ORIGIN_WHITELIST',
config_key='cors.whitelist',
default_value=[],
typecast=list,
)
# If no CORS origins are specified, but a site URL has been specified, use that
if SITE_URL and len(CORS_ALLOWED_ORIGINS) == 0:
CORS_ALLOWED_ORIGINS.append(SITE_URL)
for app in SOCIAL_BACKENDS:
# Ensure that the app starts with 'allauth.socialaccount.providers'
social_prefix = 'allauth.socialaccount.providers.'
@ -996,6 +1047,11 @@ SOCIALACCOUNT_PROVIDERS = get_setting(
SOCIALACCOUNT_STORE_TOKENS = True
# Explicitly set empty URL prefix for OIDC
# The SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX setting was introduced in v0.60.0
# Ref: https://github.com/pennersr/django-allauth/blob/0.60.0/ChangeLog.rst#backwards-incompatible-changes
SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = ''
# settings for allauth
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting(
'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int
@ -1064,8 +1120,8 @@ MARKDOWNIFY = {
IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied]
# Maintenance mode
MAINTENANCE_MODE_RETRY_AFTER = 60
MAINTENANCE_MODE_STATE_BACKEND = 'maintenance_mode.backends.StaticStorageBackend'
MAINTENANCE_MODE_RETRY_AFTER = 10
MAINTENANCE_MODE_STATE_BACKEND = 'InvenTree.backends.InvenTreeMaintenanceModeBackend'
# Are plugins enabled?
PLUGINS_ENABLED = get_boolean_setting(
@ -1087,16 +1143,6 @@ PLUGIN_RETRY = get_setting(
) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
if SITE_URL:
logger.info('Site URL: %s', SITE_URL)
# Check that the site URL is valid
validator = URLValidator()
validator(SITE_URL)
# User interface customization values
CUSTOM_LOGO = get_custom_file(
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
@ -1139,5 +1185,4 @@ if CUSTOM_FLAGS:
# Magic login django-sesame
SESAME_MAX_AGE = 300
# LOGIN_REDIRECT_URL = f"/{FRONTEND_URL_BASE}/logged-in/"
LOGIN_REDIRECT_URL = '/index/'
LOGIN_REDIRECT_URL = '/api/auth/login-redirect/'

View File

@ -9,6 +9,7 @@ from allauth.account.models import EmailAddress
from allauth.socialaccount import providers
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
@ -16,7 +17,7 @@ from rest_framework.response import Response
import InvenTree.sso
from common.models import InvenTreeSetting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
logger = logging.getLogger('inventree')
@ -112,11 +113,36 @@ for name, provider in providers.registry.provider_map.items():
social_auth_urlpatterns += provider_urlpatterns
class SocialProviderListResponseSerializer(serializers.Serializer):
"""Serializer for the SocialProviderListView."""
class SocialProvider(serializers.Serializer):
"""Serializer for the SocialProviderListResponseSerializer."""
id = serializers.CharField()
name = serializers.CharField()
configured = serializers.BooleanField()
login = serializers.URLField()
connect = serializers.URLField()
display_name = serializers.CharField()
sso_enabled = serializers.BooleanField()
sso_registration = serializers.BooleanField()
mfa_required = serializers.BooleanField()
providers = SocialProvider(many=True)
registration_enabled = serializers.BooleanField()
password_forgotten_enabled = serializers.BooleanField()
class SocialProviderListView(ListAPI):
"""List of available social providers."""
permission_classes = (AllowAny,)
serializer_class = EmptySerializer
@extend_schema(
responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)}
)
def get(self, request, *args, **kwargs):
"""Get the list of providers."""
provider_list = []
@ -153,6 +179,10 @@ class SocialProviderListView(ListAPI):
'sso_registration': InvenTree.sso.registration_enabled(),
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
'providers': provider_list,
'registration_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_REG'),
'password_forgotten_enabled': InvenTreeSetting.get_setting(
'LOGIN_ENABLE_PWD_FORGOT'
),
}
return Response(data)

View File

@ -9,7 +9,7 @@ import time
import warnings
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Callable, List
from typing import Callable
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
@ -291,7 +291,7 @@ class ScheduledTask:
class TaskRegister:
"""Registry for periodic tasks."""
task_list: List[ScheduledTask] = []
task_list: list[ScheduledTask] = []
def register(self, task, schedule, minutes: int = None):
"""Register a task with the que."""
@ -644,7 +644,7 @@ def get_migration_plan():
@scheduled_task(ScheduledTask.DAILY)
def check_for_migrations():
def check_for_migrations(force: bool = False, reload_registry: bool = True):
"""Checks if migrations are needed.
If the setting auto_update is enabled we will start updating.
@ -659,8 +659,9 @@ def check_for_migrations():
logger.info('Checking for pending database migrations')
# Force plugin registry reload
registry.check_reload()
if reload_registry:
# Force plugin registry reload
registry.check_reload()
plan = get_migration_plan()
@ -674,7 +675,7 @@ def check_for_migrations():
set_pending_migrations(n)
# Test if auto-updates are enabled
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
if not force and not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
logger.info('Auto-update is disabled - skipping migrations')
return
@ -706,6 +707,7 @@ def check_for_migrations():
set_maintenance_mode(False)
logger.info('Manually released maintenance mode')
# We should be current now - triggering full reload to make sure all models
# are loaded fully in their new state.
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
if reload_registry:
# We should be current now - triggering full reload to make sure all models
# are loaded fully in their new state.
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)

View File

@ -52,7 +52,18 @@ class CustomTranslateNode(TranslateNode):
# Escape any quotes contained in the string, if the request is for a javascript file
request = context.get('request', None)
if self.escape or (request and request.path.endswith('.js')):
template = getattr(context, 'template_name', None)
request = context.get('request', None)
escape = self.escape
if template and str(template).endswith('.js'):
escape = True
if request and str(request.path).endswith('.js'):
escape = True
if escape:
result = result.replace("'", r'\'')
result = result.replace('"', r'\"')

View File

@ -334,7 +334,10 @@ def setting_object(key, *args, **kwargs):
plg = kwargs['plugin']
if issubclass(plg.__class__, InvenTreePlugin):
plg = plg.plugin_config()
try:
plg = plg.plugin_config()
except plugin.models.PluginConfig.DoesNotExist:
return None
return plugin.models.PluginSetting.get_setting_object(
key, plugin=plg, cache=cache

View File

@ -25,7 +25,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-part-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_build_api(self):
@ -33,7 +33,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-build-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_stock_api(self):
@ -41,7 +41,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-stock-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_company_list(self):
@ -49,7 +49,7 @@ class HTMLAPITests(InvenTreeTestCase):
url = reverse('api-company-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
response = self.client.get(url, headers={'accept': 'application/json'})
self.assertEqual(response.status_code, 200)
def test_not_found(self):

View File

@ -18,6 +18,11 @@ class ApiVersionTests(InvenTreeAPITestCase):
self.assertEqual(len(data), 10)
response = self.client.get(reverse('api-version'), format='json').json()
self.assertIn('version', response)
self.assertIn('dev', response)
self.assertIn('up_to_date', response)
def test_inventree_api_text(self):
"""Test that the inventreeApiText function works expected."""
# Normal run

View File

@ -15,7 +15,9 @@ class MiddlewareTests(InvenTreeTestCase):
def check_path(self, url, code=200, **kwargs):
"""Helper function to run a request."""
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
response = self.client.get(
url, headers={'accept': 'application/json'}, **kwargs
)
self.assertEqual(response.status_code, code)
return response

View File

@ -10,7 +10,6 @@ from unittest import mock
import django.core.exceptions as django_exceptions
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
@ -20,6 +19,7 @@ import pint.errors
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
from sesame.utils import get_user
import InvenTree.conversion
@ -29,6 +29,7 @@ import InvenTree.helpers_model
import InvenTree.tasks
from common.models import CustomUnit, InvenTreeSetting
from common.settings import currency_codes
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase
from part.models import Part, PartCategory
@ -372,7 +373,7 @@ class TestHelpers(TestCase):
for url, expected in tests.items():
# Test with supplied base URL
self.assertEqual(
InvenTree.helpers_model.construct_absolute_url(url, site_url=base),
InvenTree.helpers_model.construct_absolute_url(url, base_url=base),
expected,
)
@ -508,6 +509,20 @@ class TestHelpers(TestCase):
self.assertNotIn(PartCategory, models)
self.assertNotIn(InvenTreeSetting, models)
def test_test_key(self):
"""Test for the generateTestKey function."""
tests = {
' Hello World ': 'helloworld',
' MY NEW TEST KEY ': 'mynewtestkey',
' 1234 5678': '_12345678',
' 100 percenT': '_100percent',
' MY_NEW_TEST': 'my_new_test',
' 100_new_tests': '_100_new_tests',
}
for name, key in tests.items():
self.assertEqual(helpers.generateTestKey(name), key)
class TestQuoteWrap(TestCase):
"""Tests for string wrapping."""
@ -1049,6 +1064,12 @@ class TestInstanceName(InvenTreeTestCase):
self.assertEqual(version.inventreeInstanceTitle(), 'Testing title')
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
# The site should also be changed
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.name, 'Testing title')
@ -1060,9 +1081,18 @@ class TestInstanceName(InvenTreeTestCase):
'INVENTREE_BASE_URL', 'http://127.1.2.3', self.user
)
# No further tests if multi-site support is not enabled
if not settings.SITE_MULTI:
return
# The site should also be changed
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
try:
from django.contrib.sites.models import Site
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.domain, 'http://127.1.2.3')
except Exception:
pass
class TestOffloadTask(InvenTreeTestCase):
@ -1234,7 +1264,7 @@ class MagicLoginTest(InvenTreeTestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, '[example.com] Log in to the app')
self.assertEqual(mail.outbox[0].subject, '[InvenTree] Log in to the app')
# Check that the token is in the email
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)
@ -1247,9 +1277,153 @@ class MagicLoginTest(InvenTreeTestCase):
# Check that the login works
resp = self.client.get(reverse('sesame-login') + '?sesame=' + token)
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.url, '/index/')
# Note: 2023-08-08 - This test has been changed because "platform UI" is not generally available yet
# TODO: In the future, the URL comparison will need to be reverted
# self.assertEqual(resp.url, f'/{settings.FRONTEND_URL_BASE}/logged-in/')
self.assertEqual(resp.url, '/api/auth/login-redirect/')
# And we should be logged in again
self.assertEqual(resp.wsgi_request.user, self.user)
class MaintenanceModeTest(InvenTreeTestCase):
"""Unit tests for maintenance mode."""
def test_basic(self):
"""Test basic maintenance mode operation."""
for value in [False, True, False]:
set_maintenance_mode(value)
self.assertEqual(get_maintenance_mode(), value)
# API request is blocked in maintenance mode
set_maintenance_mode(True)
response = self.client.get('/api/')
self.assertEqual(response.status_code, 503)
set_maintenance_mode(False)
response = self.client.get('/api/')
self.assertEqual(response.status_code, 200)
def test_timestamp(self):
"""Test that the timestamp value is interpreted correctly."""
KEY = '_MAINTENANCE_MODE'
# Deleting the setting means maintenance mode is off
InvenTreeSetting.objects.filter(key=KEY).delete()
self.assertFalse(get_maintenance_mode())
def set_timestamp(value):
InvenTreeSetting.set_setting(KEY, value, None)
# Test blank value
set_timestamp('')
self.assertFalse(get_maintenance_mode())
# Test timestamp in the past
ts = datetime.now() - timedelta(minutes=10)
set_timestamp(ts.isoformat())
self.assertFalse(get_maintenance_mode())
# Test timestamp in the future
ts = datetime.now() + timedelta(minutes=10)
set_timestamp(ts.isoformat())
self.assertTrue(get_maintenance_mode())
# Set to false, check for empty string
set_maintenance_mode(False)
self.assertFalse(get_maintenance_mode())
self.assertEqual(InvenTreeSetting.get_setting(KEY, None), '')
class ClassValidationMixinTest(TestCase):
"""Tests for the ClassValidationMixin class."""
class BaseTestClass(ClassValidationMixin):
"""A valid class that inherits from ClassValidationMixin."""
NAME: str
def test(self):
"""Test function."""
pass
def test1(self):
"""Test function."""
pass
def test2(self):
"""Test function."""
pass
required_attributes = ['NAME']
required_overrides = [test, [test1, test2]]
class InvalidClass:
"""An invalid class that does not inherit from ClassValidationMixin."""
pass
def test_valid_class(self):
"""Test that a valid class passes the validation."""
class TestClass(self.BaseTestClass):
"""A valid class that inherits from BaseTestClass."""
NAME = 'Test'
def test(self):
"""Test function."""
pass
def test2(self):
"""Test function."""
pass
TestClass.validate()
def test_invalid_class(self):
"""Test that an invalid class fails the validation."""
class TestClass1(self.BaseTestClass):
"""A bad class that inherits from BaseTestClass."""
with self.assertRaisesRegex(
NotImplementedError,
r'\'<.*TestClass1\'>\' did not provide the following attributes: NAME and did not override the required attributes: test, one of test1 or test2',
):
TestClass1.validate()
class TestClass2(self.BaseTestClass):
"""A bad class that inherits from BaseTestClass."""
NAME = 'Test'
def test2(self):
"""Test function."""
pass
with self.assertRaisesRegex(
NotImplementedError,
r'\'<.*TestClass2\'>\' did not override the required attributes: test',
):
TestClass2.validate()
class ClassProviderMixinTest(TestCase):
"""Tests for the ClassProviderMixin class."""
class TestClass(ClassProviderMixin):
"""This class is a dummy class to test the ClassProviderMixin."""
pass
def test_get_provider_file(self):
"""Test the get_provider_file function."""
self.assertEqual(self.TestClass.get_provider_file(), __file__)
def test_provider_plugin(self):
"""Test the provider_plugin function."""
self.assertEqual(self.TestClass.get_provider_plugin(), None)
def test_get_is_builtin(self):
"""Test the get_is_builtin function."""
self.assertTrue(self.TestClass.get_is_builtin())

View File

@ -19,6 +19,7 @@ from opentelemetry.sdk.metrics.export import (
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
import InvenTree.ready
from InvenTree.version import inventreeVersion
# Logger configuration
@ -42,6 +43,9 @@ def setup_tracing(
resources_input: The resources to send with the traces.
console: Whether to output the traces to the console.
"""
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return
if resources_input is None:
resources_input = {}
if auth is None:

View File

@ -22,6 +22,7 @@ import build.api
import common.api
import company.api
import label.api
import machine.api
import order.api
import part.api
import plugin.api
@ -35,6 +36,7 @@ from order.urls import order_urls
from part.urls import part_urls
from plugin.urls import get_plugin_urls
from stock.urls import stock_urls
from web.urls import api_urls as web_api_urls
from web.urls import urlpatterns as platform_urls
from .api import APISearchView, InfoView, NotFoundView, VersionTextView, VersionView
@ -82,8 +84,10 @@ apipatterns = [
path('order/', include(order.api.order_api_urls)),
path('label/', include(label.api.label_api_urls)),
path('report/', include(report.api.report_api_urls)),
path('machine/', include(machine.api.machine_api_urls)),
path('user/', include(users.api.user_urls)),
path('admin/', include(common.api.admin_api_urls)),
path('web/', include(web_api_urls)),
# Plugin endpoints
path('', include(plugin.api.plugin_api_urls)),
# Common endpoints endpoint
@ -148,6 +152,12 @@ apipatterns = [
SocialAccountDisconnectView.as_view(),
name='social_account_disconnect',
),
path('logout/', users.api.Logout.as_view(), name='api-logout'),
path(
'login-redirect/',
users.api.LoginRedirect.as_view(),
name='api-login-redirect',
),
path('', include('dj_rest_auth.urls')),
]),
),

View File

@ -51,6 +51,7 @@ class BuildResource(InvenTreeResource):
notes = Field(attribute='notes')
@admin.register(Build)
class BuildAdmin(ImportExportModelAdmin):
"""Class for managing the Build model via the admin interface"""
@ -83,6 +84,7 @@ class BuildAdmin(ImportExportModelAdmin):
]
@admin.register(BuildItem)
class BuildItemAdmin(admin.ModelAdmin):
"""Class for managing the BuildItem model via the admin interface."""
@ -98,6 +100,7 @@ class BuildItemAdmin(admin.ModelAdmin):
]
@admin.register(BuildLine)
class BuildLineAdmin(admin.ModelAdmin):
"""Class for managing the BuildLine model via the admin interface"""
@ -112,8 +115,3 @@ class BuildLineAdmin(admin.ModelAdmin):
'build__reference',
'bom_item__sub_part__name',
]
admin.site.register(Build, BuildAdmin)
admin.site.register(BuildItem, BuildItemAdmin)
admin.site.register(BuildLine, BuildLineAdmin)

View File

@ -3,12 +3,6 @@
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
from build.models import Build
def update_tree(apps, schema_editor):
# Update the Build MPTT model
Build.objects.rebuild()
class Migration(migrations.Migration):
@ -49,5 +43,4 @@ class Migration(migrations.Migration):
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop),
]

View File

@ -57,6 +57,4 @@ class Migration(migrations.Migration):
('build', '0028_builditem_bom_item'),
]
operations = [
migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop),
]
operations = []

View File

@ -28,7 +28,6 @@ from build.validators import generate_next_build_reference, validate_build_order
import InvenTree.fields
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.mixins
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
@ -45,7 +44,7 @@ import users.models
logger = logging.getLogger('inventree')
class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@ -916,6 +915,11 @@ class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBar
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
if (common.settings.prevent_build_output_complete_on_incompleted_tests() and output.hasRequiredTests() and not output.passedAllRequiredTests()):
serial = output.serial
raise ValidationError(
_(f"Build output {serial} has not passed all required tests"))
for build_item in allocated_items:
# Complete the allocation of stock for that item
build_item.complete_allocation(user)
@ -1247,7 +1251,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(models.Model):
class BuildLine(InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
@ -1326,7 +1330,7 @@ class BuildLine(models.Model):
return self.allocated_quantity() > self.quantity
class BuildItem(InvenTree.models.MetadataMixin, models.Model):
class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""A BuildItem links multiple StockItem objects to a Build.
These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.

View File

@ -1,5 +1,7 @@
"""JSON serializers for Build API."""
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
@ -7,22 +9,25 @@ from django.utils.translation import gettext_lazy as _
from django.db import models
from django.db.models import ExpressionWrapper, F, FloatField
from django.db.models import Case, Sum, When, Value
from django.db.models import BooleanField
from django.db.models import BooleanField, Q
from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializer
import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus
from InvenTree.status_codes import BuildStatusGroups, StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
import common.models
from common.serializers import ProjectCodeSerializer
import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
@ -519,6 +524,17 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
outputs = data.get('outputs', [])
if common.settings.prevent_build_output_complete_on_incompleted_tests():
errors = []
for output in outputs:
stock_item = output['output']
if stock_item.hasRequiredTests() and not stock_item.passedAllRequiredTests():
serial = stock_item.serial
errors.append(_(f"Build output {serial} has not passed all required tests"))
if errors:
raise ValidationError(errors)
if len(outputs) == 0:
raise ValidationError(_("A list of build outputs must be provided"))
@ -1019,7 +1035,7 @@ class BuildItemSerializer(InvenTreeModelSerializer):
"""Determine which extra details fields should be included"""
part_detail = kwargs.pop('part_detail', True)
location_detail = kwargs.pop('location_detail', True)
stock_detail = kwargs.pop('stock_detail', False)
stock_detail = kwargs.pop('stock_detail', True)
build_detail = kwargs.pop('build_detail', False)
super().__init__(*args, **kwargs)
@ -1055,6 +1071,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
# Annotated fields
'allocated',
'in_production',
'on_order',
'available_stock',
'available_substitute_stock',
@ -1070,15 +1087,34 @@ class BuildLineSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
# Foreign key fields
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields
allocated = serializers.FloatField(read_only=True)
on_order = serializers.FloatField(read_only=True)
available_stock = serializers.FloatField(read_only=True)
allocated = serializers.FloatField(
label=_('Allocated Stock'),
read_only=True
)
on_order = serializers.FloatField(
label=_('On Order'),
read_only=True
)
in_production = serializers.FloatField(
label=_('In Production'),
read_only=True
)
available_stock = serializers.FloatField(
label=_('Available Stock'),
read_only=True
)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
total_available_stock = serializers.FloatField(read_only=True)
@ -1090,6 +1126,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
- allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
- in_production: Total stock currently in production for this build line
"""
queryset = queryset.select_related(
'build', 'bom_item',
@ -1126,6 +1163,11 @@ class BuildLineSerializer(InvenTreeModelSerializer):
ref = 'bom_item__sub_part__'
# Annotate the "in_production" quantity
queryset = queryset.annotate(
in_production=part.filters.annotate_in_production_quantity(reference=ref)
)
# Annotate the "on_order" quantity
# Difficulty: Medium
queryset = queryset.annotate(

View File

@ -373,7 +373,11 @@ onPanelLoad('allocate', function() {
loadBuildLineTable(
"#build-lines-table",
{{ build.pk }},
{}
{
{% if build.project_code %}
project_code: {{ build.project_code.pk }},
{% endif %}
}
);
});

View File

@ -1,5 +1,5 @@
"""Unit tests for the 'build' models"""
import uuid
from datetime import datetime, timedelta
from django.test import TestCase
@ -14,8 +14,8 @@ from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
from stock.models import StockItem, StockItemTestResult
from users.models import Owner
import logging
@ -55,6 +55,76 @@ class BuildTestBase(TestCase):
trackable=True,
)
# create one build with one required test template
cls.tested_part_with_required_test = Part.objects.create(
name="Part having required tests",
description="Why does it matter what my description is?",
assembly=True,
trackable=True,
)
cls.test_template_required = PartTestTemplate.objects.create(
part=cls.tested_part_with_required_test,
test_name="Required test",
description="Required test template description",
required=True,
requires_value=False,
requires_attachment=False
)
ref = generate_next_build_reference()
cls.build_w_tests_trackable = Build.objects.create(
reference=ref,
title="This is a build",
part=cls.tested_part_with_required_test,
quantity=1,
issued_by=get_user_model().objects.get(pk=1),
)
cls.stockitem_with_required_test = StockItem.objects.create(
part=cls.tested_part_with_required_test,
quantity=1,
is_building=True,
serial=uuid.uuid4(),
build=cls.build_w_tests_trackable
)
# now create a part with a non-required test template
cls.tested_part_wo_required_test = Part.objects.create(
name="Part with one non.required test",
description="Why does it matter what my description is?",
assembly=True,
trackable=True,
)
cls.test_template_non_required = PartTestTemplate.objects.create(
part=cls.tested_part_wo_required_test,
test_name="Required test template",
description="Required test template description",
required=False,
requires_value=False,
requires_attachment=False
)
ref = generate_next_build_reference()
cls.build_wo_tests_trackable = Build.objects.create(
reference=ref,
title="This is a build",
part=cls.tested_part_wo_required_test,
quantity=1,
issued_by=get_user_model().objects.get(pk=1),
)
cls.stockitem_wo_required_test = StockItem.objects.create(
part=cls.tested_part_wo_required_test,
quantity=1,
is_building=True,
serial=uuid.uuid4(),
build=cls.build_wo_tests_trackable
)
cls.sub_part_1 = Part.objects.create(
name="Widget A",
description="A widget",
@ -245,7 +315,7 @@ class BuildTest(BuildTestBase):
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
self.assertEqual(StockItem.objects.count(), 10)
self.assertEqual(StockItem.objects.count(), 12)
# Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
@ -558,7 +628,7 @@ class BuildTest(BuildTestBase):
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 13)
self.assertEqual(StockItem.objects.count(), 15)
# This stock item has been marked as "consumed"
item = StockItem.objects.get(pk=self.stock_1_1.pk)
@ -573,6 +643,27 @@ class BuildTest(BuildTestBase):
for output in outputs:
self.assertFalse(output.is_building)
def test_complete_with_required_tests(self):
"""Test the prevention completion when a required test is missing feature"""
# with required tests incompleted the save should fail
common.models.InvenTreeSetting.set_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
with self.assertRaises(ValidationError):
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
# let's complete the required test and see if it could be saved
StockItemTestResult.objects.create(
stock_item=self.stockitem_with_required_test,
template=self.test_template_required,
result=True
)
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
# let's see if a non required test could be saved
self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None)
def test_overdue_notification(self):
"""Test sending of notifications when a build order is overdue."""
self.build.target_date = datetime.now().date() - timedelta(days=1)

View File

@ -11,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
import django_q.models
from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound
@ -53,7 +54,15 @@ class WebhookView(CsrfExemptMixin, APIView):
permission_classes = []
model_class = common.models.WebhookEndpoint
run_async = False
serializer_class = None
@extend_schema(
responses={
200: OpenApiResponse(
description='Any data can be posted to the endpoint - everything will be passed to the WebhookEndpoint model.'
)
}
)
def post(self, request, endpoint, *args, **kwargs):
"""Process incoming webhook."""
# get webhook definition
@ -115,6 +124,7 @@ class CurrencyExchangeView(APIView):
"""API endpoint for displaying currency information."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, format=None):
"""Return information on available currency conversions."""
@ -157,6 +167,7 @@ class CurrencyRefreshView(APIView):
"""
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser]
serializer_class = None
def post(self, request, *args, **kwargs):
"""Performing a POST request will update currency exchange rates."""
@ -516,6 +527,7 @@ class BackgroundTaskOverview(APIView):
"""Provides an overview of the background task queue status."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
serializer_class = None
def get(self, request, format=None):
"""Return information about the current status of the background task queue."""
@ -668,6 +680,13 @@ common_api_urls = [
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
]),
),
path(
'error-report/',
include([
path('<int:pk>/', ErrorMessageDetail.as_view(), name='api-error-detail'),
path('', ErrorMessageList.as_view(), name='api-error-list'),
]),
),
# Project codes
path(
'project-code/',

View File

@ -13,10 +13,10 @@ import math
import os
import re
import uuid
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from enum import Enum
from secrets import compare_digest
from typing import Any, Callable, Dict, List, Tuple, TypedDict, Union
from typing import Any, Callable, TypedDict, Union
from django.apps import apps
from django.conf import settings
@ -24,7 +24,6 @@ from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
@ -101,6 +100,10 @@ class BaseURLValidator(URLValidator):
"""Make sure empty values pass."""
value = str(value).strip()
# If a configuration level value has been specified, prevent change
if settings.SITE_URL:
raise ValidationError(_('Site URL is locked by configuration'))
if len(value) == 0:
pass
@ -108,7 +111,7 @@ class BaseURLValidator(URLValidator):
super().__call__(value)
class ProjectCode(InvenTree.models.MetadataMixin, models.Model):
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
"""A ProjectCode is a unique identifier for a project."""
@staticmethod
@ -154,7 +157,7 @@ class SettingsKeyType(TypedDict, total=False):
units: Units of the particular setting (optional)
validator: Validation function/list of functions for the setting (optional, default: None, e.g: bool, int, str, MinValueValidator, ...)
default: Default value or function that returns default value (optional)
choices: (Function that returns) Tuple[str: key, str: display value] (optional)
choices: Function that returns or value of list[tuple[str: key, str: display value]] (optional)
hidden: Hide this setting from settings page (optional)
before_save: Function that gets called after save with *args, **kwargs (optional)
after_save: Function that gets called after save with *args, **kwargs (optional)
@ -166,9 +169,9 @@ class SettingsKeyType(TypedDict, total=False):
name: str
description: str
units: str
validator: Union[Callable, List[Callable], Tuple[Callable]]
validator: Union[Callable, list[Callable], tuple[Callable]]
default: Union[Callable, Any]
choices: Union[Tuple[str, str], Callable[[], Tuple[str, str]]]
choices: Union[list[tuple[str, str]], Callable[[], list[tuple[str, str]]]]
hidden: bool
before_save: Callable[..., None]
after_save: Callable[..., None]
@ -185,9 +188,9 @@ class BaseInvenTreeSetting(models.Model):
extra_unique_fields: List of extra fields used to be unique, e.g. for PluginConfig -> plugin
"""
SETTINGS: Dict[str, SettingsKeyType] = {}
SETTINGS: dict[str, SettingsKeyType] = {}
extra_unique_fields: List[str] = []
extra_unique_fields: list[str] = []
class Meta:
"""Meta options for BaseInvenTreeSetting -> abstract stops creation of database entry."""
@ -329,7 +332,7 @@ class BaseInvenTreeSetting(models.Model):
cls,
*,
exclude_hidden=False,
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
**kwargs,
):
"""Return a list of "all" defined settings.
@ -349,7 +352,7 @@ class BaseInvenTreeSetting(models.Model):
# Optionally filter by other keys
results = results.filter(**filters)
settings: Dict[str, BaseInvenTreeSetting] = {}
settings: dict[str, BaseInvenTreeSetting] = {}
# Query the database
for setting in results:
@ -391,7 +394,7 @@ class BaseInvenTreeSetting(models.Model):
cls,
*,
exclude_hidden=False,
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
**kwargs,
):
"""Return a dict of "all" defined global settings.
@ -406,7 +409,7 @@ class BaseInvenTreeSetting(models.Model):
**kwargs,
)
settings: Dict[str, Any] = {}
settings: dict[str, Any] = {}
for key, setting in all_settings.items():
settings[key] = setting.value
@ -418,7 +421,7 @@ class BaseInvenTreeSetting(models.Model):
cls,
*,
exclude_hidden=False,
settings_definition: Union[Dict[str, SettingsKeyType], None] = None,
settings_definition: Union[dict[str, SettingsKeyType], None] = None,
**kwargs,
):
"""Check if all required settings are set by definition.
@ -433,7 +436,7 @@ class BaseInvenTreeSetting(models.Model):
**kwargs,
)
missing_settings: List[str] = []
missing_settings: list[str] = []
for setting in all_settings.values():
if setting.required:
@ -522,7 +525,11 @@ class BaseInvenTreeSetting(models.Model):
if callable(choices):
# Evaluate the function (we expect it will return a list of tuples...)
return choices()
try:
# Attempt to pass the kwargs to the function, if it doesn't expect them, ignore and call without
return choices(**kwargs)
except TypeError:
return choices()
return choices
@ -647,7 +654,7 @@ class BaseInvenTreeSetting(models.Model):
return value
@classmethod
def set_setting(cls, key, value, change_user, create=True, **kwargs):
def set_setting(cls, key, value, change_user=None, create=True, **kwargs):
"""Set the value of a particular setting. If it does not exist, option to create it.
Args:
@ -674,6 +681,16 @@ class BaseInvenTreeSetting(models.Model):
setting = cls(key=key, **kwargs)
else:
return
except (OperationalError, ProgrammingError):
if not key.startswith('_'):
logger.warning("Database is locked, cannot set setting '%s'", key)
# Likely the DB is locked - not much we can do here
return
except Exception as exc:
logger.exception(
"Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))
)
return
# Enforce standard boolean representation
if setting.is_bool():
@ -700,6 +717,10 @@ class BaseInvenTreeSetting(models.Model):
attempts=attempts - 1,
**kwargs,
)
except (OperationalError, ProgrammingError):
logger.warning("Database is locked, cannot set setting '%s'", key)
# Likely the DB is locked - not much we can do here
pass
except Exception as exc:
# Some other error
logger.exception(
@ -1065,6 +1086,15 @@ def settings_group_options():
def update_instance_url(setting):
"""Update the first site objects domain to url."""
if not settings.SITE_MULTI:
return
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
site_obj = Site.objects.all().order_by('id').first()
site_obj.domain = setting.value
site_obj.save()
@ -1072,6 +1102,15 @@ def update_instance_url(setting):
def update_instance_name(setting):
"""Update the first site objects name to instance name."""
if not settings.SITE_MULTI:
return
try:
from django.contrib.sites.models import Site
except (ImportError, RuntimeError):
# Multi-site support not enabled
return
site_obj = Site.objects.all().order_by('id').first()
site_obj.name = setting.value
site_obj.save()
@ -1132,6 +1171,16 @@ def reload_plugin_registry(setting):
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
class InvenTreeSettingsKeyType(SettingsKeyType):
"""InvenTreeSettingsKeyType has additional properties only global settings support.
Attributes:
requires_restart: If True, a server restart is required after changing the setting
"""
requires_restart: bool
class InvenTreeSetting(BaseInvenTreeSetting):
"""An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).
@ -1139,6 +1188,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
even if that key does not exist.
"""
SETTINGS: dict[str, InvenTreeSettingsKeyType]
class Meta:
"""Meta options for InvenTreeSetting."""
@ -1843,6 +1894,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
'requires_restart': True,
},
'PLUGIN_UPDATE_CHECK': {
'name': _('Check for plugin updates'),
'description': _('Enable periodic checks for updates to installed plugins'),
'default': True,
'validator': bool,
},
# Settings for plugin mixin features
'ENABLE_PLUGINS_URL': {
'name': _('Enable URL integration'),
@ -1924,6 +1981,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
'name': _('Block Until Tests Pass'),
'description': _(
'Prevent build outputs from being completed until all required tests pass'
),
'default': False,
'validator': bool,
},
}
typ = 'inventree'
@ -2318,6 +2383,11 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': True,
'validator': bool,
},
'LAST_USED_PRINTING_MACHINES': {
'name': _('Last used printing machines'),
'description': _('Save the last used printing machines for a user'),
'default': '',
},
}
typ = 'user'
@ -2827,12 +2897,17 @@ class NotificationMessage(models.Model):
"""Return API endpoint."""
return reverse('api-notifications-list')
def age(self):
def age(self) -> int:
"""Age of the message in seconds."""
delta = now() - self.creation
# Add timezone information if TZ is enabled (in production mode mostly)
delta = now() - (
self.creation.replace(tzinfo=timezone.utc)
if settings.USE_TZ
else self.creation
)
return delta.seconds
def age_human(self):
def age_human(self) -> str:
"""Humanized age."""
return naturaltime(self.creation)

View File

@ -142,7 +142,7 @@ class NotificationMethod:
return False
# Check if method globally enabled
plg_instance = registry.plugins.get(plg_cls.NAME.lower())
plg_instance = registry.get_plugin(plg_cls.NAME.lower())
if plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING):
return True
@ -422,7 +422,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
# Collect possible methods
if delivery_methods is None:
delivery_methods = storage.liste
delivery_methods = storage.liste or []
else:
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS

View File

@ -59,6 +59,10 @@ class SettingsSerializer(InvenTreeModelSerializer):
units = serializers.CharField(read_only=True)
required = serializers.BooleanField(read_only=True)
typ = serializers.CharField(read_only=True)
def get_choices(self, obj):
"""Returns the choices available for a given item."""
results = []
@ -148,6 +152,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'model_name',
'api_url',
'typ',
'required',
]
# set Meta class
@ -195,7 +200,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
user = serializers.PrimaryKeyRelatedField(read_only=True)
read = serializers.BooleanField()
def get_target(self, obj):
def get_target(self, obj) -> dict:
"""Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id')
@ -217,7 +222,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
return target
def get_source(self, obj):
def get_source(self, obj) -> dict:
"""Function to resolve generic object reference to source."""
return get_objectreference(obj, 'source_content_type', 'source_object_id')

View File

@ -56,3 +56,12 @@ def stock_expiry_enabled():
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY', False, create=False)
def prevent_build_output_complete_on_incompleted_tests():
"""Returns True if the completion of the build outputs is disabled until the required tests are passed."""
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', False, create=False
)

View File

@ -658,6 +658,30 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
...
class ErrorReportTest(InvenTreeAPITestCase):
"""Unit tests for the error report API."""
def test_error_list(self):
"""Test error list."""
from InvenTree.exceptions import log_error
url = reverse('api-error-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 0)
# Throw an error!
log_error(
'test error', error_name='My custom error', error_info={'test': 'data'}
)
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 1)
err = response.data[0]
for k in ['when', 'info', 'data', 'path']:
self.assertIn(k, err)
class TaskListApiTests(InvenTreeAPITestCase):
"""Unit tests for the background task API endpoints."""

View File

@ -33,6 +33,7 @@ class CompanyResource(InvenTreeResource):
clean_model_instances = True
@admin.register(Company)
class CompanyAdmin(ImportExportModelAdmin):
"""Admin class for the Company model."""
@ -69,6 +70,7 @@ class SupplierPriceBreakInline(admin.TabularInline):
model = SupplierPriceBreak
@admin.register(SupplierPart)
class SupplierPartAdmin(ImportExportModelAdmin):
"""Admin class for the SupplierPart model."""
@ -105,6 +107,7 @@ class ManufacturerPartResource(InvenTreeResource):
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
@admin.register(ManufacturerPart)
class ManufacturerPartAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPart model."""
@ -117,6 +120,7 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer')
@admin.register(ManufacturerPartAttachment)
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPartAttachment model."""
@ -137,6 +141,7 @@ class ManufacturerPartParameterResource(InvenTreeResource):
clean_model_instance = True
@admin.register(ManufacturerPartParameter)
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
"""Admin class for ManufacturerPartParameter model."""
@ -173,6 +178,7 @@ class SupplierPriceBreakResource(InvenTreeResource):
MPN = Field(attribute='part__MPN', readonly=True)
@admin.register(SupplierPriceBreak)
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
"""Admin class for the SupplierPriceBreak model."""
@ -197,6 +203,7 @@ class AddressResource(InvenTreeResource):
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
@admin.register(Address)
class AddressAdmin(ImportExportModelAdmin):
"""Admin class for the Address model."""
@ -221,6 +228,7 @@ class ContactResource(InvenTreeResource):
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
@admin.register(Contact)
class ContactAdmin(ImportExportModelAdmin):
"""Admin class for the Contact model."""
@ -229,15 +237,3 @@ class ContactAdmin(ImportExportModelAdmin):
list_display = ('company', 'name', 'role', 'email', 'phone')
search_fields = ['company', 'name', 'email']
admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
admin.site.register(Address, AddressAdmin)
admin.site.register(Contact, ContactAdmin)

View File

@ -24,17 +24,12 @@ import common.settings
import InvenTree.conversion
import InvenTree.fields
import InvenTree.helpers
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
)
from InvenTree.status_codes import PurchaseOrderStatusGroups
@ -63,7 +58,9 @@ def rename_company_image(instance, filename):
return os.path.join(base, fn)
class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
class Company(
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
):
"""A Company object represents an external company.
It may be a supplier or a customer or a manufacturer (or a combination)
@ -250,7 +247,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
).distinct()
class CompanyAttachment(InvenTreeAttachment):
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file or URL attachments against a Company object."""
@staticmethod
@ -270,7 +267,7 @@ class CompanyAttachment(InvenTreeAttachment):
)
class Contact(MetadataMixin, models.Model):
class Contact(InvenTree.models.InvenTreeMetadataModel):
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
Attributes:
@ -299,7 +296,7 @@ class Contact(MetadataMixin, models.Model):
role = models.CharField(max_length=100, blank=True)
class Address(models.Model):
class Address(InvenTree.models.InvenTreeModel):
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations.
Attributes:
@ -454,7 +451,9 @@ class Address(models.Model):
)
class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
class ManufacturerPart(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
Attributes:
@ -555,7 +554,7 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
return s
class ManufacturerPartAttachment(InvenTreeAttachment):
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ManufacturerPart object."""
@staticmethod
@ -575,7 +574,7 @@ class ManufacturerPartAttachment(InvenTreeAttachment):
)
class ManufacturerPartParameter(models.Model):
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
This is used to represent parameters / properties for a particular manufacturer part.
@ -640,7 +639,12 @@ class SupplierPartManager(models.Manager):
)
class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin):
class SupplierPart(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
):
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
Attributes:
@ -895,6 +899,11 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
self.availability_updated = datetime.now()
self.save()
@property
def name(self):
"""Return string representation of own name."""
return str(self)
@property
def manufacturer_string(self):
"""Format a MPN string for this SupplierPart.

View File

@ -309,6 +309,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
'manufacturer_part',
'manufacturer_part_detail',
'MPN',
'name',
'note',
'pk',
'barcode_hash',
@ -395,6 +396,8 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
source='manufacturer_part', part_detail=False, read_only=True
)
name = serializers.CharField(read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True)
# Date fields

View File

@ -90,8 +90,8 @@ language: en-us
timezone: UTC
# Base URL for the InvenTree server
# Use the environment variable INVENTREE_BASE_URL
# base_url: 'http://localhost:8000'
# Use the environment variable INVENTREE_SITE_URL
# site_url: 'http://localhost:8000'
# Base currency code (or use env var INVENTREE_BASE_CURRENCY)
base_currency: USD
@ -171,13 +171,26 @@ auto_update: False
allowed_hosts:
- '*'
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/ottoyiu/django-cors-headers)
# Following parameters are
cors:
# CORS_ORIGIN_ALLOW_ALL - If True, the whitelist will not be used and all origins will be accepted.
allow_all: True
# Trusted origins (see CSRF_TRUSTED_ORIGINS in Django settings documentation)
# If you are running behind a proxy, you may need to add the proxy address here
trusted_origins:
- 'http://localhost:8000'
# Proxy forwarding settings
# If InvenTree is running behind a proxy, you may need to configure these settings
# Override with the environment variable INVENTREE_USE_X_FORWARDED_HOST
use_x_forwarded_host: false
# Override with the environment variable INVENTREE_USE_X_FORWARDED_PORT
use_x_forwarded_port: false
# Cross Origin Resource Sharing (CORS) settings (see https://github.com/adamchainz/django-cors-headers)
cors:
allow_all: True
allow_credentials: True,
# CORS_ORIGIN_WHITELIST - A list of origins that are authorized to make cross-site HTTP requests. Defaults to []
# whitelist:
# - https://example.com
# - https://sub.example.com

View File

@ -2,15 +2,24 @@
import inspect
from rest_framework import permissions
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from InvenTree.serializers import EmptySerializer
from .states import StatusCode
class StatusView(APIView):
class StatusViewSerializer(serializers.Serializer):
"""Serializer for the StatusView responses."""
class_name = serializers.CharField()
values = serializers.DictField()
class StatusView(GenericAPIView):
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
This class should be implemented as a subclass for each type of status.
@ -28,12 +37,19 @@ class StatusView(APIView):
status_model = self.kwargs.get(self.MODEL_REF, None)
if status_model is None:
raise ValidationError(
raise serializers.ValidationError(
f"StatusView view called without '{self.MODEL_REF}' parameter"
)
return status_model
@extend_schema(
description='Retrieve information about a specific status code',
responses={
200: OpenApiResponse(description='Status code information'),
400: OpenApiResponse(description='Invalid request'),
},
)
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
status_class = self.get_status_model()
@ -53,15 +69,22 @@ class AllStatusViews(StatusView):
"""Endpoint for listing all defined status models."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = EmptySerializer
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
data = {}
for status_class in StatusCode.__subclasses__():
data[status_class.__name__] = {
'class': status_class.__name__,
'values': status_class.dict(),
}
def discover_status_codes(parent_status_class, prefix=None):
"""Recursively discover status classes."""
for status_class in parent_status_class.__subclasses__():
name = '__'.join([*(prefix or []), status_class.__name__])
data[name] = {
'class': status_class.__name__,
'values': status_class.dict(),
}
discover_status_codes(status_class, [name])
discover_status_codes(StatusCode)
return Response(data)

View File

@ -4,6 +4,7 @@ from django.core.exceptions import FieldError, ValidationError
from django.http import JsonResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page, never_cache
from django_filters.rest_framework import DjangoFilterBackend
@ -13,6 +14,7 @@ from rest_framework.request import clone_request
import build.models
import common.models
import InvenTree.exceptions
import InvenTree.helpers
import label.models
import label.serializers
@ -232,9 +234,17 @@ class LabelPrintMixin(LabelFilterMixin):
# At this point, we offload the label(s) to the selected plugin.
# The plugin is responsible for handling the request and returning a response.
result = plugin.print_labels(
label, items_to_print, request, printing_options=request.data
)
try:
result = plugin.print_labels(
label,
items_to_print,
request,
printing_options=(serializer.data if serializer else {}),
)
except ValidationError as e:
raise (e)
except Exception as e:
raise ValidationError([_('Error printing label'), str(e)])
if isinstance(result, JsonResponse):
result['plugin'] = plugin.plugin_slug()

View File

@ -12,6 +12,8 @@ from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
import InvenTree.helpers
import InvenTree.ready
@ -32,13 +34,10 @@ class LabelConfig(AppConfig):
):
return
if InvenTree.ready.isRunningMigrations():
return
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
if (
InvenTree.ready.canAppAccessDatabase(allow_test=False)
and not InvenTree.ready.isImportingData()
):
with maintenance_mode_on():
try:
self.create_labels() # pragma: no cover
except (
@ -52,6 +51,8 @@ class LabelConfig(AppConfig):
'Database was not ready for creating labels', stacklevel=2
)
set_maintenance_mode(False)
def create_labels(self):
"""Create all default templates."""
# Test if models are ready

View File

@ -15,11 +15,11 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import build.models
import InvenTree.models
import part.models
import stock.models
from InvenTree.helpers import normalize, validateFilterString
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
from plugin.registry import registry
try:
@ -88,7 +88,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(MetadataMixin, models.Model):
class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
"""Base class for generic, filterable labels."""
class Meta:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

4
InvenTree/machine/__init__.py Executable file
View File

@ -0,0 +1,4 @@
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from machine.registry import registry
__all__ = ['registry', 'BaseMachineType', 'BaseDriver', 'MachineStatus']

48
InvenTree/machine/admin.py Executable file
View File

@ -0,0 +1,48 @@
"""Django admin interface for the machine app."""
from django.contrib import admin
from django.http.request import HttpRequest
from machine import models
class MachineSettingInline(admin.TabularInline):
"""Inline admin class for MachineSetting."""
model = models.MachineSetting
read_only_fields = ['key', 'config_type']
def has_add_permission(self, request, obj):
"""The machine settings should not be meddled with manually."""
return False
@admin.register(models.MachineConfig)
class MachineConfigAdmin(admin.ModelAdmin):
"""Custom admin with restricted id fields."""
list_filter = ['active']
list_display = [
'name',
'machine_type',
'driver',
'initialized',
'active',
'no_errors',
'get_machine_status',
]
readonly_fields = [
'initialized',
'is_driver_available',
'get_admin_errors',
'get_machine_status',
]
inlines = [MachineSettingInline]
def get_readonly_fields(self, request, obj):
"""If update, don't allow changes on machine_type and driver."""
if obj is not None:
return ['machine_type', 'driver', *self.readonly_fields]
return self.readonly_fields

251
InvenTree/machine/api.py Normal file
View File

@ -0,0 +1,251 @@
"""JSON API for the machine app."""
from django.urls import include, path, re_path
from drf_spectacular.utils import extend_schema
from rest_framework import permissions
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
import machine.serializers as MachineSerializers
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI
from machine import registry
from machine.models import MachineConfig, MachineSetting
class MachineList(ListCreateAPI):
"""API endpoint for list of Machine objects.
- GET: Return a list of all Machine objects
- POST: create a MachineConfig
"""
queryset = MachineConfig.objects.all()
serializer_class = MachineSerializers.MachineConfigSerializer
def get_serializer_class(self):
"""Allow driver, machine_type fields on creation."""
if self.request.method == 'POST':
return MachineSerializers.MachineConfigCreateSerializer
return super().get_serializer_class()
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['machine_type', 'driver', 'active']
ordering_fields = ['name', 'machine_type', 'driver', 'active']
ordering = ['-active', 'machine_type']
search_fields = ['name']
class MachineDetail(RetrieveUpdateDestroyAPI):
"""API detail endpoint for MachineConfig object.
- GET: return a single MachineConfig
- PUT: update a MachineConfig
- PATCH: partial update a MachineConfig
- DELETE: delete a MachineConfig
"""
queryset = MachineConfig.objects.all()
serializer_class = MachineSerializers.MachineConfigSerializer
def get_machine(machine_pk):
"""Get machine by pk.
Raises:
NotFound: If machine is not found
Returns:
BaseMachineType: The machine instance in the registry
"""
machine = registry.get_machine(machine_pk)
if machine is None:
raise NotFound(detail=f"Machine '{machine_pk}' not found")
return machine
class MachineSettingList(APIView):
"""List endpoint for all machine related settings.
- GET: return all settings for a machine config
"""
permission_classes = [permissions.IsAuthenticated]
@extend_schema(
responses={200: MachineSerializers.MachineSettingSerializer(many=True)}
)
def get(self, request, pk):
"""Return all settings for a machine config."""
machine = get_machine(pk)
all_settings = []
for settings, config_type in machine.setting_types:
settings_dict = MachineSetting.all_settings(
settings_definition=settings,
machine_config=machine.machine_config,
config_type=config_type,
)
all_settings.extend(list(settings_dict.values()))
results = MachineSerializers.MachineSettingSerializer(
all_settings, many=True
).data
return Response(results)
class MachineSettingDetail(RetrieveUpdateAPI):
"""Detail endpoint for a machine-specific setting.
- GET: Get machine setting detail
- PUT: Update machine setting
- PATCH: Update machine setting
(Note that these cannot be created or deleted via API)
"""
lookup_field = 'key'
queryset = MachineSetting.objects.all()
serializer_class = MachineSerializers.MachineSettingSerializer
def get_object(self):
"""Lookup machine setting object, based on the URL."""
pk = self.kwargs['pk']
key = self.kwargs['key']
config_type = MachineSetting.get_config_type(self.kwargs['config_type'])
machine = get_machine(pk)
setting_map = {d: s for s, d in machine.setting_types}
if key.upper() not in setting_map[config_type]:
raise NotFound(
detail=f"Machine '{machine.name}' has no {config_type.name} setting matching '{key.upper()}'"
)
return MachineSetting.get_setting_object(
key, machine_config=machine.machine_config, config_type=config_type
)
class MachineRestart(APIView):
"""Endpoint for performing a machine restart.
- POST: restart machine by pk
"""
permission_classes = [permissions.IsAuthenticated]
@extend_schema(responses={200: MachineSerializers.MachineRestartSerializer()})
def post(self, request, pk):
"""Restart machine by pk."""
machine = get_machine(pk)
registry.restart_machine(machine)
result = MachineSerializers.MachineRestartSerializer({'ok': True}).data
return Response(result)
class MachineTypesList(APIView):
"""List API Endpoint for all discovered machine types.
- GET: List all machine types
"""
permission_classes = [permissions.IsAuthenticated]
@extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)})
def get(self, request):
"""List all machine types."""
machine_types = list(registry.machine_types.values())
results = MachineSerializers.MachineTypeSerializer(
machine_types, many=True
).data
return Response(results)
class MachineDriverList(APIView):
"""List API Endpoint for all discovered machine drivers.
- GET: List all machine drivers
"""
permission_classes = [permissions.IsAuthenticated]
@extend_schema(
responses={200: MachineSerializers.MachineDriverSerializer(many=True)}
)
def get(self, request):
"""List all machine drivers."""
drivers = registry.drivers.values()
if machine_type := request.query_params.get('machine_type', None):
drivers = filter(lambda d: d.machine_type == machine_type, drivers)
results = MachineSerializers.MachineDriverSerializer(
list(drivers), many=True
).data
return Response(results)
class RegistryStatusView(APIView):
"""Status API endpoint for the machine registry.
- GET: Provide status data for the machine registry
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = MachineSerializers.MachineRegistryStatusSerializer
@extend_schema(
responses={200: MachineSerializers.MachineRegistryStatusSerializer()}
)
def get(self, request):
"""Provide status data for the machine registry."""
result = MachineSerializers.MachineRegistryStatusSerializer({
'registry_errors': [{'message': str(error)} for error in registry.errors]
}).data
return Response(result)
machine_api_urls = [
# machine types
path('types/', MachineTypesList.as_view(), name='api-machine-types'),
# machine drivers
path('drivers/', MachineDriverList.as_view(), name='api-machine-drivers'),
# registry status
path('status/', RegistryStatusView.as_view(), name='api-machine-registry-status'),
# detail views for a single Machine
path(
'<uuid:pk>/',
include([
# settings
path(
'settings/',
include([
re_path(
r'^(?P<config_type>M|D)/(?P<key>\w+)/',
MachineSettingDetail.as_view(),
name='api-machine-settings-detail',
),
path('', MachineSettingList.as_view(), name='api-machine-settings'),
]),
),
# restart
path('restart/', MachineRestart.as_view(), name='api-machine-restart'),
# detail
path('', MachineDetail.as_view(), name='api-machine-detail'),
]),
),
# machine list and create
path('', MachineList.as_view(), name='api-machine-list'),
]

43
InvenTree/machine/apps.py Executable file
View File

@ -0,0 +1,43 @@
"""Django machine app config."""
import logging
from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError
from InvenTree.ready import (
canAppAccessDatabase,
isImportingData,
isInMainThread,
isPluginRegistryLoaded,
isRunningMigrations,
)
logger = logging.getLogger('inventree')
class MachineConfig(AppConfig):
"""AppConfig class for the machine app."""
name = 'machine'
def ready(self) -> None:
"""Initialization method for the machine app."""
if (
not canAppAccessDatabase(allow_test=True)
or not isPluginRegistryLoaded()
or not isInMainThread()
or isRunningMigrations()
or isImportingData()
):
logger.debug('Machine app: Skipping machine loading sequence')
return
from machine import registry
try:
logger.info('Loading InvenTree machines')
registry.initialize()
except (OperationalError, ProgrammingError):
# Database might not yet be ready
logger.warn('Database was not ready for initializing machines')

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