mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into pr/ChristianSchindler/6305
This commit is contained in:
commit
65f6a51260
@ -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
|
||||
|
1
.github/workflows/docker.yaml
vendored
1
.github/workflows/docker.yaml
vendored
@ -46,6 +46,7 @@ jobs:
|
||||
- docker.dev.env
|
||||
- Dockerfile
|
||||
- requirements.txt
|
||||
- tasks.py
|
||||
|
||||
|
||||
# Build the docker image
|
||||
|
96
.github/workflows/qc_checks.yaml
vendored
96
.github/workflows/qc_checks.yaml
vendored
@ -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
5
.gitignore
vendored
@ -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
|
||||
|
@ -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
7
.vscode/launch.json
vendored
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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:
|
||||
|
12
Dockerfile
12
Dockerfile
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
85
InvenTree/InvenTree/backends.py
Normal file
85
InvenTree/InvenTree/backends.py
Normal 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')
|
@ -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']:
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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]
|
||||
|
106
InvenTree/InvenTree/helpers_mixin.py
Normal file
106
InvenTree/InvenTree/helpers_mixin.py
Normal 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
|
@ -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
|
||||
)
|
||||
|
@ -36,6 +36,7 @@ LOCALES = [
|
||||
('pt', _('Portuguese')),
|
||||
('pt-br', _('Portuguese (Brazilian)')),
|
||||
('ru', _('Russian')),
|
||||
('sk', _('Slovak')),
|
||||
('sl', _('Slovenian')),
|
||||
('sr', _('Serbian')),
|
||||
('sv', _('Swedish')),
|
||||
|
@ -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 = ()
|
||||
|
19
InvenTree/InvenTree/management/commands/check_migrations.py
Normal file
19
InvenTree/InvenTree/management/commands/check_migrations.py
Normal 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')
|
@ -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')
|
||||
|
19
InvenTree/InvenTree/management/commands/runmigrations.py
Normal file
19
InvenTree/InvenTree/management/commands/runmigrations.py
Normal 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')
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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).
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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/'
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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'\"')
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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())
|
||||
|
@ -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:
|
||||
|
@ -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')),
|
||||
]),
|
||||
),
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
]
|
||||
|
@ -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 = []
|
||||
|
@ -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.
|
||||
|
@ -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(
|
||||
|
@ -373,7 +373,11 @@ onPanelLoad('allocate', function() {
|
||||
loadBuildLineTable(
|
||||
"#build-lines-table",
|
||||
{{ build.pk }},
|
||||
{}
|
||||
{
|
||||
{% if build.project_code %}
|
||||
project_code: {{ build.project_code.pk }},
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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/',
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
13914
InvenTree/locale/sk/LC_MESSAGES/django.po
Normal file
13914
InvenTree/locale/sk/LC_MESSAGES/django.po
Normal file
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
4
InvenTree/machine/__init__.py
Executable 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
48
InvenTree/machine/admin.py
Executable 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
251
InvenTree/machine/api.py
Normal 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
43
InvenTree/machine/apps.py
Executable 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
Loading…
Reference in New Issue
Block a user