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 matmair/issue4184
This commit is contained in:
commit
5ed6bd5db6
71
.devops/testing_ci.yml
Normal file
71
.devops/testing_ci.yml
Normal file
@ -0,0 +1,71 @@
|
||||
# Python Django
|
||||
# Test a Django project on multiple versions of Python.
|
||||
# Add steps that analyze code, save build artifacts, deploy, and more:
|
||||
# https://docs.microsoft.com/azure/devops/pipelines/languages/python
|
||||
|
||||
trigger:
|
||||
- master
|
||||
|
||||
pool:
|
||||
vmImage: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
Python39:
|
||||
PYTHON_VERSION: '3.9'
|
||||
maxParallel: 3
|
||||
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: '$(PYTHON_VERSION)'
|
||||
architecture: 'x64'
|
||||
|
||||
- task: PythonScript@0
|
||||
displayName: 'Export project path'
|
||||
inputs:
|
||||
scriptSource: 'inline'
|
||||
script: |
|
||||
"""Search all subdirectories for `manage.py`."""
|
||||
from glob import iglob
|
||||
from os import path
|
||||
# Python >= 3.5
|
||||
manage_py = next(iglob(path.join('**', 'manage.py'), recursive=True), None)
|
||||
if not manage_py:
|
||||
raise SystemExit('Could not find a Django project')
|
||||
project_location = path.dirname(path.abspath(manage_py))
|
||||
print('Found Django project in', project_location)
|
||||
print('##vso[task.setvariable variable=projectRoot]{}'.format(project_location))
|
||||
|
||||
- script: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
pip install unittest-xml-reporting coverage invoke
|
||||
sudo apt-get install poppler-utils
|
||||
sudo apt-get install libpoppler-dev
|
||||
displayName: 'Install prerequisites'
|
||||
|
||||
- script: |
|
||||
pushd '$(projectRoot)'
|
||||
invoke update
|
||||
coverage run manage.py test --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner --no-input
|
||||
coverage xml -i
|
||||
displayName: 'Run tests'
|
||||
env:
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: inventree
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
INVENTREE_BACKUP_DIR: ./backup
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
- task: PublishTestResults@2
|
||||
inputs:
|
||||
testResultsFiles: "**/TEST-*.xml"
|
||||
testRunTitle: 'Python $(PYTHON_VERSION)'
|
||||
condition: succeededOrFailed()
|
||||
|
||||
- task: PublishCodeCoverageResults@1
|
||||
inputs:
|
||||
codeCoverageTool: Cobertura
|
||||
summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml'
|
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -6,7 +6,7 @@ body:
|
||||
id: no-duplicate-issues
|
||||
attributes:
|
||||
label: "Please verify that this bug has NOT been raised before."
|
||||
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=)"
|
||||
description: "Search in the issues sections by clicking [HERE](https://github.com/inventree/inventree/issues?q=) and read the [Frequently Asked Questions](https://docs.inventree.org/en/latest/faq/)!"
|
||||
options:
|
||||
- label: "I checked and didn't find a similar issue"
|
||||
required: true
|
||||
|
2
.github/actions/setup/action.yaml
vendored
2
.github/actions/setup/action.yaml
vendored
@ -48,7 +48,7 @@ runs:
|
||||
if: ${{ inputs.python == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m pip install -U pip
|
||||
python3 -m pip install pip==23.0.1
|
||||
pip3 install invoke wheel
|
||||
- name: Install Specific Python Dependencies
|
||||
if: ${{ inputs.pip-dependency }}
|
||||
|
14
.github/workflows/docker.yaml
vendored
14
.github/workflows/docker.yaml
vendored
@ -34,6 +34,8 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -95,6 +97,15 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Log into registry ghcr.io
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a # pin@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
if: github.event_name != 'pull_request'
|
||||
id: meta
|
||||
@ -102,6 +113,8 @@ jobs:
|
||||
with:
|
||||
images: |
|
||||
inventree/inventree
|
||||
ghcr.io/inventree/inventree
|
||||
|
||||
- name: Build and Push
|
||||
id: build-and-push
|
||||
if: github.event_name != 'pull_request'
|
||||
@ -115,6 +128,7 @@ jobs:
|
||||
build-args: |
|
||||
commit_hash=${{ env.git_commit_hash }}
|
||||
commit_date=${{ env.git_commit_date }}
|
||||
|
||||
- name: Sign the published image
|
||||
if: ${{ false }} # github.event_name != 'pull_request'
|
||||
env:
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -43,6 +43,7 @@ _tmp.csv
|
||||
inventree/label.pdf
|
||||
inventree/label.png
|
||||
inventree/my_special*
|
||||
_tests*.txt
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
@ -19,17 +19,16 @@ repos:
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [
|
||||
'flake8-bugbear',
|
||||
'flake8-docstrings',
|
||||
'flake8-string-format',
|
||||
'pep8-naming ',
|
||||
]
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: '5.11.4'
|
||||
rev: '5.12.0'
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/jazzband/pip-tools
|
||||
rev: 6.12.1
|
||||
rev: 6.12.3
|
||||
hooks:
|
||||
- id: pip-compile
|
||||
name: pip-compile requirements-dev.in
|
||||
|
@ -23,7 +23,7 @@ docker compose run inventree-dev-server invoke setup-test
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Read the [InvenTree setup documentation](https://inventree.readthedocs.io/en/latest/start/intro/) for a complete installation reference guide.
|
||||
Read the [InvenTree setup documentation](https://docs.inventree.org/en/latest/start/intro/) for a complete installation reference guide.
|
||||
|
||||
### Setup Devtools
|
||||
|
||||
|
20
Dockerfile
20
Dockerfile
@ -9,8 +9,7 @@
|
||||
# - Runs InvenTree web server under django development server
|
||||
# - Monitors source files for any changes, and live-reloads server
|
||||
|
||||
|
||||
FROM python:3.9-slim as base
|
||||
FROM python:3.9-slim as inventree_base
|
||||
|
||||
# Build arguments for this image
|
||||
ARG commit_hash=""
|
||||
@ -19,9 +18,6 @@ ARG commit_tag=""
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Ref: https://github.com/pyca/cryptography/issues/5776
|
||||
ENV CRYPTOGRAPHY_DONT_BUILD_RUST 1
|
||||
|
||||
ENV INVENTREE_LOG_LEVEL="WARNING"
|
||||
ENV INVENTREE_DOCKER="true"
|
||||
|
||||
@ -60,7 +56,7 @@ RUN apt-get update
|
||||
|
||||
# Install required system packages
|
||||
RUN apt-get install -y --no-install-recommends \
|
||||
git gcc g++ gettext gnupg libffi-dev \
|
||||
git gcc g++ gettext gnupg libffi-dev libssl-dev \
|
||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
||||
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
||||
# Image format support
|
||||
@ -76,6 +72,14 @@ RUN apt-get install -y --no-install-recommends \
|
||||
# Update pip
|
||||
RUN pip install --upgrade pip
|
||||
|
||||
# For ARMv7 architecture, add the pinwheels repo (for cryptography library)
|
||||
# Otherwise, we have to build from source, which is difficult
|
||||
# Ref: https://github.com/inventree/InvenTree/pull/4598
|
||||
RUN \
|
||||
if [ `dpkg --print-architecture` = "armhf" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
|
||||
# Install required base-level python packages
|
||||
COPY ./docker/requirements.txt base_requirements.txt
|
||||
RUN pip install --disable-pip-version-check -U -r base_requirements.txt
|
||||
@ -85,7 +89,7 @@ RUN pip install --disable-pip-version-check -U -r base_requirements.txt
|
||||
# - Installs required python packages from requirements.txt
|
||||
# - Starts a gunicorn webserver
|
||||
|
||||
FROM base as production
|
||||
FROM inventree_base as production
|
||||
|
||||
ENV INVENTREE_DEBUG=False
|
||||
|
||||
@ -121,7 +125,7 @@ ENTRYPOINT ["/bin/bash", "./init.sh"]
|
||||
# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
|
||||
CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
|
||||
|
||||
FROM base as dev
|
||||
FROM inventree_base as dev
|
||||
|
||||
# The development image requires the source code to be mounted to /home/inventree/
|
||||
# So from here, we don't actually "do" anything, apart from some file management
|
||||
|
@ -5,16 +5,20 @@ from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_q.models import OrmQ
|
||||
from rest_framework import filters, permissions
|
||||
from rest_framework import permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import users.models
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import ListCreateAPI
|
||||
from InvenTree.permissions import RolePermission
|
||||
from part.templatetags.inventree_extras import plugins_info
|
||||
from plugin.serializers import MetadataSerializer
|
||||
|
||||
from .mixins import RetrieveUpdateAPI
|
||||
from .status import is_worker_running
|
||||
from .version import (inventreeApiVersion, inventreeInstanceName,
|
||||
inventreeVersion)
|
||||
@ -197,14 +201,174 @@ class AttachmentMixin:
|
||||
RolePermission,
|
||||
]
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.OrderingFilter,
|
||||
filters.SearchFilter,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save the user information when a file is uploaded."""
|
||||
attachment = serializer.save()
|
||||
attachment.user = self.request.user
|
||||
attachment.save()
|
||||
|
||||
|
||||
class APISearchView(APIView):
|
||||
"""A general-purpose 'search' API endpoint
|
||||
|
||||
Returns hits against a number of different models simultaneously,
|
||||
to consolidate multiple API requests into a single query.
|
||||
|
||||
Is much more efficient and simplifies code!
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
def get_result_types(self):
|
||||
"""Construct a list of search types we can return"""
|
||||
|
||||
import build.api
|
||||
import company.api
|
||||
import order.api
|
||||
import part.api
|
||||
import stock.api
|
||||
|
||||
return {
|
||||
'build': build.api.BuildList,
|
||||
'company': company.api.CompanyList,
|
||||
'manufacturerpart': company.api.ManufacturerPartList,
|
||||
'supplierpart': company.api.SupplierPartList,
|
||||
'part': part.api.PartList,
|
||||
'partcategory': part.api.CategoryList,
|
||||
'purchaseorder': order.api.PurchaseOrderList,
|
||||
'returnorder': order.api.ReturnOrderList,
|
||||
'salesorder': order.api.SalesOrderList,
|
||||
'stockitem': stock.api.StockList,
|
||||
'stocklocation': stock.api.StockLocationList,
|
||||
}
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Perform search query against available models"""
|
||||
|
||||
data = request.data
|
||||
|
||||
results = {}
|
||||
|
||||
# These parameters are passed through to the individual queries, with optional default values
|
||||
pass_through_params = {
|
||||
'search': '',
|
||||
'search_regex': False,
|
||||
'search_whole': False,
|
||||
'limit': 1,
|
||||
'offset': 0,
|
||||
}
|
||||
|
||||
for key, cls in self.get_result_types().items():
|
||||
# Only return results which are specifically requested
|
||||
if key in data:
|
||||
|
||||
params = data[key]
|
||||
|
||||
for k, v in pass_through_params.items():
|
||||
params[k] = request.data.get(k, v)
|
||||
|
||||
# Enforce json encoding
|
||||
params['format'] = 'json'
|
||||
|
||||
# Ignore if the params are wrong
|
||||
if type(params) is not dict:
|
||||
continue
|
||||
|
||||
view = cls()
|
||||
|
||||
# Override regular query params with specific ones for this search request
|
||||
request._request.GET = params
|
||||
view.request = request
|
||||
view.format_kwarg = 'format'
|
||||
|
||||
# Check permissions and update results dict with particular query
|
||||
model = view.serializer_class.Meta.model
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
table = f'{app_label}_{model_name}'
|
||||
|
||||
try:
|
||||
if users.models.RuleSet.check_table_permission(request.user, table, 'view'):
|
||||
results[key] = view.list(request, *args, **kwargs).data
|
||||
else:
|
||||
results[key] = {
|
||||
'error': _('User does not have permission to view this model')
|
||||
}
|
||||
except Exception as exc:
|
||||
results[key] = {
|
||||
'error': str(exc)
|
||||
}
|
||||
|
||||
return Response(results)
|
||||
|
||||
|
||||
class StatusView(APIView):
|
||||
"""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.
|
||||
For example, the API endpoint /stock/status/ will have information about
|
||||
all available 'StockStatus' codes
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
# Override status_class for implementing subclass
|
||||
MODEL_REF = 'statusmodel'
|
||||
|
||||
def get_status_model(self, *args, **kwargs):
|
||||
"""Return the StatusCode moedl based on extra parameters passed to the view"""
|
||||
|
||||
status_model = self.kwargs.get(self.MODEL_REF, None)
|
||||
|
||||
if status_model is None:
|
||||
raise ValidationError(f"StatusView view called without '{self.MODEL_REF}' parameter")
|
||||
|
||||
return status_model
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes"""
|
||||
|
||||
status_class = self.get_status_model()
|
||||
|
||||
if not status_class:
|
||||
raise NotImplementedError("status_class not defined for this endpoint")
|
||||
|
||||
data = {
|
||||
'class': status_class.__name__,
|
||||
'values': status_class.dict(),
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
class MetadataView(RetrieveUpdateAPI):
|
||||
"""Generic API endpoint for reading and editing metadata for a model"""
|
||||
|
||||
MODEL_REF = 'model'
|
||||
|
||||
def get_model_type(self):
|
||||
"""Return the model type associated with this API instance"""
|
||||
model = self.kwargs.get(self.MODEL_REF, None)
|
||||
|
||||
if model is None:
|
||||
raise ValidationError(f"MetadataView called without '{self.MODEL_REF}' parameter")
|
||||
|
||||
return model
|
||||
|
||||
def get_permission_model(self):
|
||||
"""Return the 'permission' model associated with this view"""
|
||||
return self.get_model_type()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for this endpoint"""
|
||||
return self.get_model_type().objects.all()
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance"""
|
||||
return MetadataSerializer(self.get_model_type(), *args, **kwargs)
|
||||
|
@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http.response import StreamingHttpResponse
|
||||
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from plugin import registry
|
||||
@ -32,48 +33,69 @@ class UserMixin:
|
||||
# Set list of roles automatically associated with the user
|
||||
roles = []
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
super().setUp()
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Run setup for all tests in a given class"""
|
||||
super().setUpTestData()
|
||||
|
||||
# Create a user to log in with
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
email=self.email
|
||||
cls.user = get_user_model().objects.create_user(
|
||||
username=cls.username,
|
||||
password=cls.password,
|
||||
email=cls.email
|
||||
)
|
||||
|
||||
# Create a group for the user
|
||||
self.group = Group.objects.create(name='my_test_group')
|
||||
self.user.groups.add(self.group)
|
||||
cls.group = Group.objects.create(name='my_test_group')
|
||||
cls.user.groups.add(cls.group)
|
||||
|
||||
if self.superuser:
|
||||
self.user.is_superuser = True
|
||||
if cls.superuser:
|
||||
cls.user.is_superuser = True
|
||||
|
||||
if self.is_staff:
|
||||
self.user.is_staff = True
|
||||
if cls.is_staff:
|
||||
cls.user.is_staff = True
|
||||
|
||||
self.user.save()
|
||||
cls.user.save()
|
||||
|
||||
# Assign all roles if set
|
||||
if self.roles == 'all':
|
||||
self.assignRole(assign_all=True)
|
||||
if cls.roles == 'all':
|
||||
cls.assignRole(group=cls.group, assign_all=True)
|
||||
|
||||
# else filter the roles
|
||||
else:
|
||||
for role in self.roles:
|
||||
self.assignRole(role)
|
||||
for role in cls.roles:
|
||||
cls.assignRole(role=role, group=cls.group)
|
||||
|
||||
def setUp(self):
|
||||
"""Run setup for individual test methods"""
|
||||
|
||||
if self.auto_login:
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
|
||||
def assignRole(self, role=None, assign_all: bool = False):
|
||||
"""Set the user roles for the registered user."""
|
||||
# role is of the format 'rule.permission' e.g. 'part.add'
|
||||
@classmethod
|
||||
def assignRole(cls, role=None, assign_all: bool = False, group=None):
|
||||
"""Set the user roles for the registered user.
|
||||
|
||||
Arguments:
|
||||
role: Role of the format 'rule.permission' e.g. 'part.add'
|
||||
assign_all: Set to True to assign *all* roles
|
||||
group: The group to assign roles to (or leave None to use the group assigned to this class)
|
||||
"""
|
||||
|
||||
if group is None:
|
||||
group = cls.group
|
||||
|
||||
if type(assign_all) is not bool:
|
||||
# Raise exception if common mistake is made!
|
||||
raise TypeError('assignRole: assign_all must be a boolean value')
|
||||
|
||||
if not role and not assign_all:
|
||||
raise ValueError('assignRole: either role must be provided, or assign_all must be set')
|
||||
|
||||
if not assign_all and role:
|
||||
rule, perm = role.split('.')
|
||||
|
||||
for ruleset in self.group.rule_sets.all():
|
||||
for ruleset in group.rule_sets.all():
|
||||
|
||||
if assign_all or ruleset.name == rule:
|
||||
|
||||
@ -105,9 +127,64 @@ class PluginMixin:
|
||||
self.plugin_confs = PluginConfig.objects.all()
|
||||
|
||||
|
||||
class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
class ExchangeRateMixin:
|
||||
"""Mixin class for generating exchange rate data"""
|
||||
|
||||
def generate_exchange_rates(self):
|
||||
"""Helper function which generates some exchange rates to work with"""
|
||||
|
||||
rates = {
|
||||
'AUD': 1.5,
|
||||
'CAD': 1.7,
|
||||
'GBP': 0.9,
|
||||
'USD': 1.0,
|
||||
}
|
||||
|
||||
# Create a dummy backend
|
||||
ExchangeBackend.objects.create(
|
||||
name='InvenTreeExchange',
|
||||
base_currency='USD',
|
||||
)
|
||||
|
||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||
|
||||
items = []
|
||||
|
||||
for currency, rate in rates.items():
|
||||
items.append(
|
||||
Rate(
|
||||
currency=currency,
|
||||
value=rate,
|
||||
backend=backend,
|
||||
)
|
||||
)
|
||||
|
||||
Rate.objects.bulk_create(items)
|
||||
|
||||
|
||||
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Base class for running InvenTree API tests."""
|
||||
|
||||
def checkResponse(self, url, method, expected_code, response):
|
||||
"""Debug output for an unexpected response"""
|
||||
|
||||
# No expected code, return
|
||||
if expected_code is None:
|
||||
return
|
||||
|
||||
if expected_code != response.status_code:
|
||||
|
||||
print(f"Unexpected {method} response at '{url}': status_code = {response.status_code}")
|
||||
|
||||
if hasattr(response, 'data'):
|
||||
print('data:', response.data)
|
||||
if hasattr(response, 'body'):
|
||||
print('body:', response.body)
|
||||
if hasattr(response, 'content'):
|
||||
print('content:', response.content)
|
||||
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
|
||||
def getActions(self, url):
|
||||
"""Return a dict of the 'actions' available at a given endpoint.
|
||||
|
||||
@ -131,13 +208,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
|
||||
response = self.client.get(url, data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
|
||||
if response.status_code != expected_code:
|
||||
print(f"Unexpected response at '{url}': status_code = {response.status_code}")
|
||||
print(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'GET', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -150,17 +221,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
|
||||
response = self.client.post(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
|
||||
if response.status_code != expected_code:
|
||||
print(f"Unexpected response at '{url}': status code = {response.status_code}")
|
||||
|
||||
if hasattr(response, 'data'):
|
||||
print(response.data)
|
||||
else:
|
||||
print(f"(response object {type(response)} has no 'data' attribute")
|
||||
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'POST', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -172,8 +233,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
|
||||
response = self.client.delete(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'DELETE', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -181,8 +241,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
"""Issue a PATCH request."""
|
||||
response = self.client.patch(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'PATCH', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -190,13 +249,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
"""Issue a PUT request."""
|
||||
response = self.client.put(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
|
||||
if response.status_code != expected_code:
|
||||
print(f"Unexpected response at '{url}':")
|
||||
print(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'PUT', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -204,8 +257,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
||||
"""Issue an OPTIONS request."""
|
||||
response = self.client.options(url, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'OPTIONS', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
|
@ -2,11 +2,55 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 94
|
||||
INVENTREE_API_VERSION = 107
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v107 -> 2023-04-04 : https://github.com/inventree/InvenTree/pull/4575
|
||||
- Adds barcode support for PurchaseOrder model
|
||||
- Adds barcode support for ReturnOrder model
|
||||
- Adds barcode support for SalesOrder model
|
||||
- Adds barcode support for BuildOrder model
|
||||
|
||||
v106 -> 2023-04-03 : https://github.com/inventree/InvenTree/pull/4566
|
||||
- Adds 'search_regex' parameter to all searchable API endpoints
|
||||
|
||||
v105 -> 2023-03-31 : https://github.com/inventree/InvenTree/pull/4543
|
||||
- Adds API endpoints for status label information on various models
|
||||
|
||||
v104 -> 2023-03-23 : https://github.com/inventree/InvenTree/pull/4488
|
||||
- Adds various endpoints for new "ReturnOrder" models
|
||||
- Adds various endpoints for new "ReturnOrderReport" templates
|
||||
- Exposes API endpoints for "Contact" model
|
||||
|
||||
v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410
|
||||
- Add metadata to several more models
|
||||
|
||||
v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505
|
||||
- Adds global search API endpoint for consolidated search results
|
||||
|
||||
v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462
|
||||
- Adds 'total_in_stock' to Part serializer, and supports API ordering
|
||||
|
||||
v100 -> 2023-03-04 : https://github.com/inventree/InvenTree/pull/4452
|
||||
- Adds bulk delete of PurchaseOrderLineItems to API
|
||||
|
||||
v99 -> 2023-03-03 : https://github.com/inventree/InvenTree/pull/4445
|
||||
- Adds sort by "responsible" to PurchaseOrderAPI
|
||||
|
||||
v98 -> 2023-02-24 : https://github.com/inventree/InvenTree/pull/4408
|
||||
- Adds "responsible" filter to Build API
|
||||
|
||||
v97 -> 2023-02-20 : https://github.com/inventree/InvenTree/pull/4377
|
||||
- Adds "external" attribute to StockLocation model
|
||||
|
||||
v96 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4345
|
||||
- Adds stocktake report generation functionality
|
||||
|
||||
v95 -> 2023-02-16 : https://github.com/inventree/InvenTree/pull/4346
|
||||
- Adds "CompanyAttachment" model (and associated API endpoints)
|
||||
|
||||
v94 -> 2023-02-10 : https://github.com/inventree/InvenTree/pull/4327
|
||||
- Adds API endpoints for the "Group" auth model
|
||||
|
||||
|
@ -12,10 +12,9 @@ from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
import InvenTree.tasks
|
||||
from InvenTree.config import get_setting
|
||||
from InvenTree.ready import canAppAccessDatabase, isInTestMode
|
||||
|
||||
from .config import get_setting
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
@ -24,8 +23,18 @@ class InvenTreeConfig(AppConfig):
|
||||
name = 'InvenTree'
|
||||
|
||||
def ready(self):
|
||||
"""Setup background tasks and update exchange rates."""
|
||||
"""Run system wide setup init steps.
|
||||
|
||||
Like:
|
||||
- Checking if migrations should be run
|
||||
- Cleaning up tasks
|
||||
- Starting regular tasks
|
||||
- Updateing exchange rates
|
||||
- Collecting notification mehods
|
||||
- Adding users set in the current environment
|
||||
"""
|
||||
if canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
InvenTree.tasks.check_for_migrations(worker=False)
|
||||
|
||||
self.remove_obsolete_tasks()
|
||||
|
||||
@ -60,7 +69,10 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
logger.info("Starting background tasks...")
|
||||
|
||||
for task in InvenTree.tasks.tasks.task_list:
|
||||
# List of collected tasks found with the @scheduled_task decorator
|
||||
tasks = InvenTree.tasks.tasks.task_list
|
||||
|
||||
for task in tasks:
|
||||
ref_name = f'{task.func.__module__}.{task.func.__name__}'
|
||||
InvenTree.tasks.schedule_task(
|
||||
ref_name,
|
||||
@ -75,7 +87,7 @@ class InvenTreeConfig(AppConfig):
|
||||
force_async=True,
|
||||
)
|
||||
|
||||
logger.info("Started background tasks...")
|
||||
logger.info(f"Started {len(tasks)} scheduled background tasks...")
|
||||
|
||||
def collect_tasks(self):
|
||||
"""Collect all background tasks."""
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Helper functions for loading InvenTree configuration options."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
@ -13,6 +14,47 @@ CONFIG_DATA = None
|
||||
CONFIG_LOOKUPS = {}
|
||||
|
||||
|
||||
def to_list(value, delimiter=','):
|
||||
"""Take a configuration setting and make sure it is a list.
|
||||
|
||||
For example, we might have a configuration setting taken from the .config file,
|
||||
which is already a list.
|
||||
|
||||
However, the same setting may be specified via an environment variable,
|
||||
using a comma delimited string!
|
||||
"""
|
||||
|
||||
if type(value) in [list, tuple]:
|
||||
return value
|
||||
|
||||
# Otherwise, force string value
|
||||
value = str(value)
|
||||
|
||||
return [x.strip() for x in value.split(delimiter)]
|
||||
|
||||
|
||||
def to_dict(value):
|
||||
"""Take a configuration setting and make sure it is a dict.
|
||||
|
||||
For example, we might have a configuration setting taken from the .config file,
|
||||
which is already an object/dict.
|
||||
|
||||
However, the same setting may be specified via an environment variable,
|
||||
using a valid JSON string!
|
||||
"""
|
||||
if value is None:
|
||||
return {}
|
||||
|
||||
if type(value) == dict:
|
||||
return value
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to parse value '{value}' as JSON with error {error}. Ensure value is a valid JSON string.")
|
||||
return {}
|
||||
|
||||
|
||||
def is_true(x):
|
||||
"""Shortcut function to determine if a value "looks" like a boolean"""
|
||||
return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on']
|
||||
@ -101,7 +143,16 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
|
||||
"""
|
||||
def try_typecasting(value, source: str):
|
||||
"""Attempt to typecast the value"""
|
||||
if typecast is not None:
|
||||
|
||||
# Force 'list' of strings
|
||||
if typecast is list:
|
||||
value = to_list(value)
|
||||
|
||||
# Valid JSON string is required
|
||||
elif typecast is dict:
|
||||
value = to_dict(value)
|
||||
|
||||
elif typecast is not None:
|
||||
# Try to typecast the value
|
||||
try:
|
||||
val = typecast(value)
|
||||
@ -109,6 +160,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
|
||||
return val
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}")
|
||||
|
||||
set_metadata(source)
|
||||
return value
|
||||
|
||||
|
@ -4,6 +4,7 @@
|
||||
|
||||
import InvenTree.status
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
ReturnOrderLineStatus, ReturnOrderStatus,
|
||||
SalesOrderStatus, StockHistoryCode,
|
||||
StockStatus)
|
||||
from users.models import RuleSet, check_user_role
|
||||
@ -58,6 +59,8 @@ def status_codes(request):
|
||||
|
||||
return {
|
||||
# Expose the StatusCode classes to the templates
|
||||
'ReturnOrderStatus': ReturnOrderStatus,
|
||||
'ReturnOrderLineStatus': ReturnOrderLineStatus,
|
||||
'SalesOrderStatus': SalesOrderStatus,
|
||||
'PurchaseOrderStatus': PurchaseOrderStatus,
|
||||
'BuildStatus': BuildStatus,
|
||||
|
@ -55,10 +55,25 @@ def exception_handler(exc, context):
|
||||
"""Custom exception handler for DRF framework.
|
||||
|
||||
Ref: https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling
|
||||
Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle
|
||||
Catches any errors not natively handled by DRF, and re-throws as an error DRF can handle.
|
||||
|
||||
If sentry error reporting is enabled, we will also provide the original exception to sentry.io
|
||||
"""
|
||||
response = None
|
||||
|
||||
if settings.SENTRY_ENABLED and settings.SENTRY_DSN and not settings.DEBUG:
|
||||
# Report this exception to sentry.io
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# The following types of errors are ignored, they are "expected"
|
||||
do_not_report = [
|
||||
DjangoValidationError,
|
||||
DRFValidationError,
|
||||
]
|
||||
|
||||
if not any([isinstance(exc, err) for err in do_not_report]):
|
||||
capture_exception(exc)
|
||||
|
||||
# Catch any django validation error, and re-throw a DRF validation error
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
exc = DRFValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
@ -1,9 +1,66 @@
|
||||
"""General filters for InvenTree."""
|
||||
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from rest_framework import filters
|
||||
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
|
||||
class InvenTreeOrderingFilter(OrderingFilter):
|
||||
class InvenTreeSearchFilter(filters.SearchFilter):
|
||||
"""Custom search filter which allows adjusting of search terms dynamically"""
|
||||
|
||||
def get_search_fields(self, view, request):
|
||||
"""Return a set of search fields for the request, adjusted based on request params.
|
||||
|
||||
The following query params are available to 'augment' the search (in decreasing order of priority)
|
||||
- search_regex: If True, search is perfomed on 'regex' comparison
|
||||
"""
|
||||
|
||||
regex = str2bool(request.query_params.get('search_regex', False))
|
||||
|
||||
search_fields = super().get_search_fields(view, request)
|
||||
|
||||
fields = []
|
||||
|
||||
if search_fields:
|
||||
for field in search_fields:
|
||||
if regex:
|
||||
field = '$' + field
|
||||
|
||||
fields.append(field)
|
||||
|
||||
return fields
|
||||
|
||||
def get_search_terms(self, request):
|
||||
"""Return the search terms for this search request.
|
||||
|
||||
Depending on the request parameters, we may "augment" these somewhat
|
||||
"""
|
||||
|
||||
whole = str2bool(request.query_params.get('search_whole', False))
|
||||
|
||||
terms = []
|
||||
|
||||
search_terms = super().get_search_terms(request)
|
||||
|
||||
if search_terms:
|
||||
for term in search_terms:
|
||||
term = term.strip()
|
||||
|
||||
if not term:
|
||||
# Ignore blank inputs
|
||||
continue
|
||||
|
||||
if whole:
|
||||
# Wrap the search term to enable word-boundary matching
|
||||
term = r"\y" + term + r"\y"
|
||||
|
||||
terms.append(term)
|
||||
|
||||
return terms
|
||||
|
||||
|
||||
class InvenTreeOrderingFilter(filters.OrderingFilter):
|
||||
"""Custom OrderingFilter class which allows aliased filtering of related fields.
|
||||
|
||||
To use, simply specify this filter in the "filter_backends" section.
|
||||
@ -74,3 +131,21 @@ class InvenTreeOrderingFilter(OrderingFilter):
|
||||
ordering.append(a)
|
||||
|
||||
return ordering
|
||||
|
||||
|
||||
SEARCH_ORDER_FILTER = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
InvenTreeSearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
SEARCH_ORDER_FILTER_ALIAS = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
InvenTreeSearchFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
ORDER_FILTER = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
@ -126,6 +126,16 @@ class EditUserForm(HelperForm):
|
||||
class SetPasswordForm(HelperForm):
|
||||
"""Form for setting user password."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = User
|
||||
fields = [
|
||||
'enter_password',
|
||||
'confirm_password',
|
||||
'old_password',
|
||||
]
|
||||
|
||||
enter_password = forms.CharField(
|
||||
max_length=100,
|
||||
min_length=8,
|
||||
@ -152,16 +162,6 @@ class SetPasswordForm(HelperForm):
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = User
|
||||
fields = [
|
||||
'enter_password',
|
||||
'confirm_password',
|
||||
'old_password',
|
||||
]
|
||||
|
||||
|
||||
# override allauth
|
||||
class CustomSignupForm(SignupForm):
|
||||
|
@ -21,9 +21,11 @@ from django.http import StreamingHttpResponse
|
||||
from django.test import TestCase
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import moneyed.localization
|
||||
import regex
|
||||
import requests
|
||||
from bleach import clean
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
@ -33,7 +35,7 @@ from common.notifications import (InvenTreeNotificationBodies,
|
||||
NotificationBody, trigger_notification)
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from .api_tester import UserMixin
|
||||
from .api_tester import ExchangeRateMixin, UserMixin
|
||||
from .settings import MEDIA_URL, STATIC_URL
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -523,6 +525,7 @@ def DownloadFile(data, filename, content_type='application/text', inline=False)
|
||||
A StreamingHttpResponse object wrapping the supplied data
|
||||
"""
|
||||
filename = WrapWithQuotes(filename)
|
||||
length = len(data)
|
||||
|
||||
if type(data) == str:
|
||||
wrapper = FileWrapper(io.StringIO(data))
|
||||
@ -530,7 +533,9 @@ def DownloadFile(data, filename, content_type='application/text', inline=False)
|
||||
wrapper = FileWrapper(io.BytesIO(data))
|
||||
|
||||
response = StreamingHttpResponse(wrapper, content_type=content_type)
|
||||
response['Content-Length'] = len(data)
|
||||
if type(data) == str:
|
||||
length = len(bytes(data, response.charset))
|
||||
response['Content-Length'] = length
|
||||
|
||||
disposition = "inline" if inline else "attachment"
|
||||
|
||||
@ -1059,7 +1064,7 @@ def inheritors(cls):
|
||||
return subcls
|
||||
|
||||
|
||||
class InvenTreeTestCase(UserMixin, TestCase):
|
||||
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
|
||||
"""Testcase with user setup buildin."""
|
||||
pass
|
||||
|
||||
@ -1105,3 +1110,54 @@ def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNo
|
||||
target_exclude=[exclude],
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
def render_currency(money, decimal_places=None, currency=None, include_symbol=True, min_decimal_places=None):
|
||||
"""Render a currency / Money object to a formatted string (e.g. for reports)
|
||||
|
||||
Arguments:
|
||||
money: The Money instance to be rendered
|
||||
decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
currency: Optionally convert to the specified currency
|
||||
include_symbol: Render with the appropriate currency symbol
|
||||
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
|
||||
"""
|
||||
|
||||
if money in [None, '']:
|
||||
return '-'
|
||||
|
||||
if type(money) is not Money:
|
||||
return '-'
|
||||
|
||||
if currency is not None:
|
||||
# Attempt to convert to the provided currency
|
||||
# If cannot be done, leave the original
|
||||
try:
|
||||
money = convert_money(money, currency)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if decimal_places is None:
|
||||
decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
|
||||
|
||||
if min_decimal_places is None:
|
||||
min_decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0)
|
||||
|
||||
value = Decimal(str(money.amount)).normalize()
|
||||
value = str(value)
|
||||
|
||||
if '.' in value:
|
||||
decimals = len(value.split('.')[-1])
|
||||
|
||||
decimals = max(decimals, min_decimal_places)
|
||||
decimals = min(decimals, decimal_places)
|
||||
|
||||
decimal_places = decimals
|
||||
else:
|
||||
decimal_places = max(decimal_places, 2)
|
||||
|
||||
return moneyed.localization.format_money(
|
||||
money,
|
||||
decimal_places=decimal_places,
|
||||
include_symbol=include_symbol,
|
||||
)
|
||||
|
@ -12,8 +12,15 @@ from django.utils.translation import override as lang_over
|
||||
|
||||
def render_file(file_name, source, target, locales, ctx):
|
||||
"""Renders a file into all provided locales."""
|
||||
|
||||
for locale in locales:
|
||||
|
||||
# Enforce lower-case for locale names
|
||||
locale = locale.lower()
|
||||
locale = locale.replace('_', '-')
|
||||
|
||||
target_file = os.path.join(target, locale + '.' + file_name)
|
||||
|
||||
with open(target_file, 'w') as localised_file:
|
||||
with lang_over(locale):
|
||||
renderd = render_to_string(os.path.join(source, file_name), ctx)
|
||||
|
@ -7,6 +7,7 @@ from rest_framework.fields import empty
|
||||
from rest_framework.metadata import SimpleMetadata
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
import InvenTree.permissions
|
||||
import users.models
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
@ -58,7 +59,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
try:
|
||||
# Extract the model name associated with the view
|
||||
self.model = view.serializer_class.Meta.model
|
||||
self.model = InvenTree.permissions.get_model_for_view(view)
|
||||
|
||||
# Construct the 'table name' from the model
|
||||
app_label = self.model._meta.app_label
|
||||
|
12
InvenTree/InvenTree/migrations/0001_initial.py
Normal file
12
InvenTree/InvenTree/migrations/0001_initial.py
Normal file
@ -0,0 +1,12 @@
|
||||
# Generated by Django 3.2.15 on 2022-10-03 18:57
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
0
InvenTree/InvenTree/migrations/__init__.py
Normal file
0
InvenTree/InvenTree/migrations/__init__.py
Normal file
@ -116,6 +116,11 @@ class ReferenceIndexingMixin(models.Model):
|
||||
# Name of the global setting which defines the required reference pattern for this model
|
||||
REFERENCE_PATTERN_SETTING = None
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options. Abstract ensures no database table is created."""
|
||||
|
||||
abstract = True
|
||||
|
||||
@classmethod
|
||||
def get_reference_pattern(cls):
|
||||
"""Returns the reference pattern associated with this model.
|
||||
@ -272,11 +277,6 @@ class ReferenceIndexingMixin(models.Model):
|
||||
# Check that the reference field can be rebuild
|
||||
cls.rebuild_reference_field(value, validate=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options. Abstract ensures no database table is created."""
|
||||
|
||||
abstract = True
|
||||
|
||||
@classmethod
|
||||
def rebuild_reference_field(cls, reference, validate=False):
|
||||
"""Extract integer out of reference for sorting.
|
||||
@ -369,6 +369,10 @@ class InvenTreeAttachment(models.Model):
|
||||
upload_date: Date the file was uploaded
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options. Abstract ensures no database table is created."""
|
||||
abstract = True
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory under which attachments should be stored.
|
||||
|
||||
@ -483,11 +487,6 @@ class InvenTreeAttachment(models.Model):
|
||||
except Exception:
|
||||
raise ValidationError(_("Error renaming file"))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options. Abstract ensures no database table is created."""
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
class InvenTreeTree(MPTTModel):
|
||||
"""Provides an abstracted self-referencing tree model for data categories.
|
||||
@ -501,6 +500,33 @@ class InvenTreeTree(MPTTModel):
|
||||
parent: The item immediately above this one. An item with a null parent is a top-level item
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties."""
|
||||
abstract = True
|
||||
|
||||
class MPTTMeta:
|
||||
"""Set insert order."""
|
||||
order_insertion_by = ['name']
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""Validate that this tree instance satisfies our uniqueness requirements.
|
||||
|
||||
Note that a 'unique_together' requirement for ('name', 'parent') is insufficient,
|
||||
as it ignores cases where parent=None (i.e. top-level items)
|
||||
"""
|
||||
|
||||
super().validate_unique(exclude)
|
||||
|
||||
results = self.__class__.objects.filter(
|
||||
name=self.name,
|
||||
parent=self.parent
|
||||
).exclude(pk=self.pk)
|
||||
|
||||
if results.exists():
|
||||
raise ValidationError({
|
||||
'name': _('Duplicate names cannot exist under the same parent')
|
||||
})
|
||||
|
||||
def api_instance_filters(self):
|
||||
"""Instance filters for InvenTreeTree models."""
|
||||
return {
|
||||
@ -539,18 +565,6 @@ class InvenTreeTree(MPTTModel):
|
||||
for child in self.get_children():
|
||||
child.save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties."""
|
||||
|
||||
abstract = True
|
||||
|
||||
# Names must be unique at any given level in the tree
|
||||
unique_together = ('name', 'parent')
|
||||
|
||||
class MPTTMeta:
|
||||
"""Set insert order."""
|
||||
order_insertion_by = ['name']
|
||||
|
||||
name = models.CharField(
|
||||
blank=False,
|
||||
max_length=100,
|
||||
@ -717,7 +731,7 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
|
||||
return cls.objects.filter(barcode_hash=barcode_hash).first()
|
||||
|
||||
def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True):
|
||||
def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True, save=True):
|
||||
"""Assign an external (third-party) barcode to this object."""
|
||||
|
||||
# Must provide either barcode_hash or barcode_data
|
||||
@ -740,6 +754,7 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
|
||||
self.barcode_hash = barcode_hash
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
return True
|
||||
|
@ -7,6 +7,21 @@ from rest_framework import permissions
|
||||
import users.models
|
||||
|
||||
|
||||
def get_model_for_view(view, raise_error=True):
|
||||
"""Attempt to introspect the 'model' type for an API view"""
|
||||
|
||||
if hasattr(view, 'get_permission_model'):
|
||||
return view.get_permission_model()
|
||||
|
||||
if hasattr(view, 'serializer_class'):
|
||||
return view.serializer_class.Meta.model
|
||||
|
||||
if hasattr(view, 'get_serializer_class'):
|
||||
return view.get_serializr_class().Meta.model
|
||||
|
||||
raise AttributeError(f"Serializer class not specified for {view.__class__}")
|
||||
|
||||
|
||||
class RolePermission(permissions.BasePermission):
|
||||
"""Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations.
|
||||
|
||||
@ -49,9 +64,13 @@ class RolePermission(permissions.BasePermission):
|
||||
|
||||
permission = rolemap[request.method]
|
||||
|
||||
# The required role may be defined for the view class
|
||||
if role := getattr(view, 'role_required', None):
|
||||
return users.models.check_user_role(user, role, permission)
|
||||
|
||||
try:
|
||||
# Extract the model name associated with this request
|
||||
model = view.serializer_class.Meta.model
|
||||
model = get_model_for_view(view)
|
||||
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
@ -62,9 +81,7 @@ class RolePermission(permissions.BasePermission):
|
||||
# then we don't need a permission
|
||||
return True
|
||||
|
||||
result = users.models.RuleSet.check_table_permission(user, table, permission)
|
||||
|
||||
return result
|
||||
return users.models.RuleSet.check_table_permission(user, table, permission)
|
||||
|
||||
|
||||
class IsSuperuser(permissions.IsAdminUser):
|
||||
|
@ -13,7 +13,7 @@ def isImportingData():
|
||||
return 'loaddata' in sys.argv
|
||||
|
||||
|
||||
def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
|
||||
def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False):
|
||||
"""Returns True if the apps.py file can access database records.
|
||||
|
||||
There are some circumstances where we don't want the ready function in apps.py
|
||||
@ -26,7 +26,6 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
|
||||
'loaddata',
|
||||
'dumpdata',
|
||||
'check',
|
||||
'shell',
|
||||
'createsuperuser',
|
||||
'wait_for_db',
|
||||
'prerender',
|
||||
@ -42,6 +41,9 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
|
||||
'mediarestore',
|
||||
]
|
||||
|
||||
if not allow_shell:
|
||||
excluded_commands.append('shell')
|
||||
|
||||
if not allow_test:
|
||||
# Override for testing mode?
|
||||
excluded_commands.append('test')
|
||||
|
@ -21,6 +21,7 @@ from rest_framework.serializers import DecimalField
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
|
||||
from InvenTree.helpers import download_image_from_url
|
||||
|
||||
@ -66,6 +67,26 @@ class InvenTreeMoneySerializer(MoneyField):
|
||||
return amount
|
||||
|
||||
|
||||
class InvenTreeCurrencySerializer(serializers.ChoiceField):
|
||||
"""Custom serializers for selecting currency option"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the currency serializer"""
|
||||
|
||||
kwargs['choices'] = currency_code_mappings()
|
||||
|
||||
if 'default' not in kwargs and 'required' not in kwargs:
|
||||
kwargs['default'] = currency_code_default
|
||||
|
||||
if 'label' not in kwargs:
|
||||
kwargs['label'] = _('Currency')
|
||||
|
||||
if 'help_text' not in kwargs:
|
||||
kwargs['help_text'] = _('Select currency from available options')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
|
||||
|
||||
@ -282,6 +303,25 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||
The only real addition here is that we support "renaming" of the attachment file.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def attachment_fields(extra_fields=None):
|
||||
"""Default set of fields for an attachment serializer"""
|
||||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
if extra_fields:
|
||||
fields += extra_fields
|
||||
|
||||
return fields
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
attachment = InvenTreeAttachmentSerializerField(
|
||||
@ -297,6 +337,8 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||
allow_blank=False,
|
||||
)
|
||||
|
||||
upload_date = serializers.DateField(read_only=True)
|
||||
|
||||
|
||||
class InvenTreeImageSerializerField(serializers.ImageField):
|
||||
"""Custom image serializer.
|
||||
|
@ -26,15 +26,25 @@ from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from . import config
|
||||
from .config import get_boolean_setting, get_custom_file, get_setting
|
||||
from .version import inventreeApiVersion
|
||||
|
||||
INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
|
||||
|
||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||
TESTING = 'test' in sys.argv
|
||||
|
||||
if TESTING:
|
||||
|
||||
# Use a weaker password hasher for testing (improves testing speed)
|
||||
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher',]
|
||||
|
||||
# Enable slow-test-runner
|
||||
TEST_RUNNER = 'django_slowtests.testrunner.DiscoverSlowestTestsRunner'
|
||||
NUM_SLOW_TESTS = 25
|
||||
|
||||
# Note: The following fix is "required" for docker build workflow
|
||||
# Note: 2022-12-12 still unsure why...
|
||||
if TESTING and os.getenv('INVENTREE_DOCKER'):
|
||||
if os.getenv('INVENTREE_DOCKER'):
|
||||
# Ensure that sys.path includes global python libs
|
||||
site_packages = '/usr/local/lib/python3.9/site-packages'
|
||||
|
||||
@ -102,8 +112,10 @@ 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=['*']
|
||||
default_value=['*'],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# Cross Origin Resource Sharing (CORS) options
|
||||
@ -113,13 +125,16 @@ CORS_URLS_REGEX = r'^/api/.*$'
|
||||
|
||||
# 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=[]
|
||||
default_value=[],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# Needed for the parts importer, directly impacts the maximum parts that can be uploaded
|
||||
@ -219,6 +234,7 @@ INSTALLED_APPS = [
|
||||
'django_otp.plugins.otp_static', # Backup codes
|
||||
|
||||
'allauth_2fa', # MFA flow for allauth
|
||||
'drf_spectacular', # API documentation
|
||||
|
||||
'django_ical', # For exporting calendars
|
||||
]
|
||||
@ -342,7 +358,7 @@ REST_FRAMEWORK = {
|
||||
'rest_framework.permissions.DjangoModelPermissions',
|
||||
'InvenTree.permissions.RolePermission',
|
||||
),
|
||||
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
@ -353,6 +369,15 @@ if DEBUG:
|
||||
# Enable browsable API if in DEBUG mode
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
|
||||
|
||||
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'},
|
||||
'VERSION': inventreeApiVersion(),
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
}
|
||||
|
||||
WSGI_APPLICATION = 'InvenTree.wsgi.application'
|
||||
|
||||
"""
|
||||
@ -554,6 +579,9 @@ SENTRY_DSN = get_setting('INVENTREE_SENTRY_DSN', 'sentry_dsn', INVENTREE_DSN)
|
||||
SENTRY_SAMPLE_RATE = float(get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1))
|
||||
|
||||
if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover
|
||||
|
||||
logger.info("Running with sentry.io integration enabled")
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[DjangoIntegration(), ],
|
||||
@ -713,7 +741,7 @@ LANGUAGES = [
|
||||
('th', _('Thai')),
|
||||
('tr', _('Turkish')),
|
||||
('vi', _('Vietnamese')),
|
||||
('zh-cn', _('Chinese')),
|
||||
('zh-hans', _('Chinese')),
|
||||
]
|
||||
|
||||
# Testing interface translations
|
||||
@ -736,9 +764,11 @@ if get_boolean_setting('TEST_TRANSLATIONS', default_value=False): # pragma: no
|
||||
django.conf.locale.LANG_INFO = LANG_INFO
|
||||
|
||||
# Currencies available for use
|
||||
CURRENCIES = get_setting('INVENTREE_CURRENCIES', 'currencies', [
|
||||
'AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD',
|
||||
])
|
||||
CURRENCIES = get_setting(
|
||||
'INVENTREE_CURRENCIES', 'currencies',
|
||||
['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD'],
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# Maximum number of decimal places for currency rendering
|
||||
CURRENCY_DECIMAL_PLACES = 6
|
||||
@ -746,7 +776,7 @@ CURRENCY_DECIMAL_PLACES = 6
|
||||
# Check that each provided currency is supported
|
||||
for currency in CURRENCIES:
|
||||
if currency not in moneyed.CURRENCIES: # pragma: no cover
|
||||
print(f"Currency code '{currency}' is not supported")
|
||||
logger.error(f"Currency code '{currency}' is not supported")
|
||||
sys.exit(1)
|
||||
|
||||
# Custom currency exchange backend
|
||||
@ -795,15 +825,12 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||
SITE_ID = 1
|
||||
|
||||
# Load the allauth social backends
|
||||
SOCIAL_BACKENDS = get_setting('INVENTREE_SOCIAL_BACKENDS', 'social_backends', [])
|
||||
SOCIAL_BACKENDS = get_setting('INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list)
|
||||
|
||||
for app in SOCIAL_BACKENDS:
|
||||
INSTALLED_APPS.append(app) # pragma: no cover
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = get_setting('INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None)
|
||||
|
||||
if SOCIALACCOUNT_PROVIDERS is None:
|
||||
SOCIALACCOUNT_PROVIDERS = {}
|
||||
SOCIALACCOUNT_PROVIDERS = get_setting('INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict)
|
||||
|
||||
SOCIALACCOUNT_STORE_TOKENS = True
|
||||
|
||||
@ -894,7 +921,6 @@ CUSTOM_LOGO = get_custom_file('INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom
|
||||
CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash')
|
||||
|
||||
CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {})
|
||||
|
||||
if DEBUG:
|
||||
logger.info("InvenTree running with DEBUG enabled")
|
||||
|
||||
|
@ -315,9 +315,7 @@ main {
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
padding: 2px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
@ -786,6 +784,12 @@ input[type="submit"] {
|
||||
|
||||
.alert-block {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.alert-small {
|
||||
padding: 0.35rem;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.navbar .btn {
|
||||
|
@ -155,15 +155,14 @@ function inventreeDocReady() {
|
||||
}
|
||||
|
||||
|
||||
function isFileTransfer(transfer) {
|
||||
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
|
||||
/*
|
||||
* Determine if a transfer (e.g. drag-and-drop) is a file transfer
|
||||
*/
|
||||
|
||||
function isFileTransfer(transfer) {
|
||||
return transfer.files.length > 0;
|
||||
}
|
||||
|
||||
|
||||
function enableDragAndDrop(element, url, options) {
|
||||
/* Enable drag-and-drop file uploading for a given element.
|
||||
|
||||
Params:
|
||||
@ -176,9 +175,17 @@ function enableDragAndDrop(element, url, options) {
|
||||
error - Callback function in case of error
|
||||
method - HTTP method
|
||||
*/
|
||||
function enableDragAndDrop(elementId, url, options={}) {
|
||||
|
||||
var data = options.data || {};
|
||||
|
||||
let element = $(elementId);
|
||||
|
||||
if (!element.exists()) {
|
||||
console.error(`enableDragAndDrop called with invalid target: '${elementId}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
$(element).on('drop', function(event) {
|
||||
|
||||
var transfer = event.originalEvent.dataTransfer;
|
||||
@ -200,6 +207,11 @@ function enableDragAndDrop(element, url, options) {
|
||||
formData,
|
||||
{
|
||||
success: function(data, status, xhr) {
|
||||
// Reload a table
|
||||
if (options.refreshTable) {
|
||||
reloadBootstrapTable(options.refreshTable);
|
||||
}
|
||||
|
||||
if (options.success) {
|
||||
options.success(data, status, xhr);
|
||||
}
|
||||
|
1
InvenTree/InvenTree/static/script/qrcode.min.js
vendored
Normal file
1
InvenTree/InvenTree/static/script/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -61,16 +61,10 @@ def is_email_configured():
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
logger.debug("EMAIL_HOST is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_USER:
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
logger.debug("EMAIL_HOST_USER is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_PASSWORD:
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING: # pragma: no cover
|
||||
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||
|
@ -31,23 +31,7 @@ class StatusCode:
|
||||
@classmethod
|
||||
def list(cls):
|
||||
"""Return the StatusCode options as a list of mapped key / value items."""
|
||||
codes = []
|
||||
|
||||
for key in cls.options.keys():
|
||||
|
||||
opt = {
|
||||
'key': key,
|
||||
'value': cls.options[key]
|
||||
}
|
||||
|
||||
color = cls.colors.get(key, None)
|
||||
|
||||
if color:
|
||||
opt['color'] = color
|
||||
|
||||
codes.append(opt)
|
||||
|
||||
return codes
|
||||
return list(cls.dict().values())
|
||||
|
||||
@classmethod
|
||||
def text(cls, key):
|
||||
@ -69,6 +53,62 @@ class StatusCode:
|
||||
"""All status code labels."""
|
||||
return cls.options.values()
|
||||
|
||||
@classmethod
|
||||
def names(cls):
|
||||
"""Return a map of all 'names' of status codes in this class
|
||||
|
||||
Will return a dict object, with the attribute name indexed to the integer value.
|
||||
|
||||
e.g.
|
||||
{
|
||||
'PENDING': 10,
|
||||
'IN_PROGRESS': 20,
|
||||
}
|
||||
"""
|
||||
keys = cls.keys()
|
||||
status_names = {}
|
||||
|
||||
for d in dir(cls):
|
||||
if d.startswith('_'):
|
||||
continue
|
||||
if d != d.upper():
|
||||
continue
|
||||
|
||||
value = getattr(cls, d, None)
|
||||
|
||||
if value is None:
|
||||
continue
|
||||
if callable(value):
|
||||
continue
|
||||
if type(value) != int:
|
||||
continue
|
||||
if value not in keys:
|
||||
continue
|
||||
|
||||
status_names[d] = value
|
||||
|
||||
return status_names
|
||||
|
||||
@classmethod
|
||||
def dict(cls):
|
||||
"""Return a dict representation containing all required information"""
|
||||
values = {}
|
||||
|
||||
for name, value, in cls.names().items():
|
||||
entry = {
|
||||
'key': value,
|
||||
'name': name,
|
||||
'label': cls.label(value),
|
||||
}
|
||||
|
||||
if hasattr(cls, 'colors'):
|
||||
if color := cls.colors.get(value, None):
|
||||
entry['color'] = color
|
||||
|
||||
values[name] = entry
|
||||
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def label(cls, value):
|
||||
"""Return the status code label associated with the provided value."""
|
||||
@ -131,6 +171,7 @@ class SalesOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a SalesOrder."""
|
||||
|
||||
PENDING = 10 # Order is pending
|
||||
IN_PROGRESS = 15 # Order has been issued, and is in progress
|
||||
SHIPPED = 20 # Order has been shipped to customer
|
||||
CANCELLED = 40 # Order has been cancelled
|
||||
LOST = 50 # Order was lost
|
||||
@ -138,6 +179,7 @@ class SalesOrderStatus(StatusCode):
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
IN_PROGRESS: _("In Progress"),
|
||||
SHIPPED: _("Shipped"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
LOST: _("Lost"),
|
||||
@ -146,6 +188,7 @@ class SalesOrderStatus(StatusCode):
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'primary',
|
||||
SHIPPED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
LOST: 'warning',
|
||||
@ -155,6 +198,7 @@ class SalesOrderStatus(StatusCode):
|
||||
# Open orders
|
||||
OPEN = [
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
]
|
||||
|
||||
# Completed orders
|
||||
@ -247,10 +291,14 @@ class StockHistoryCode(StatusCode):
|
||||
BUILD_CONSUMED = 57
|
||||
|
||||
# Sales order codes
|
||||
SHIPPED_AGAINST_SALES_ORDER = 60
|
||||
|
||||
# Purchase order codes
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER = 70
|
||||
|
||||
# Return order codes
|
||||
RETURNED_AGAINST_RETURN_ORDER = 80
|
||||
|
||||
# Customer actions
|
||||
SENT_TO_CUSTOMER = 100
|
||||
RETURNED_FROM_CUSTOMER = 105
|
||||
@ -289,8 +337,11 @@ class StockHistoryCode(StatusCode):
|
||||
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
|
||||
BUILD_CONSUMED: _('Consumed by build order'),
|
||||
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order')
|
||||
SHIPPED_AGAINST_SALES_ORDER: _("Shipped against Sales Order"),
|
||||
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against Purchase Order'),
|
||||
|
||||
RETURNED_AGAINST_RETURN_ORDER: _('Returned against Return Order'),
|
||||
}
|
||||
|
||||
|
||||
@ -320,3 +371,74 @@ class BuildStatus(StatusCode):
|
||||
PENDING,
|
||||
PRODUCTION,
|
||||
]
|
||||
|
||||
|
||||
class ReturnOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a ReturnOrder"""
|
||||
|
||||
# Order is pending, waiting for receipt of items
|
||||
PENDING = 10
|
||||
|
||||
# Items have been received, and are being inspected
|
||||
IN_PROGRESS = 20
|
||||
|
||||
COMPLETE = 30
|
||||
CANCELLED = 40
|
||||
|
||||
OPEN = [
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
]
|
||||
|
||||
options = {
|
||||
PENDING: _("Pending"),
|
||||
IN_PROGRESS: _("In Progress"),
|
||||
COMPLETE: _("Complete"),
|
||||
CANCELLED: _("Cancelled"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'primary',
|
||||
COMPLETE: 'success',
|
||||
CANCELLED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
class ReturnOrderLineStatus(StatusCode):
|
||||
"""Defines a set of status codes for a ReturnOrderLineItem"""
|
||||
|
||||
PENDING = 10
|
||||
|
||||
# Item is to be returned to customer, no other action
|
||||
RETURN = 20
|
||||
|
||||
# Item is to be repaired, and returned to customer
|
||||
REPAIR = 30
|
||||
|
||||
# Item is to be replaced (new item shipped)
|
||||
REPLACE = 40
|
||||
|
||||
# Item is to be refunded (cannot be repaired)
|
||||
REFUND = 50
|
||||
|
||||
# Item is rejected
|
||||
REJECT = 60
|
||||
|
||||
options = {
|
||||
PENDING: _('Pending'),
|
||||
RETURN: _('Return'),
|
||||
REPAIR: _('Repair'),
|
||||
REFUND: _('Refund'),
|
||||
REPLACE: _('Replace'),
|
||||
REJECT: _('Reject')
|
||||
}
|
||||
|
||||
colors = {
|
||||
PENDING: 'secondary',
|
||||
RETURN: 'success',
|
||||
REPAIR: 'primary',
|
||||
REFUND: 'info',
|
||||
REPLACE: 'warning',
|
||||
REJECT: 'danger',
|
||||
}
|
||||
|
@ -15,10 +15,17 @@ from django.conf import settings
|
||||
from django.core import mail as django_mail
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.core.management import call_command
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.db import DEFAULT_DB_ALIAS, connections
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db.utils import (NotSupportedError, OperationalError,
|
||||
ProgrammingError)
|
||||
from django.utils import timezone
|
||||
|
||||
import requests
|
||||
from maintenance_mode.core import (get_maintenance_mode, maintenance_mode_on,
|
||||
set_maintenance_mode)
|
||||
|
||||
from InvenTree.config import get_setting
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
@ -67,6 +74,93 @@ def raise_warning(msg):
|
||||
warnings.warn(msg)
|
||||
|
||||
|
||||
def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
|
||||
"""Check if a periodic task should be run, based on the provided setting name.
|
||||
|
||||
Arguments:
|
||||
task_name: The name of the task being run, e.g. 'dummy_task'
|
||||
setting_name: The name of the global setting, e.g. 'INVENTREE_DUMMY_TASK_INTERVAL'
|
||||
|
||||
Returns:
|
||||
bool: If the task should be run *now*, or wait another day
|
||||
|
||||
This function will determine if the task should be run *today*,
|
||||
based on when it was last run, or if we have a record of it running at all.
|
||||
|
||||
Note that this function creates some *hidden* global settings (designated with the _ prefix),
|
||||
which are used to keep a running track of when the particular task was was last run.
|
||||
"""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
if n_days <= 0:
|
||||
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
|
||||
return False
|
||||
|
||||
# Sleep a random number of seconds to prevent worker conflict
|
||||
time.sleep(random.randint(1, 5))
|
||||
|
||||
attempt_key = f'_{task_name}_ATTEMPT'
|
||||
success_key = f'_{task_name}_SUCCESS'
|
||||
|
||||
# Check for recent success information
|
||||
last_success = InvenTreeSetting.get_setting(success_key, '', cache=False)
|
||||
|
||||
if last_success:
|
||||
try:
|
||||
last_success = datetime.fromisoformat(last_success)
|
||||
except ValueError:
|
||||
last_success = None
|
||||
|
||||
if last_success:
|
||||
threshold = datetime.now() - timedelta(days=n_days)
|
||||
|
||||
if last_success > threshold:
|
||||
logger.info(f"Last successful run for '{task_name}' was too recent - skipping task")
|
||||
return False
|
||||
|
||||
# Check for any information we have about this task
|
||||
last_attempt = InvenTreeSetting.get_setting(attempt_key, '', cache=False)
|
||||
|
||||
if last_attempt:
|
||||
try:
|
||||
last_attempt = datetime.fromisoformat(last_attempt)
|
||||
except ValueError:
|
||||
last_attempt = None
|
||||
|
||||
if last_attempt:
|
||||
# Do not attempt if the most recent *attempt* was within 12 hours
|
||||
threshold = datetime.now() - timedelta(hours=12)
|
||||
|
||||
if last_attempt > threshold:
|
||||
logger.info(f"Last attempt for '{task_name}' was too recent - skipping task")
|
||||
return False
|
||||
|
||||
# Record this attempt
|
||||
record_task_attempt(task_name)
|
||||
|
||||
# No reason *not* to run this task now
|
||||
return True
|
||||
|
||||
|
||||
def record_task_attempt(task_name: str):
|
||||
"""Record that a multi-day task has been attempted *now*"""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
logger.info(f"Logging task attempt for '{task_name}'")
|
||||
|
||||
InvenTreeSetting.set_setting(f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None)
|
||||
|
||||
|
||||
def record_task_success(task_name: str):
|
||||
"""Record that a multi-day task was successful *now*"""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
InvenTreeSetting.set_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None)
|
||||
|
||||
|
||||
def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs):
|
||||
"""Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once!
|
||||
|
||||
@ -341,6 +435,14 @@ def check_for_updates():
|
||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||
return
|
||||
|
||||
interval = int(common.models.InvenTreeSetting.get_setting('INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False))
|
||||
|
||||
# Check if we should check for updates *today*
|
||||
if not check_daily_holdoff('check_for_updates', interval):
|
||||
return
|
||||
|
||||
logger.info("Checking for InvenTree software updates")
|
||||
|
||||
headers = {}
|
||||
|
||||
# If running within github actions, use authentication token
|
||||
@ -350,7 +452,10 @@ def check_for_updates():
|
||||
if token:
|
||||
headers['Authorization'] = f"Bearer {token}"
|
||||
|
||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest', headers=headers)
|
||||
response = requests.get(
|
||||
'https://api.github.com/repos/inventree/inventree/releases/latest',
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover
|
||||
@ -377,11 +482,14 @@ def check_for_updates():
|
||||
|
||||
# Save the version to the database
|
||||
common.models.InvenTreeSetting.set_setting(
|
||||
'INVENTREE_LATEST_VERSION',
|
||||
'_INVENTREE_LATEST_VERSION',
|
||||
tag,
|
||||
None
|
||||
)
|
||||
|
||||
# Record that this task was successful
|
||||
record_task_success('check_for_updates')
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def update_exchange_rates():
|
||||
@ -428,68 +536,26 @@ def update_exchange_rates():
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def run_backup():
|
||||
"""Run the backup command."""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
if not InvenTreeSetting.get_setting('INVENTREE_BACKUP_ENABLE', False, cache=False):
|
||||
# Backups are not enabled - exit early
|
||||
return
|
||||
|
||||
interval = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
|
||||
|
||||
# Check if should run this task *today*
|
||||
if not check_daily_holdoff('run_backup', interval):
|
||||
return
|
||||
|
||||
logger.info("Performing automated database backup task")
|
||||
|
||||
# Sleep a random number of seconds to prevent worker conflict
|
||||
time.sleep(random.randint(1, 5))
|
||||
|
||||
# Check for records of previous backup attempts
|
||||
last_attempt = InvenTreeSetting.get_setting('INVENTREE_BACKUP_ATTEMPT', '', cache=False)
|
||||
last_success = InvenTreeSetting.get_setting('INVENTREE_BACKUP_SUCCESS', '', cache=False)
|
||||
|
||||
try:
|
||||
backup_n_days = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
|
||||
except Exception:
|
||||
backup_n_days = 1
|
||||
|
||||
if last_attempt:
|
||||
try:
|
||||
last_attempt = datetime.fromisoformat(last_attempt)
|
||||
except ValueError:
|
||||
last_attempt = None
|
||||
|
||||
if last_attempt:
|
||||
# Do not attempt if the 'last attempt' at backup was within 12 hours
|
||||
threshold = timezone.now() - timezone.timedelta(hours=12)
|
||||
|
||||
if last_attempt > threshold:
|
||||
logger.info('Last backup attempt was too recent - skipping backup operation')
|
||||
return
|
||||
|
||||
# Record the timestamp of most recent backup attempt
|
||||
InvenTreeSetting.set_setting('INVENTREE_BACKUP_ATTEMPT', timezone.now().isoformat(), None)
|
||||
|
||||
if not last_attempt:
|
||||
# If there is no record of a previous attempt, exit quickly
|
||||
# This prevents the backup operation from happening when the server first launches, for example
|
||||
logger.info("No previous backup attempts recorded - waiting until tomorrow")
|
||||
return
|
||||
|
||||
if last_success:
|
||||
try:
|
||||
last_success = datetime.fromisoformat(last_success)
|
||||
except ValueError:
|
||||
last_success = None
|
||||
|
||||
# Exit early if the backup was successful within the number of required days
|
||||
if last_success:
|
||||
threshold = timezone.now() - timezone.timedelta(days=backup_n_days)
|
||||
|
||||
if last_success > threshold:
|
||||
logger.info('Last successful backup was too recent - skipping backup operation')
|
||||
return
|
||||
|
||||
call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False)
|
||||
call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
|
||||
|
||||
# Record the timestamp of most recent backup success
|
||||
InvenTreeSetting.set_setting('INVENTREE_BACKUP_SUCCESS', datetime.now().isoformat(), None)
|
||||
# Record that this task was successful
|
||||
record_task_success('run_backup')
|
||||
|
||||
|
||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||
@ -506,3 +572,74 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||
fail_silently=False,
|
||||
html_message=html_message
|
||||
)
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def check_for_migrations(worker: bool = True):
|
||||
"""Checks if migrations are needed.
|
||||
|
||||
If the setting auto_update is enabled we will start updateing.
|
||||
"""
|
||||
# Test if auto-updates are enabled
|
||||
if not get_setting('INVENTREE_AUTO_UPDATE', 'auto_update'):
|
||||
return
|
||||
|
||||
from plugin import registry
|
||||
|
||||
plan = get_migration_plan()
|
||||
|
||||
# Check if there are any open migrations
|
||||
if not plan:
|
||||
logger.info('There are no open migrations')
|
||||
return
|
||||
|
||||
logger.info('There are open migrations')
|
||||
|
||||
# Log open migrations
|
||||
for migration in plan:
|
||||
logger.info(migration[0])
|
||||
|
||||
# Set the application to maintenance mode - no access from now on.
|
||||
logger.info('Going into maintenance')
|
||||
set_maintenance_mode(True)
|
||||
logger.info('Mainentance mode is on now')
|
||||
|
||||
# Check if we are worker - go kill all other workers then.
|
||||
# Only the frontend workers run updates.
|
||||
if worker:
|
||||
logger.info('Current process is a worker - shutting down cluster')
|
||||
|
||||
# Ok now we are ready to go ahead!
|
||||
# To be sure we are in maintenance this is wrapped
|
||||
with maintenance_mode_on():
|
||||
logger.info('Starting migrations')
|
||||
print('Starting migrations')
|
||||
|
||||
try:
|
||||
call_command('migrate', interactive=False)
|
||||
except NotSupportedError as e: # pragma: no cover
|
||||
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
|
||||
raise e
|
||||
logger.error(f'Error during migrations: {e}')
|
||||
|
||||
print('Migrations done')
|
||||
logger.info('Ran migrations')
|
||||
|
||||
# Make sure we are out of maintenance again
|
||||
logger.info('Checking InvenTree left maintenance mode')
|
||||
if get_maintenance_mode():
|
||||
|
||||
logger.warning('Mainentance was still on - releasing now')
|
||||
set_maintenance_mode(False)
|
||||
logger.info('Released out of maintenance')
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
def get_migration_plan():
|
||||
"""Returns a list of migrations which are needed to be run."""
|
||||
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
|
||||
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
|
||||
return plan
|
||||
|
@ -8,7 +8,7 @@ from rest_framework import status
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from users.models import RuleSet
|
||||
from users.models import RuleSet, update_group_roles
|
||||
|
||||
|
||||
class HTMLAPITests(InvenTreeTestCase):
|
||||
@ -126,6 +126,10 @@ class APITests(InvenTreeAPITestCase):
|
||||
"""
|
||||
url = reverse('api-user-roles')
|
||||
|
||||
# Delete all rules
|
||||
self.group.rule_sets.all().delete()
|
||||
update_group_roles(self.group)
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
|
||||
# Not logged in, so cannot access user role data
|
||||
@ -297,3 +301,133 @@ class BulkDeleteTests(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
self.assertIn("'filters' must be supplied as a dict object", str(response.data))
|
||||
|
||||
|
||||
class SearchTests(InvenTreeAPITestCase):
|
||||
"""Unit tests for global search endpoint"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'company',
|
||||
'location',
|
||||
'supplier_part',
|
||||
'stock',
|
||||
'order',
|
||||
'sales_order',
|
||||
]
|
||||
|
||||
def test_results(self):
|
||||
"""Test individual result types"""
|
||||
|
||||
response = self.post(
|
||||
reverse('api-search'),
|
||||
{
|
||||
'search': 'chair',
|
||||
'limit': 3,
|
||||
'part': {},
|
||||
'build': {},
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# No build results
|
||||
self.assertEqual(response.data['build']['count'], 0)
|
||||
|
||||
# 3 (of 5) part results
|
||||
self.assertEqual(response.data['part']['count'], 5)
|
||||
self.assertEqual(len(response.data['part']['results']), 3)
|
||||
|
||||
# Other results not included
|
||||
self.assertNotIn('purchaseorder', response.data)
|
||||
self.assertNotIn('salesorder', response.data)
|
||||
|
||||
# Search for orders
|
||||
response = self.post(
|
||||
reverse('api-search'),
|
||||
{
|
||||
'search': '01',
|
||||
'limit': 2,
|
||||
'purchaseorder': {},
|
||||
'salesorder': {},
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['purchaseorder']['count'], 1)
|
||||
self.assertEqual(response.data['salesorder']['count'], 0)
|
||||
|
||||
self.assertNotIn('stockitem', response.data)
|
||||
self.assertNotIn('build', response.data)
|
||||
|
||||
def test_permissions(self):
|
||||
"""Test that users with insufficient permissions are handled correctly"""
|
||||
|
||||
# First, remove all roles
|
||||
for ruleset in self.group.rule_sets.all():
|
||||
ruleset.can_view = False
|
||||
ruleset.can_change = False
|
||||
ruleset.can_delete = False
|
||||
ruleset.can_add = False
|
||||
ruleset.save()
|
||||
|
||||
models = [
|
||||
'build',
|
||||
'company',
|
||||
'manufacturerpart',
|
||||
'supplierpart',
|
||||
'part',
|
||||
'partcategory',
|
||||
'purchaseorder',
|
||||
'stockitem',
|
||||
'stocklocation',
|
||||
'salesorder',
|
||||
]
|
||||
|
||||
query = {
|
||||
'search': 'c',
|
||||
'limit': 3,
|
||||
}
|
||||
|
||||
for mdl in models:
|
||||
query[mdl] = {}
|
||||
|
||||
response = self.post(
|
||||
reverse('api-search'),
|
||||
query,
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# Check for 'permission denied' error
|
||||
for mdl in models:
|
||||
self.assertEqual(response.data[mdl]['error'], 'User does not have permission to view this model')
|
||||
|
||||
# Assign view roles for some parts
|
||||
self.assignRole('build.view')
|
||||
self.assignRole('part.view')
|
||||
|
||||
response = self.post(
|
||||
reverse('api-search'),
|
||||
query,
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# Check for expected results, based on permissions
|
||||
# We expect results to be returned for the following model types
|
||||
has_permission = [
|
||||
'build',
|
||||
'manufacturerpart',
|
||||
'supplierpart',
|
||||
'part',
|
||||
'partcategory',
|
||||
'stocklocation',
|
||||
'stockitem',
|
||||
]
|
||||
|
||||
for mdl in models:
|
||||
result = response.data[mdl]
|
||||
if mdl in has_permission:
|
||||
self.assertIn('count', result)
|
||||
else:
|
||||
self.assertIn('error', result)
|
||||
self.assertEqual(result['error'], 'User does not have permission to view this model')
|
||||
|
@ -1,7 +1,11 @@
|
||||
"""Unit tests for task management."""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.db.utils import NotSupportedError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
@ -108,12 +112,41 @@ class InvenTreeTaskTests(TestCase):
|
||||
def test_task_check_for_updates(self):
|
||||
"""Test the task check_for_updates."""
|
||||
# Check that setting should be empty
|
||||
self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '')
|
||||
self.assertEqual(InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION'), '')
|
||||
|
||||
# Get new version
|
||||
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates)
|
||||
|
||||
# Check that setting is not empty
|
||||
response = InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION')
|
||||
response = InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION')
|
||||
self.assertNotEqual(response, '')
|
||||
self.assertTrue(bool(response))
|
||||
|
||||
def test_task_check_for_migrations(self):
|
||||
"""Test the task check_for_migrations."""
|
||||
# Update disabled
|
||||
InvenTree.tasks.check_for_migrations()
|
||||
|
||||
# Update enabled - no migrations
|
||||
os.environ['INVENTREE_AUTO_UPDATE'] = 'True'
|
||||
InvenTree.tasks.check_for_migrations()
|
||||
|
||||
# Create migration
|
||||
self.assertEqual(len(InvenTree.tasks.get_migration_plan()), 0)
|
||||
call_command('makemigrations', ['InvenTree', '--empty'], interactive=False)
|
||||
self.assertEqual(len(InvenTree.tasks.get_migration_plan()), 1)
|
||||
|
||||
# Run with migrations - catch no foreigner error
|
||||
try:
|
||||
InvenTree.tasks.check_for_migrations()
|
||||
except NotSupportedError as e: # pragma: no cover
|
||||
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
|
||||
raise e
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
migration_name = InvenTree.tasks.get_migration_plan()[0][0].name + '.py'
|
||||
migration_path = settings.BASE_DIR / 'InvenTree' / 'migrations' / migration_name
|
||||
migration_path.unlink()
|
||||
except IndexError: # pragma: no cover
|
||||
pass
|
||||
|
@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
@ -29,20 +30,12 @@ from stock.models import StockItem, StockLocation
|
||||
|
||||
from . import config, helpers, ready, status, version
|
||||
from .tasks import offload_task
|
||||
from .validators import validate_overage, validate_part_name
|
||||
from .validators import validate_overage
|
||||
|
||||
|
||||
class ValidatorTest(TestCase):
|
||||
"""Simple tests for custom field validators."""
|
||||
|
||||
def test_part_name(self):
|
||||
"""Test part name validator."""
|
||||
validate_part_name('hello world')
|
||||
|
||||
# Validate with some strange chars
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
validate_part_name('### <> This | name is not } valid')
|
||||
|
||||
def test_overage(self):
|
||||
"""Test overage validator."""
|
||||
validate_overage("100%")
|
||||
@ -398,10 +391,10 @@ class TestMPTT(TestCase):
|
||||
'location',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Setup for all tests."""
|
||||
super().setUp()
|
||||
|
||||
super().setUpTestData()
|
||||
StockLocation.objects.rebuild()
|
||||
|
||||
def test_self_as_parent(self):
|
||||
@ -830,6 +823,17 @@ class TestSettings(helpers.InvenTreeTestCase):
|
||||
with self.in_env_context({TEST_ENV_NAME: '321'}):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
|
||||
|
||||
# test typecasting to dict - None should be mapped to empty dict
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, None, typecast=dict), {})
|
||||
|
||||
# test typecasting to dict - valid JSON string should be mapped to corresponding dict
|
||||
with self.in_env_context({TEST_ENV_NAME: '{"a": 1}'}):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {"a": 1})
|
||||
|
||||
# test typecasting to dict - invalid JSON string should be mapped to empty dict
|
||||
with self.in_env_context({TEST_ENV_NAME: "{'a': 1}"}):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {})
|
||||
|
||||
|
||||
class TestInstanceName(helpers.InvenTreeTestCase):
|
||||
"""Unit tests for instance name."""
|
||||
@ -903,6 +907,58 @@ class TestOffloadTask(helpers.InvenTreeTestCase):
|
||||
force_async=True
|
||||
)
|
||||
|
||||
def test_daily_holdoff(self):
|
||||
"""Tests for daily task holdoff helper functions"""
|
||||
|
||||
import InvenTree.tasks
|
||||
|
||||
with self.assertLogs(logger='inventree', level='INFO') as cm:
|
||||
# With a non-positive interval, task will not run
|
||||
result = InvenTree.tasks.check_daily_holdoff('some_task', 0)
|
||||
self.assertFalse(result)
|
||||
self.assertIn('Specified interval', str(cm.output))
|
||||
|
||||
with self.assertLogs(logger='inventree', level='INFO') as cm:
|
||||
# First call should run without issue
|
||||
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
|
||||
self.assertTrue(result)
|
||||
self.assertIn("Logging task attempt for 'dummy_task'", str(cm.output))
|
||||
|
||||
with self.assertLogs(logger='inventree', level='INFO') as cm:
|
||||
# An attempt has been logged, but it is too recent
|
||||
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
|
||||
self.assertFalse(result)
|
||||
self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output))
|
||||
|
||||
# Mark last attempt a few days ago - should now return True
|
||||
t_old = datetime.now() - timedelta(days=3)
|
||||
t_old = t_old.isoformat()
|
||||
InvenTreeSetting.set_setting('_dummy_task_ATTEMPT', t_old, None)
|
||||
|
||||
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 5)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Last attempt should have been updated
|
||||
self.assertNotEqual(t_old, InvenTreeSetting.get_setting('_dummy_task_ATTEMPT', '', cache=False))
|
||||
|
||||
# Last attempt should prevent us now
|
||||
with self.assertLogs(logger='inventree', level='INFO') as cm:
|
||||
result = InvenTree.tasks.check_daily_holdoff('dummy_task')
|
||||
self.assertFalse(result)
|
||||
self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output))
|
||||
|
||||
# Configure so a task was successful too recently
|
||||
InvenTreeSetting.set_setting('_dummy_task_ATTEMPT', t_old, None)
|
||||
InvenTreeSetting.set_setting('_dummy_task_SUCCESS', t_old, None)
|
||||
|
||||
with self.assertLogs(logger='inventree', level='INFO') as cm:
|
||||
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 7)
|
||||
self.assertFalse(result)
|
||||
self.assertIn('Last successful run for', str(cm.output))
|
||||
|
||||
result = InvenTree.tasks.check_daily_holdoff('dummy_task', 2)
|
||||
self.assertTrue(result)
|
||||
|
||||
|
||||
class BarcodeMixinTest(helpers.InvenTreeTestCase):
|
||||
"""Tests for the InvenTreeBarcodeMixin mixin class"""
|
||||
|
@ -9,7 +9,7 @@ from django.contrib import admin
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from rest_framework.documentation import include_docs_urls
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
||||
|
||||
from build.api import build_api_urls
|
||||
from build.urls import build_urls
|
||||
@ -30,7 +30,7 @@ from stock.api import stock_api_urls
|
||||
from stock.urls import stock_urls
|
||||
from users.api import user_urls
|
||||
|
||||
from .api import InfoView, NotFoundView
|
||||
from .api import APISearchView, InfoView, NotFoundView
|
||||
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
|
||||
CustomEmailView, CustomLoginView,
|
||||
CustomPasswordResetFromKeyView,
|
||||
@ -43,6 +43,10 @@ admin.site.site_header = "InvenTree Admin"
|
||||
|
||||
|
||||
apipatterns = [
|
||||
|
||||
# Global search
|
||||
path('search/', APISearchView.as_view(), name='api-search'),
|
||||
|
||||
re_path(r'^settings/', include(settings_api_urls)),
|
||||
re_path(r'^part/', include(part_api_urls)),
|
||||
re_path(r'^bom/', include(bom_api_urls)),
|
||||
@ -58,9 +62,12 @@ apipatterns = [
|
||||
# Plugin endpoints
|
||||
path('', include(plugin_api_urls)),
|
||||
|
||||
# Webhook enpoint
|
||||
# Common endpoints enpoint
|
||||
path('', include(common_api_urls)),
|
||||
|
||||
# OpenAPI Schema
|
||||
re_path('schema/', SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), name='schema'),
|
||||
|
||||
# InvenTree information endpoint
|
||||
path('', InfoView.as_view(), name='api-inventree-info'),
|
||||
|
||||
@ -108,9 +115,13 @@ translated_javascript_urls = [
|
||||
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
|
||||
re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
|
||||
re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
|
||||
re_path(r'^purchase_order.js', DynamicJsView.as_view(template_name='js/translated/purchase_order.js'), name='purchase_order.js'),
|
||||
re_path(r'^return_order.js', DynamicJsView.as_view(template_name='js/translated/return_order.js'), name='return_order.js'),
|
||||
re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
|
||||
re_path(r'^sales_order.js', DynamicJsView.as_view(template_name='js/translated/sales_order.js'), name='sales_order.js'),
|
||||
re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
|
||||
re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
|
||||
re_path(r'^status_codes.js', DynamicJsView.as_view(template_name='js/translated/status_codes.js'), name='status_codes.js'),
|
||||
re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
|
||||
re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'),
|
||||
re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'),
|
||||
@ -128,7 +139,7 @@ backendpatterns = [
|
||||
re_path(r'^auth/?', auth_request),
|
||||
|
||||
re_path(r'^api/', include(apipatterns)),
|
||||
re_path(r'^api-doc/', include_docs_urls(title='InvenTree API')),
|
||||
re_path(r'^api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
|
||||
]
|
||||
|
||||
frontendpatterns = [
|
||||
|
@ -11,8 +11,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jinja2 import Template
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import common.models
|
||||
|
||||
|
||||
def validate_currency_code(code):
|
||||
"""Check that a given code is a valid currency code."""
|
||||
@ -47,50 +45,6 @@ class AllowedURLValidator(validators.URLValidator):
|
||||
super().__call__(value)
|
||||
|
||||
|
||||
def validate_part_name(value):
|
||||
"""Validate the name field for a Part instance
|
||||
|
||||
This function is exposed to any Validation plugins, and thus can be customized.
|
||||
"""
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
# Run the name through each custom validator
|
||||
# If the plugin returns 'True' we will skip any subsequent validation
|
||||
if plugin.validate_part_name(value):
|
||||
return
|
||||
|
||||
|
||||
def validate_part_ipn(value):
|
||||
"""Validate the IPN field for a Part instance.
|
||||
|
||||
This function is exposed to any Validation plugins, and thus can be customized.
|
||||
|
||||
If no validation errors are raised, the IPN is also validated against a configurable regex pattern.
|
||||
"""
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
plugins = registry.with_mixin('validation')
|
||||
|
||||
for plugin in plugins:
|
||||
# Run the IPN through each custom validator
|
||||
# If the plugin returns 'True' we will skip any subsequent validation
|
||||
if plugin.validate_part_ipn(value):
|
||||
return
|
||||
|
||||
# If we get to here, none of the plugins have raised an error
|
||||
|
||||
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX')
|
||||
|
||||
if pattern:
|
||||
match = re.search(pattern, value)
|
||||
|
||||
if match is None:
|
||||
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
|
||||
|
||||
|
||||
def validate_purchase_order_reference(value):
|
||||
"""Validate the 'reference' field of a PurchaseOrder."""
|
||||
|
||||
|
@ -9,20 +9,23 @@ import subprocess
|
||||
|
||||
import django
|
||||
|
||||
import common.models
|
||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = "0.11.0 dev"
|
||||
INVENTREE_SW_VERSION = "0.12.0 dev"
|
||||
|
||||
|
||||
def inventreeInstanceName():
|
||||
"""Returns the InstanceName settings for the current database."""
|
||||
import common.models
|
||||
|
||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||
|
||||
|
||||
def inventreeInstanceTitle():
|
||||
"""Returns the InstanceTitle for the current database."""
|
||||
import common.models
|
||||
|
||||
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
|
||||
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
|
||||
else:
|
||||
@ -64,9 +67,10 @@ def inventreeDocsVersion():
|
||||
def isInvenTreeUpToDate():
|
||||
"""Test if the InvenTree instance is "up to date" with the latest version.
|
||||
|
||||
A background task periodically queries GitHub for latest version, and stores it to the database as INVENTREE_LATEST_VERSION
|
||||
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
|
||||
"""
|
||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||
import common.models
|
||||
latest = common.models.InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||
|
||||
# No record for "latest" version - we must assume we are up to date!
|
||||
if not latest:
|
||||
|
@ -320,44 +320,6 @@ class AjaxView(AjaxMixin, View):
|
||||
return self.renderJsonResponse(request)
|
||||
|
||||
|
||||
class QRCodeView(AjaxView):
|
||||
"""An 'AJAXified' view for displaying a QR code.
|
||||
|
||||
Subclasses should implement the get_qr_data(self) function.
|
||||
"""
|
||||
|
||||
ajax_template_name = "qr_code.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Return json with qr-code data."""
|
||||
self.request = request
|
||||
self.pk = self.kwargs['pk']
|
||||
return self.renderJsonResponse(request, None, context=self.get_context_data())
|
||||
|
||||
def get_qr_data(self):
|
||||
"""Returns the text object to render to a QR code.
|
||||
|
||||
The actual rendering will be handled by the template
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_context_data(self):
|
||||
"""Get context data for passing to the rendering template.
|
||||
|
||||
Explicity passes the parameter 'qr_data'
|
||||
"""
|
||||
context = {}
|
||||
|
||||
qr = self.get_qr_data()
|
||||
|
||||
if qr:
|
||||
context['qr_data'] = qr
|
||||
else:
|
||||
context['error_msg'] = 'Error generating QR code'
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class AjaxUpdateView(AjaxMixin, UpdateView):
|
||||
"""An 'AJAXified' UpdateView for updating an object in the db.
|
||||
|
||||
|
@ -17,7 +17,18 @@ class BuildResource(InvenTreeResource):
|
||||
# but we don't for other ones.
|
||||
# TODO: 2022-05-12 - Need to investigate why this is the case!
|
||||
|
||||
id = Field(attribute='pk')
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
models = Build
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
exclude = [
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
'metadata',
|
||||
]
|
||||
|
||||
id = Field(attribute='pk', widget=widgets.IntegerWidget())
|
||||
|
||||
reference = Field(attribute='reference')
|
||||
|
||||
@ -39,17 +50,6 @@ class BuildResource(InvenTreeResource):
|
||||
|
||||
notes = Field(attribute='notes')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
models = Build
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
exclude = [
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
'metadata',
|
||||
]
|
||||
|
||||
|
||||
class BuildAdmin(ImportExportModelAdmin):
|
||||
"""Class for managing the Build model via the admin interface"""
|
||||
|
@ -1,30 +1,39 @@
|
||||
"""JSON API for the Build app."""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from rest_framework import filters
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView
|
||||
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView, StatusView
|
||||
from InvenTree.helpers import str2bool, isNull, DownloadFile
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
|
||||
|
||||
import build.admin
|
||||
import build.serializers
|
||||
from build.models import Build, BuildItem, BuildOrderAttachment
|
||||
|
||||
import part.models
|
||||
from users.models import Owner
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
|
||||
class BuildFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for BuildList API endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = Build
|
||||
fields = [
|
||||
'parent',
|
||||
'sales_order',
|
||||
'part',
|
||||
]
|
||||
|
||||
status = rest_filters.NumberFilter(label='Status')
|
||||
|
||||
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
|
||||
@ -32,22 +41,18 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
def filter_active(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are active."""
|
||||
if str2bool(value):
|
||||
queryset = queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
|
||||
return queryset.filter(status__in=BuildStatus.ACTIVE_CODES)
|
||||
else:
|
||||
queryset = queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
||||
|
||||
return queryset
|
||||
return queryset.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
||||
|
||||
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
|
||||
|
||||
def filter_overdue(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are overdue."""
|
||||
if str2bool(value):
|
||||
queryset = queryset.filter(Build.OVERDUE_FILTER)
|
||||
return queryset.filter(Build.OVERDUE_FILTER)
|
||||
else:
|
||||
queryset = queryset.exclude(Build.OVERDUE_FILTER)
|
||||
|
||||
return queryset
|
||||
return queryset.exclude(Build.OVERDUE_FILTER)
|
||||
|
||||
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
|
||||
|
||||
@ -59,11 +64,21 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
owners = Owner.get_owners_matching_user(self.request.user)
|
||||
|
||||
if value:
|
||||
queryset = queryset.filter(responsible__in=owners)
|
||||
return queryset.filter(responsible__in=owners)
|
||||
else:
|
||||
queryset = queryset.exclude(responsible__in=owners)
|
||||
return queryset.exclude(responsible__in=owners)
|
||||
|
||||
return queryset
|
||||
assigned_to = rest_filters.NumberFilter(label='responsible', method='filter_responsible')
|
||||
|
||||
def filter_responsible(self, queryset, name, value):
|
||||
"""Filter by orders which are assigned to the specified owner."""
|
||||
owners = list(Owner.objects.filter(pk=value))
|
||||
|
||||
# if we query by a user, also find all ownerships through group memberships
|
||||
if len(owners) > 0 and owners[0].label() == 'user':
|
||||
owners = Owner.get_owners_matching_user(User.objects.get(pk=owners[0].owner_id))
|
||||
|
||||
return queryset.filter(responsible__in=owners)
|
||||
|
||||
# Exact match for reference
|
||||
reference = rest_filters.CharFilter(
|
||||
@ -84,11 +99,7 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
serializer_class = build.serializers.BuildSerializer
|
||||
filterset_class = BuildFilter
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = [
|
||||
'reference',
|
||||
@ -157,18 +168,6 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by "parent"
|
||||
parent = params.get('parent', None)
|
||||
|
||||
if parent is not None:
|
||||
queryset = queryset.filter(parent=parent)
|
||||
|
||||
# Filter by sales_order
|
||||
sales_order = params.get('sales_order', None)
|
||||
|
||||
if sales_order is not None:
|
||||
queryset = queryset.filter(sales_order=sales_order)
|
||||
|
||||
# Filter by "ancestor" builds
|
||||
ancestor = params.get('ancestor', None)
|
||||
|
||||
@ -185,12 +184,6 @@ class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by associated part?
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(part=part)
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
@ -359,6 +352,34 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = build.serializers.BuildItemSerializer
|
||||
|
||||
|
||||
class BuildItemFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the BuildItemList API endpoint"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass option"""
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'build',
|
||||
'stock_item',
|
||||
'bom_item',
|
||||
'install_into',
|
||||
]
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=part.models.Part.objects.all(),
|
||||
field_name='stock_item__part',
|
||||
)
|
||||
|
||||
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
|
||||
|
||||
def filter_tracked(self, queryset, name, value):
|
||||
"""Filter the queryset based on whether build items are tracked"""
|
||||
if str2bool(value):
|
||||
return queryset.exclude(install_into=None)
|
||||
else:
|
||||
return queryset.filter(install_into=None)
|
||||
|
||||
|
||||
class BuildItemList(ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildItem objects.
|
||||
|
||||
@ -367,6 +388,7 @@ class BuildItemList(ListCreateAPI):
|
||||
"""
|
||||
|
||||
serializer_class = build.serializers.BuildItemSerializer
|
||||
filterset_class = BuildItemFilter
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Returns a BuildItemSerializer instance based on the request."""
|
||||
@ -404,24 +426,6 @@ class BuildItemList(ListCreateAPI):
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Does the user wish to filter by part?
|
||||
part_pk = params.get('part', None)
|
||||
|
||||
if part_pk:
|
||||
queryset = queryset.filter(stock_item__part=part_pk)
|
||||
|
||||
# Filter by "tracked" status
|
||||
# Tracked means that the item is "installed" into a build output (stock item)
|
||||
tracked = params.get('tracked', None)
|
||||
|
||||
if tracked is not None:
|
||||
tracked = str2bool(tracked)
|
||||
|
||||
if tracked:
|
||||
queryset = queryset.exclude(install_into=None)
|
||||
else:
|
||||
queryset = queryset.filter(install_into=None)
|
||||
|
||||
# Filter by output target
|
||||
output = params.get('output', None)
|
||||
|
||||
@ -438,13 +442,6 @@ class BuildItemList(ListCreateAPI):
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'build',
|
||||
'stock_item',
|
||||
'bom_item',
|
||||
'install_into',
|
||||
]
|
||||
|
||||
|
||||
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
||||
@ -472,18 +469,21 @@ build_api_urls = [
|
||||
|
||||
# Attachments
|
||||
re_path(r'^attachment/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
|
||||
path(r'<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
|
||||
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||
])),
|
||||
|
||||
# Build Items
|
||||
re_path(r'^item/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^metadata/', MetadataView.as_view(), {'model': BuildItem}, name='api-build-item-metadata'),
|
||||
re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
|
||||
])),
|
||||
|
||||
# Build Detail
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
re_path(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
|
||||
re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||
@ -492,9 +492,13 @@ build_api_urls = [
|
||||
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
|
||||
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
re_path(r'^metadata/', MetadataView.as_view(), {'model': Build}, name='api-build-metadata'),
|
||||
re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
||||
# Build order status code information
|
||||
re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: BuildStatus}, name='api-build-status-codes'),
|
||||
|
||||
# Build List
|
||||
re_path(r'^.*$', BuildList.as_view(), name='api-build-list'),
|
||||
]
|
||||
|
23
InvenTree/build/migrations/0039_auto_20230317_0816.py
Normal file
23
InvenTree/build/migrations/0039_auto_20230317_0816.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-17 08:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0038_alter_build_responsible'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='build',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='builditem',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
]
|
23
InvenTree/build/migrations/0040_auto_20230404_1310.py
Normal file
23
InvenTree/build/migrations/0040_auto_20230404_1310.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-04 13:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0039_auto_20230317_0816'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='build',
|
||||
name='barcode_data',
|
||||
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='build',
|
||||
name='barcode_hash',
|
||||
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
|
||||
),
|
||||
]
|
18
InvenTree/build/migrations/0041_alter_build_title.py
Normal file
18
InvenTree/build/migrations/0041_alter_build_title.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-12 17:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0040_auto_20230404_1310'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, help_text='Brief description of the build (optional)', max_length=100, verbose_name='Description'),
|
||||
),
|
||||
]
|
@ -23,7 +23,7 @@ from rest_framework import serializers
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||
from InvenTree.helpers import increment, normalize, notify_responsible
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin, ReferenceIndexingMixin
|
||||
|
||||
from build.validators import generate_next_build_reference, validate_build_order_reference
|
||||
|
||||
@ -33,14 +33,16 @@ import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
|
||||
from plugin.events import trigger_event
|
||||
from plugin.models import MetadataMixin
|
||||
|
||||
import common.notifications
|
||||
from part import models as PartModels
|
||||
from stock import models as StockModels
|
||||
from users import models as UserModels
|
||||
|
||||
import part.models
|
||||
import stock.models
|
||||
import users.models
|
||||
|
||||
|
||||
class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
class Build(MPTTModel, InvenTreeBarcodeMixin, MetadataMixin, ReferenceIndexingMixin):
|
||||
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||
|
||||
Attributes:
|
||||
@ -64,6 +66,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
priority: Priority of the build
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for the BuildOrder model"""
|
||||
verbose_name = _("Build Order")
|
||||
verbose_name_plural = _("Build Orders")
|
||||
|
||||
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
@ -106,11 +113,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
'parent': _('Invalid choice for parent build'),
|
||||
})
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for the BuildOrder model"""
|
||||
verbose_name = _("Build Order")
|
||||
verbose_name_plural = _("Build Orders")
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
@ -162,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
title = models.CharField(
|
||||
verbose_name=_('Description'),
|
||||
blank=False,
|
||||
blank=True,
|
||||
max_length=100,
|
||||
help_text=_('Brief description of the build')
|
||||
help_text=_('Brief description of the build (optional)')
|
||||
)
|
||||
|
||||
parent = TreeForeignKey(
|
||||
@ -278,7 +280,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
)
|
||||
|
||||
responsible = models.ForeignKey(
|
||||
UserModels.Owner,
|
||||
users.models.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Responsible'),
|
||||
@ -394,9 +396,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
if in_stock is not None:
|
||||
if in_stock:
|
||||
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER)
|
||||
else:
|
||||
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Filter by 'complete' status
|
||||
complete = kwargs.get('complete', None)
|
||||
@ -658,7 +660,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
else:
|
||||
serial = None
|
||||
|
||||
output = StockModels.StockItem.objects.create(
|
||||
output = stock.models.StockItem.objects.create(
|
||||
quantity=1,
|
||||
location=location,
|
||||
part=self.part,
|
||||
@ -676,11 +678,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
parts = bom_item.get_valid_parts_for_allocation()
|
||||
|
||||
items = StockModels.StockItem.objects.filter(
|
||||
items = stock.models.StockItem.objects.filter(
|
||||
part__in=parts,
|
||||
serial=str(serial),
|
||||
quantity=1,
|
||||
).filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
).filter(stock.models.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
"""
|
||||
Test if there is a matching serial number!
|
||||
@ -700,7 +702,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
else:
|
||||
"""Create a single build output of the given quantity."""
|
||||
|
||||
StockModels.StockItem.objects.create(
|
||||
stock.models.StockItem.objects.create(
|
||||
quantity=quantity,
|
||||
location=location,
|
||||
part=self.part,
|
||||
@ -876,7 +878,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
)
|
||||
|
||||
# Look for available stock items
|
||||
available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
available_stock = stock.models.StockItem.objects.filter(stock.models.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Filter by list of available parts
|
||||
available_stock = available_stock.filter(
|
||||
@ -1140,7 +1142,7 @@ class BuildOrderAttachment(InvenTreeAttachment):
|
||||
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
|
||||
|
||||
|
||||
class BuildItem(models.Model):
|
||||
class BuildItem(MetadataMixin, models.Model):
|
||||
"""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.
|
||||
@ -1153,17 +1155,17 @@ class BuildItem(models.Model):
|
||||
install_into: Destination stock item (or None)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL used to access this model"""
|
||||
return reverse('api-build-item-list')
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
unique_together = [
|
||||
('build', 'stock_item', 'install_into'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL used to access this model"""
|
||||
return reverse('api-build-item-list')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom save method for the BuildItem model"""
|
||||
self.clean()
|
||||
@ -1219,7 +1221,7 @@ class BuildItem(models.Model):
|
||||
'quantity': _('Quantity must be 1 for serialized stock')
|
||||
})
|
||||
|
||||
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
|
||||
except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
"""
|
||||
@ -1258,8 +1260,8 @@ class BuildItem(models.Model):
|
||||
for idx, ancestor in enumerate(ancestors):
|
||||
|
||||
try:
|
||||
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
|
||||
except PartModels.BomItem.DoesNotExist:
|
||||
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
|
||||
except part.models.BomItem.DoesNotExist:
|
||||
continue
|
||||
|
||||
# A matching BOM item has been found!
|
||||
@ -1349,7 +1351,7 @@ class BuildItem(models.Model):
|
||||
# Internal model which links part <-> sub_part
|
||||
# We need to track this separately, to allow for "variant' stock
|
||||
bom_item = models.ForeignKey(
|
||||
PartModels.BomItem,
|
||||
part.models.BomItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocate_build_items',
|
||||
blank=True, null=True,
|
||||
|
@ -30,7 +30,49 @@ from .models import Build, BuildItem, BuildOrderAttachment
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
"""Serializes a Build object."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
model = Build
|
||||
fields = [
|
||||
'pk',
|
||||
'url',
|
||||
'title',
|
||||
'barcode_hash',
|
||||
'batch',
|
||||
'creation_date',
|
||||
'completed',
|
||||
'completion_date',
|
||||
'destination',
|
||||
'parent',
|
||||
'part',
|
||||
'part_detail',
|
||||
'overdue',
|
||||
'reference',
|
||||
'sales_order',
|
||||
'quantity',
|
||||
'status',
|
||||
'status_text',
|
||||
'target_date',
|
||||
'take_from',
|
||||
'notes',
|
||||
'link',
|
||||
'issued_by',
|
||||
'issued_by_detail',
|
||||
'responsible',
|
||||
'responsible_detail',
|
||||
'priority',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'completed',
|
||||
'creation_date',
|
||||
'completion_data',
|
||||
'status',
|
||||
'status_text',
|
||||
]
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
@ -43,6 +85,8 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
|
||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True)
|
||||
|
||||
barcode_hash = serializers.CharField(read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
|
||||
@ -83,46 +127,6 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
|
||||
return reference
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
model = Build
|
||||
fields = [
|
||||
'pk',
|
||||
'url',
|
||||
'title',
|
||||
'batch',
|
||||
'creation_date',
|
||||
'completed',
|
||||
'completion_date',
|
||||
'destination',
|
||||
'parent',
|
||||
'part',
|
||||
'part_detail',
|
||||
'overdue',
|
||||
'reference',
|
||||
'sales_order',
|
||||
'quantity',
|
||||
'status',
|
||||
'status_text',
|
||||
'target_date',
|
||||
'take_from',
|
||||
'notes',
|
||||
'link',
|
||||
'issued_by',
|
||||
'issued_by_detail',
|
||||
'responsible',
|
||||
'responsible_detail',
|
||||
'priority',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'completed',
|
||||
'creation_date',
|
||||
'completion_data',
|
||||
'status',
|
||||
'status_text',
|
||||
]
|
||||
|
||||
|
||||
class BuildOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for a "BuildOutput".
|
||||
@ -130,6 +134,12 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
Note that a "BuildOutput" is really just a StockItem which is "in production"!
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'output',
|
||||
]
|
||||
|
||||
output = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
@ -170,12 +180,6 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
|
||||
return output
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'output',
|
||||
]
|
||||
|
||||
|
||||
class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
"""Serializer for creating a new BuildOutput against a BuildOrder.
|
||||
@ -633,6 +637,15 @@ class BuildUnallocationSerializer(serializers.Serializer):
|
||||
class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
"""A serializer for allocating a single stock item against a build order."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'bom_item',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'output',
|
||||
]
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BomItem.objects.all(),
|
||||
many=False,
|
||||
@ -693,15 +706,6 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
label=_('Build Output'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'bom_item',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'output',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
"""Perform data validation for this item"""
|
||||
super().validate(data)
|
||||
@ -751,14 +755,14 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
class BuildAllocationSerializer(serializers.Serializer):
|
||||
"""DRF serializer for allocation stock items against a build order."""
|
||||
|
||||
items = BuildAllocationItemSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = [
|
||||
'items',
|
||||
]
|
||||
|
||||
items = BuildAllocationItemSerializer(many=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validation."""
|
||||
data = super().validate(data)
|
||||
@ -870,6 +874,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
"""Serializes a BuildItem object."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'pk',
|
||||
'bom_part',
|
||||
'build',
|
||||
'build_detail',
|
||||
'install_into',
|
||||
'location',
|
||||
'location_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'stock_item',
|
||||
'stock_item_detail',
|
||||
'quantity'
|
||||
]
|
||||
|
||||
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
|
||||
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
|
||||
@ -903,24 +925,6 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
if not stock_detail:
|
||||
self.fields.pop('stock_item_detail')
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'pk',
|
||||
'bom_part',
|
||||
'build',
|
||||
'build_detail',
|
||||
'install_into',
|
||||
'location',
|
||||
'location_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'stock_item',
|
||||
'stock_item_detail',
|
||||
'quantity'
|
||||
]
|
||||
|
||||
|
||||
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for a BuildAttachment."""
|
||||
@ -929,18 +933,6 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer metaclass"""
|
||||
model = BuildOrderAttachment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'build',
|
||||
'attachment',
|
||||
'link',
|
||||
'filename',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'upload_date',
|
||||
]
|
||||
])
|
||||
|
@ -33,6 +33,24 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% url 'admin:build_build_change' build.pk as url %}
|
||||
{% include "admin_button.html" with url=url %}
|
||||
{% endif %}
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
<span class='fas fa-qrcode'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if roles.build.change %}
|
||||
{% if order.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Printing options -->
|
||||
{% if report_enabled %}
|
||||
<div class='btn-group'>
|
||||
@ -90,6 +108,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% trans "Build Description" %}</td>
|
||||
<td>{{ build.title }}</td>
|
||||
</tr>
|
||||
{% include "barcode_data.html" with instance=build %}
|
||||
</table>
|
||||
|
||||
<div class='info-messages'>
|
||||
@ -98,13 +117,6 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% trans "No build outputs have been created for this build order" %}<br>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if build.sales_order %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
|
||||
{% blocktrans %}This Build Order is allocated to Sales Order {{link}}{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if build.parent %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'build-detail' build.parent.id build.parent as link %}
|
||||
@ -162,7 +174,12 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-check-circle'></span></td>
|
||||
<td>
|
||||
{% if build.completed >= build.quantity %}
|
||||
<span class='fas fa-check-circle icon-green'></span>
|
||||
{% else %}
|
||||
<span class='fa fa-times-circle icon-red'></span>
|
||||
{% endif %}
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
|
||||
</tr>
|
||||
@ -247,7 +264,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-build-report').click(function() {
|
||||
printBuildReports([{{ build.pk }}]);
|
||||
printReports({
|
||||
items: [{{ build.pk }}],
|
||||
key: 'build',
|
||||
url: '{% url "api-build-report-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
@ -262,4 +283,33 @@ src="{% static 'img/blank_image.png' %}"
|
||||
);
|
||||
});
|
||||
|
||||
{% if barcodes %}
|
||||
<!-- Barcode functionality callbacks -->
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Build Order QR Code" %}',
|
||||
'{"build": {{ build.pk }}}'
|
||||
);
|
||||
});
|
||||
|
||||
{% if roles.purchase_order.change %}
|
||||
$("#barcode-link").click(function() {
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
build: {{ build.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Build Order" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-unlink").click(function() {
|
||||
unlinkBarcode({
|
||||
build: {{ build.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
@ -67,7 +67,7 @@
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
|
||||
</tr>
|
||||
{% if build.active and build.has_untracked_bom_items %}
|
||||
{% if build.active and has_untracked_bom_items %}
|
||||
<tr>
|
||||
<td><span class='fas fa-list'></span></td>
|
||||
<td>{% trans "Allocated Parts" %}</td>
|
||||
@ -179,7 +179,7 @@
|
||||
<h4>{% trans "Allocate Stock to Build" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.build.add and build.active and build.has_untracked_bom_items %}
|
||||
{% if roles.build.add and build.active and has_untracked_bom_items %}
|
||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||
</button>
|
||||
@ -199,7 +199,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if build.has_untracked_bom_items %}
|
||||
{% if has_untracked_bom_items %}
|
||||
{% if build.active %}
|
||||
{% if build.are_untracked_parts_allocated %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
@ -268,24 +268,6 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Label Printing Actions -->
|
||||
<div class='btn-group'>
|
||||
<button id='output-print-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Printing Actions" %}'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a class='dropdown-item' href='#' id='incomplete-output-print-label' title='{% trans "Print labels" %}'>
|
||||
<span class='fas fa-tags'></span> {% trans "Print labels" %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if build.has_tracked_bom_items %}
|
||||
{% include "expand_rows.html" with label="outputs" %}
|
||||
{% include "collapse_rows.html" with label="outputs" %}
|
||||
{% endif %}
|
||||
|
||||
{% include "filter_list.html" with id='incompletebuilditems' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -372,20 +354,6 @@ onPanelLoad('children', function() {
|
||||
|
||||
onPanelLoad('attachments', function() {
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-build-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
build: {{ build.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
$('#attachment-table').bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
|
||||
filters: {
|
||||
build: {{ build.pk }},
|
||||
@ -414,10 +382,6 @@ onPanelLoad('notes', function() {
|
||||
);
|
||||
});
|
||||
|
||||
function reloadTable() {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
onPanelLoad('outputs', function() {
|
||||
{% if build.active %}
|
||||
|
||||
@ -436,7 +400,7 @@ onPanelLoad('outputs', function() {
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
{% if build.active and build.has_untracked_bom_items %}
|
||||
{% if build.active and has_untracked_bom_items %}
|
||||
|
||||
function loadUntrackedStockTable() {
|
||||
|
||||
|
@ -26,20 +26,6 @@
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
|
||||
{% if report_enabled %}
|
||||
<div class='btn-group' role='group'>
|
||||
<!-- Print actions -->
|
||||
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a class='dropdown-item' href='#' id='multi-build-print' title='{% trans "Print Build Orders" %}'>
|
||||
<span class='fas fa-file-pdf'></span> {% trans "Print Build Orders" %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "filter_list.html" with id="build" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -62,17 +48,4 @@ loadBuildTable($("#build-table"), {
|
||||
locale: '{{ request.LANGUAGE_CODE }}',
|
||||
});
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#multi-build-print').click(function() {
|
||||
var rows = getTableData("#build-table");
|
||||
var build_ids = [];
|
||||
|
||||
rows.forEach(function(row) {
|
||||
build_ids.push(row.pk);
|
||||
});
|
||||
|
||||
printBuildReports(build_ids);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
@ -37,49 +37,50 @@ class TestBuildAPI(InvenTreeAPITestCase):
|
||||
def test_get_build_list(self):
|
||||
"""Test that we can retrieve list of build objects."""
|
||||
url = reverse('api-build-list')
|
||||
response = self.client.get(url, format='json')
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Filter query by build status
|
||||
response = self.client.get(url, {'status': 40}, format='json')
|
||||
response = self.get(url, {'status': 40}, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Filter by "active" status
|
||||
response = self.client.get(url, {'active': True}, format='json')
|
||||
response = self.get(url, {'active': True}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], 1)
|
||||
|
||||
response = self.client.get(url, {'active': False}, format='json')
|
||||
response = self.get(url, {'active': False}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Filter by 'part' status
|
||||
response = self.client.get(url, {'part': 25}, format='json')
|
||||
response = self.get(url, {'part': 25}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# Filter by an invalid part
|
||||
response = self.client.get(url, {'part': 99999}, format='json')
|
||||
self.assertEqual(len(response.data), 0)
|
||||
response = self.get(url, {'part': 99999}, expected_code=400)
|
||||
self.assertIn('Select a valid choice', str(response.data))
|
||||
|
||||
# Get a certain reference
|
||||
response = self.client.get(url, {'reference': 'BO-0001'}, format='json')
|
||||
response = self.get(url, {'reference': 'BO-0001'}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# Get a certain reference
|
||||
response = self.client.get(url, {'reference': 'BO-9999XX'}, format='json')
|
||||
response = self.get(url, {'reference': 'BO-9999XX'}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_get_build_item_list(self):
|
||||
"""Test that we can retrieve list of BuildItem objects."""
|
||||
url = reverse('api-build-item-list')
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# Test again, filtering by park ID
|
||||
response = self.client.get(url, {'part': '1'}, format='json')
|
||||
response = self.get(url, {'part': '1'}, expected_code=200)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
@ -731,38 +732,42 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
Using same Build ID=1 as allocation test above.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Basic operation as part of test suite setup"""
|
||||
super().setUp()
|
||||
super().setUpTestData()
|
||||
|
||||
self.assignRole('build.add')
|
||||
self.assignRole('build.change')
|
||||
cls.assignRole('build.add')
|
||||
cls.assignRole('build.change')
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
self.url = reverse('api-build-finish', kwargs={'pk': self.build.pk})
|
||||
cls.build = Build.objects.get(pk=1)
|
||||
cls.url = reverse('api-build-finish', kwargs={'pk': cls.build.pk})
|
||||
|
||||
StockItem.objects.create(part=Part.objects.get(pk=50), quantity=30)
|
||||
|
||||
# Keep some state for use in later assertions, and then overallocate
|
||||
self.state = {}
|
||||
self.allocation = {}
|
||||
for i, bi in enumerate(self.build.part.bom_items.all()):
|
||||
rq = self.build.required_quantity(bi, None) + i + 1
|
||||
cls.state = {}
|
||||
cls.allocation = {}
|
||||
|
||||
for i, bi in enumerate(cls.build.part.bom_items.all()):
|
||||
rq = cls.build.required_quantity(bi, None) + i + 1
|
||||
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
|
||||
|
||||
self.state[bi.sub_part] = (si, si.quantity, rq)
|
||||
cls.state[bi.sub_part] = (si, si.quantity, rq)
|
||||
BuildItem.objects.create(
|
||||
build=self.build,
|
||||
build=cls.build,
|
||||
stock_item=si,
|
||||
quantity=rq,
|
||||
)
|
||||
|
||||
# create and complete outputs
|
||||
self.build.create_build_output(self.build.quantity)
|
||||
outputs = self.build.build_outputs.all()
|
||||
self.build.complete_build_output(outputs[0], self.user)
|
||||
cls.build.create_build_output(cls.build.quantity)
|
||||
outputs = cls.build.build_outputs.all()
|
||||
cls.build.complete_build_output(outputs[0], cls.user)
|
||||
|
||||
def test_setup(self):
|
||||
"""Validate expected state after set-up."""
|
||||
|
||||
# Validate expected state after set-up.
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
self.assertEqual(self.build.complete_outputs.count(), 1)
|
||||
self.assertEqual(self.build.completed, self.build.quantity)
|
||||
|
@ -29,7 +29,8 @@ class BuildTestBase(TestCase):
|
||||
'users',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Initialize data to use for these tests.
|
||||
|
||||
The base Part 'assembly' has a BOM consisting of three parts:
|
||||
@ -45,27 +46,29 @@ class BuildTestBase(TestCase):
|
||||
|
||||
"""
|
||||
|
||||
super().setUpTestData()
|
||||
|
||||
# Create a base "Part"
|
||||
self.assembly = Part.objects.create(
|
||||
cls.assembly = Part.objects.create(
|
||||
name="An assembled part",
|
||||
description="Why does it matter what my description is?",
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
self.sub_part_1 = Part.objects.create(
|
||||
cls.sub_part_1 = Part.objects.create(
|
||||
name="Widget A",
|
||||
description="A widget",
|
||||
component=True
|
||||
)
|
||||
|
||||
self.sub_part_2 = Part.objects.create(
|
||||
cls.sub_part_2 = Part.objects.create(
|
||||
name="Widget B",
|
||||
description="A widget",
|
||||
component=True
|
||||
)
|
||||
|
||||
self.sub_part_3 = Part.objects.create(
|
||||
cls.sub_part_3 = Part.objects.create(
|
||||
name="Widget C",
|
||||
description="A widget",
|
||||
component=True,
|
||||
@ -73,63 +76,63 @@ class BuildTestBase(TestCase):
|
||||
)
|
||||
|
||||
# Create BOM item links for the parts
|
||||
self.bom_item_1 = BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_1,
|
||||
cls.bom_item_1 = BomItem.objects.create(
|
||||
part=cls.assembly,
|
||||
sub_part=cls.sub_part_1,
|
||||
quantity=5
|
||||
)
|
||||
|
||||
self.bom_item_2 = BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_2,
|
||||
cls.bom_item_2 = BomItem.objects.create(
|
||||
part=cls.assembly,
|
||||
sub_part=cls.sub_part_2,
|
||||
quantity=3,
|
||||
optional=True
|
||||
)
|
||||
|
||||
# sub_part_3 is trackable!
|
||||
self.bom_item_3 = BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_3,
|
||||
cls.bom_item_3 = BomItem.objects.create(
|
||||
part=cls.assembly,
|
||||
sub_part=cls.sub_part_3,
|
||||
quantity=2
|
||||
)
|
||||
|
||||
ref = generate_next_build_reference()
|
||||
|
||||
# Create a "Build" object to make 10x objects
|
||||
self.build = Build.objects.create(
|
||||
cls.build = Build.objects.create(
|
||||
reference=ref,
|
||||
title="This is a build",
|
||||
part=self.assembly,
|
||||
part=cls.assembly,
|
||||
quantity=10,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
)
|
||||
|
||||
# Create some build output (StockItem) objects
|
||||
self.output_1 = StockItem.objects.create(
|
||||
part=self.assembly,
|
||||
cls.output_1 = StockItem.objects.create(
|
||||
part=cls.assembly,
|
||||
quantity=3,
|
||||
is_building=True,
|
||||
build=self.build
|
||||
build=cls.build
|
||||
)
|
||||
|
||||
self.output_2 = StockItem.objects.create(
|
||||
part=self.assembly,
|
||||
cls.output_2 = StockItem.objects.create(
|
||||
part=cls.assembly,
|
||||
quantity=7,
|
||||
is_building=True,
|
||||
build=self.build,
|
||||
build=cls.build,
|
||||
)
|
||||
|
||||
# Create some stock items to assign to the build
|
||||
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=3)
|
||||
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
|
||||
cls.stock_1_1 = StockItem.objects.create(part=cls.sub_part_1, quantity=3)
|
||||
cls.stock_1_2 = StockItem.objects.create(part=cls.sub_part_1, quantity=100)
|
||||
|
||||
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_3 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_4 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_5 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
cls.stock_2_1 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
|
||||
cls.stock_2_2 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
|
||||
cls.stock_2_3 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
|
||||
cls.stock_2_4 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
|
||||
cls.stock_2_5 = StockItem.objects.create(part=cls.sub_part_2, quantity=5)
|
||||
|
||||
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
|
||||
cls.stock_3_1 = StockItem.objects.create(part=cls.sub_part_3, quantity=1000)
|
||||
|
||||
|
||||
class BuildTest(BuildTestBase):
|
||||
@ -567,6 +570,29 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.assertTrue(messages.filter(user__pk=4).exists())
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the metadata field."""
|
||||
|
||||
# Make sure a BuildItem exists before trying to run this test
|
||||
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
|
||||
b.save()
|
||||
|
||||
for model in [Build, BuildItem]:
|
||||
p = model.objects.first()
|
||||
self.assertIsNone(p.metadata)
|
||||
|
||||
self.assertIsNone(p.get_metadata('test'))
|
||||
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
|
||||
|
||||
# Test update via the set_metadata() method
|
||||
p.set_metadata('test', 3)
|
||||
self.assertEqual(p.get_metadata('test'), 3)
|
||||
|
||||
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
|
||||
p.set_metadata(k, k)
|
||||
|
||||
self.assertEqual(len(p.metadata.keys()), 4)
|
||||
|
||||
|
||||
class AutoAllocationTests(BuildTestBase):
|
||||
"""Tests for auto allocating stock against a build order"""
|
||||
|
@ -1,13 +1,13 @@
|
||||
"""URL lookup for Build app."""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
build_urls = [
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
])),
|
||||
|
||||
|
@ -34,15 +34,11 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
|
||||
ctx['BuildStatus'] = BuildStatus
|
||||
ctx['sub_build_count'] = build.sub_build_count()
|
||||
|
||||
part = build.part
|
||||
bom_items = build.bom_items
|
||||
|
||||
ctx['part'] = part
|
||||
ctx['bom_items'] = bom_items
|
||||
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
|
||||
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
|
||||
|
||||
|
@ -7,10 +7,9 @@ from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_q.tasks import async_task
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from rest_framework import filters, permissions, serializers
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
@ -20,6 +19,7 @@ import common.models
|
||||
import common.serializers
|
||||
from InvenTree.api import BulkDeleteMixin
|
||||
from InvenTree.config import CONFIG_LOOKUPS
|
||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import inheritors
|
||||
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI)
|
||||
@ -167,11 +167,7 @@ class SettingsList(ListAPI):
|
||||
This is inheritted by all list views for settings.
|
||||
"""
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
'pk',
|
||||
@ -187,7 +183,7 @@ class SettingsList(ListAPI):
|
||||
class GlobalSettingsList(SettingsList):
|
||||
"""API endpoint for accessing a list of global settings objects."""
|
||||
|
||||
queryset = common.models.InvenTreeSetting.objects.all()
|
||||
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
|
||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||
|
||||
|
||||
@ -216,7 +212,7 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
|
||||
"""
|
||||
|
||||
lookup_field = 'key'
|
||||
queryset = common.models.InvenTreeSetting.objects.all()
|
||||
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
|
||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||
|
||||
def get_object(self):
|
||||
@ -226,7 +222,10 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
|
||||
if key not in common.models.InvenTreeSetting.SETTINGS.keys():
|
||||
raise NotFound()
|
||||
|
||||
return common.models.InvenTreeSetting.get_setting_object(key)
|
||||
return common.models.InvenTreeSetting.get_setting_object(
|
||||
key,
|
||||
cache=False, create=True
|
||||
)
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
@ -284,7 +283,11 @@ class UserSettingsDetail(RetrieveUpdateAPI):
|
||||
if key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
|
||||
raise NotFound()
|
||||
|
||||
return common.models.InvenTreeUserSetting.get_setting_object(key, user=self.request.user)
|
||||
return common.models.InvenTreeUserSetting.get_setting_object(
|
||||
key,
|
||||
user=self.request.user,
|
||||
cache=False, create=True
|
||||
)
|
||||
|
||||
permission_classes = [
|
||||
UserSettingsPermissions,
|
||||
@ -332,11 +335,7 @@ class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, ]
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
'category',
|
||||
@ -401,10 +400,7 @@ class NewsFeedMixin:
|
||||
|
||||
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List view for all news items."""
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filter_backends = ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
'published',
|
||||
@ -457,7 +453,7 @@ settings_api_urls = [
|
||||
# Notification settings
|
||||
re_path(r'^notification/', include([
|
||||
# Notification Settings Detail
|
||||
re_path(r'^(?P<pk>\d+)/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
|
||||
path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
|
||||
|
||||
# Notification Settings List
|
||||
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
|
||||
@ -486,7 +482,7 @@ common_api_urls = [
|
||||
# Notifications
|
||||
re_path(r'^notifications/', include([
|
||||
# Individual purchase order detail URLs
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'),
|
||||
])),
|
||||
# Read all
|
||||
@ -498,7 +494,7 @@ common_api_urls = [
|
||||
|
||||
# News
|
||||
re_path(r'^news/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),
|
||||
|
@ -59,7 +59,8 @@ class FileManager:
|
||||
# Reset stream position to beginning of file
|
||||
file.seek(0)
|
||||
else:
|
||||
raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
|
||||
fmt = ext.upper()
|
||||
raise ValidationError(_(f'Unsupported file format: {fmt}'))
|
||||
except UnicodeEncodeError:
|
||||
raise ValidationError(_('Error reading file (invalid encoding)'))
|
||||
|
||||
|
@ -46,6 +46,7 @@ import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
import order.validators
|
||||
from plugin import registry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@ -179,6 +180,10 @@ class BaseInvenTreeSetting(models.Model):
|
||||
"""
|
||||
results = cls.objects.all()
|
||||
|
||||
if exclude_hidden:
|
||||
# Keys which start with an undersore are used for internal functionality
|
||||
results = results.exclude(key__startswith='_')
|
||||
|
||||
# Optionally filter by user
|
||||
if user is not None:
|
||||
results = results.filter(user=user)
|
||||
@ -383,10 +388,10 @@ class BaseInvenTreeSetting(models.Model):
|
||||
if not setting:
|
||||
|
||||
# Unless otherwise specified, attempt to create the setting
|
||||
create = kwargs.get('create', True)
|
||||
create = kwargs.pop('create', True)
|
||||
|
||||
# Prevent creation of new settings objects when importing data
|
||||
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True):
|
||||
create = False
|
||||
|
||||
if create:
|
||||
@ -852,6 +857,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
even if that key does not exist.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for InvenTreeSetting."""
|
||||
|
||||
verbose_name = "InvenTree Setting"
|
||||
verbose_name_plural = "InvenTree Settings"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""When saving a global setting, check to see if it requires a server restart.
|
||||
|
||||
@ -973,6 +984,17 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
]
|
||||
},
|
||||
|
||||
'INVENTREE_UPDATE_CHECK_INTERVAL': {
|
||||
'name': _('Update Check Inverval'),
|
||||
'description': _('How often to check for updates (set to zero to disable)'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(0),
|
||||
],
|
||||
'default': 7,
|
||||
'units': _('days'),
|
||||
},
|
||||
|
||||
'INVENTREE_BACKUP_ENABLE': {
|
||||
'name': _('Automatic Backup'),
|
||||
'description': _('Enable automatic backup of database and media files'),
|
||||
@ -981,20 +1003,21 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
|
||||
'INVENTREE_BACKUP_DAYS': {
|
||||
'name': _('Days Between Backup'),
|
||||
'name': _('Auto Backup Interval'),
|
||||
'description': _('Specify number of days between automated backup events'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(1),
|
||||
],
|
||||
'default': 1,
|
||||
'units': _('days'),
|
||||
},
|
||||
|
||||
'INVENTREE_DELETE_TASKS_DAYS': {
|
||||
'name': _('Delete Old Tasks'),
|
||||
'name': _('Task Deletion Interval'),
|
||||
'description': _('Background task results will be deleted after specified number of days'),
|
||||
'default': 30,
|
||||
'units': 'days',
|
||||
'units': _('days'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(7),
|
||||
@ -1002,10 +1025,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
|
||||
'INVENTREE_DELETE_ERRORS_DAYS': {
|
||||
'name': _('Delete Error Logs'),
|
||||
'name': _('Error Log Deletion Interval'),
|
||||
'description': _('Error logs will be deleted after specified number of days'),
|
||||
'default': 30,
|
||||
'units': 'days',
|
||||
'units': _('days'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(7)
|
||||
@ -1013,10 +1036,10 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
|
||||
'INVENTREE_DELETE_NOTIFICATIONS_DAYS': {
|
||||
'name': _('Delete Notifications'),
|
||||
'name': _('Notification Deletion Interval'),
|
||||
'description': _('User notifications will be deleted after specified number of days'),
|
||||
'default': 30,
|
||||
'units': 'days',
|
||||
'units': _('days'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(7),
|
||||
@ -1048,6 +1071,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_ENABLE_REVISION': {
|
||||
'name': _('Part Revisions'),
|
||||
'description': _('Enable revision field for Part'),
|
||||
'validator': bool,
|
||||
'default': True,
|
||||
},
|
||||
|
||||
'PART_IPN_REGEX': {
|
||||
'name': _('IPN Regex'),
|
||||
'description': _('Regular expression pattern for matching Part IPN')
|
||||
@ -1186,9 +1216,20 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': '',
|
||||
},
|
||||
|
||||
'PRICING_DECIMAL_PLACES_MIN': {
|
||||
'name': _('Minimum Pricing Decimal Places'),
|
||||
'description': _('Minimum number of decimal places to display when rendering pricing data'),
|
||||
'default': 0,
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(0),
|
||||
MaxValueValidator(4),
|
||||
]
|
||||
},
|
||||
|
||||
'PRICING_DECIMAL_PLACES': {
|
||||
'name': _('Pricing Decimal Places'),
|
||||
'description': _('Number of decimal places to display when rendering pricing data'),
|
||||
'name': _('Maximum Pricing Decimal Places'),
|
||||
'description': _('Maximum number of decimal places to display when rendering pricing data'),
|
||||
'default': 6,
|
||||
'validator': [
|
||||
int,
|
||||
@ -1222,7 +1263,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'name': _('Stock Item Pricing Age'),
|
||||
'description': _('Exclude stock items older than this number of days from pricing calculations'),
|
||||
'default': 0,
|
||||
'units': 'days',
|
||||
'units': _('days'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(0),
|
||||
@ -1244,7 +1285,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
|
||||
'PRICING_UPDATE_DAYS': {
|
||||
'name': _('Pricing Rebuild Time'),
|
||||
'name': _('Pricing Rebuild Interval'),
|
||||
'description': _('Number of days before part pricing is automatically updated'),
|
||||
'units': _('days'),
|
||||
'default': 30,
|
||||
@ -1400,6 +1441,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': build.validators.validate_build_order_reference_pattern,
|
||||
},
|
||||
|
||||
'RETURNORDER_ENABLED': {
|
||||
'name': _('Enable Return Orders'),
|
||||
'description': _('Enable return order functionality in the user interface'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
|
||||
'RETURNORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Return Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Return Order reference field'),
|
||||
'default': 'RMA-{ref:04d}',
|
||||
'validator': order.validators.validate_return_order_reference_pattern,
|
||||
},
|
||||
|
||||
'RETURNORDER_EDIT_COMPLETED_ORDERS': {
|
||||
'name': _('Edit Completed Return Orders'),
|
||||
'description': _('Allow editing of return orders after they have been completed'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SALESORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Sales Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Sales Order reference field'),
|
||||
@ -1568,16 +1630,39 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
'requires_restart': True,
|
||||
},
|
||||
|
||||
'STOCKTAKE_ENABLE': {
|
||||
'name': _('Stocktake Functionality'),
|
||||
'description': _('Enable stocktake functionality for recording stock levels and calculating stock value'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
|
||||
'STOCKTAKE_AUTO_DAYS': {
|
||||
'name': _('Automatic Stocktake Period'),
|
||||
'description': _('Number of days between automatic stocktake recording (set to zero to disable)'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(0),
|
||||
],
|
||||
'default': 0,
|
||||
},
|
||||
|
||||
'STOCKTAKE_DELETE_REPORT_DAYS': {
|
||||
'name': _('Report Deletion Interval'),
|
||||
'description': _('Stocktake reports will be deleted after specified number of days'),
|
||||
'default': 30,
|
||||
'units': _('days'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(7),
|
||||
]
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
typ = 'inventree'
|
||||
|
||||
class Meta:
|
||||
"""Meta options for InvenTreeSetting."""
|
||||
|
||||
verbose_name = "InvenTree Setting"
|
||||
verbose_name_plural = "InvenTree Settings"
|
||||
|
||||
key = models.CharField(
|
||||
max_length=50,
|
||||
blank=False,
|
||||
@ -1599,9 +1684,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
return False
|
||||
|
||||
|
||||
def label_printer_options():
|
||||
"""Build a list of available label printer options."""
|
||||
printers = [('', _('No Printer (Export to PDF)'))]
|
||||
label_printer_plugins = registry.with_mixin('labels')
|
||||
if label_printer_plugins:
|
||||
printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins])
|
||||
return printers
|
||||
|
||||
|
||||
class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
"""An InvenTreeSetting object with a usercontext."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for InvenTreeUserSetting."""
|
||||
|
||||
verbose_name = "InvenTree User Setting"
|
||||
verbose_name_plural = "InvenTree User Settings"
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
|
||||
]
|
||||
|
||||
SETTINGS = {
|
||||
'HOMEPAGE_PART_STARRED': {
|
||||
'name': _('Show subscribed parts'),
|
||||
@ -1743,6 +1846,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
"LABEL_DEFAULT_PRINTER": {
|
||||
'name': _('Default label printer'),
|
||||
'description': _('Configure which label printer should be selected by default'),
|
||||
'default': '',
|
||||
'choices': label_printer_options
|
||||
},
|
||||
|
||||
"REPORT_INLINE": {
|
||||
'name': _('Inline report display'),
|
||||
'description': _('Display PDF reports in the browser, instead of downloading as a file'),
|
||||
@ -1758,7 +1868,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS': {
|
||||
'name': _('Seach Supplier Parts'),
|
||||
'name': _('Search Supplier Parts'),
|
||||
'description': _('Display supplier parts in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
@ -1848,6 +1958,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'default': True,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': {
|
||||
'name': _('Search Return Orders'),
|
||||
'description': _('Display return orders in search preview window'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': {
|
||||
'name': _('Exclude Inactive Return Orders'),
|
||||
'description': _('Exclude inactive return orders from search preview window'),
|
||||
'validator': bool,
|
||||
'default': True,
|
||||
},
|
||||
|
||||
'SEARCH_PREVIEW_RESULTS': {
|
||||
'name': _('Search Preview Results'),
|
||||
'description': _('Number of results to show in each section of the search preview window'),
|
||||
@ -1855,6 +1979,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': [int, MinValueValidator(1)]
|
||||
},
|
||||
|
||||
'SEARCH_REGEX': {
|
||||
'name': _('Regex Search'),
|
||||
'description': _('Enable regular expressions in search queries'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'SEARCH_WHOLE': {
|
||||
'name': _('Whole Word Search'),
|
||||
'description': _('Search queries return results for whole word matches'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_QUANTITY_IN_FORMS': {
|
||||
'name': _('Show Quantity in Forms'),
|
||||
'description': _('Display available part quantity in some forms'),
|
||||
@ -1900,7 +2038,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
|
||||
'DISPLAY_STOCKTAKE_TAB': {
|
||||
'name': _('Part Stocktake'),
|
||||
'description': _('Display part stocktake information'),
|
||||
'description': _('Display part stocktake information (if stocktake functionality is enabled)'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
@ -1918,15 +2056,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
|
||||
typ = 'user'
|
||||
|
||||
class Meta:
|
||||
"""Meta options for InvenTreeUserSetting."""
|
||||
|
||||
verbose_name = "InvenTree User Setting"
|
||||
verbose_name_plural = "InvenTree User Settings"
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
|
||||
]
|
||||
|
||||
key = models.CharField(
|
||||
max_length=50,
|
||||
blank=False,
|
||||
|
@ -180,7 +180,7 @@ class MethodStorageClass:
|
||||
Args:
|
||||
selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
|
||||
"""
|
||||
logger.info('collecting notification methods')
|
||||
logger.debug('Collecting notification methods')
|
||||
current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
|
||||
|
||||
# for testing selective loading is made available
|
||||
@ -196,7 +196,7 @@ class MethodStorageClass:
|
||||
filtered_list[ref] = item
|
||||
|
||||
storage.liste = list(filtered_list.values())
|
||||
logger.info(f'found {len(storage.liste)} notification methods')
|
||||
logger.info(f'Found {len(storage.liste)} notification methods')
|
||||
|
||||
def get_usersettings(self, user) -> list:
|
||||
"""Returns all user settings for a specific user.
|
||||
@ -305,6 +305,13 @@ class InvenTreeNotificationBodies:
|
||||
template='email/purchase_order_received.html',
|
||||
)
|
||||
|
||||
ReturnOrderItemsReceived = NotificationBody(
|
||||
name=_('Items Received'),
|
||||
slug='return_order.items_received',
|
||||
message=_('Items have been received against a return order'),
|
||||
template='email/return_order_received.html',
|
||||
)
|
||||
|
||||
|
||||
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
"""Send out a notification."""
|
||||
|
@ -77,8 +77,6 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
||||
class UserSettingsSerializer(SettingsSerializer):
|
||||
"""Serializer for the InvenTreeUserSetting model."""
|
||||
|
||||
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Meta options for UserSettingsSerializer."""
|
||||
|
||||
@ -97,6 +95,8 @@ class UserSettingsSerializer(SettingsSerializer):
|
||||
'typ',
|
||||
]
|
||||
|
||||
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
|
||||
class GenericReferencedSettingSerializer(SettingsSerializer):
|
||||
"""Serializer for a GenericReferencedSetting model.
|
||||
@ -140,28 +140,41 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
|
||||
class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the InvenTreeUserSetting model."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for NotificationMessageSerializer."""
|
||||
|
||||
model = NotificationMessage
|
||||
fields = [
|
||||
'pk',
|
||||
'target',
|
||||
'source',
|
||||
'user',
|
||||
'category',
|
||||
'name',
|
||||
'message',
|
||||
'creation',
|
||||
'age',
|
||||
'age_human',
|
||||
'read',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'category',
|
||||
'name',
|
||||
'message',
|
||||
'creation',
|
||||
'age',
|
||||
'age_human',
|
||||
]
|
||||
|
||||
target = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
source = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
category = serializers.CharField(read_only=True)
|
||||
|
||||
name = serializers.CharField(read_only=True)
|
||||
|
||||
message = serializers.CharField(read_only=True)
|
||||
|
||||
creation = serializers.CharField(read_only=True)
|
||||
|
||||
age = serializers.IntegerField(read_only=True)
|
||||
|
||||
age_human = serializers.CharField(read_only=True)
|
||||
|
||||
read = serializers.BooleanField()
|
||||
|
||||
def get_target(self, obj):
|
||||
"""Function to resolve generic object reference to target."""
|
||||
|
||||
target = get_objectreference(obj, 'target_content_type', 'target_object_id')
|
||||
|
||||
if target and 'link' not in target:
|
||||
@ -184,30 +197,10 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
|
||||
"""Function to resolve generic object reference to source."""
|
||||
return get_objectreference(obj, 'source_content_type', 'source_object_id')
|
||||
|
||||
class Meta:
|
||||
"""Meta options for NotificationMessageSerializer."""
|
||||
|
||||
model = NotificationMessage
|
||||
fields = [
|
||||
'pk',
|
||||
'target',
|
||||
'source',
|
||||
'user',
|
||||
'category',
|
||||
'name',
|
||||
'message',
|
||||
'creation',
|
||||
'age',
|
||||
'age_human',
|
||||
'read',
|
||||
]
|
||||
|
||||
|
||||
class NewsFeedEntrySerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the NewsFeedEntry model."""
|
||||
|
||||
read = serializers.BooleanField()
|
||||
|
||||
class Meta:
|
||||
"""Meta options for NewsFeedEntrySerializer."""
|
||||
|
||||
@ -223,6 +216,8 @@ class NewsFeedEntrySerializer(InvenTreeModelSerializer):
|
||||
'read',
|
||||
]
|
||||
|
||||
read = serializers.BooleanField()
|
||||
|
||||
|
||||
class ConfigSerializer(serializers.Serializer):
|
||||
"""Serializer for the InvenTree configuration.
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Tests for mechanisms in common."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
@ -921,7 +922,16 @@ class CurrencyAPITests(InvenTreeAPITestCase):
|
||||
# Delete any existing exchange rate data
|
||||
Rate.objects.all().delete()
|
||||
|
||||
# Updating via the external exchange may not work every time
|
||||
for _idx in range(5):
|
||||
self.post(reverse('api-currency-refresh'))
|
||||
|
||||
# There should be some new exchange rate objects now
|
||||
self.assertTrue(Rate.objects.all().exists())
|
||||
if Rate.objects.all().exists():
|
||||
# Exit early
|
||||
return
|
||||
|
||||
# Delay and try again
|
||||
time.sleep(10)
|
||||
|
||||
raise TimeoutError("Could not refresh currency exchange data after 5 attempts")
|
||||
|
@ -41,6 +41,13 @@ class CompanyAdmin(ImportExportModelAdmin):
|
||||
class SupplierPartResource(InvenTreeResource):
|
||||
"""Class for managing SupplierPart data import/export."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra admin options"""
|
||||
model = SupplierPart
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
clean_model_instances = True
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
part_name = Field(attribute='part__full_name', readonly=True)
|
||||
@ -49,13 +56,6 @@ class SupplierPartResource(InvenTreeResource):
|
||||
|
||||
supplier_name = Field(attribute='supplier__name', readonly=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra admin options"""
|
||||
model = SupplierPart
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SupplierPriceBreakInline(admin.TabularInline):
|
||||
"""Inline for supplier-part pricing"""
|
||||
@ -87,6 +87,13 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
class ManufacturerPartResource(InvenTreeResource):
|
||||
"""Class for managing ManufacturerPart data import/export."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra admin options"""
|
||||
model = ManufacturerPart
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
clean_model_instances = True
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
part_name = Field(attribute='part__full_name', readonly=True)
|
||||
@ -95,13 +102,6 @@ class ManufacturerPartResource(InvenTreeResource):
|
||||
|
||||
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra admin options"""
|
||||
model = ManufacturerPart
|
||||
skip_unchanged = True
|
||||
report_skipped = True
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for ManufacturerPart model."""
|
||||
@ -157,6 +157,13 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
||||
class SupplierPriceBreakResource(InvenTreeResource):
|
||||
"""Class for managing SupplierPriceBreak data import/export."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra admin options"""
|
||||
model = SupplierPriceBreak
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
supplier_id = Field(attribute='part__supplier__pk', readonly=True)
|
||||
@ -169,13 +176,6 @@ class SupplierPriceBreakResource(InvenTreeResource):
|
||||
|
||||
MPN = Field(attribute='part__MPN', readonly=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra admin options"""
|
||||
model = SupplierPriceBreak
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SupplierPriceBreak model"""
|
||||
|
@ -1,24 +1,24 @@
|
||||
"""Provides a JSON API for the Company app."""
|
||||
|
||||
from django.db.models import Q
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
|
||||
import part.models
|
||||
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView,
|
||||
MetadataView)
|
||||
from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS)
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI)
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
|
||||
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter, SupplierPart,
|
||||
SupplierPriceBreak)
|
||||
from .serializers import (CompanySerializer,
|
||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
SupplierPart, SupplierPriceBreak)
|
||||
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
|
||||
ContactSerializer,
|
||||
ManufacturerPartAttachmentSerializer,
|
||||
ManufacturerPartParameterSerializer,
|
||||
ManufacturerPartSerializer, SupplierPartSerializer,
|
||||
@ -44,11 +44,7 @@ class CompanyList(ListCreateAPI):
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
filterset_fields = [
|
||||
'is_customer',
|
||||
@ -86,14 +82,57 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
|
||||
return queryset
|
||||
|
||||
|
||||
class CompanyMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating Company metadata."""
|
||||
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for the CompanyAttachment model"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance for a Company"""
|
||||
return MetadataSerializer(Company, *args, **kwargs)
|
||||
queryset = CompanyAttachment.objects.all()
|
||||
serializer_class = CompanyAttachmentSerializer
|
||||
|
||||
queryset = Company.objects.all()
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'company',
|
||||
]
|
||||
|
||||
|
||||
class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for CompanyAttachment model."""
|
||||
|
||||
queryset = CompanyAttachment.objects.all()
|
||||
serializer_class = CompanyAttachmentSerializer
|
||||
|
||||
|
||||
class ContactList(ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of Company model"""
|
||||
|
||||
queryset = Contact.objects.all()
|
||||
serializer_class = ContactSerializer
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
filterset_fields = [
|
||||
'company',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'company__name',
|
||||
'name',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'name',
|
||||
]
|
||||
|
||||
ordering = 'name'
|
||||
|
||||
|
||||
class ContactDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for Company model"""
|
||||
|
||||
queryset = Contact.objects.all()
|
||||
serializer_class = ContactSerializer
|
||||
|
||||
|
||||
class ManufacturerPartFilter(rest_filters.FilterSet):
|
||||
@ -145,11 +184,7 @@ class ManufacturerPartList(ListCreateDestroyAPIView):
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = [
|
||||
'manufacturer__name',
|
||||
@ -195,11 +230,30 @@ class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI
|
||||
serializer_class = ManufacturerPartAttachmentSerializer
|
||||
|
||||
|
||||
class ManufacturerPartParameterFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the ManufacturerPartParameterList API endpoint"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = ManufacturerPartParameter
|
||||
fields = [
|
||||
'name',
|
||||
'value',
|
||||
'units',
|
||||
'manufacturer_part',
|
||||
]
|
||||
|
||||
manufacturer = rest_filters.ModelChoiceFilter(queryset=Company.objects.all(), field_name='manufacturer_part__manufacturer')
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(queryset=part.models.Part.objects.all(), field_name='manufacturer_part__part')
|
||||
|
||||
|
||||
class ManufacturerPartParameterList(ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of ManufacturerPartParamater model."""
|
||||
|
||||
queryset = ManufacturerPartParameter.objects.all()
|
||||
serializer_class = ManufacturerPartParameterSerializer
|
||||
filterset_class = ManufacturerPartParameterFilter
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return serializer instance for this endpoint"""
|
||||
@ -221,38 +275,7 @@ class ManufacturerPartParameterList(ListCreateDestroyAPIView):
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom filtering for the queryset."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by manufacturer?
|
||||
manufacturer = params.get('manufacturer', None)
|
||||
|
||||
if manufacturer is not None:
|
||||
queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer)
|
||||
|
||||
# Filter by part?
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(manufacturer_part__part=part)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'name',
|
||||
'value',
|
||||
'units',
|
||||
'manufacturer_part',
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
@ -349,11 +372,7 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
||||
|
||||
serializer_class = SupplierPartSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = [
|
||||
'SKU',
|
||||
@ -405,6 +424,15 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
|
||||
class SupplierPriceBreakFilter(rest_filters.FilterSet):
|
||||
"""Custom API filters for the SupplierPriceBreak list endpoint"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
base_part = rest_filters.ModelChoiceFilter(
|
||||
label='Base Part',
|
||||
queryset=part.models.Part.objects.all(),
|
||||
@ -417,15 +445,6 @@ class SupplierPriceBreakFilter(rest_filters.FilterSet):
|
||||
field_name='part__supplier',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
class SupplierPriceBreakList(ListCreateAPI):
|
||||
"""API endpoint for list view of SupplierPriceBreak object.
|
||||
@ -454,10 +473,7 @@ class SupplierPriceBreakList(ListCreateAPI):
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filter_backends = ORDER_FILTER
|
||||
|
||||
ordering_fields = [
|
||||
'quantity',
|
||||
@ -477,18 +493,21 @@ manufacturer_part_api_urls = [
|
||||
|
||||
# Base URL for ManufacturerPartAttachment API endpoints
|
||||
re_path(r'^attachment/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
|
||||
path(r'<int:pk>/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
|
||||
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^parameter/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
|
||||
path(r'<int:pk>/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
|
||||
|
||||
# Catch anything else
|
||||
re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/?', include([
|
||||
re_path('^metadata/', MetadataView.as_view(), {'model': ManufacturerPart}, name='api-manufacturer-part-metadata'),
|
||||
re_path('^.*$', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
|
||||
])),
|
||||
|
||||
# Catch anything else
|
||||
re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
|
||||
@ -497,7 +516,10 @@ manufacturer_part_api_urls = [
|
||||
|
||||
supplier_part_api_urls = [
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/?', include([
|
||||
re_path('^metadata/', MetadataView.as_view(), {'model': SupplierPart}, name='api-supplier-part-metadata'),
|
||||
re_path('^.*$', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
|
||||
])),
|
||||
|
||||
# Catch anything else
|
||||
re_path(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'),
|
||||
@ -517,10 +539,20 @@ company_api_urls = [
|
||||
])),
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/?', include([
|
||||
re_path(r'^metadata/', CompanyMetadata.as_view(), name='api-company-metadata'),
|
||||
re_path(r'^metadata/', MetadataView.as_view(), {'model': Company}, name='api-company-metadata'),
|
||||
re_path(r'^.*$', CompanyDetail.as_view(), name='api-company-detail'),
|
||||
])),
|
||||
|
||||
re_path(r'^attachment/', include([
|
||||
path(r'<int:pk>/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'),
|
||||
re_path(r'^$', CompanyAttachmentList.as_view(), name='api-company-attachment-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^contact/', include([
|
||||
path('<int:pk>/', ContactDetail.as_view(), name='api-contact-detail'),
|
||||
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
|
||||
|
||||
]
|
||||
|
@ -23,7 +23,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
for the SupplierPriceBreak model, to a new django-money compatible currency.
|
||||
"""
|
||||
|
||||
logger.info("Updating currency references for SupplierPriceBreak model...")
|
||||
logger.debug("Updating currency references for SupplierPriceBreak model...")
|
||||
|
||||
# A list of available currency codes
|
||||
currency_codes = CURRENCIES.keys()
|
||||
|
33
InvenTree/company/migrations/0054_companyattachment.py
Normal file
33
InvenTree/company/migrations/0054_companyattachment.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 3.2.16 on 2023-02-15 12:55
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('company', '0053_supplierpart_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CompanyAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='company.company', verbose_name='Company')),
|
||||
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
23
InvenTree/company/migrations/0055_auto_20230317_0816.py
Normal file
23
InvenTree/company/migrations/0055_auto_20230317_0816.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-17 08:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0054_companyattachment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='manufacturerpart',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supplierpart',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
]
|
@ -81,11 +81,6 @@ class Company(MetadataMixin, models.Model):
|
||||
currency_code: Specifies the default currency for the company
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the Company model"""
|
||||
return reverse('api-company-list')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
ordering = ['name', ]
|
||||
@ -94,6 +89,11 @@ class Company(MetadataMixin, models.Model):
|
||||
]
|
||||
verbose_name_plural = "Companies"
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the Company model"""
|
||||
return reverse('api-company-list')
|
||||
|
||||
name = models.CharField(max_length=100, blank=False,
|
||||
help_text=_('Company name'),
|
||||
verbose_name=_('Company name'))
|
||||
@ -205,6 +205,25 @@ class Company(MetadataMixin, models.Model):
|
||||
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all()
|
||||
|
||||
|
||||
class CompanyAttachment(InvenTreeAttachment):
|
||||
"""Model for storing file or URL attachments against a Company object"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with this model"""
|
||||
return reverse('api-company-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory where these attachments are uploaded"""
|
||||
return os.path.join('company_files', str(self.company.pk))
|
||||
|
||||
company = models.ForeignKey(
|
||||
Company, on_delete=models.CASCADE,
|
||||
verbose_name=_('Company'),
|
||||
related_name='attachments',
|
||||
)
|
||||
|
||||
|
||||
class Contact(models.Model):
|
||||
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
|
||||
|
||||
@ -216,6 +235,11 @@ class Contact(models.Model):
|
||||
role: position in company
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the Contcat model"""
|
||||
return reverse('api-contact-list')
|
||||
|
||||
company = models.ForeignKey(Company, related_name='contacts',
|
||||
on_delete=models.CASCADE)
|
||||
|
||||
@ -228,7 +252,7 @@ class Contact(models.Model):
|
||||
role = models.CharField(max_length=100, blank=True)
|
||||
|
||||
|
||||
class ManufacturerPart(models.Model):
|
||||
class ManufacturerPart(MetadataMixin, models.Model):
|
||||
"""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:
|
||||
@ -239,15 +263,15 @@ class ManufacturerPart(models.Model):
|
||||
description: Descriptive notes field
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
unique_together = ('part', 'manufacturer', 'MPN')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the ManufacturerPart instance"""
|
||||
return reverse('api-manufacturer-part-list')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
unique_together = ('part', 'manufacturer', 'MPN')
|
||||
|
||||
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
|
||||
related_name='manufacturer_parts',
|
||||
verbose_name=_('Base Part'),
|
||||
@ -341,15 +365,15 @@ class ManufacturerPartParameter(models.Model):
|
||||
Each parameter is a simple string (text) value.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
unique_together = ('manufacturer_part', 'name')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the ManufacturerPartParameter model"""
|
||||
return reverse('api-manufacturer-part-parameter-list')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
unique_together = ('manufacturer_part', 'name')
|
||||
|
||||
manufacturer_part = models.ForeignKey(
|
||||
ManufacturerPart,
|
||||
on_delete=models.CASCADE,
|
||||
@ -396,7 +420,7 @@ class SupplierPartManager(models.Manager):
|
||||
)
|
||||
|
||||
|
||||
class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin):
|
||||
class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin):
|
||||
"""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:
|
||||
@ -415,6 +439,13 @@ class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin):
|
||||
updated: Date that the SupplierPart was last updated
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
unique_together = ('part', 'supplier', 'SKU')
|
||||
|
||||
# This model was moved from the 'Part' app
|
||||
db_table = 'part_supplierpart'
|
||||
|
||||
objects = SupplierPartManager()
|
||||
|
||||
@staticmethod
|
||||
@ -434,13 +465,6 @@ class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin):
|
||||
}
|
||||
}
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
unique_together = ('part', 'supplier', 'SKU')
|
||||
|
||||
# This model was moved from the 'Part' app
|
||||
db_table = 'part_supplierpart'
|
||||
|
||||
def clean(self):
|
||||
"""Custom clean action for the SupplierPart model:
|
||||
|
||||
@ -677,13 +701,6 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
currency: Reference to the currency of this pricebreak (leave empty for base currency)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the SupplierPriceBreak model"""
|
||||
return reverse('api-part-supplier-price-list')
|
||||
|
||||
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks', verbose_name=_('Part'),)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options"""
|
||||
unique_together = ("part", "quantity")
|
||||
@ -695,6 +712,13 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
"""Format a string representation of a SupplierPriceBreak instance"""
|
||||
return f'{self.part.SKU} - {self.price} @ {self.quantity}'
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the SupplierPriceBreak model"""
|
||||
return reverse('api-part-supplier-price-list')
|
||||
|
||||
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks', verbose_name=_('Part'),)
|
||||
|
||||
|
||||
@receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break')
|
||||
def after_save_supplier_price(sender, instance, created, **kwargs):
|
||||
@ -703,7 +727,7 @@ def after_save_supplier_price(sender, instance, created, **kwargs):
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.schedule_pricing_update()
|
||||
instance.part.part.schedule_pricing_update(create=True)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
|
||||
@ -713,4 +737,4 @@ def after_delete_supplier_price(sender, instance, **kwargs):
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.schedule_pricing_update()
|
||||
instance.part.part.schedule_pricing_update(create=False)
|
||||
|
@ -9,26 +9,22 @@ from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
import part.filters
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeCurrencySerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer, RemoteImageMixin)
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter, SupplierPart,
|
||||
SupplierPriceBreak)
|
||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
SupplierPart, SupplierPriceBreak)
|
||||
|
||||
|
||||
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for Company object (limited detail)"""
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -41,39 +37,14 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
'image',
|
||||
]
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
|
||||
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for Company object (full detail)"""
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Annoate the supplied queryset with aggregated information"""
|
||||
# Add count of parts manufactured
|
||||
queryset = queryset.annotate(
|
||||
parts_manufactured=SubqueryCount('manufactured_parts')
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
parts_supplied=SubqueryCount('supplied_parts')
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
image = InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||
|
||||
parts_supplied = serializers.IntegerField(read_only=True)
|
||||
parts_manufactured = serializers.IntegerField(read_only=True)
|
||||
|
||||
currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
initial=currency_code_default,
|
||||
help_text=_('Default currency used for this supplier'),
|
||||
label=_('Currency Code'),
|
||||
required=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -101,6 +72,29 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
'remote_image',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Annoate the supplied queryset with aggregated information"""
|
||||
# Add count of parts manufactured
|
||||
queryset = queryset.annotate(
|
||||
parts_manufactured=SubqueryCount('manufactured_parts')
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
parts_supplied=SubqueryCount('supplied_parts')
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
image = InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||
|
||||
parts_supplied = serializers.IntegerField(read_only=True)
|
||||
parts_manufactured = serializers.IntegerField(read_only=True)
|
||||
|
||||
currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True)
|
||||
|
||||
def save(self):
|
||||
"""Save the Company instance"""
|
||||
super().save()
|
||||
@ -126,14 +120,53 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
return self.instance
|
||||
|
||||
|
||||
class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the CompanyAttachment class"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines serializer options"""
|
||||
model = CompanyAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'company',
|
||||
])
|
||||
|
||||
|
||||
class ContactSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer class for the Contact model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = Contact
|
||||
fields = [
|
||||
'pk',
|
||||
'company',
|
||||
'name',
|
||||
'phone',
|
||||
'email',
|
||||
'role',
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for ManufacturerPart object."""
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
|
||||
|
||||
pretty_name = serializers.CharField(read_only=True)
|
||||
model = ManufacturerPart
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'pretty_name',
|
||||
'manufacturer',
|
||||
'manufacturer_detail',
|
||||
'description',
|
||||
'MPN',
|
||||
'link',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
@ -152,24 +185,14 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
|
||||
|
||||
pretty_name = serializers.CharField(read_only=True)
|
||||
|
||||
manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = ManufacturerPart
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'pretty_name',
|
||||
'manufacturer',
|
||||
'manufacturer_detail',
|
||||
'description',
|
||||
'MPN',
|
||||
'link',
|
||||
]
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the ManufacturerPartAttachment class."""
|
||||
@ -179,37 +202,14 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
|
||||
model = ManufacturerPartAttachment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'manufacturer_part',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'upload_date',
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the ManufacturerPartParameter model."""
|
||||
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
man_detail = kwargs.pop('manufacturer_part_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not man_detail:
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -224,66 +224,20 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
'units',
|
||||
]
|
||||
|
||||
|
||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for SupplierPart object."""
|
||||
|
||||
# Annotated field showing total in-stock quantity
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
|
||||
manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True)
|
||||
|
||||
pretty_name = serializers.CharField(read_only=True)
|
||||
|
||||
pack_size = serializers.FloatField(label=_('Pack Quantity'))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
|
||||
# Check if 'available' quantity was supplied
|
||||
self.has_available_quantity = 'available' in kwargs.get('data', {})
|
||||
|
||||
brief = kwargs.pop('brief', False)
|
||||
|
||||
detail_default = not brief
|
||||
|
||||
part_detail = kwargs.pop('part_detail', detail_default)
|
||||
supplier_detail = kwargs.pop('supplier_detail', detail_default)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
|
||||
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
man_detail = kwargs.pop('manufacturer_part_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
if supplier_detail is not True:
|
||||
self.fields.pop('supplier_detail')
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
if not man_detail:
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
|
||||
|
||||
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
|
||||
|
||||
manufacturer = serializers.CharField(read_only=True)
|
||||
|
||||
MPN = serializers.CharField(read_only=True)
|
||||
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
# Date fields
|
||||
updated = serializers.DateTimeField(allow_null=True, read_only=True)
|
||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for SupplierPart object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@ -320,6 +274,63 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
|
||||
# Check if 'available' quantity was supplied
|
||||
self.has_available_quantity = 'available' in kwargs.get('data', {})
|
||||
|
||||
brief = kwargs.pop('brief', False)
|
||||
|
||||
detail_default = not brief
|
||||
|
||||
part_detail = kwargs.pop('part_detail', detail_default)
|
||||
supplier_detail = kwargs.pop('supplier_detail', detail_default)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
|
||||
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
if supplier_detail is not True:
|
||||
self.fields.pop('supplier_detail')
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
|
||||
# Annotated field showing total in-stock quantity
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
|
||||
manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True)
|
||||
|
||||
pretty_name = serializers.CharField(read_only=True)
|
||||
|
||||
pack_size = serializers.FloatField(label=_('Pack Quantity'))
|
||||
|
||||
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
|
||||
|
||||
manufacturer = serializers.CharField(read_only=True)
|
||||
|
||||
MPN = serializers.CharField(read_only=True)
|
||||
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
# Date fields
|
||||
updated = serializers.DateTimeField(allow_null=True, read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Annotate the SupplierPart queryset with extra fields:
|
||||
@ -375,6 +386,22 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for SupplierPriceBreak object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'updated',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra fields as required"""
|
||||
|
||||
@ -397,11 +424,7 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
label=_('Price'),
|
||||
)
|
||||
|
||||
price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
default=currency_code_default,
|
||||
label=_('Currency'),
|
||||
)
|
||||
price_currency = InvenTreeCurrencySerializer()
|
||||
|
||||
supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True)
|
||||
|
||||
@ -409,19 +432,3 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
|
||||
# Detail serializer for SupplierPart
|
||||
part_detail = SupplierPartSerializer(source='part', brief=True, many=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'updated',
|
||||
]
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'company/sidebar.html' %}
|
||||
@ -137,6 +138,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if company.is_customer %}
|
||||
{% if roles.sales_order.view %}
|
||||
<div class='panel panel-hidden' id='panel-sales-orders'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
@ -162,7 +165,9 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if roles.stock.view %}
|
||||
<div class='panel panel-hidden' id='panel-assigned-stock'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Assigned Stock" %}</h4>
|
||||
@ -175,9 +180,40 @@
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if roles.return_order.view and return_order_enabled %}
|
||||
<div class='panel panel-hidden' id='panel-return-orders'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Return Orders" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.return_order.add %}
|
||||
<button class='btn btn-success' type='button' id='new-return-order' title='{% trans "Create new return order" %}'>
|
||||
<div class='fas fa-plus-circle'></div> {% trans "New Return Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
{% include "filter_list.html" with id="returnorder" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class='panel panel-hidden' id='panel-company-notes'>
|
||||
<div class='panel-heading'>
|
||||
@ -194,11 +230,86 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-company-contacts'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Company Contacts" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.purchase_order.add or roles.sales_order.add %}
|
||||
<button class='btn btn-success' type='button' id='new-contact' title='{% trans "Add Contact" %}'>
|
||||
<div class='fas fa-plus-circle'></div> {% trans "Add Contact" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='contacts-button-toolbar'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="contacts" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='contacts-table' data-toolbar='#contacts-button-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-attachments'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Attachments" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "attachment_button.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "attachment_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad("attachments", function() {
|
||||
loadAttachmentTable('{% url "api-company-attachment-list" %}', {
|
||||
filters: {
|
||||
company: {{ company.pk }},
|
||||
},
|
||||
fields: {
|
||||
company: {
|
||||
value: {{ company.pk }},
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback function when the 'contacts' panel is loaded
|
||||
onPanelLoad('company-contacts', function() {
|
||||
loadContactTable('#contacts-table', {
|
||||
params: {
|
||||
company: {{ company.pk }},
|
||||
},
|
||||
allow_edit: {% js_bool roles.purchase_order.change %} || {% js_bool roles.sales_order.change %},
|
||||
allow_delete: {% js_bool roles.purchase_order.delete %} || {% js_bool roles.sales_order.delete %},
|
||||
});
|
||||
|
||||
$('#new-contact').click(function() {
|
||||
createContact({
|
||||
company: {{ company.pk }},
|
||||
onSuccess: function() {
|
||||
$('#contacts-table').bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Callback function when the 'notes' panel is loaded
|
||||
onPanelLoad('company-notes', function() {
|
||||
|
||||
setupNotesField(
|
||||
@ -207,18 +318,7 @@
|
||||
{
|
||||
editable: true,
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
loadStockTable($("#assigned-stock-table"), {
|
||||
params: {
|
||||
customer: {{ company.id }},
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
filterKey: "customerstock",
|
||||
filterTarget: '#filter-list-customerstock',
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad('company-stock', function() {
|
||||
@ -239,20 +339,65 @@
|
||||
});
|
||||
|
||||
{% if company.is_customer %}
|
||||
|
||||
{% if return_order_enabled %}
|
||||
// Callback function when the 'return orders' panel is loaded
|
||||
onPanelLoad('return-orders', function() {
|
||||
|
||||
{% if roles.return_order.view %}
|
||||
loadReturnOrderTable('#return-order-table', {
|
||||
params: {
|
||||
customer: {{ company.pk }},
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if roles.return_order.add %}
|
||||
$('#new-return-order').click(function() {
|
||||
createReturnOrder({
|
||||
customer: {{ company.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Callback function when the 'assigned stock' panel is loaded
|
||||
onPanelLoad('assigned-stock', function() {
|
||||
|
||||
{% if roles.stock.view %}
|
||||
loadStockTable($("#assigned-stock-table"), {
|
||||
params: {
|
||||
customer: {{ company.id }},
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
filterKey: "customerstock",
|
||||
filterTarget: '#filter-list-customerstock',
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
// Callback function when the 'sales orders' panel is loaded
|
||||
onPanelLoad('sales-orders', function() {
|
||||
{% if roles.sales_order.view %}
|
||||
loadSalesOrderTable("#sales-order-table", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
params: {
|
||||
customer: {{ company.id }},
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if roles.salse_order.add %}
|
||||
$("#new-sales-order").click(function() {
|
||||
|
||||
createSalesOrder({
|
||||
customer: {{ company.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
@ -291,7 +436,7 @@
|
||||
createManufacturerPart({
|
||||
manufacturer: {{ company.pk }},
|
||||
onSuccess: function() {
|
||||
$("#part-table").bootstrapTable("refresh");
|
||||
$("#part-table").bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -313,7 +458,7 @@
|
||||
|
||||
deleteManufacturerParts(selections, {
|
||||
success: function() {
|
||||
$("#manufacturer-part-table").bootstrapTable("refresh");
|
||||
$("#manufacturer-part-table").bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -209,26 +209,8 @@ onPanelLoad("attachments", function() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-manufacturer-part-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
manufacturer_part: {{ part.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
reloadAttachmentTable();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function reloadParameters() {
|
||||
$("#parameter-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
$('#parameter-create').click(function() {
|
||||
|
||||
constructForm('{% url "api-manufacturer-part-parameter-list" %}', {
|
||||
@ -243,7 +225,7 @@ $('#parameter-create').click(function() {
|
||||
}
|
||||
},
|
||||
title: '{% trans "Add Parameter" %}',
|
||||
onSuccess: reloadParameters
|
||||
refreshTable: '#parameter-table',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -17,10 +17,22 @@
|
||||
{% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %}
|
||||
{% endif %}
|
||||
{% if company.is_customer %}
|
||||
{% if roles.sales_order.view %}
|
||||
{% trans "Sales Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %}
|
||||
{% endif %}
|
||||
{% if roles.stock.view %}
|
||||
{% trans "Assigned Stock Items" as text %}
|
||||
{% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %}
|
||||
{% endif %}
|
||||
{% if roles.return_order.view and return_order_enabled %}
|
||||
{% trans "Return Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='return-orders' text=text icon="fa-undo" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% trans "Contacts" as text %}
|
||||
{% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %}
|
||||
{% trans "Notes" as text %}
|
||||
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
|
||||
{% trans "Attachments" as text %}
|
||||
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
||||
|
@ -116,13 +116,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% include "barcode_data.html" with instance=part %}
|
||||
</table>
|
||||
|
||||
{% endblock details %}
|
||||
@ -270,10 +264,10 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% if barcodes %}
|
||||
|
||||
$("#show-qr-code").click(function() {
|
||||
launchModalForm("{% url 'supplier-part-qr' part.pk %}",
|
||||
{
|
||||
no_post: true,
|
||||
});
|
||||
showQRDialog(
|
||||
'{% trans "Supplier Part QR Code" %}',
|
||||
'{"supplierpart": {{ part.pk }}}'
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-link").click(function() {
|
||||
@ -299,27 +293,12 @@ loadSupplierPriceBreakTable({
|
||||
});
|
||||
|
||||
$('#new-price-break').click(function() {
|
||||
|
||||
constructForm(
|
||||
'{% url "api-part-supplier-price-list" %}',
|
||||
{
|
||||
method: 'POST',
|
||||
fields: {
|
||||
quantity: {},
|
||||
part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
price: {},
|
||||
price_currency: {
|
||||
},
|
||||
},
|
||||
title: '{% trans "Add Price Break" %}',
|
||||
createSupplierPartPriceBreak({{ part.pk }}, {
|
||||
onSuccess: function() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
$("#price-break-table").bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
loadPurchaseOrderTable($("#purchase-order-table"), {
|
||||
|
@ -1,33 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
<li class='list-group-item'>
|
||||
<a href='#' id='supplier-part-menu-toggle'>
|
||||
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item' title='{% trans "Supplier Part Stock" %}'>
|
||||
<a href='#' id='select-stock' class='nav-toggle'>
|
||||
<span class='fas fa-boxes sidebar-icon'></span>
|
||||
{% trans "Stock" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item' title='{% trans "Supplier Part Orders" %}'>
|
||||
<a href='#' id='select-purchase-orders' class='nav-toggle'>
|
||||
<span class='fas fa-shopping-cart sidebar-icon'></span>
|
||||
{% trans "Orders" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item' title='{% trans "Supplier Part Pricing" %}'>
|
||||
<a href='#' id='select-pricing' class='nav-toggle'>
|
||||
<span class='fas fa-dollar-sign sidebar-icon'></span>
|
||||
{% trans "Pricing" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
@ -6,7 +6,7 @@ from rest_framework import status
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
|
||||
from .models import Company, SupplierPart
|
||||
from .models import Company, Contact, SupplierPart
|
||||
|
||||
|
||||
class CompanyTest(InvenTreeAPITestCase):
|
||||
@ -17,11 +17,14 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
'purchase_order.change',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Perform initialization for the unit test class"""
|
||||
super().setUp()
|
||||
|
||||
self.acme = Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True)
|
||||
super().setUpTestData()
|
||||
|
||||
# Create some company objects to work with
|
||||
cls.acme = Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True)
|
||||
Company.objects.create(name='Drippy Cup Co.', description='Customer', is_customer=True, is_supplier=False)
|
||||
Company.objects.create(name='Sippy Cup Emporium', description='Another supplier')
|
||||
|
||||
@ -137,6 +140,144 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
self.assertTrue('currency' in response.data)
|
||||
|
||||
|
||||
class ContactTest(InvenTreeAPITestCase):
|
||||
"""Tests for the Contact models"""
|
||||
|
||||
roles = []
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Perform init for this test class"""
|
||||
|
||||
super().setUpTestData()
|
||||
|
||||
# Create some companies
|
||||
companies = [
|
||||
Company(
|
||||
name=f"Company {idx}",
|
||||
description="Some company"
|
||||
) for idx in range(3)
|
||||
]
|
||||
|
||||
Company.objects.bulk_create(companies)
|
||||
|
||||
contacts = []
|
||||
|
||||
# Create some contacts
|
||||
for cmp in Company.objects.all():
|
||||
contacts += [
|
||||
Contact(
|
||||
company=cmp,
|
||||
name=f"My name {idx}",
|
||||
) for idx in range(3)
|
||||
]
|
||||
|
||||
Contact.objects.bulk_create(contacts)
|
||||
|
||||
cls.url = reverse('api-contact-list')
|
||||
|
||||
def test_list(self):
|
||||
"""Test company list API endpoint"""
|
||||
|
||||
# List all results
|
||||
response = self.get(self.url, {}, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 9)
|
||||
|
||||
for result in response.data:
|
||||
for key in ['name', 'email', 'pk', 'company']:
|
||||
self.assertIn(key, result)
|
||||
|
||||
# Filter by particular company
|
||||
for cmp in Company.objects.all():
|
||||
response = self.get(
|
||||
self.url,
|
||||
{
|
||||
'company': cmp.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_create(self):
|
||||
"""Test that we can create a new Contact object via the API"""
|
||||
|
||||
n = Contact.objects.count()
|
||||
|
||||
company = Company.objects.first()
|
||||
|
||||
# Without required permissions, creation should fail
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'company': company.pk,
|
||||
'name': 'Joe Bloggs',
|
||||
},
|
||||
expected_code=403
|
||||
)
|
||||
|
||||
self.assignRole('return_order.add')
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'company': company.pk,
|
||||
'name': 'Joe Bloggs',
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
self.assertEqual(Contact.objects.count(), n + 1)
|
||||
|
||||
def test_edit(self):
|
||||
"""Test that we can edit a Contact via the API"""
|
||||
|
||||
url = reverse('api-contact-detail', kwargs={'pk': 1})
|
||||
|
||||
# Retrieve detail view
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
for key in ['pk', 'name', 'role']:
|
||||
self.assertIn(key, data)
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'role': 'model',
|
||||
},
|
||||
expected_code=403
|
||||
)
|
||||
|
||||
self.assignRole('purchase_order.change')
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'role': 'x',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
contact = Contact.objects.get(pk=1)
|
||||
self.assertEqual(contact.role, 'x')
|
||||
|
||||
def test_delete(self):
|
||||
"""Tests that we can delete a Contact via the API"""
|
||||
|
||||
url = reverse('api-contact-detail', kwargs={'pk': 6})
|
||||
|
||||
# Delete (without required permissions)
|
||||
self.delete(url, expected_code=403)
|
||||
|
||||
self.assignRole('sales_order.delete')
|
||||
|
||||
self.delete(url, expected_code=204)
|
||||
|
||||
# Try to access again (gone!)
|
||||
self.get(url, expected_code=404)
|
||||
|
||||
|
||||
class ManufacturerTest(InvenTreeAPITestCase):
|
||||
"""Series of tests for the Manufacturer DRF API."""
|
||||
|
||||
|
@ -26,8 +26,12 @@ class CompanySimpleTest(TestCase):
|
||||
'price_breaks',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Perform initialization for the tests in this class"""
|
||||
|
||||
super().setUpTestData()
|
||||
|
||||
Company.objects.create(name='ABC Co.',
|
||||
description='Seller of ABC products',
|
||||
website='www.abc-sales.com',
|
||||
@ -35,10 +39,10 @@ class CompanySimpleTest(TestCase):
|
||||
is_customer=False,
|
||||
is_supplier=True)
|
||||
|
||||
self.acme0001 = SupplierPart.objects.get(SKU='ACME0001')
|
||||
self.acme0002 = SupplierPart.objects.get(SKU='ACME0002')
|
||||
self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
|
||||
self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
|
||||
cls.acme0001 = SupplierPart.objects.get(SKU='ACME0001')
|
||||
cls.acme0002 = SupplierPart.objects.get(SKU='ACME0002')
|
||||
cls.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
|
||||
cls.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
|
||||
|
||||
def test_company_model(self):
|
||||
"""Tests for the company model data"""
|
||||
@ -128,6 +132,23 @@ class CompanySimpleTest(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
company.full_clean()
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the metadata field."""
|
||||
p = Company.objects.first()
|
||||
self.assertIsNone(p.metadata)
|
||||
|
||||
self.assertIsNone(p.get_metadata('test'))
|
||||
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
|
||||
|
||||
# Test update via the set_metadata() method
|
||||
p.set_metadata('test', 3)
|
||||
self.assertEqual(p.get_metadata('test'), 3)
|
||||
|
||||
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
|
||||
p.set_metadata(k, k)
|
||||
|
||||
self.assertEqual(len(p.metadata.keys()), 4)
|
||||
|
||||
|
||||
class ContactSimpleTest(TestCase):
|
||||
"""Unit tests for the Contact model"""
|
||||
@ -201,3 +222,21 @@ class ManufacturerPartSimpleTest(TestCase):
|
||||
Part.objects.get(pk=self.part.id).delete()
|
||||
# Check that ManufacturerPart was deleted
|
||||
self.assertEqual(ManufacturerPart.objects.count(), 3)
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the metadata field."""
|
||||
for model in [ManufacturerPart, SupplierPart]:
|
||||
p = model.objects.first()
|
||||
self.assertIsNone(p.metadata)
|
||||
|
||||
self.assertIsNone(p.get_metadata('test'))
|
||||
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
|
||||
|
||||
# Test update via the set_metadata() method
|
||||
p.set_metadata('test', 3)
|
||||
self.assertEqual(p.get_metadata('test'), 3)
|
||||
|
||||
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
|
||||
p.set_metadata(k, k)
|
||||
|
||||
self.assertEqual(len(p.metadata.keys()), 4)
|
||||
|
@ -1,13 +1,13 @@
|
||||
"""URL lookup for Company app."""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
company_urls = [
|
||||
|
||||
# Detail URLs for a specific Company instance
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
|
||||
])),
|
||||
|
||||
@ -21,12 +21,11 @@ company_urls = [
|
||||
|
||||
manufacturer_part_urls = [
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
|
||||
path(r'<int:pk>/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
|
||||
]
|
||||
|
||||
supplier_part_urls = [
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path('^qr_code/?', views.SupplierPartQRCode.as_view(), name='supplier-part-qr'),
|
||||
path(r'<int:pk>/', include([
|
||||
re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
|
||||
]))
|
||||
|
||||
|
@ -4,7 +4,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from plugin.views import InvenTreePluginViewMixin
|
||||
|
||||
from .models import Company, ManufacturerPart, SupplierPart
|
||||
@ -112,18 +112,3 @@ class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||
context_object_name = 'part'
|
||||
queryset = SupplierPart.objects.all()
|
||||
permission_required = 'purchase_order.view'
|
||||
|
||||
|
||||
class SupplierPartQRCode(QRCodeView):
|
||||
"""View for displaying a QR code for a StockItem object."""
|
||||
|
||||
ajax_form_title = _("Stock Item QR Code")
|
||||
role_required = 'stock.view'
|
||||
|
||||
def get_qr_data(self):
|
||||
"""Generate QR code data for the StockItem."""
|
||||
try:
|
||||
part = SupplierPart.objects.get(id=self.pk)
|
||||
return part.format_barcode()
|
||||
except SupplierPart.DoesNotExist:
|
||||
return None
|
||||
|
@ -119,6 +119,10 @@ plugins_enabled: False
|
||||
#plugin_file: '/path/to/plugins.txt'
|
||||
#plugin_dir: '/path/to/plugins/'
|
||||
|
||||
# Set this variable to True to enable auto-migrations
|
||||
# Alternatively, use the environment variable INVENTREE_AUTO_UPDATE
|
||||
auto_update: False
|
||||
|
||||
# Allowed hosts (see ALLOWED_HOSTS in Django settings documentation)
|
||||
# A list of strings representing the host/domain names that this Django site can serve.
|
||||
# Default behaviour is to allow all hosts (THIS IS NOT SECURE!)
|
||||
|
@ -3,14 +3,17 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page, never_cache
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
from InvenTree.api import MetadataView
|
||||
from InvenTree.filters import InvenTreeSearchFilter
|
||||
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
|
||||
from InvenTree.tasks import offload_task
|
||||
from part.models import Part
|
||||
@ -45,7 +48,7 @@ class LabelFilterMixin:
|
||||
ids = []
|
||||
|
||||
# Construct a list of possible query parameter value options
|
||||
# e.g. if self.ITEM_KEY = 'part' -> ['part', 'part', 'parts', parts[]']
|
||||
# e.g. if self.ITEM_KEY = 'part' -> ['part', 'part[]', 'parts', parts[]']
|
||||
for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]:
|
||||
if ids := self.request.query_params.getlist(k, []):
|
||||
# Return the first list of matches
|
||||
@ -121,7 +124,7 @@ class LabelListView(LabelFilterMixin, ListAPI):
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter
|
||||
InvenTreeSearchFilter
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
@ -134,9 +137,15 @@ class LabelListView(LabelFilterMixin, ListAPI):
|
||||
]
|
||||
|
||||
|
||||
@method_decorator(cache_page(5), name='dispatch')
|
||||
class LabelPrintMixin(LabelFilterMixin):
|
||||
"""Mixin for printing labels."""
|
||||
|
||||
@method_decorator(never_cache)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
"""Prevent caching when printing report templates"""
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request against this endpoint to print labels"""
|
||||
return self.print(request, self.get_items())
|
||||
@ -185,7 +194,7 @@ class LabelPrintMixin(LabelFilterMixin):
|
||||
outputs = []
|
||||
|
||||
# In debug mode, generate single HTML output, rather than PDF
|
||||
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
|
||||
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE', cache=False)
|
||||
|
||||
label_name = "label.pdf"
|
||||
|
||||
@ -260,7 +269,7 @@ class LabelPrintMixin(LabelFilterMixin):
|
||||
|
||||
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||
|
||||
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user)
|
||||
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user, cache=False)
|
||||
|
||||
return InvenTree.helpers.DownloadFile(
|
||||
pdf,
|
||||
@ -270,7 +279,17 @@ class LabelPrintMixin(LabelFilterMixin):
|
||||
)
|
||||
|
||||
|
||||
class StockItemLabelList(LabelListView):
|
||||
class StockItemLabelMixin:
|
||||
"""Mixin for StockItemLabel endpoints"""
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
serializer_class = StockItemLabelSerializer
|
||||
|
||||
ITEM_MODEL = StockItem
|
||||
ITEM_KEY = 'item'
|
||||
|
||||
|
||||
class StockItemLabelList(StockItemLabelMixin, LabelListView):
|
||||
"""API endpoint for viewing list of StockItemLabel objects.
|
||||
|
||||
Filterable by:
|
||||
@ -279,32 +298,30 @@ class StockItemLabelList(LabelListView):
|
||||
- item: Filter by single stock item
|
||||
- items: Filter by list of stock items
|
||||
"""
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
serializer_class = StockItemLabelSerializer
|
||||
|
||||
ITEM_MODEL = StockItem
|
||||
ITEM_KEY = 'item'
|
||||
pass
|
||||
|
||||
|
||||
class StockItemLabelDetail(RetrieveUpdateDestroyAPI):
|
||||
class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for a single StockItemLabel object."""
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
serializer_class = StockItemLabelSerializer
|
||||
pass
|
||||
|
||||
|
||||
class StockItemLabelPrint(LabelPrintMixin, RetrieveAPI):
|
||||
class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
|
||||
"""API endpoint for printing a StockItemLabel object."""
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
serializer_class = StockItemLabelSerializer
|
||||
|
||||
ITEM_MODEL = StockItem
|
||||
ITEM_KEY = 'item'
|
||||
pass
|
||||
|
||||
|
||||
class StockLocationLabelList(LabelListView):
|
||||
class StockLocationLabelMixin:
|
||||
"""Mixin for StockLocationLabel endpoints"""
|
||||
|
||||
queryset = StockLocationLabel.objects.all()
|
||||
serializer_class = StockLocationLabelSerializer
|
||||
|
||||
ITEM_MODEL = StockLocation
|
||||
ITEM_KEY = 'location'
|
||||
|
||||
|
||||
class StockLocationLabelList(StockLocationLabelMixin, LabelListView):
|
||||
"""API endpoint for viewiing list of StockLocationLabel objects.
|
||||
|
||||
Filterable by:
|
||||
@ -313,56 +330,41 @@ class StockLocationLabelList(LabelListView):
|
||||
- location: Filter by a single stock location
|
||||
- locations: Filter by list of stock locations
|
||||
"""
|
||||
|
||||
queryset = StockLocationLabel.objects.all()
|
||||
serializer_class = StockLocationLabelSerializer
|
||||
|
||||
ITEM_MODEL = StockLocation
|
||||
ITEM_KEY = 'location'
|
||||
pass
|
||||
|
||||
|
||||
class StockLocationLabelDetail(RetrieveUpdateDestroyAPI):
|
||||
class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for a single StockLocationLabel object."""
|
||||
|
||||
queryset = StockLocationLabel.objects.all()
|
||||
serializer_class = StockLocationLabelSerializer
|
||||
pass
|
||||
|
||||
|
||||
class StockLocationLabelPrint(LabelPrintMixin, RetrieveAPI):
|
||||
class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
|
||||
"""API endpoint for printing a StockLocationLabel object."""
|
||||
|
||||
queryset = StockLocationLabel.objects.all()
|
||||
seiralizer_class = StockLocationLabelSerializer
|
||||
|
||||
ITEM_MODEL = StockLocation
|
||||
ITEM_KEY = 'location'
|
||||
pass
|
||||
|
||||
|
||||
class PartLabelList(LabelListView):
|
||||
class PartLabelMixin:
|
||||
"""Mixin for PartLabel endpoints"""
|
||||
queryset = PartLabel.objects.all()
|
||||
serializer_class = PartLabelSerializer
|
||||
|
||||
ITEM_MODEL = Part
|
||||
ITEM_KEY = 'part'
|
||||
|
||||
|
||||
class PartLabelList(PartLabelMixin, LabelListView):
|
||||
"""API endpoint for viewing list of PartLabel objects."""
|
||||
|
||||
queryset = PartLabel.objects.all()
|
||||
serializer_class = PartLabelSerializer
|
||||
|
||||
ITEM_MODEL = Part
|
||||
ITEM_KEY = 'part'
|
||||
pass
|
||||
|
||||
|
||||
class PartLabelDetail(RetrieveUpdateDestroyAPI):
|
||||
class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for a single PartLabel object."""
|
||||
|
||||
queryset = PartLabel.objects.all()
|
||||
serializer_class = PartLabelSerializer
|
||||
pass
|
||||
|
||||
|
||||
class PartLabelPrint(LabelPrintMixin, RetrieveAPI):
|
||||
class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
|
||||
"""API endpoint for printing a PartLabel object."""
|
||||
|
||||
queryset = PartLabel.objects.all()
|
||||
serializer_class = PartLabelSerializer
|
||||
|
||||
ITEM_MODEL = Part
|
||||
ITEM_KEY = 'part'
|
||||
pass
|
||||
|
||||
|
||||
label_api_urls = [
|
||||
@ -370,8 +372,9 @@ label_api_urls = [
|
||||
# Stock item labels
|
||||
re_path(r'stock/', include([
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'),
|
||||
re_path(r'metadata/', MetadataView.as_view(), {'model': StockItemLabel}, name='api-stockitem-label-metadata'),
|
||||
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
|
||||
])),
|
||||
|
||||
@ -382,8 +385,9 @@ label_api_urls = [
|
||||
# Stock location labels
|
||||
re_path(r'location/', include([
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'),
|
||||
re_path(r'metadata/', MetadataView.as_view(), {'model': StockLocationLabel}, name='api-stocklocation-label-metadata'),
|
||||
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
|
||||
])),
|
||||
|
||||
@ -394,8 +398,9 @@ label_api_urls = [
|
||||
# Part labels
|
||||
re_path(r'^part/', include([
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
|
||||
re_path(r'^metadata/', MetadataView.as_view(), {'model': PartLabel}, name='api-part-label-metadata'),
|
||||
re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),
|
||||
])),
|
||||
|
||||
|
28
InvenTree/label/migrations/0009_auto_20230317_0816.py
Normal file
28
InvenTree/label/migrations/0009_auto_20230317_0816.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-17 08:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('label', '0008_auto_20210708_2106'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='partlabel',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stockitemlabel',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stocklocationlabel',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
]
|
@ -17,6 +17,7 @@ import common.models
|
||||
import part.models
|
||||
import stock.models
|
||||
from InvenTree.helpers import normalize, validateFilterString
|
||||
from plugin.models import MetadataMixin
|
||||
|
||||
try:
|
||||
from django_weasyprint import WeasyTemplateResponseMixin
|
||||
@ -70,7 +71,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
|
||||
self.pdf_filename = kwargs.get('filename', 'label.pdf')
|
||||
|
||||
|
||||
class LabelTemplate(models.Model):
|
||||
class LabelTemplate(MetadataMixin, models.Model):
|
||||
"""Base class for generic, filterable labels."""
|
||||
|
||||
class Meta:
|
||||
|
@ -9,8 +9,6 @@ from .models import PartLabel, StockItemLabel, StockLocationLabel
|
||||
class StockItemLabelSerializer(InvenTreeModelSerializer):
|
||||
"""Serializes a StockItemLabel object."""
|
||||
|
||||
label = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -24,12 +22,12 @@ class StockItemLabelSerializer(InvenTreeModelSerializer):
|
||||
'enabled',
|
||||
]
|
||||
|
||||
label = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
|
||||
class StockLocationLabelSerializer(InvenTreeModelSerializer):
|
||||
"""Serializes a StockLocationLabel object."""
|
||||
|
||||
label = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -43,12 +41,12 @@ class StockLocationLabelSerializer(InvenTreeModelSerializer):
|
||||
'enabled',
|
||||
]
|
||||
|
||||
label = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
|
||||
class PartLabelSerializer(InvenTreeModelSerializer):
|
||||
"""Serializes a PartLabel object."""
|
||||
|
||||
label = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -61,3 +59,5 @@ class PartLabelSerializer(InvenTreeModelSerializer):
|
||||
'filters',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
label = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
@ -27,9 +27,10 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
'stock'
|
||||
]
|
||||
|
||||
def setUp(self) -> None:
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Ensure that some label instances exist as part of init routine"""
|
||||
super().setUp()
|
||||
super().setUpTestData()
|
||||
apps.get_app_config('label').create_labels()
|
||||
|
||||
def test_default_labels(self):
|
||||
@ -129,3 +130,21 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
self.assertIn("http://testserver/part/1/", content)
|
||||
self.assertIn("image: /static/img/blank_image.png", content)
|
||||
self.assertIn("logo: /static/img/inventree.png", content)
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the metadata field."""
|
||||
for model in [StockItemLabel, StockLocationLabel, PartLabel]:
|
||||
p = model.objects.first()
|
||||
self.assertIsNone(p.metadata)
|
||||
|
||||
self.assertIsNone(p.get_metadata('test'))
|
||||
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
|
||||
|
||||
# Test update via the set_metadata() method
|
||||
p.set_metadata('test', 3)
|
||||
self.assertEqual(p.get_metadata('test'), 3)
|
||||
|
||||
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
|
||||
p.set_metadata(k, k)
|
||||
|
||||
self.assertEqual(len(p.metadata.keys()), 4)
|
||||
|
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
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user