2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue6281

This commit is contained in:
Matthias Mair
2024-12-25 01:25:23 +01:00
57 changed files with 1756 additions and 4078 deletions

View File

@ -1,132 +1,10 @@
"""Admin classes."""
from django.contrib import admin
from django.db.models.fields import CharField
from django.http.request import HttpRequest
from djmoney.contrib.exchange.admin import RateAdmin
from djmoney.contrib.exchange.models import Rate
from import_export.exceptions import ImportExportError
from import_export.resources import ModelResource
class InvenTreeResource(ModelResource):
"""Custom subclass of the ModelResource class provided by django-import-export".
Ensures that exported data are escaped to prevent malicious formula injection.
Ref: https://owasp.org/www-community/attacks/CSV_Injection
"""
MAX_IMPORT_ROWS = 1000
MAX_IMPORT_COLS = 100
# List of fields which should be converted to empty strings if they are null
CONVERT_NULL_FIELDS = []
def import_data_inner(
self,
dataset,
dry_run,
raise_errors,
using_transactions,
collect_failed_rows,
rollback_on_validation_errors=None,
**kwargs,
):
"""Override the default import_data_inner function to provide better error handling."""
if len(dataset) > self.MAX_IMPORT_ROWS:
raise ImportExportError(
f'Dataset contains too many rows (max {self.MAX_IMPORT_ROWS})'
)
if len(dataset.headers) > self.MAX_IMPORT_COLS:
raise ImportExportError(
f'Dataset contains too many columns (max {self.MAX_IMPORT_COLS})'
)
return super().import_data_inner(
dataset,
dry_run,
raise_errors,
using_transactions,
collect_failed_rows,
rollback_on_validation_errors=rollback_on_validation_errors,
**kwargs,
)
def export_resource(self, obj):
"""Custom function to override default row export behavior.
Specifically, strip illegal leading characters to prevent formula injection
"""
row = super().export_resource(obj)
illegal_start_vals = ['@', '=', '+', '-', '@', '\t', '\r', '\n']
for idx, val in enumerate(row):
if type(val) is str:
val = val.strip()
# If the value starts with certain 'suspicious' values, remove it!
while len(val) > 0 and val[0] in illegal_start_vals:
# Remove the first character
val = val[1:]
row[idx] = val
return row
def get_fields(self, **kwargs):
"""Return fields, with some common exclusions."""
fields = super().get_fields(**kwargs)
fields_to_exclude = ['metadata', 'lft', 'rght', 'tree_id', 'level']
return [f for f in fields if f.column_name not in fields_to_exclude]
def before_import(self, dataset, using_transactions, dry_run, **kwargs):
"""Run custom code before importing data.
- Determine the list of fields which need to be converted to empty strings
"""
# Construct a map of field names
db_fields = {field.name: field for field in self.Meta.model._meta.fields}
for field_name, field in self.fields.items():
# Skip read-only fields (they cannot be imported)
if field.readonly:
continue
# Determine the name of the associated column in the dataset
column = getattr(field, 'column_name', field_name)
# Determine the attribute name of the associated database field
attribute = getattr(field, 'attribute', field_name)
# Check if the associated database field is a non-nullable string
if (
(db_field := db_fields.get(attribute))
and (
isinstance(db_field, CharField)
and db_field.blank
and not db_field.null
)
and column not in self.CONVERT_NULL_FIELDS
):
self.CONVERT_NULL_FIELDS.append(column)
return super().before_import(dataset, using_transactions, dry_run, **kwargs)
def before_import_row(self, row, row_number=None, **kwargs):
"""Run custom code before importing each row.
- Convert any null fields to empty strings, for fields which do not support null values
"""
for field in self.CONVERT_NULL_FIELDS:
if field in row and row[field] is None:
row[field] = ''
return super().before_import_row(row, row_number, **kwargs)
class CustomRateAdmin(RateAdmin):

View File

@ -1,16 +1,22 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 294
INVENTREE_API_VERSION = 296
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v294 - 2024-12-22 : https://github.com/inventree/InvenTree/pull/6293
v296 - 2024-12-22 : https://github.com/inventree/InvenTree/pull/6293
- Removes a considerable amount of old auth endpoints
- Introduces allauth based REST API
v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746
- Improve API documentation for build APIs
v294 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8738
- Extends registration API documentation
v293 - 2024-12-14 : https://github.com/inventree/InvenTree/pull/8658
- Adds new fields to the supplier barcode API endpoints

View File

@ -0,0 +1,40 @@
"""Overrides for registration view."""
from django.utils.translation import gettext_lazy as _
from allauth.account import app_settings as allauth_account_settings
from dj_rest_auth.app_settings import api_settings
from dj_rest_auth.registration.views import RegisterView
class CustomRegisterView(RegisterView):
"""Registers a new user.
Accepts the following POST parameters: username, email, password1, password2.
"""
# Fixes https://github.com/inventree/InvenTree/issues/8707
# This contains code from dj-rest-auth 7.0 - therefore the version was pinned
def get_response_data(self, user):
"""Override to fix check for auth_model."""
if (
allauth_account_settings.EMAIL_VERIFICATION
== allauth_account_settings.EmailVerificationMethod.MANDATORY
):
return {'detail': _('Verification e-mail sent.')}
if api_settings.USE_JWT:
data = {
'user': user,
'access': self.access_token,
'refresh': self.refresh_token,
}
return api_settings.JWT_SERIALIZER(
data, context=self.get_serializer_context()
).data
elif self.token_model:
# Only change in this block is below
return api_settings.TOKEN_SERIALIZER(
user.api_tokens.last(), context=self.get_serializer_context()
).data
return None

View File

@ -98,7 +98,7 @@ def registration_enabled():
return False
class RegistratonMixin:
class RegistrationMixin:
"""Mixin to check if registration should be enabled."""
def is_open_for_signup(self, request, *args, **kwargs):
@ -168,7 +168,7 @@ class CustomUrlMixin:
return InvenTree.helpers_model.construct_absolute_url(url)
class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultAccountAdapter):
class CustomAccountAdapter(CustomUrlMixin, RegistrationMixin, DefaultAccountAdapter):
"""Override of adapter to use dynamic settings."""
def send_mail(self, template_prefix, email, context):
@ -194,7 +194,7 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultAccountAdapt
class CustomSocialAccountAdapter(
CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter
CustomUrlMixin, RegistrationMixin, DefaultSocialAccountAdapter
):
"""Override of adapter to use dynamic settings."""

View File

@ -283,7 +283,6 @@ INSTALLED_APPS = [
'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework)
'corsheaders', # Cross-origin Resource Sharing for DRF
'import_export', # Import / export tables to file
'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files
'mptt', # Modified Preorder Tree Traversal
'markdownify', # Markdown template rendering
@ -1018,9 +1017,6 @@ USE_TZ = bool(not TESTING)
DATE_INPUT_FORMATS = ['%Y-%m-%d']
# Use database transactions when importing / exporting data
IMPORT_EXPORT_USE_TRANSACTIONS = True
# Site URL can be specified statically, or via a run-time setting
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)

View File

@ -1,6 +1,8 @@
"""Test the sso module functionality."""
"""Test the sso and auth module functionality."""
from django.conf import settings
from django.contrib.auth.models import Group, User
from django.core.exceptions import ValidationError
from django.test import override_settings
from django.test.testcases import TransactionTestCase
@ -8,18 +10,19 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin
from common.models import InvenTreeSetting
from InvenTree import sso
from InvenTree.auth_overrides import RegistratonMixin
from InvenTree.auth_overrides import RegistrationMixin
from InvenTree.unit_test import InvenTreeAPITestCase
class Dummy:
"""Simulate super class of RegistratonMixin."""
"""Simulate super class of RegistrationMixin."""
def save_user(self, _request, user: User, *args) -> User:
"""This method is only used that the super() call of RegistrationMixin does not fail."""
return user
class MockRegistrationMixin(RegistratonMixin, Dummy):
class MockRegistrationMixin(RegistrationMixin, Dummy):
"""Mocked implementation of the RegistrationMixin."""
@ -119,3 +122,90 @@ class TestSsoGroupSync(TransactionTestCase):
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 0)
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1)
class EmailSettingsContext:
"""Context manager to enable email settings for tests."""
def __enter__(self):
"""Enable stuff."""
InvenTreeSetting.set_setting('LOGIN_ENABLE_REG', True)
settings.EMAIL_HOST = 'localhost'
def __exit__(self, type, value, traceback):
"""Exit stuff."""
InvenTreeSetting.set_setting('LOGIN_ENABLE_REG', False)
settings.EMAIL_HOST = ''
class TestAuth(InvenTreeAPITestCase):
"""Test authentication functionality."""
def email_args(self, user=None, email=None):
"""Generate registration arguments."""
return {
'username': user or 'user1',
'email': email or 'test@example.com',
'password1': '#asdf1234',
'password2': '#asdf1234',
}
def test_registration(self):
"""Test the registration process."""
self.logout()
# Duplicate username
resp = self.post(
'/api/auth/registration/',
self.email_args(user='testuser'),
expected_code=400,
)
self.assertIn(
'A user with that username already exists.', resp.data['username']
)
# Registration is disabled
resp = self.post(
'/api/auth/registration/', self.email_args(), expected_code=400
)
self.assertIn('Registration is disabled.', resp.data['non_field_errors'])
# Enable registration - now it should work
with EmailSettingsContext():
resp = self.post(
'/api/auth/registration/', self.email_args(), expected_code=201
)
self.assertIn('key', resp.data)
def test_registration_email(self):
"""Test that LOGIN_SIGNUP_MAIL_RESTRICTION works."""
self.logout()
# Check the setting validation is working
with self.assertRaises(ValidationError):
InvenTreeSetting.set_setting(
'LOGIN_SIGNUP_MAIL_RESTRICTION', 'example.com,inventree.org'
)
# Setting setting correctly
correct_setting = '@example.com,@inventree.org'
InvenTreeSetting.set_setting('LOGIN_SIGNUP_MAIL_RESTRICTION', correct_setting)
self.assertEqual(
InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_RESTRICTION'),
correct_setting,
)
# Wrong email format
resp = self.post(
'/api/auth/registration/',
self.email_args(email='admin@invenhost.com'),
expected_code=400,
)
self.assertIn('The provided email domain is not approved.', resp.data['email'])
# Right format should work
with EmailSettingsContext():
resp = self.post(
'/api/auth/registration/', self.email_args(), expected_code=201
)
self.assertIn('key', resp.data)

View File

@ -32,7 +32,7 @@ from common.currency import currency_codes
from common.models import CustomUnit, InvenTreeSetting
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase
from InvenTree.unit_test import InvenTreeTestCase, in_env_context
from part.models import Part, PartCategory
from stock.models import StockItem, StockLocation
@ -1121,10 +1121,6 @@ class TestSettings(InvenTreeTestCase):
superuser = True
def in_env_context(self, envs):
"""Patch the env to include the given dict."""
return mock.patch.dict(os.environ, envs)
def run_reload(self, envs=None):
"""Helper function to reload InvenTree."""
# Set default - see B006
@ -1133,7 +1129,7 @@ class TestSettings(InvenTreeTestCase):
from plugin import registry
with self.in_env_context(envs):
with in_env_context(envs):
settings.USER_ADDED = False
registry.reload_plugins()
@ -1198,7 +1194,7 @@ class TestSettings(InvenTreeTestCase):
)
# with env set
with self.in_env_context({
with in_env_context({
'INVENTREE_CONFIG_FILE': '_testfolder/my_special_conf.yaml'
}):
self.assertIn(
@ -1217,7 +1213,7 @@ class TestSettings(InvenTreeTestCase):
)
# with env set
with self.in_env_context({
with in_env_context({
'INVENTREE_PLUGIN_FILE': '_testfolder/my_special_plugins.txt'
}):
self.assertIn(
@ -1231,7 +1227,7 @@ class TestSettings(InvenTreeTestCase):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
# with env set
with self.in_env_context({TEST_ENV_NAME: '321'}):
with 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
@ -1240,13 +1236,13 @@ class TestSettings(InvenTreeTestCase):
)
# test typecasting to dict - valid JSON string should be mapped to corresponding dict
with self.in_env_context({TEST_ENV_NAME: '{"a": 1}'}):
with 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}"}):
with in_env_context({TEST_ENV_NAME: "{'a': 1}"}):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {})

View File

@ -3,10 +3,12 @@
import csv
import io
import json
import os
import re
import time
from contextlib import contextmanager
from pathlib import Path
from unittest import mock
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
@ -601,3 +603,8 @@ class AdminTestCase(InvenTreeAPITestCase):
self.assertEqual(response.status_code, 200)
return obj
def in_env_context(envs):
"""Patch the env to include the given dict."""
return mock.patch.dict(os.environ, envs)

View File

@ -4,15 +4,7 @@ In particular these views provide base functionality for rendering Django forms
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
"""
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.template.loader import render_to_string
from django.views import View
from django.views.generic import DeleteView, DetailView, ListView, UpdateView
from users.models import RuleSet, check_user_role
from .helpers import is_ajax
from django.http import HttpResponse
def auth_request(request):
@ -20,354 +12,6 @@ def auth_request(request):
Useful for (for example) redirecting authentication requests through django's permission framework.
"""
if request.user.is_authenticated:
if request.user and request.user.is_authenticated:
return HttpResponse(status=200)
return HttpResponse(status=403)
class InvenTreeRoleMixin(PermissionRequiredMixin):
"""Permission class based on user roles, not user 'permissions'.
There are a number of ways that the permissions can be specified for a view:
1. Specify the 'role_required' attribute (e.g. part.change)
2. Specify the 'permission_required' attribute (e.g. part.change_bomitem)
(Note: This is the "normal" django-esque way of doing this)
3. Do nothing. The mixin will attempt to "guess" what permission you require:
a) If there is a queryset associated with the View, we have the model!
b) The *type* of View tells us the permission level (e.g. AjaxUpdateView = change)
c) 1 + 1 = 3
d) Use the combination of model + permission as we would in 2)
1. Specify the 'role_required' attribute
=====================================
To specify which role is required for the mixin,
set the class attribute 'role_required' to something like the following:
role_required = 'part.add'
role_required = [
'part.change',
'build.add',
]
2. Specify the 'permission_required' attribute
===========================================
To specify a particular low-level permission,
set the class attribute 'permission_required' to something like:
permission_required = 'company.delete_company'
3. Do Nothing
==========
See above.
"""
# By default, no roles are required
# Roles must be specified
role_required = None
def has_permission(self):
"""Determine if the current user has specified permissions."""
roles_required = []
if type(self.role_required) is str:
roles_required.append(self.role_required)
elif type(self.role_required) in [list, tuple]:
roles_required = self.role_required
user = self.request.user
# Superuser can have any permissions they desire
if user.is_superuser:
return True
for required in roles_required:
(role, permission) = required.split('.')
if role not in RuleSet.RULESET_NAMES:
raise ValueError(f"Role '{role}' is not a valid role")
if permission not in RuleSet.RULESET_PERMISSIONS:
raise ValueError(f"Permission '{permission}' is not a valid permission")
# Return False if the user does not have *any* of the required roles
if not check_user_role(user, role, permission):
return False
# If a permission_required is specified, use that!
if self.permission_required:
# Ignore role-based permissions
return super().has_permission()
# Ok, so at this point we have not explicitly require a "role" or a "permission"
# Instead, we will use the model to introspect the data we need
model = getattr(self, 'model', None)
if not model:
queryset = getattr(self, 'queryset', None)
if queryset is not None:
model = queryset.model
# We were able to introspect a database model
if model is not None:
app_label = model._meta.app_label
model_name = model._meta.model_name
table = f'{app_label}_{model_name}'
permission = self.get_permission_class()
if not permission:
raise AttributeError(
f'permission_class not defined for {type(self).__name__}'
)
# Check if the user has the required permission
return RuleSet.check_table_permission(user, table, permission)
# We did not fail any required checks
return True
def get_permission_class(self):
"""Return the 'permission_class' required for the current View.
Must be one of:
- view
- change
- add
- delete
This can either be explicitly defined, by setting the
'permission_class' attribute,
or it can be "guessed" by looking at the type of class
"""
perm = getattr(self, 'permission_class', None)
# Permission is specified by the class itself
if perm:
return perm
# Otherwise, we will need to have a go at guessing...
permission_map = {
AjaxView: 'view',
ListView: 'view',
DetailView: 'view',
UpdateView: 'change',
DeleteView: 'delete',
AjaxUpdateView: 'change',
}
for view_class in permission_map:
if issubclass(type(self), view_class):
return permission_map[view_class]
return None
class AjaxMixin(InvenTreeRoleMixin):
"""AjaxMixin provides basic functionality for rendering a Django form to JSON. Handles jsonResponse rendering, and adds extra data for the modal forms to process on the client side.
Any view which inherits the AjaxMixin will need
correct permissions set using the 'role_required' attribute
"""
# By default, allow *any* role
role_required = None
# By default, point to the modal_form template
# (this can be overridden by a child class)
ajax_template_name = 'modal_form.html'
ajax_form_title = ''
def get_form_title(self):
"""Default implementation - return the ajax_form_title variable."""
return self.ajax_form_title
def get_param(self, name, method='GET'):
"""Get a request query parameter value from URL e.g. ?part=3.
Args:
name: Variable name e.g. 'part'
method: Request type ('GET' or 'POST')
Returns:
Value of the supplier parameter or None if parameter is not available
"""
if method == 'POST':
return self.request.POST.get(name, None)
return self.request.GET.get(name, None)
def get_data(self):
"""Get extra context data (default implementation is empty dict).
Returns:
dict object (empty)
"""
return {}
def validate(self, obj, form, **kwargs):
"""Hook for performing custom form validation steps.
If a form error is detected, add it to the form,
with 'form.add_error()'
Ref: https://docs.djangoproject.com/en/dev/topics/forms/
"""
# Do nothing by default
def renderJsonResponse(self, request, form=None, data=None, context=None):
"""Render a JSON response based on specific class context.
Args:
request: HTTP request object (e.g. GET / POST)
form: Django form object (may be None)
data: Extra JSON data to pass to client
context: Extra context data to pass to template rendering
Returns:
JSON response object
"""
# a empty dict as default can be dangerous - set it here if empty
if not data:
data = {}
if not is_ajax(request):
return HttpResponseRedirect('/')
if context is None:
try:
context = self.get_context_data()
except AttributeError:
context = {}
# If no 'form' argument is supplied, look at the underlying class
if form is None:
try:
form = self.get_form()
except AttributeError:
pass
if form:
context['form'] = form
else:
context['form'] = None
data['title'] = self.get_form_title()
data['html_form'] = render_to_string(
self.ajax_template_name, context, request=request
)
# Custom feedback`data
fb = self.get_data()
for key in fb:
data[key] = fb[key]
return JsonResponse(data, safe=False)
class AjaxView(AjaxMixin, View):
"""An 'AJAXified' View for displaying an object."""
def post(self, request, *args, **kwargs):
"""Return a json formatted response.
This renderJsonResponse function must be supplied by your function.
"""
return self.renderJsonResponse(request)
def get(self, request, *args, **kwargs):
"""Return a json formatted response.
This renderJsonResponse function must be supplied by your function.
"""
return self.renderJsonResponse(request)
class AjaxUpdateView(AjaxMixin, UpdateView):
"""An 'AJAXified' UpdateView for updating an object in the db.
- Returns form in JSON format (for delivery to a modal window)
- Handles repeated form validation (via AJAX) until the form is valid
"""
def get(self, request, *args, **kwargs):
"""Respond to GET request.
- Populates form with object data
- Renders form to JSON and returns to client
"""
super(UpdateView, self).get(request, *args, **kwargs)
return self.renderJsonResponse(
request, self.get_form(), context=self.get_context_data()
)
def save(self, obj, form, **kwargs):
"""Method for updating the object in the database. Default implementation is very simple, but can be overridden if required.
Args:
obj: The current object, to be updated
form: The validated form
Returns:
object instance for supplied form
"""
self.object = form.save()
return self.object
def post(self, request, *args, **kwargs):
"""Respond to POST request.
- Updates model with POST field data
- Performs form and object validation
- If errors exist, re-render the form
- Otherwise, return success status
"""
self.request = request
# Make sure we have an object to point to
self.object = self.get_object()
form = self.get_form()
# Perform initial form validation
form.is_valid()
# Perform custom validation
self.validate(self.object, form)
valid = form.is_valid()
data = {
'form_valid': valid,
'form_errors': form.errors.as_json(),
'non_field_errors': form.non_field_errors().as_json(),
}
# Add in any extra class data
for value, key in enumerate(self.get_data()):
data[key] = value
if valid:
# Save the updated object to the database
self.save(self.object, form)
self.object = self.get_object()
# Include context data about the updated object
data['pk'] = self.object.pk
try:
data['url'] = self.object.get_absolute_url()
except AttributeError:
pass
return self.renderJsonResponse(request, form, data)

View File

@ -1,117 +1,36 @@
"""Admin functionality for the BuildOrder app"""
"""Admin functionality for the BuildOrder app."""
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export import widgets
from build.models import Build, BuildLine, BuildItem
from InvenTree.admin import InvenTreeResource
import part.models
class BuildResource(InvenTreeResource):
"""Class for managing import/export of Build data."""
# For some reason, we need to specify the fields individually for this ModelResource,
# but we don't for other ones.
# TODO: 2022-05-12 - Need to investigate why this is the case!
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')
title = Field(attribute='title')
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(part.models.Part))
part_name = Field(attribute='part__full_name', readonly=True)
overdue = Field(attribute='is_overdue', readonly=True, widget=widgets.BooleanWidget())
completed = Field(attribute='completed', readonly=True)
quantity = Field(attribute='quantity')
status = Field(attribute='status')
batch = Field(attribute='batch')
notes = Field(attribute='notes')
from build.models import Build, BuildItem, BuildLine
@admin.register(Build)
class BuildAdmin(ImportExportModelAdmin):
"""Class for managing the Build model via the admin interface"""
class BuildAdmin(admin.ModelAdmin):
"""Class for managing the Build model via the admin interface."""
exclude = [
'reference_int',
]
exclude = ['reference_int']
list_display = (
'reference',
'title',
'part',
'status',
'batch',
'quantity',
)
list_display = ('reference', 'title', 'part', 'status', 'batch', 'quantity')
search_fields = [
'reference',
'title',
'part__name',
'part__description',
]
search_fields = ['reference', 'title', 'part__name', 'part__description']
autocomplete_fields = [
'parent',
'part',
'sales_order',
'take_from',
'destination',
]
autocomplete_fields = ['parent', 'part', 'sales_order', 'take_from', 'destination']
@admin.register(BuildItem)
class BuildItemAdmin(admin.ModelAdmin):
"""Class for managing the BuildItem model via the admin interface."""
list_display = (
'stock_item',
'quantity'
)
list_display = ('stock_item', 'quantity')
autocomplete_fields = [
'build_line',
'stock_item',
'install_into',
]
autocomplete_fields = ['build_line', 'stock_item', 'install_into']
@admin.register(BuildLine)
class BuildLineAdmin(admin.ModelAdmin):
"""Class for managing the BuildLine model via the admin interface"""
"""Class for managing the BuildLine model via the admin interface."""
list_display = (
'build',
'bom_item',
'quantity',
)
list_display = ('build', 'bom_item', 'quantity')
search_fields = [
'build__title',
'build__reference',
'bom_item__sub_part__name',
]
search_fields = ['build__title', 'build__reference', 'bom_item__sub_part__name']

View File

@ -1,29 +1,28 @@
"""JSON API for the Build app."""
from __future__ import annotations
from django.contrib.auth.models import User
from django.db.models import F, Q
from django.urls import include, path
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from django_filters import rest_framework as rest_filters
from rest_framework.exceptions import ValidationError
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from generic.states.api import StatusView
from InvenTree.helpers import str2bool, isNull
from build.status_codes import BuildStatus, BuildStatusGroups
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import common.models
import build.admin
import build.serializers
from build.models import Build, BuildLine, BuildItem
import common.models
import part.models
from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus, BuildStatusGroups
from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter
from InvenTree.helpers import isNull, str2bool
from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
from users.models import Owner
from InvenTree.filters import InvenTreeDateFilter, SEARCH_ORDER_FILTER_ALIAS
class BuildFilter(rest_filters.FilterSet):
@ -31,17 +30,18 @@ class BuildFilter(rest_filters.FilterSet):
class Meta:
"""Metaclass options."""
model = Build
fields = [
'sales_order',
]
fields = ['sales_order']
status = rest_filters.NumberFilter(label='Status')
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
# 'outstanding' is an alias for 'active' here
outstanding = rest_filters.BooleanFilter(label='Build is outstanding', method='filter_active')
outstanding = rest_filters.BooleanFilter(
label='Build is outstanding', method='filter_active'
)
def filter_active(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are active."""
@ -50,12 +50,12 @@ class BuildFilter(rest_filters.FilterSet):
return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES)
parent = rest_filters.ModelChoiceFilter(
queryset=Build.objects.all(),
label=_('Parent Build'),
field_name='parent',
queryset=Build.objects.all(), label=_('Parent Build'), field_name='parent'
)
include_variants = rest_filters.BooleanFilter(label=_('Include Variants'), method='filter_include_variants')
include_variants = rest_filters.BooleanFilter(
label=_('Include Variants'), method='filter_include_variants'
)
def filter_include_variants(self, queryset, name, value):
"""Filter by whether or not to include variants of the selected part.
@ -64,13 +64,10 @@ class BuildFilter(rest_filters.FilterSet):
- This filter does nothing by itself, and requires the 'part' filter to be set.
- Refer to the 'filter_part' method for more information.
"""
return queryset
part = rest_filters.ModelChoiceFilter(
queryset=part.models.Part.objects.all(),
field_name='part',
method='filter_part'
queryset=part.models.Part.objects.all(), field_name='part', method='filter_part'
)
def filter_part(self, queryset, name, part):
@ -80,7 +77,6 @@ class BuildFilter(rest_filters.FilterSet):
- If "include_variants" is True, include all variants of the selected part.
- Otherwise, just filter by the selected part.
"""
include_variants = str2bool(self.data.get('include_variants', False))
if include_variants:
@ -91,16 +87,17 @@ class BuildFilter(rest_filters.FilterSet):
ancestor = rest_filters.ModelChoiceFilter(
queryset=Build.objects.all(),
label=_('Ancestor Build'),
method='filter_ancestor'
method='filter_ancestor',
)
def filter_ancestor(self, queryset, name, parent):
"""Filter by 'parent' build order."""
builds = parent.get_descendants(include_self=False)
return queryset.filter(pk__in=[b.pk for b in builds])
overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue')
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."""
@ -109,8 +106,7 @@ class BuildFilter(rest_filters.FilterSet):
return queryset.exclude(Build.OVERDUE_FILTER)
assigned_to_me = rest_filters.BooleanFilter(
label=_('Assigned to me'),
method='filter_assigned_to_me'
label=_('Assigned to me'), method='filter_assigned_to_me'
)
def filter_assigned_to_me(self, queryset, name, value):
@ -125,14 +121,11 @@ class BuildFilter(rest_filters.FilterSet):
return queryset.exclude(responsible__in=owners)
issued_by = rest_filters.ModelChoiceFilter(
queryset=Owner.objects.all(),
label=_('Issued By'),
method='filter_issued_by'
queryset=Owner.objects.all(), label=_('Issued By'), method='filter_issued_by'
)
def filter_issued_by(self, queryset, name, owner):
"""Filter by 'owner' which issued the order."""
if owner.label() == 'user':
user = User.objects.get(pk=owner.owner_id)
return queryset.filter(issued_by=user)
@ -143,70 +136,62 @@ class BuildFilter(rest_filters.FilterSet):
return queryset.none()
assigned_to = rest_filters.ModelChoiceFilter(
queryset=Owner.objects.all(),
field_name='responsible',
label=_('Assigned To')
queryset=Owner.objects.all(), field_name='responsible', label=_('Assigned To')
)
def filter_responsible(self, queryset, name, owner):
"""Filter by orders which are assigned to the specified owner."""
owners = list(Owner.objects.filter(pk=owner))
# 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))
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(
label='Filter by exact reference',
field_name='reference',
lookup_expr="iexact"
label='Filter by exact reference', field_name='reference', lookup_expr='iexact'
)
project_code = rest_filters.ModelChoiceFilter(
queryset=common.models.ProjectCode.objects.all(),
field_name='project_code'
queryset=common.models.ProjectCode.objects.all(), field_name='project_code'
)
has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code')
has_project_code = rest_filters.BooleanFilter(
label='has_project_code', method='filter_has_project_code'
)
def filter_has_project_code(self, queryset, name, value):
"""Filter by whether or not the order has a project code"""
"""Filter by whether or not the order has a project code."""
if str2bool(value):
return queryset.exclude(project_code=None)
return queryset.filter(project_code=None)
created_before = InvenTreeDateFilter(
label=_('Created before'),
field_name='creation_date', lookup_expr='lt'\
label=_('Created before'), field_name='creation_date', lookup_expr='lt'
)
created_after = InvenTreeDateFilter(
label=_('Created after'),
field_name='creation_date', lookup_expr='gt'
label=_('Created after'), field_name='creation_date', lookup_expr='gt'
)
target_date_before = InvenTreeDateFilter(
label=_('Target date before'),
field_name='target_date', lookup_expr='lt'
label=_('Target date before'), field_name='target_date', lookup_expr='lt'
)
target_date_after = InvenTreeDateFilter(
label=_('Target date after'),
field_name='target_date', lookup_expr='gt'
label=_('Target date after'), field_name='target_date', lookup_expr='gt'
)
completed_before = InvenTreeDateFilter(
label=_('Completed before'),
field_name='completion_date', lookup_expr='lt'
label=_('Completed before'), field_name='completion_date', lookup_expr='lt'
)
completed_after = InvenTreeDateFilter(
label=_('Completed after'),
field_name='completion_date', lookup_expr='gt'
label=_('Completed after'), field_name='completion_date', lookup_expr='gt'
)
@ -227,7 +212,7 @@ class BuildMixin:
'build_lines__bom_item',
'build_lines__build',
'part',
'part__pricing_data'
'part__pricing_data',
)
return queryset
@ -295,7 +280,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
build = Build.objects.get(pk=exclude_tree)
@ -332,12 +316,14 @@ class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a Build object."""
def destroy(self, request, *args, **kwargs):
"""Only allow deletion of a BuildOrder if the build status is CANCELLED"""
"""Only allow deletion of a BuildOrder if the build status is CANCELLED."""
build = self.get_object()
if build.status != BuildStatus.CANCELLED:
raise ValidationError({
"non_field_errors": [_("Build must be cancelled before it can be deleted")]
'non_field_errors': [
_('Build must be cancelled before it can be deleted')
]
})
return super().destroy(request, *args, **kwargs)
@ -374,18 +360,26 @@ class BuildLineFilter(rest_filters.FilterSet):
class Meta:
"""Meta information for the BuildLineFilter class."""
model = BuildLine
fields = [
'build',
'bom_item',
]
fields = ['build', 'bom_item']
# Fields on related models
consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable')
optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional')
assembly = rest_filters.BooleanFilter(label=_('Assembly'), field_name='bom_item__sub_part__assembly')
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
testable = rest_filters.BooleanFilter(label=_('Testable'), field_name='bom_item__sub_part__testable')
consumable = rest_filters.BooleanFilter(
label=_('Consumable'), field_name='bom_item__consumable'
)
optional = rest_filters.BooleanFilter(
label=_('Optional'), field_name='bom_item__optional'
)
assembly = rest_filters.BooleanFilter(
label=_('Assembly'), field_name='bom_item__sub_part__assembly'
)
tracked = rest_filters.BooleanFilter(
label=_('Tracked'), field_name='bom_item__sub_part__trackable'
)
testable = rest_filters.BooleanFilter(
label=_('Testable'), field_name='bom_item__sub_part__testable'
)
part = rest_filters.ModelChoiceFilter(
queryset=part.models.Part.objects.all(),
@ -394,8 +388,7 @@ class BuildLineFilter(rest_filters.FilterSet):
)
order_outstanding = rest_filters.BooleanFilter(
label=_('Order Outstanding'),
method='filter_order_outstanding'
label=_('Order Outstanding'), method='filter_order_outstanding'
)
def filter_order_outstanding(self, queryset, name, value):
@ -404,18 +397,22 @@ class BuildLineFilter(rest_filters.FilterSet):
return queryset.filter(build__status__in=BuildStatusGroups.ACTIVE_CODES)
return queryset.exclude(build__status__in=BuildStatusGroups.ACTIVE_CODES)
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
allocated = rest_filters.BooleanFilter(
label=_('Allocated'), method='filter_allocated'
)
def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated"""
"""Filter by whether each BuildLine is fully allocated."""
if str2bool(value):
return queryset.filter(allocated__gte=F('quantity'))
return queryset.filter(allocated__lt=F('quantity'))
available = rest_filters.BooleanFilter(label=_('Available'), method='filter_available')
available = rest_filters.BooleanFilter(
label=_('Available'), method='filter_available'
)
def filter_available(self, queryset, name, value):
"""Filter by whether there is sufficient stock available for each BuildLine:
"""Filter by whether there is sufficient stock available for each BuildLine.
To determine this, we need to know:
@ -423,14 +420,18 @@ class BuildLineFilter(rest_filters.FilterSet):
- The quantity available for each BuildLine (including variants and substitutes)
- The quantity allocated for each BuildLine
"""
flt = Q(quantity__lte=F('allocated') + F('available_stock') + F('available_substitute_stock') + F('available_variant_stock'))
flt = Q(
quantity__lte=F('allocated')
+ F('available_stock')
+ F('available_substitute_stock')
+ F('available_variant_stock')
)
if str2bool(value):
return queryset.filter(flt)
return queryset.exclude(flt)
class BuildLineEndpoint:
"""Mixin class for BuildLine API endpoints."""
@ -439,7 +440,6 @@ class BuildLineEndpoint:
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint."""
kwargs['context'] = self.get_serializer_context()
try:
@ -460,10 +460,12 @@ class BuildLineEndpoint:
- If this is a "detail" view, use the build associated with the line
- If this is a "list" view, use the build associated with the request
"""
raise NotImplementedError("get_source_build must be implemented in the child class")
raise NotImplementedError(
'get_source_build must be implemented in the child class'
)
def get_queryset(self):
"""Override queryset to select-related and annotate"""
"""Override queryset to select-related and annotate."""
queryset = super().get_queryset()
if not hasattr(self, 'source_build'):
@ -471,11 +473,13 @@ class BuildLineEndpoint:
source_build = self.source_build
return build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
return build.serializers.BuildLineSerializer.annotate_queryset(
queryset, build=source_build
)
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
"""API endpoint for accessing a list of BuildLine objects"""
"""API endpoint for accessing a list of BuildLine objects."""
filterset_class = BuildLineFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
@ -514,7 +518,6 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
def get_source_build(self) -> Build | None:
"""Return the target build for the BuildLine queryset."""
source_build = None
try:
@ -532,7 +535,6 @@ class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
def get_source_build(self) -> Build | None:
"""Return the target source location for the BuildLine queryset."""
return None
@ -607,12 +609,8 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI):
def get_queryset(self):
"""Return the queryset for the BuildFinish API endpoint."""
queryset = super().get_queryset()
queryset = queryset.prefetch_related(
'build_lines',
'build_lines__allocations'
)
queryset = queryset.prefetch_related('build_lines', 'build_lines__allocations')
return queryset
@ -658,6 +656,7 @@ class BuildHold(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.all()
serializer_class = build.serializers.BuildHoldSerializer
class BuildCancel(BuildOrderContextMixin, CreateAPI):
"""API endpoint for cancelling a BuildOrder."""
@ -673,16 +672,13 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI):
class BuildItemFilter(rest_filters.FilterSet):
"""Custom filterset for the BuildItemList API endpoint"""
"""Custom filterset for the BuildItemList API endpoint."""
class Meta:
"""Metaclass option"""
"""Metaclass option."""
model = BuildItem
fields = [
'build_line',
'stock_item',
'install_into',
]
fields = ['build_line', 'stock_item', 'install_into']
include_variants = rest_filters.BooleanFilter(
label=_('Include Variants'), method='filter_include_variants'
@ -695,7 +691,6 @@ class BuildItemFilter(rest_filters.FilterSet):
- This filter does nothing by itself, and requires the 'part' filter to be set.
- Refer to the 'filter_part' method for more information.
"""
return queryset
part = rest_filters.ModelChoiceFilter(
@ -712,11 +707,12 @@ class BuildItemFilter(rest_filters.FilterSet):
- If "include_variants" is True, include all variants of the selected part.
- Otherwise, just filter by the selected part.
"""
include_variants = str2bool(self.data.get('include_variants', False))
if include_variants:
return queryset.filter(stock_item__part__in=part.get_descendants(include_self=True))
return queryset.filter(
stock_item__part__in=part.get_descendants(include_self=True)
)
else:
return queryset.filter(stock_item__part=part)
@ -729,7 +725,7 @@ class BuildItemFilter(rest_filters.FilterSet):
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"""
"""Filter the queryset based on whether build items are tracked."""
if str2bool(value):
return queryset.exclude(install_into=None)
return queryset.filter(install_into=None)
@ -752,7 +748,12 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
try:
params = self.request.query_params
for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
for key in [
'part_detail',
'location_detail',
'stock_detail',
'build_detail',
]:
if key in params:
kwargs[key] = str2bool(params.get(key, False))
except AttributeError:
@ -778,9 +779,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
'stock_item__supplier_part__supplier',
'stock_item__supplier_part__manufacturer_part',
'stock_item__supplier_part__manufacturer_part__manufacturer',
).prefetch_related(
'stock_item__location__tags',
)
).prefetch_related('stock_item__location__tags')
return queryset
@ -794,7 +793,6 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
output = params.get('output', None)
if output:
if isNull(output):
queryset = queryset.filter(install_into=None)
else:
@ -802,14 +800,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
return queryset
ordering_fields = [
'part',
'sku',
'quantity',
'location',
'reference',
'IPN',
]
ordering_fields = ['part', 'sku', 'quantity', 'location', 'reference', 'IPN']
ordering_field_aliases = {
'part': 'stock_item__part__name',
@ -828,42 +819,84 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
build_api_urls = [
# Build lines
path('line/', include([
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
path('', BuildLineList.as_view(), name='api-build-line-list'),
])),
path(
'line/',
include([
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
path('', BuildLineList.as_view(), name='api-build-line-list'),
]),
),
# Build Items
path('item/', include([
path('<int:pk>/', include([
path('metadata/', MetadataView.as_view(), {'model': BuildItem}, name='api-build-item-metadata'),
path('', BuildItemDetail.as_view(), name='api-build-item-detail'),
])),
path('', BuildItemList.as_view(), name='api-build-item-list'),
])),
path(
'item/',
include([
path(
'<int:pk>/',
include([
path(
'metadata/',
MetadataView.as_view(),
{'model': BuildItem},
name='api-build-item-metadata',
),
path('', BuildItemDetail.as_view(), name='api-build-item-detail'),
]),
),
path('', BuildItemList.as_view(), name='api-build-item-list'),
]),
),
# Build Detail
path('<int:pk>/', include([
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
path('auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
path('complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
path('create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
path('scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'),
path('issue/', BuildIssue.as_view(), name='api-build-issue'),
path('hold/', BuildHold.as_view(), name='api-build-hold'),
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
path('metadata/', MetadataView.as_view(), {'model': Build}, name='api-build-metadata'),
path('', BuildDetail.as_view(), name='api-build-detail'),
])),
path(
'<int:pk>/',
include([
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
path(
'auto-allocate/',
BuildAutoAllocate.as_view(),
name='api-build-auto-allocate',
),
path(
'complete/',
BuildOutputComplete.as_view(),
name='api-build-output-complete',
),
path(
'create-output/',
BuildOutputCreate.as_view(),
name='api-build-output-create',
),
path(
'delete-outputs/',
BuildOutputDelete.as_view(),
name='api-build-output-delete',
),
path(
'scrap-outputs/',
BuildOutputScrap.as_view(),
name='api-build-output-scrap',
),
path('issue/', BuildIssue.as_view(), name='api-build-issue'),
path('hold/', BuildHold.as_view(), name='api-build-hold'),
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
path(
'metadata/',
MetadataView.as_view(),
{'model': Build},
name='api-build-metadata',
),
path('', BuildDetail.as_view(), name='api-build-detail'),
]),
),
# Build order status code information
path('status/', StatusView.as_view(), {StatusView.MODEL_REF: BuildStatus}, name='api-build-status-codes'),
path(
'status/',
StatusView.as_view(),
{StatusView.MODEL_REF: BuildStatus},
name='api-build-status-codes',
),
# Build List
path('', BuildList.as_view(), name='api-build-list'),
]

View File

@ -1,8 +1,9 @@
"""Django app for the BuildOrder module"""
"""Django app for the BuildOrder module."""
from django.apps import AppConfig
class BuildConfig(AppConfig):
"""BuildOrder app config class"""
"""BuildOrder app config class."""
name = 'build'

View File

@ -1,25 +1,21 @@
"""Queryset filtering helper functions for the Build app."""
from django.db import models
from django.db.models import Sum, Q
from django.db.models import Q, Sum
from django.db.models.functions import Coalesce
def annotate_allocated_quantity(queryset: Q) -> Q:
"""
Annotate the 'allocated' quantity for each build item in the queryset.
"""Annotate the 'allocated' quantity for each build item in the queryset.
Arguments:
queryset: The BuildLine queryset to annotate
"""
queryset = queryset.prefetch_related('allocations')
return queryset.annotate(
allocated=Coalesce(
Sum('allocations__quantity'), 0,
output_field=models.DecimalField()
Sum('allocations__quantity'), 0, output_field=models.DecimalField()
)
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,4 @@ class BuildStatusGroups:
BuildStatus.PRODUCTION.value,
]
COMPLETE = [
BuildStatus.COMPLETE.value,
]
COMPLETE = [BuildStatus.COMPLETE.value]

View File

@ -3,13 +3,12 @@
import logging
import random
import time
from datetime import timedelta
from decimal import Decimal
from django.contrib.auth.models import User
from django.template.loader import render_to_string
from django.db import transaction
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from allauth.account.models import EmailAddress
@ -34,7 +33,10 @@ def auto_allocate_build(build_id: int, **kwargs):
build_order = build_models.Build.objects.filter(pk=build_id).first()
if not build_order:
logger.warning("Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist", build_id)
logger.warning(
'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist',
build_id,
)
return
build_order.auto_allocate_stock(**kwargs)
@ -48,13 +50,19 @@ def complete_build_allocations(build_id: int, user_id: int):
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
logger.warning("Could not complete build allocations for BuildOrder <%s> - User does not exist", build_id)
logger.warning(
'Could not complete build allocations for BuildOrder <%s> - User does not exist',
build_id,
)
return
else:
user = None
if not build_order:
logger.warning("Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist", build_id)
logger.warning(
'Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist',
build_id,
)
return
build_order.complete_allocations(user)
@ -65,7 +73,7 @@ def update_build_order_lines(bom_item_pk: int):
This task is triggered when a BomItem is created or updated.
"""
logger.info("Updating build order lines for BomItem %s", bom_item_pk)
logger.info('Updating build order lines for BomItem %s', bom_item_pk)
bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first()
@ -77,16 +85,14 @@ def update_build_order_lines(bom_item_pk: int):
# Find all active builds which reference any of the parts
builds = build_models.Build.objects.filter(
part__in=list(assemblies),
status__in=BuildStatusGroups.ACTIVE_CODES
part__in=list(assemblies), status__in=BuildStatusGroups.ACTIVE_CODES
)
# Iterate through each build, and update the relevant line items
for bo in builds:
# Try to find a matching build order line
line = build_models.BuildLine.objects.filter(
build=bo,
bom_item=bom_item,
build=bo, bom_item=bom_item
).first()
q = bom_item.get_required_quantity(bo.quantity)
@ -99,13 +105,13 @@ def update_build_order_lines(bom_item_pk: int):
else:
# Create a new line item
build_models.BuildLine.objects.create(
build=bo,
bom_item=bom_item,
quantity=q,
build=bo, bom_item=bom_item, quantity=q
)
if builds.count() > 0:
logger.info("Updated %s build orders for part %s", builds.count(), bom_item.part)
logger.info(
'Updated %s build orders for part %s', builds.count(), bom_item.part
)
def check_build_stock(build: build_models.Build):
@ -133,7 +139,6 @@ def check_build_stock(build: build_models.Build):
return
for bom_item in part.get_bom_items():
sub_part = bom_item.sub_part
# The 'in stock' quantity depends on whether the bom_item allows variants
@ -149,7 +154,9 @@ def check_build_stock(build: build_models.Build):
# There is not sufficient stock for this part
lines.append({
'link': InvenTree.helpers_model.construct_absolute_url(sub_part.get_absolute_url()),
'link': InvenTree.helpers_model.construct_absolute_url(
sub_part.get_absolute_url()
),
'part': sub_part,
'in_stock': in_stock,
'allocated': allocated,
@ -164,29 +171,32 @@ def check_build_stock(build: build_models.Build):
# Are there any users subscribed to these parts?
subscribers = build.part.get_subscribers()
emails = EmailAddress.objects.filter(
user__in=subscribers,
)
emails = EmailAddress.objects.filter(user__in=subscribers)
if len(emails) > 0:
logger.info("Notifying users of stock required for build %s", build.pk)
logger.info('Notifying users of stock required for build %s', build.pk)
context = {
'link': InvenTree.helpers_model.construct_absolute_url(build.get_absolute_url()),
'link': InvenTree.helpers_model.construct_absolute_url(
build.get_absolute_url()
),
'build': build,
'part': build.part,
'lines': lines,
}
# Render the HTML message
html_message = render_to_string('email/build_order_required_stock.html', context)
html_message = render_to_string(
'email/build_order_required_stock.html', context
)
subject = _("Stock required for build order")
subject = _('Stock required for build order')
recipients = emails.values_list('email', flat=True)
InvenTree.helpers_email.send_email(subject, '', recipients, html_message=html_message)
InvenTree.helpers_email.send_email(
subject, '', recipients, html_message=html_message
)
def create_child_builds(build_id: int) -> None:
@ -195,7 +205,6 @@ def create_child_builds(build_id: int) -> None:
- Will create a build order for each assembly part in the BOM
- Runs recursively, also creating child builds for each sub-assembly part
"""
try:
build_order = build_models.Build.objects.get(pk=build_id)
except (build_models.Build.DoesNotExist, ValueError):
@ -215,13 +224,12 @@ def create_child_builds(build_id: int) -> None:
for item in assembly_items:
quantity = item.quantity * build_order.quantity
# Check if the child build order has already been created
if build_models.Build.objects.filter(
part=item.sub_part,
parent=build_order,
quantity=quantity,
status__in=BuildStatusGroups.ACTIVE_CODES
status__in=BuildStatusGroups.ACTIVE_CODES,
).exists():
continue
@ -241,11 +249,7 @@ def create_child_builds(build_id: int) -> None:
for pk in sub_build_ids:
# Offload the child build order creation to the background task queue
InvenTree.tasks.offload_task(
create_child_builds,
pk,
group='build'
)
InvenTree.tasks.offload_task(create_child_builds, pk, group='build')
def notify_overdue_build_order(bo: build_models.Build):
@ -263,24 +267,16 @@ def notify_overdue_build_order(bo: build_models.Build):
context = {
'order': bo,
'name': name,
'message': _(f"Build order {bo} is now overdue"),
'link': InvenTree.helpers_model.construct_absolute_url(
bo.get_absolute_url(),
),
'template': {
'html': 'email/overdue_build_order.html',
'subject': name,
}
'message': _(f'Build order {bo} is now overdue'),
'link': InvenTree.helpers_model.construct_absolute_url(bo.get_absolute_url()),
'template': {'html': 'email/overdue_build_order.html', 'subject': name},
}
event_name = BuildEvents.OVERDUE
# Send a notification to the appropriate users
common.notifications.trigger_notification(
bo,
event_name,
targets=targets,
context=context
bo, event_name, targets=targets, context=context
)
# Register a matching event to the plugin system
@ -298,8 +294,7 @@ def check_overdue_build_orders():
yesterday = InvenTree.helpers.current_date() - timedelta(days=1)
overdue_orders = build_models.Build.objects.filter(
target_date=yesterday,
status__in=BuildStatusGroups.ACTIVE_CODES
target_date=yesterday, status__in=BuildStatusGroups.ACTIVE_CODES
)
for bo in overdue_orders:

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +1,34 @@
"""Unit tests for the 'build' models"""
"""Unit tests for the 'build' models."""
import logging
import uuid
from datetime import datetime, timedelta
from django.test import TestCase
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.db.models import Sum
from django.test import TestCase
from django.test.utils import override_settings
from InvenTree import status_codes as status
from InvenTree.unit_test import findOffloadedEvent
import common.models
from common.settings import set_global_setting
import build.tasks
import common.models
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from build.status_codes import BuildStatus
from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate
from common.settings import set_global_setting
from InvenTree import status_codes as status
from InvenTree.unit_test import findOffloadedEvent
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
from stock.models import StockItem, StockItemTestResult
from users.models import Owner
import logging
logger = logging.getLogger('inventree')
class BuildTestBase(TestCase):
"""Run some tests to ensure that the Build model is working properly."""
fixtures = [
'users',
]
fixtures = ['users']
@classmethod
def setUpTestData(cls):
@ -54,8 +50,8 @@ class BuildTestBase(TestCase):
# Create a base "Part"
cls.assembly = Part.objects.create(
name="An assembled part",
description="Why does it matter what my description is?",
name='An assembled part',
description='Why does it matter what my description is?',
assembly=True,
trackable=True,
testable=True,
@ -63,8 +59,8 @@ class BuildTestBase(TestCase):
# create one build with one required test template
cls.tested_part_with_required_test = Part.objects.create(
name="Part having required tests",
description="Why does it matter what my description is?",
name='Part having required tests',
description='Why does it matter what my description is?',
assembly=True,
trackable=True,
testable=True,
@ -72,18 +68,18 @@ class BuildTestBase(TestCase):
cls.test_template_required = PartTestTemplate.objects.create(
part=cls.tested_part_with_required_test,
test_name="Required test",
description="Required test template description",
test_name='Required test',
description='Required test template description',
required=True,
requires_value=False,
requires_attachment=False
requires_attachment=False,
)
ref = generate_next_build_reference()
cls.build_w_tests_trackable = Build.objects.create(
reference=ref,
title="This is a build",
title='This is a build',
part=cls.tested_part_with_required_test,
quantity=1,
issued_by=get_user_model().objects.get(pk=1),
@ -94,13 +90,13 @@ class BuildTestBase(TestCase):
quantity=1,
is_building=True,
serial=uuid.uuid4(),
build=cls.build_w_tests_trackable
build=cls.build_w_tests_trackable,
)
# now create a part with a non-required test template
cls.tested_part_wo_required_test = Part.objects.create(
name="Part with one non.required test",
description="Why does it matter what my description is?",
name='Part with one non.required test',
description='Why does it matter what my description is?',
assembly=True,
trackable=True,
testable=True,
@ -108,18 +104,18 @@ class BuildTestBase(TestCase):
cls.test_template_non_required = PartTestTemplate.objects.create(
part=cls.tested_part_wo_required_test,
test_name="Required test template",
description="Required test template description",
test_name='Required test template',
description='Required test template description',
required=False,
requires_value=False,
requires_attachment=False
requires_attachment=False,
)
ref = generate_next_build_reference()
cls.build_wo_tests_trackable = Build.objects.create(
reference=ref,
title="This is a build",
title='This is a build',
part=cls.tested_part_wo_required_test,
quantity=1,
issued_by=get_user_model().objects.get(pk=1),
@ -130,47 +126,33 @@ class BuildTestBase(TestCase):
quantity=1,
is_building=True,
serial=uuid.uuid4(),
build=cls.build_wo_tests_trackable
build=cls.build_wo_tests_trackable,
)
cls.sub_part_1 = Part.objects.create(
name="Widget A",
description="A widget",
component=True
name='Widget A', description='A widget', component=True
)
cls.sub_part_2 = Part.objects.create(
name="Widget B",
description="A widget",
component=True
name='Widget B', description='A widget', component=True
)
cls.sub_part_3 = Part.objects.create(
name="Widget C",
description="A widget",
component=True,
trackable=True
name='Widget C', description='A widget', component=True, trackable=True
)
# Create BOM item links for the parts
cls.bom_item_1 = BomItem.objects.create(
part=cls.assembly,
sub_part=cls.sub_part_1,
quantity=5
part=cls.assembly, sub_part=cls.sub_part_1, quantity=5
)
cls.bom_item_2 = BomItem.objects.create(
part=cls.assembly,
sub_part=cls.sub_part_2,
quantity=3,
optional=True
part=cls.assembly, sub_part=cls.sub_part_2, quantity=3, optional=True
)
# sub_part_3 is trackable!
cls.bom_item_3 = BomItem.objects.create(
part=cls.assembly,
sub_part=cls.sub_part_3,
quantity=2
part=cls.assembly, sub_part=cls.sub_part_3, quantity=2
)
ref = generate_next_build_reference()
@ -178,7 +160,7 @@ class BuildTestBase(TestCase):
# Create a "Build" object to make 10x objects
cls.build = Build.objects.create(
reference=ref,
title="This is a build",
title='This is a build',
part=cls.assembly,
quantity=10,
issued_by=get_user_model().objects.get(pk=1),
@ -192,17 +174,11 @@ class BuildTestBase(TestCase):
# Create some build output (StockItem) objects
cls.output_1 = StockItem.objects.create(
part=cls.assembly,
quantity=3,
is_building=True,
build=cls.build
part=cls.assembly, quantity=3, is_building=True, build=cls.build
)
cls.output_2 = StockItem.objects.create(
part=cls.assembly,
quantity=7,
is_building=True,
build=cls.build,
part=cls.assembly, quantity=7, is_building=True, build=cls.build
)
# Create some stock items to assign to the build
@ -219,12 +195,14 @@ class BuildTestBase(TestCase):
class BuildTest(BuildTestBase):
"""Unit testing class for the Build model"""
"""Unit testing class for the Build model."""
def test_ref_int(self):
"""Test the "integer reference" field used for natural sorting"""
"""Test the "integer reference" field used for natural sorting."""
# Set build reference to new value
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
set_global_setting(
'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None
)
refs = {
'BO-123-456': 123,
@ -236,10 +214,7 @@ class BuildTest(BuildTestBase):
for ref, ref_int in refs.items():
build = Build(
reference=ref,
quantity=1,
part=self.assembly,
title='Making some parts',
reference=ref, quantity=1, part=self.assembly, title='Making some parts'
)
self.assertEqual(build.reference_int, 0)
@ -247,18 +222,17 @@ class BuildTest(BuildTestBase):
self.assertEqual(build.reference_int, ref_int)
# Set build reference back to default value
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
set_global_setting(
'BUILDORDER_REFERENCE_PATTERN',
'BO-{ref:04d}', # noqa: RUF027
change_user=None,
)
def test_ref_validation(self):
"""Test that the reference field validation works as expected"""
"""Test that the reference field validation works as expected."""
# Default reference pattern = 'BO-{ref:04d}
# These patterns should fail
for ref in [
'BO-1234x',
'BO1234',
'OB-1234',
'BO--1234'
]:
for ref in ['BO-1234x', 'BO1234', 'OB-1234', 'BO--1234']:
with self.assertRaises(ValidationError):
Build.objects.create(
part=self.assembly,
@ -267,63 +241,53 @@ class BuildTest(BuildTestBase):
title='Invalid reference',
)
for ref in [
'BO-1234',
'BO-9999',
'BO-123'
]:
for ref in ['BO-1234', 'BO-9999', 'BO-123']:
Build.objects.create(
part=self.assembly,
quantity=10,
reference=ref,
title='Valid reference',
part=self.assembly, quantity=10, reference=ref, title='Valid reference'
)
# Try a new validator pattern
set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None) # noqa: RUF027
for ref in [
'1234-BO',
'9999-BO'
]:
for ref in ['1234-BO', '9999-BO']:
Build.objects.create(
part=self.assembly,
quantity=10,
reference=ref,
title='Valid reference',
part=self.assembly, quantity=10, reference=ref, title='Valid reference'
)
# Set build reference back to default value
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
set_global_setting(
'BUILDORDER_REFERENCE_PATTERN',
'BO-{ref:04d}', # noqa: RUF027
change_user=None,
)
def test_next_ref(self):
"""Test that the next reference is automatically generated"""
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
"""Test that the next reference is automatically generated."""
set_global_setting(
'BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None
)
build = Build.objects.create(
part=self.assembly,
quantity=5,
reference='XYZ-987',
title='Some thing',
part=self.assembly, quantity=5, reference='XYZ-987', title='Some thing'
)
self.assertEqual(build.reference_int, 987)
# Now create one *without* specifying the reference
build = Build.objects.create(
part=self.assembly,
quantity=1,
title='Some new title',
part=self.assembly, quantity=1, title='Some new title'
)
self.assertEqual(build.reference, 'XYZ-000988')
self.assertEqual(build.reference_int, 988)
# Set build reference back to default value
set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None)
set_global_setting(
'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None
)
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
"""Perform some basic tests before we start the ball rolling."""
self.assertEqual(StockItem.objects.count(), 12)
# Build is PENDING
@ -348,7 +312,7 @@ class BuildTest(BuildTestBase):
self.assertFalse(self.build.is_complete)
def test_build_item_clean(self):
"""Ensure that dodgy BuildItem objects cannot be created"""
"""Ensure that dodgy BuildItem objects cannot be created."""
stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem
@ -358,7 +322,9 @@ class BuildTest(BuildTestBase):
b.save()
# Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
b = BuildItem(
stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999
)
with self.assertRaises(ValidationError):
b.clean()
@ -370,19 +336,22 @@ class BuildTest(BuildTestBase):
b.clean()
# Ok, what about we make one that does *not* fail?
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b = BuildItem(
stock_item=self.stock_1_2,
build_line=self.line_1,
install_into=self.output_1,
quantity=10,
)
b.save()
def test_duplicate_bom_line(self):
"""Try to add a duplicate BOM item - it should be allowed"""
"""Try to add a duplicate BOM item - it should be allowed."""
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_1,
quantity=99
part=self.assembly, sub_part=self.sub_part_1, quantity=99
)
def allocate_stock(self, output, allocations):
"""Allocate stock to this build, against a particular output
"""Allocate stock to this build, against a particular output.
Args:
output: StockItem object (or None)
@ -391,52 +360,36 @@ class BuildTest(BuildTestBase):
items_to_create = []
for item, quantity in allocations.items():
# Find an appropriate BuildLine to allocate against
line = BuildLine.objects.filter(
build=self.build,
bom_item__sub_part=item.part
build=self.build, bom_item__sub_part=item.part
).first()
items_to_create.append(BuildItem(
build_line=line,
stock_item=item,
quantity=quantity,
install_into=output
))
items_to_create.append(
BuildItem(
build_line=line,
stock_item=item,
quantity=quantity,
install_into=output,
)
)
BuildItem.objects.bulk_create(items_to_create)
def test_partial_allocation(self):
"""Test partial allocation of stock"""
"""Test partial allocation of stock."""
# Fully allocate tracked stock against build output 1
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6,
}
)
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
# Partially allocate tracked stock against build output 2
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 1,
}
)
self.allocate_stock(self.output_2, {self.stock_3_1: 1})
self.assertFalse(self.build.is_output_fully_allocated(self.output_2))
# Partially allocate untracked stock against build
self.allocate_stock(
None,
{
self.stock_1_1: 1,
self.stock_2_1: 1
}
)
self.allocate_stock(None, {self.stock_1_1: 1, self.stock_2_1: 1})
self.assertFalse(self.build.is_output_fully_allocated(None))
@ -445,12 +398,7 @@ class BuildTest(BuildTestBase):
self.assertEqual(len(unallocated), 3)
self.allocate_stock(
None,
{
self.stock_1_2: 100,
}
)
self.allocate_stock(None, {self.stock_1_2: 100})
self.assertFalse(self.build.is_fully_allocated(None))
@ -470,44 +418,21 @@ class BuildTest(BuildTestBase):
self.stock_2_1.save()
# Now we "fully" allocate the untracked untracked items
self.allocate_stock(
None,
{
self.stock_1_2: 50,
self.stock_2_1: 50,
}
)
self.allocate_stock(None, {self.stock_1_2: 50, self.stock_2_1: 50})
self.assertTrue(self.build.is_fully_allocated(tracked=False))
def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function"""
"""Test overallocation of stock and trim function."""
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
self.build.issue_build()
self.assertEqual(self.build.status, status.BuildStatus.PRODUCTION)
# Fully allocate tracked stock (not eligible for trimming)
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6,
}
)
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 14,
}
)
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
# Fully allocate part 1 (should be left alone)
self.allocate_stock(
None,
{
self.stock_1_1: 3,
self.stock_1_2: 47,
}
)
self.allocate_stock(None, {self.stock_1_1: 3, self.stock_1_2: 47})
extra_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=6)
extra_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=4)
@ -521,9 +446,9 @@ class BuildTest(BuildTestBase):
self.stock_2_3: 5,
self.stock_2_4: 5,
self.stock_2_5: 5, # 25
extra_2_1: 6, # 31
extra_2_2: 4, # 35
}
extra_2_1: 6, # 31
extra_2_2: 4, # 35
},
)
self.assertTrue(self.build.is_overallocated())
@ -550,19 +475,28 @@ class BuildTest(BuildTestBase):
self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35)
# However, the "available" stock quantity has been decreased
self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5)
self.assertEqual(
items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'],
5,
)
# And the "consumed_by" quantity has been increased
self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30)
self.assertEqual(
items.filter(consumed_by=self.build).aggregate(Sum('quantity'))[
'quantity__sum'
],
30,
)
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
# Check that the "consumed_by" item count has increased
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8)
self.assertEqual(
StockItem.objects.filter(consumed_by=self.build).count(), n + 8
)
def test_change_part(self):
"""Try to change target part after creating a build"""
"""Try to change target part after creating a build."""
bo = Build.objects.create(
reference='BO-9999',
title='Some new build',
@ -572,9 +506,7 @@ class BuildTest(BuildTestBase):
)
assembly_2 = Part.objects.create(
name="Another assembly",
description="A different assembly",
assembly=True,
name='Another assembly', description='A different assembly', assembly=True
)
# Should not be able to change the part after the Build is saved
@ -583,7 +515,7 @@ class BuildTest(BuildTestBase):
bo.clean()
def test_cancel(self):
"""Test cancellation of the build"""
"""Test cancellation of the build."""
# TODO
"""
self.allocate_stock(50, 50, 200, self.output_1)
@ -591,10 +523,9 @@ class BuildTest(BuildTestBase):
self.assertEqual(BuildItem.objects.count(), 0)
"""
pass
def test_complete(self):
"""Test completion of a build output"""
"""Test completion of a build output."""
self.stock_1_1.quantity = 1000
self.stock_1_1.save()
@ -609,25 +540,15 @@ class BuildTest(BuildTestBase):
{
self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item
self.stock_1_2: 10,
self.stock_2_1: 30
}
self.stock_2_1: 30,
},
)
# Allocate tracked parts to output_1
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6
}
)
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
# Allocate tracked parts to output_2
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 14
}
)
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
self.assertTrue(self.build.is_fully_allocated(None))
self.assertTrue(self.build.is_fully_allocated(self.output_1))
@ -665,25 +586,32 @@ class BuildTest(BuildTestBase):
self.assertFalse(output.is_building)
def test_complete_with_required_tests(self):
"""Test the prevention completion when a required test is missing feature"""
"""Test the prevention completion when a required test is missing feature."""
# with required tests incompleted the save should fail
set_global_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None)
set_global_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None
)
with self.assertRaises(ValidationError):
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
self.build_w_tests_trackable.complete_build_output(
self.stockitem_with_required_test, None
)
# let's complete the required test and see if it could be saved
StockItemTestResult.objects.create(
stock_item=self.stockitem_with_required_test,
template=self.test_template_required,
result=True
result=True,
)
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
self.build_w_tests_trackable.complete_build_output(
self.stockitem_with_required_test, None
)
# let's see if a non required test could be saved
self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None)
self.build_wo_tests_trackable.complete_build_output(
self.stockitem_wo_required_test, None
)
def test_overdue_notification(self):
"""Test sending of notifications when a build order is overdue."""
@ -694,26 +622,25 @@ class BuildTest(BuildTestBase):
build.tasks.check_overdue_build_orders()
message = common.models.NotificationMessage.objects.get(
category='build.overdue_build_order',
user__id=1,
category='build.overdue_build_order', user__id=1
)
self.assertEqual(message.name, 'Overdue Build Order')
def test_new_build_notification(self):
"""Test that a notification is sent when a new build is created"""
"""Test that a notification is sent when a new build is created."""
Build.objects.create(
reference='BO-9999',
title='Some new build',
part=self.assembly,
quantity=5,
issued_by=get_user_model().objects.get(pk=2),
responsible=Owner.create(obj=Group.objects.get(pk=3))
responsible=Owner.create(obj=Group.objects.get(pk=3)),
)
# Two notifications should have been sent
messages = common.models.NotificationMessage.objects.filter(
category='build.new_build',
category='build.new_build'
)
self.assertEqual(messages.count(), 1)
@ -728,12 +655,12 @@ class BuildTest(BuildTestBase):
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True
PLUGIN_TESTING_EVENTS_ASYNC=True,
)
def test_events(self):
"""Test that build events are triggered correctly."""
from django_q.models import OrmQ
from build.events import BuildEvents
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
@ -747,7 +674,7 @@ class BuildTest(BuildTestBase):
part=self.assembly,
quantity=5,
issued_by=get_user_model().objects.get(pk=2),
responsible=Owner.create(obj=Group.objects.get(pk=3))
responsible=Owner.create(obj=Group.objects.get(pk=3)),
)
# Check that the 'build.created' event was triggered
@ -769,9 +696,7 @@ class BuildTest(BuildTestBase):
# Check that the 'build.issued' event was triggered
task = findOffloadedEvent(
BuildEvents.ISSUED,
matching_kwargs=['id'],
clear_after=True,
BuildEvents.ISSUED, matching_kwargs=['id'], clear_after=True
)
self.assertIsNotNone(task)
@ -781,7 +706,12 @@ class BuildTest(BuildTestBase):
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_line=self.line_1, install_into=self.output_1, quantity=10)
b = BuildItem(
stock_item=self.stock_1_2,
build_line=self.line_1,
install_into=self.output_1,
quantity=10,
)
b.save()
for model in [Build, BuildItem]:
@ -802,28 +732,20 @@ class BuildTest(BuildTestBase):
class AutoAllocationTests(BuildTestBase):
"""Tests for auto allocating stock against a build order"""
"""Tests for auto allocating stock against a build order."""
def setUp(self):
"""Init routines for this unit test class"""
"""Init routines for this unit test class."""
super().setUp()
# Add a "substitute" part for bom_item_2
alt_part = Part.objects.create(
name="alt part",
description="An alternative part!",
component=True,
name='alt part', description='An alternative part!', component=True
)
BomItemSubstitute.objects.create(
bom_item=self.bom_item_2,
part=alt_part,
)
BomItemSubstitute.objects.create(bom_item=self.bom_item_2, part=alt_part)
StockItem.objects.create(
part=alt_part,
quantity=500,
)
StockItem.objects.create(part=alt_part, quantity=500)
def test_auto_allocate(self):
"""Run the 'auto-allocate' function. What do we expect to happen?
@ -840,10 +762,7 @@ class AutoAllocationTests(BuildTestBase):
self.assertFalse(self.build.is_fully_allocated(tracked=False))
# Stock is not interchangeable, nothing will happen
self.build.auto_allocate_stock(
interchangeable=False,
substitutes=False,
)
self.build.auto_allocate_stock(interchangeable=False, substitutes=False)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
@ -857,9 +776,7 @@ class AutoAllocationTests(BuildTestBase):
# This time we expect stock to be allocated!
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=False,
optional_items=True,
interchangeable=True, substitutes=False, optional_items=True
)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
@ -873,10 +790,7 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.line_2.unallocated_quantity(), 5)
# This time, allow substitute parts to be used!
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=True,
)
self.build.auto_allocate_stock(interchangeable=True, substitutes=True)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 5)
@ -885,11 +799,9 @@ class AutoAllocationTests(BuildTestBase):
self.assertFalse(self.line_2.is_fully_allocated())
def test_fully_auto(self):
"""We should be able to auto-allocate against a build in a single go"""
"""We should be able to auto-allocate against a build in a single go."""
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=True,
optional_items=True,
interchangeable=True, substitutes=True, optional_items=True
)
self.assertTrue(self.build.is_fully_allocated(tracked=False))

View File

@ -16,21 +16,17 @@ class TestForwardMigrations(MigratorTestCase):
Part = self.old_state.apps.get_model('part', 'part')
buildable_part = Part.objects.create(
name='Widget',
description='Buildable Part',
active=True,
name='Widget', description='Buildable Part', active=True
)
Build = self.old_state.apps.get_model('build', 'build')
Build.objects.create(
part=buildable_part,
title='A build of some stuff',
quantity=50,
part=buildable_part, title='A build of some stuff', quantity=50
)
def test_items_exist(self):
"""Test to ensure that the 'assembly' field is correctly configured"""
"""Test to ensure that the 'assembly' field is correctly configured."""
Part = self.new_state.apps.get_model('part', 'part')
self.assertEqual(Part.objects.count(), 1)
@ -57,30 +53,15 @@ class TestReferenceMigration(MigratorTestCase):
"""Create some builds."""
Part = self.old_state.apps.get_model('part', 'part')
part = Part.objects.create(
name='Part',
description='A test part',
)
part = Part.objects.create(name='Part', description='A test part')
Build = self.old_state.apps.get_model('build', 'build')
Build.objects.create(
part=part,
title='My very first build',
quantity=10
)
Build.objects.create(part=part, title='My very first build', quantity=10)
Build.objects.create(
part=part,
title='My very second build',
quantity=10
)
Build.objects.create(part=part, title='My very second build', quantity=10)
Build.objects.create(
part=part,
title='My very third build',
quantity=10
)
Build.objects.create(part=part, title='My very third build', quantity=10)
# Ensure that the builds *do not* have a 'reference' field
for build in Build.objects.all():
@ -88,7 +69,7 @@ class TestReferenceMigration(MigratorTestCase):
print(build.reference)
def test_build_reference(self):
"""Test that the build reference is correctly assigned to the PK of the Build"""
"""Test that the build reference is correctly assigned to the PK of the Build."""
Build = self.new_state.apps.get_model('build', 'build')
self.assertEqual(Build.objects.count(), 3)
@ -108,21 +89,16 @@ class TestReferencePatternMigration(MigratorTestCase):
migrate_to = ('build', unit_test.getNewestMigrationFile('build'))
def prepare(self):
"""Create some initial data prior to migration"""
"""Create some initial data prior to migration."""
Setting = self.old_state.apps.get_model('common', 'inventreesetting')
# Create a custom existing prefix so we can confirm the operation is working
Setting.objects.create(
key='BUILDORDER_REFERENCE_PREFIX',
value='BuildOrder-',
)
Setting.objects.create(key='BUILDORDER_REFERENCE_PREFIX', value='BuildOrder-')
Part = self.old_state.apps.get_model('part', 'part')
assembly = Part.objects.create(
name='Assy 1',
description='An assembly',
level=0, lft=0, rght=0, tree_id=0,
name='Assy 1', description='An assembly', level=0, lft=0, rght=0, tree_id=0
)
Build = self.old_state.apps.get_model('build', 'build')
@ -130,14 +106,17 @@ class TestReferencePatternMigration(MigratorTestCase):
for idx in range(1, 11):
Build.objects.create(
part=assembly,
title=f"Build {idx}",
title=f'Build {idx}',
quantity=idx,
reference=f"{idx + 100}",
level=0, lft=0, rght=0, tree_id=0,
reference=f'{idx + 100}',
level=0,
lft=0,
rght=0,
tree_id=0,
)
def test_reference_migration(self):
"""Test that the reference fields have been correctly updated"""
"""Test that the reference fields have been correctly updated."""
Build = self.new_state.apps.get_model('build', 'build')
for build in Build.objects.all():
@ -165,7 +144,7 @@ class TestBuildLineCreation(MigratorTestCase):
migrate_to = ('build', '0047_auto_20230606_1058')
def prepare(self):
"""Create data to work with"""
"""Create data to work with."""
# Model references
Part = self.old_state.apps.get_model('part', 'part')
BomItem = self.old_state.apps.get_model('part', 'bomitem')
@ -182,40 +161,44 @@ class TestBuildLineCreation(MigratorTestCase):
name='Assembly',
description='An assembly',
assembly=True,
level=0, lft=0, rght=0, tree_id=0,
level=0,
lft=0,
rght=0,
tree_id=0,
)
# Create components
for idx in range(1, 11):
part = Part.objects.create(
name=f"Part {idx}",
description=f"Part {idx}",
level=0, lft=0, rght=0, tree_id=0,
name=f'Part {idx}',
description=f'Part {idx}',
level=0,
lft=0,
rght=0,
tree_id=0,
)
# Create plentiful stock
StockItem.objects.create(
part=part,
quantity=1000,
level=0, lft=0, rght=0, tree_id=0,
part=part, quantity=1000, level=0, lft=0, rght=0, tree_id=0
)
# Create a BOM item
BomItem.objects.create(
part=assembly,
sub_part=part,
quantity=idx,
reference=f"REF-{idx}",
part=assembly, sub_part=part, quantity=idx, reference=f'REF-{idx}'
)
# Create some builds
for idx in range(1, 4):
build = Build.objects.create(
part=assembly,
title=f"Build {idx}",
title=f'Build {idx}',
quantity=idx * 10,
reference=f"REF-{idx}",
level=0, lft=0, rght=0, tree_id=0,
reference=f'REF-{idx}',
level=0,
lft=0,
rght=0,
tree_id=0,
)
# Allocate stock to the build
@ -229,7 +212,7 @@ class TestBuildLineCreation(MigratorTestCase):
)
def test_build_line_creation(self):
"""Test that the BuildLine objects have been created correctly"""
"""Test that the BuildLine objects have been created correctly."""
Build = self.new_state.apps.get_model('build', 'build')
BomItem = self.new_state.apps.get_model('part', 'bomitem')
BuildLine = self.new_state.apps.get_model('build', 'buildline')
@ -254,10 +237,7 @@ class TestBuildLineCreation(MigratorTestCase):
# Check that each BuildItem has been linked to a BuildLine
for item in BuildItem.objects.all():
self.assertIsNotNone(item.build_line)
self.assertEqual(
item.stock_item.part,
item.build_line.bom_item.sub_part,
)
self.assertEqual(item.stock_item.part, item.build_line.bom_item.sub_part)
item = BuildItem.objects.first()
@ -273,12 +253,8 @@ class TestBuildLineCreation(MigratorTestCase):
for line in BuildLine.objects.all():
# Check that the quantity is correct
self.assertEqual(
line.quantity,
line.build.quantity * line.bom_item.quantity,
line.quantity, line.build.quantity * line.bom_item.quantity
)
# Check that the linked parts are correct
self.assertEqual(
line.build.part,
line.bom_item.part,
)
self.assertEqual(line.build.part, line.bom_item.part)

View File

@ -1,39 +1,26 @@
"""Basic unit tests for the BuildOrder app"""
from django.core.exceptions import ValidationError
from django.test import tag
from django.urls import reverse
"""Basic unit tests for the BuildOrder app."""
from datetime import datetime, timedelta
from django.core.exceptions import ValidationError
from build.status_codes import BuildStatus
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeTestCase
from part.models import BomItem, Part
from .models import Build
from part.models import Part, BomItem
from stock.models import StockItem
from common.settings import set_global_setting
from build.status_codes import BuildStatus
class BuildTestSimple(InvenTreeTestCase):
"""Basic set of tests for the BuildOrder model functionality"""
"""Basic set of tests for the BuildOrder model functionality."""
fixtures = [
'category',
'part',
'location',
'build',
]
fixtures = ['category', 'part', 'location', 'build']
roles = [
'build.change',
'build.add',
'build.delete',
]
roles = ['build.change', 'build.add', 'build.delete']
def test_build_objects(self):
"""Ensure the Build objects were correctly created"""
"""Ensure the Build objects were correctly created."""
self.assertEqual(Build.objects.count(), 5)
b = Build.objects.get(pk=2)
self.assertEqual(b.batch, 'B2')
@ -42,12 +29,12 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertEqual(str(b), 'BO-0002')
def test_url(self):
"""Test URL lookup"""
"""Test URL lookup."""
b1 = Build.objects.get(pk=1)
self.assertEqual(b1.get_absolute_url(), '/platform/manufacturing/build-order/1')
def test_is_complete(self):
"""Test build completion status"""
"""Test build completion status."""
b1 = Build.objects.get(pk=1)
b2 = Build.objects.get(pk=2)
@ -72,7 +59,7 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertFalse(build.is_overdue)
def test_is_active(self):
"""Test active / inactive build status"""
"""Test active / inactive build status."""
b1 = Build.objects.get(pk=1)
b2 = Build.objects.get(pk=2)
@ -91,7 +78,6 @@ class BuildTestSimple(InvenTreeTestCase):
def test_build_create(self):
"""Test creation of build orders via API."""
n = Build.objects.count()
# Find an assembly part
@ -105,13 +91,9 @@ class BuildTestSimple(InvenTreeTestCase):
# Let's create some BOM items for this assembly
for component in Part.objects.filter(assembly=False, component=True)[:15]:
try:
BomItem.objects.create(
part=assembly,
sub_part=component,
reference='xxx',
quantity=5
part=assembly, sub_part=component, reference='xxx', quantity=5
)
except ValidationError:
pass

View File

@ -1,15 +1,15 @@
"""Validation methods for the build app"""
"""Validation methods for the build app."""
def generate_next_build_reference():
"""Generate the next available BuildOrder reference"""
"""Generate the next available BuildOrder reference."""
from build.models import Build
return Build.generate_reference()
def validate_build_order_reference_pattern(pattern):
"""Validate the BuildOrder reference 'pattern' setting"""
"""Validate the BuildOrder reference 'pattern' setting."""
from build.models import Build
Build.validate_reference_pattern(pattern)

View File

@ -2,8 +2,6 @@
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
import common.models
import common.validators
@ -45,7 +43,7 @@ class BarcodeScanResultAdmin(admin.ModelAdmin):
@admin.register(common.models.ProjectCode)
class ProjectCodeAdmin(ImportExportModelAdmin):
class ProjectCodeAdmin(admin.ModelAdmin):
"""Admin settings for ProjectCode."""
list_display = ('code', 'description')
@ -54,7 +52,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin):
@admin.register(common.models.InvenTreeSetting)
class SettingsAdmin(ImportExportModelAdmin):
class SettingsAdmin(admin.ModelAdmin):
"""Admin settings for InvenTreeSetting."""
list_display = ('key', 'value')
@ -67,7 +65,7 @@ class SettingsAdmin(ImportExportModelAdmin):
@admin.register(common.models.InvenTreeUserSetting)
class UserSettingsAdmin(ImportExportModelAdmin):
class UserSettingsAdmin(admin.ModelAdmin):
"""Admin settings for InvenTreeUserSetting."""
list_display = ('key', 'value', 'user')
@ -80,7 +78,7 @@ class UserSettingsAdmin(ImportExportModelAdmin):
@admin.register(common.models.WebhookEndpoint)
class WebhookAdmin(ImportExportModelAdmin):
class WebhookAdmin(admin.ModelAdmin):
"""Admin settings for Webhook."""
list_display = ('endpoint_id', 'name', 'active', 'user')
@ -119,4 +117,4 @@ class NewsFeedEntryAdmin(admin.ModelAdmin):
list_display = ('title', 'author', 'published', 'summary')
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
admin.site.register(common.models.WebhookMessage, admin.ModelAdmin)

View File

@ -2,14 +2,7 @@
from django.contrib import admin
from import_export import widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
import company.serializers
import importer.admin
from InvenTree.admin import InvenTreeResource
from part.models import Part
from .models import (
Address,
@ -22,50 +15,17 @@ from .models import (
)
class CompanyResource(InvenTreeResource):
"""Class for managing Company data import/export."""
class Meta:
"""Metaclass defines extra options."""
model = Company
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@admin.register(Company)
class CompanyAdmin(importer.admin.DataExportAdmin, ImportExportModelAdmin):
class CompanyAdmin(admin.ModelAdmin):
"""Admin class for the Company model."""
serializer_class = company.serializers.CompanySerializer
resource_class = CompanyResource
list_display = ('name', 'website', 'contact')
search_fields = ['name', 'description']
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)
supplier = Field(attribute='supplier', widget=widgets.ForeignKeyWidget(Company))
supplier_name = Field(attribute='supplier__name', readonly=True)
class SupplierPriceBreakInline(admin.TabularInline):
"""Inline for supplier-part pricing."""
@ -73,11 +33,9 @@ class SupplierPriceBreakInline(admin.TabularInline):
@admin.register(SupplierPart)
class SupplierPartAdmin(ImportExportModelAdmin):
class SupplierPartAdmin(admin.ModelAdmin):
"""Admin class for the SupplierPart model."""
resource_class = SupplierPartResource
list_display = ('part', 'supplier', 'SKU')
search_fields = ['supplier__name', 'part__name', 'manufacturer_part__MPN', 'SKU']
@ -87,34 +45,10 @@ class SupplierPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'supplier', 'manufacturer_part')
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)
manufacturer = Field(
attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company)
)
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
@admin.register(ManufacturerPart)
class ManufacturerPartAdmin(ImportExportModelAdmin):
class ManufacturerPartAdmin(admin.ModelAdmin):
"""Admin class for ManufacturerPart model."""
resource_class = ManufacturerPartResource
list_display = ('part', 'manufacturer', 'MPN')
search_fields = ['manufacturer__name', 'part__name', 'MPN']
@ -122,24 +56,10 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer')
class ManufacturerPartParameterResource(InvenTreeResource):
"""Class for managing ManufacturerPartParameter data import/export."""
class Meta:
"""Metaclass defines extra admin options."""
model = ManufacturerPartParameter
skip_unchanged = True
report_skipped = True
clean_model_instance = True
@admin.register(ManufacturerPartParameter)
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
class ManufacturerPartParameterAdmin(admin.ModelAdmin):
"""Admin class for ManufacturerPartParameter model."""
resource_class = ManufacturerPartParameterResource
list_display = ('manufacturer_part', 'name', 'value')
search_fields = ['manufacturer_part__manufacturer__name', 'name', 'value']
@ -147,61 +67,19 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
autocomplete_fields = ('manufacturer_part',)
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)
supplier_name = Field(attribute='part__supplier__name', readonly=True)
part_name = Field(attribute='part__part__full_name', readonly=True)
SKU = Field(attribute='part__SKU', readonly=True)
MPN = Field(attribute='part__MPN', readonly=True)
@admin.register(SupplierPriceBreak)
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
class SupplierPriceBreakAdmin(admin.ModelAdmin):
"""Admin class for the SupplierPriceBreak model."""
resource_class = SupplierPriceBreakResource
list_display = ('part', 'quantity', 'price')
autocomplete_fields = ('part',)
class AddressResource(InvenTreeResource):
"""Class for managing Address data import/export."""
class Meta:
"""Metaclass defining extra options."""
model = Address
skip_unchanged = True
report_skipped = False
clean_model_instances = True
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
@admin.register(Address)
class AddressAdmin(ImportExportModelAdmin):
class AddressAdmin(admin.ModelAdmin):
"""Admin class for the Address model."""
resource_class = AddressResource
list_display = ('company', 'line1', 'postal_code', 'country')
search_fields = ['company', 'country', 'postal_code']
@ -209,26 +87,10 @@ class AddressAdmin(ImportExportModelAdmin):
autocomplete_fields = ['company']
class ContactResource(InvenTreeResource):
"""Class for managing Contact data import/export."""
class Meta:
"""Metaclass defining extra options."""
model = Contact
skip_unchanged = True
report_skipped = False
clean_model_instances = True
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
@admin.register(Contact)
class ContactAdmin(ImportExportModelAdmin):
class ContactAdmin(admin.ModelAdmin):
"""Admin class for the Contact model."""
resource_class = ContactResource
list_display = ('company', 'name', 'role', 'email', 'phone')
search_fields = ['company', 'name', 'email']

View File

@ -69,11 +69,3 @@ class DataImportRowAdmin(admin.ModelAdmin):
def get_readonly_fields(self, request, obj=None):
"""Return the readonly fields for the admin interface."""
return ['session', 'row_index', 'row_data', 'errors', 'valid']
class DataExportAdmin(admin.ModelAdmin):
"""Custom admin class mixin allowing for data export functionality."""
serializer_class = None
# TODO: Add custom admin action to export queryset data

View File

@ -1,54 +1,10 @@
"""Admin functionality for the 'order' app."""
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from import_export import widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
import stock.models
from InvenTree.admin import InvenTreeResource
from order import models
class ProjectCodeResourceMixin:
"""Mixin for exporting project code data."""
project_code = Field(attribute='project_code', column_name=_('Project Code'))
def dehydrate_project_code(self, order):
"""Return the project code value, not the pk."""
if order.project_code:
return order.project_code.code
return ''
class TotalPriceResourceMixin:
"""Mixin for exporting total price data."""
total_price = Field(attribute='total_price', column_name=_('Total Price'))
def dehydrate_total_price(self, order):
"""Return the total price amount, not the object itself."""
if order.total_price:
return order.total_price.amount
return ''
class PriceResourceMixin:
"""Mixin for 'price' field."""
price = Field(attribute='price', column_name=_('Price'))
def dehydrate_price(self, line):
"""Return the price amount, not the object itself."""
if line.price:
return line.price.amount
return ''
# region general classes
class GeneralExtraLineAdmin:
"""Admin class template for the 'ExtraLineItem' models."""
@ -67,9 +23,6 @@ class GeneralExtraLineMeta:
clean_model_instances = True
# endregion
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
"""Inline admin class for the PurchaseOrderLineItem model."""
@ -77,35 +30,10 @@ class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
extra = 0
class PurchaseOrderResource(
ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource
):
"""Class for managing import / export of PurchaseOrder data."""
class Meta:
"""Metaclass options."""
model = models.PurchaseOrder
skip_unchanged = True
clean_model_instances = True
exclude = ['metadata']
# Add number of line items
line_items = Field(
attribute='line_count', widget=widgets.IntegerWidget(), readonly=True
)
# Is this order overdue?
overdue = Field(
attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True
)
class PurchaseOrderAdmin(ImportExportModelAdmin):
@admin.register(models.PurchaseOrder)
class PurchaseOrderAdmin(admin.ModelAdmin):
"""Admin class for the PurchaseOrder model."""
resource_class = PurchaseOrderResource
exclude = ['reference_int']
list_display = ('reference', 'supplier', 'status', 'description', 'creation_date')
@ -117,35 +45,10 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
autocomplete_fields = ['supplier', 'project_code', 'contact', 'address']
class SalesOrderResource(
ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource
):
"""Class for managing import / export of SalesOrder data."""
class Meta:
"""Metaclass options."""
model = models.SalesOrder
skip_unchanged = True
clean_model_instances = True
exclude = ['metadata']
# Add number of line items
line_items = Field(
attribute='line_count', widget=widgets.IntegerWidget(), readonly=True
)
# Is this order overdue?
overdue = Field(
attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True
)
class SalesOrderAdmin(ImportExportModelAdmin):
@admin.register(models.SalesOrder)
class SalesOrderAdmin(admin.ModelAdmin):
"""Admin class for the SalesOrder model."""
resource_class = SalesOrderResource
exclude = ['reference_int']
list_display = ('reference', 'customer', 'status', 'description', 'creation_date')
@ -155,89 +58,10 @@ class SalesOrderAdmin(ImportExportModelAdmin):
autocomplete_fields = ['customer', 'project_code', 'contact', 'address']
class PurchaseOrderLineItemResource(PriceResourceMixin, InvenTreeResource):
"""Class for managing import / export of PurchaseOrderLineItem data."""
class Meta:
"""Metaclass."""
model = models.PurchaseOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
part_name = Field(attribute='part__part__name', readonly=True)
manufacturer = Field(attribute='part__manufacturer', readonly=True)
MPN = Field(attribute='part__MPN', readonly=True)
SKU = Field(attribute='part__SKU', readonly=True)
destination = Field(
attribute='destination',
widget=widgets.ForeignKeyWidget(stock.models.StockLocation),
)
def dehydrate_purchase_price(self, line):
"""Return a string value of the 'purchase_price' field, rather than the 'Money' object."""
if line.purchase_price:
return line.purchase_price.amount
return ''
class PurchaseOrderExtraLineResource(PriceResourceMixin, InvenTreeResource):
"""Class for managing import / export of PurchaseOrderExtraLine data."""
class Meta(GeneralExtraLineMeta):
"""Metaclass options."""
model = models.PurchaseOrderExtraLine
class SalesOrderLineItemResource(PriceResourceMixin, InvenTreeResource):
"""Class for managing import / export of SalesOrderLineItem data."""
class Meta:
"""Metaclass options."""
model = models.SalesOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
part_name = Field(attribute='part__name', readonly=True)
IPN = Field(attribute='part__IPN', readonly=True)
description = Field(attribute='part__description', readonly=True)
fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
def dehydrate_sale_price(self, item):
"""Return a string value of the 'sale_price' field, rather than the 'Money' object.
Ref: https://github.com/inventree/InvenTree/issues/2207
"""
if item.sale_price:
return item.sale_price.amount
return ''
class SalesOrderExtraLineResource(PriceResourceMixin, InvenTreeResource):
"""Class for managing import / export of SalesOrderExtraLine data."""
class Meta(GeneralExtraLineMeta):
"""Metaclass options."""
model = models.SalesOrderExtraLine
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
@admin.register(models.PurchaseOrderLineItem)
class PurchaseOrderLineItemAdmin(admin.ModelAdmin):
"""Admin class for the PurchaseOrderLine model."""
resource_class = PurchaseOrderLineItemResource
list_display = ('order', 'part', 'quantity', 'reference')
search_fields = ('reference',)
@ -245,17 +69,15 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
autocomplete_fields = ('order', 'part', 'destination')
class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
@admin.register(models.PurchaseOrderExtraLine)
class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin):
"""Admin class for the PurchaseOrderExtraLine model."""
resource_class = PurchaseOrderExtraLineResource
class SalesOrderLineItemAdmin(ImportExportModelAdmin):
@admin.register(models.SalesOrderLineItem)
class SalesOrderLineItemAdmin(admin.ModelAdmin):
"""Admin class for the SalesOrderLine model."""
resource_class = SalesOrderLineItemResource
list_display = ('order', 'part', 'quantity', 'reference')
search_fields = [
@ -268,13 +90,13 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin):
autocomplete_fields = ('order', 'part')
class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
@admin.register(models.SalesOrderExtraLine)
class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin):
"""Admin class for the SalesOrderExtraLine model."""
resource_class = SalesOrderExtraLineResource
class SalesOrderShipmentAdmin(ImportExportModelAdmin):
@admin.register(models.SalesOrderShipment)
class SalesOrderShipmentAdmin(admin.ModelAdmin):
"""Admin class for the SalesOrderShipment model."""
list_display = ['order', 'shipment_date', 'reference']
@ -284,7 +106,8 @@ class SalesOrderShipmentAdmin(ImportExportModelAdmin):
autocomplete_fields = ('order',)
class SalesOrderAllocationAdmin(ImportExportModelAdmin):
@admin.register(models.SalesOrderAllocation)
class SalesOrderAllocationAdmin(admin.ModelAdmin):
"""Admin class for the SalesOrderAllocation model."""
list_display = ('line', 'item', 'quantity')
@ -292,25 +115,10 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin):
autocomplete_fields = ('line', 'shipment', 'item')
class ReturnOrderResource(
ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource
):
"""Class for managing import / export of ReturnOrder data."""
class Meta:
"""Metaclass options."""
model = models.ReturnOrder
skip_unchanged = True
clean_model_instances = True
exclude = ['metadata']
class ReturnOrderAdmin(ImportExportModelAdmin):
@admin.register(models.ReturnOrder)
class ReturnOrderAdmin(admin.ModelAdmin):
"""Admin class for the ReturnOrder model."""
resource_class = ReturnOrderResource
exclude = ['reference_int']
list_display = ['reference', 'customer', 'status']
@ -320,54 +128,13 @@ class ReturnOrderAdmin(ImportExportModelAdmin):
autocomplete_fields = ['customer', 'project_code', 'contact', 'address']
class ReturnOrderLineItemResource(PriceResourceMixin, InvenTreeResource):
"""Class for managing import / export of ReturnOrderLineItem data."""
class Meta:
"""Metaclass options."""
model = models.ReturnOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
class ReturnOrderLineItemAdmin(ImportExportModelAdmin):
@admin.register(models.ReturnOrderLineItem)
class ReturnOrderLineItemAdmin(admin.ModelAdmin):
"""Admin class for ReturnOrderLine model."""
resource_class = ReturnOrderLineItemResource
list_display = ['order', 'item', 'reference']
class ReturnOrderExtraLineClass(PriceResourceMixin, InvenTreeResource):
"""Class for managing import/export of ReturnOrderExtraLine data."""
class Meta(GeneralExtraLineMeta):
"""Metaclass options."""
model = models.ReturnOrderExtraLine
class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
@admin.register(models.ReturnOrderExtraLine)
class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin):
"""Admin class for the ReturnOrderExtraLine model."""
resource_class = ReturnOrderExtraLineClass
# Purchase Order models
admin.site.register(models.PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(models.PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(models.PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin)
# Sales Order models
admin.site.register(models.SalesOrder, SalesOrderAdmin)
admin.site.register(models.SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(models.SalesOrderExtraLine, SalesOrderExtraLineAdmin)
admin.site.register(models.SalesOrderShipment, SalesOrderShipmentAdmin)
admin.site.register(models.SalesOrderAllocation, SalesOrderAllocationAdmin)
# Return Order models
admin.site.register(models.ReturnOrder, ReturnOrderAdmin)
admin.site.register(models.ReturnOrderLineItem, ReturnOrderLineItemAdmin)
admin.site.register(models.ReturnOrderExtraLine, ReturnOrdeerExtraLineAdmin)

View File

@ -1,225 +1,8 @@
"""Admin class definitions for the 'part' app."""
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from import_export import widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from company.models import SupplierPart
from InvenTree.admin import InvenTreeResource
from part import models
from stock.models import StockLocation
class PartResource(InvenTreeResource):
"""Class for managing Part data import/export."""
class Meta:
"""Metaclass options."""
model = models.Part
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
'bom_checksum',
'bom_checked_by',
'bom_checked_date',
'lft',
'rght',
'tree_id',
'level',
'metadata',
'barcode_data',
'barcode_hash',
]
id = Field(attribute='pk', column_name=_('Part ID'), widget=widgets.IntegerWidget())
name = Field(
attribute='name', column_name=_('Part Name'), widget=widgets.CharWidget()
)
description = Field(
attribute='description',
column_name=_('Part Description'),
widget=widgets.CharWidget(),
)
IPN = Field(attribute='IPN', column_name=_('IPN'), widget=widgets.CharWidget())
revision = Field(
attribute='revision', column_name=_('Revision'), widget=widgets.CharWidget()
)
keywords = Field(
attribute='keywords', column_name=_('Keywords'), widget=widgets.CharWidget()
)
link = Field(attribute='link', column_name=_('Link'), widget=widgets.CharWidget())
units = Field(
attribute='units', column_name=_('Units'), widget=widgets.CharWidget()
)
notes = Field(attribute='notes', column_name=_('Notes'))
image = Field(attribute='image', column_name=_('Part Image'))
category = Field(
attribute='category',
column_name=_('Category ID'),
widget=widgets.ForeignKeyWidget(models.PartCategory),
)
category_name = Field(
attribute='category__name', column_name=_('Category Name'), readonly=True
)
default_location = Field(
attribute='default_location',
column_name=_('Default Location ID'),
widget=widgets.ForeignKeyWidget(StockLocation),
)
default_supplier = Field(
attribute='default_supplier',
column_name=_('Default Supplier ID'),
widget=widgets.ForeignKeyWidget(SupplierPart),
)
variant_of = Field(
attribute='variant_of',
column_name=_('Variant Of'),
widget=widgets.ForeignKeyWidget(models.Part),
)
minimum_stock = Field(attribute='minimum_stock', column_name=_('Minimum Stock'))
# Part Attributes
active = Field(
attribute='active', column_name=_('Active'), widget=widgets.BooleanWidget()
)
assembly = Field(
attribute='assembly', column_name=_('Assembly'), widget=widgets.BooleanWidget()
)
component = Field(
attribute='component',
column_name=_('Component'),
widget=widgets.BooleanWidget(),
)
purchaseable = Field(
attribute='purchaseable',
column_name=_('Purchaseable'),
widget=widgets.BooleanWidget(),
)
salable = Field(
attribute='salable', column_name=_('Salable'), widget=widgets.BooleanWidget()
)
is_template = Field(
attribute='is_template',
column_name=_('Template'),
widget=widgets.BooleanWidget(),
)
trackable = Field(
attribute='trackable',
column_name=_('Trackable'),
widget=widgets.BooleanWidget(),
)
virtual = Field(
attribute='virtual', column_name=_('Virtual'), widget=widgets.BooleanWidget()
)
# Extra calculated meta-data (readonly)
suppliers = Field(
attribute='supplier_count', column_name=_('Suppliers'), readonly=True
)
in_stock = Field(
attribute='total_stock',
column_name=_('In Stock'),
readonly=True,
widget=widgets.IntegerWidget(),
)
on_order = Field(
attribute='on_order',
column_name=_('On Order'),
readonly=True,
widget=widgets.IntegerWidget(),
)
used_in = Field(
attribute='used_in_count',
column_name=_('Used In'),
readonly=True,
widget=widgets.IntegerWidget(),
)
allocated = Field(
attribute='allocation_count',
column_name=_('Allocated'),
readonly=True,
widget=widgets.IntegerWidget(),
)
building = Field(
attribute='quantity_being_built',
column_name=_('Building'),
readonly=True,
widget=widgets.IntegerWidget(),
)
min_cost = Field(
attribute='pricing__overall_min', column_name=_('Minimum Cost'), readonly=True
)
max_cost = Field(
attribute='pricing__overall_max', column_name=_('Maximum Cost'), readonly=True
)
def dehydrate_min_cost(self, part):
"""Render minimum cost value for this Part."""
min_cost = part.pricing.overall_min if part.pricing else None
if min_cost is not None:
return float(min_cost.amount)
def dehydrate_max_cost(self, part):
"""Render maximum cost value for this Part."""
max_cost = part.pricing.overall_max if part.pricing else None
if max_cost is not None:
return float(max_cost.amount)
def get_queryset(self):
"""Prefetch related data for quicker access."""
query = super().get_queryset()
query = query.prefetch_related(
'category',
'used_in',
'builds',
'supplier_parts__purchase_order_line_items',
'stock_items__allocations',
)
return query
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
"""Rebuild MPTT tree structure after importing Part data."""
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the Part tree(s)
models.Part.objects.rebuild()
class PartImportResource(InvenTreeResource):
"""Class for managing Part data import/export."""
class Meta(PartResource.Meta):
"""Metaclass options."""
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
'id',
'category__name',
'creation_date',
'creation_user',
'pricing__overall_min',
'pricing__overall_max',
'bom_checksum',
'bom_checked_by',
'bom_checked_date',
'lft',
'rght',
'tree_id',
'level',
'metadata',
'barcode_data',
'barcode_hash',
]
class PartParameterInline(admin.TabularInline):
@ -228,11 +11,10 @@ class PartParameterInline(admin.TabularInline):
model = models.PartParameter
class PartAdmin(ImportExportModelAdmin):
@admin.register(models.Part)
class PartAdmin(admin.ModelAdmin):
"""Admin class for the Part model."""
resource_class = PartResource
list_display = ('full_name', 'description', 'total_stock', 'category')
list_filter = ('active', 'assembly', 'is_template', 'virtual')
@ -257,6 +39,7 @@ class PartAdmin(ImportExportModelAdmin):
inlines = [PartParameterInline]
@admin.register(models.PartPricing)
class PartPricingAdmin(admin.ModelAdmin):
"""Admin class for PartPricing model."""
@ -265,81 +48,24 @@ class PartPricingAdmin(admin.ModelAdmin):
autocomplete_fields = ['part']
@admin.register(models.PartStocktake)
class PartStocktakeAdmin(admin.ModelAdmin):
"""Admin class for PartStocktake model."""
list_display = ['part', 'date', 'quantity', 'user']
@admin.register(models.PartStocktakeReport)
class PartStocktakeReportAdmin(admin.ModelAdmin):
"""Admin class for PartStocktakeReport model."""
list_display = ['date', 'user']
class PartCategoryResource(InvenTreeResource):
"""Class for managing PartCategory data import/export."""
class Meta:
"""Metaclass options."""
model = models.PartCategory
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
# Exclude MPTT internal model fields
'lft',
'rght',
'tree_id',
'level',
'metadata',
'icon',
]
id = Field(
attribute='pk', column_name=_('Category ID'), widget=widgets.IntegerWidget()
)
name = Field(attribute='name', column_name=_('Category Name'))
description = Field(attribute='description', column_name=_('Description'))
parent = Field(
attribute='parent',
column_name=_('Parent ID'),
widget=widgets.ForeignKeyWidget(models.PartCategory),
)
parent_name = Field(
attribute='parent__name', column_name=_('Parent Name'), readonly=True
)
default_location = Field(
attribute='default_location',
column_name=_('Default Location ID'),
widget=widgets.ForeignKeyWidget(StockLocation),
)
default_keywords = Field(attribute='default_keywords', column_name=_('Keywords'))
pathstring = Field(attribute='pathstring', column_name=_('Category Path'))
# Calculated fields
parts = Field(
attribute='item_count',
column_name=_('Parts'),
widget=widgets.IntegerWidget(),
readonly=True,
)
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
"""Rebuild MPTT tree structure after importing PartCategory data."""
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the PartCategory tree(s)
models.PartCategory.objects.rebuild()
class PartCategoryAdmin(ImportExportModelAdmin):
@admin.register(models.PartCategory)
class PartCategoryAdmin(admin.ModelAdmin):
"""Admin class for the PartCategory model."""
resource_class = PartCategoryResource
list_display = ('name', 'pathstring', 'description')
search_fields = ('name', 'description')
@ -347,12 +73,14 @@ class PartCategoryAdmin(ImportExportModelAdmin):
autocomplete_fields = ('parent', 'default_location')
@admin.register(models.PartRelated)
class PartRelatedAdmin(admin.ModelAdmin):
"""Class to manage PartRelated objects."""
autocomplete_fields = ('part_1', 'part_2')
@admin.register(models.PartTestTemplate)
class PartTestTemplateAdmin(admin.ModelAdmin):
"""Admin class for the PartTestTemplate model."""
@ -362,141 +90,10 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
autocomplete_fields = ('part',)
class BomItemResource(InvenTreeResource):
"""Class for managing BomItem data import/export."""
class Meta:
"""Metaclass options."""
model = models.BomItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = ['checksum', 'part', 'sub_part', 'validated']
level = Field(attribute='level', column_name=_('BOM Level'), readonly=True)
id = Field(
attribute='pk', column_name=_('BOM Item ID'), widget=widgets.IntegerWidget()
)
# ID of the parent part
parent_part_id = Field(
attribute='part',
column_name=_('Parent ID'),
widget=widgets.ForeignKeyWidget(models.Part),
)
parent_part_ipn = Field(
attribute='part__IPN', column_name=_('Parent IPN'), readonly=True
)
parent_part_name = Field(
attribute='part__name', column_name=_('Parent Name'), readonly=True
)
part_id = Field(
attribute='sub_part',
column_name=_('Part ID'),
widget=widgets.ForeignKeyWidget(models.Part),
)
part_ipn = Field(
attribute='sub_part__IPN', column_name=_('Part IPN'), readonly=True
)
part_revision = Field(
attribute='sub_part__revision', column_name=_('Part Revision'), readonly=True
)
part_name = Field(
attribute='sub_part__name', column_name=_('Part Name'), readonly=True
)
part_description = Field(
attribute='sub_part__description', column_name=_('Description'), readonly=True
)
quantity = Field(attribute='quantity', column_name=_('Quantity'))
reference = Field(attribute='reference', column_name=_('Reference'))
note = Field(attribute='note', column_name=_('Note'))
min_cost = Field(
attribute='sub_part__pricing__overall_min',
column_name=_('Minimum Price'),
readonly=True,
)
max_cost = Field(
attribute='sub_part__pricing__overall_max',
column_name=_('Maximum Price'),
readonly=True,
)
sub_assembly = Field(
attribute='sub_part__assembly', column_name=_('Assembly'), readonly=True
)
def dehydrate_min_cost(self, item):
"""Render minimum cost value for the BOM line item."""
min_price = item.sub_part.pricing.overall_min if item.sub_part.pricing else None
if min_price is not None:
return float(min_price.amount) * float(item.quantity)
def dehydrate_max_cost(self, item):
"""Render maximum cost value for the BOM line item."""
max_price = item.sub_part.pricing.overall_max if item.sub_part.pricing else None
if max_price is not None:
return float(max_price.amount) * float(item.quantity)
def dehydrate_quantity(self, item):
"""Special consideration for the 'quantity' field on data export. We do not want a spreadsheet full of "1.0000" (we'd rather "1").
Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export
"""
return float(item.quantity)
def before_export(self, queryset, *args, **kwargs):
"""Perform before exporting data."""
self.is_importing = kwargs.get('importing', False)
self.include_pricing = kwargs.pop('include_pricing', False)
def get_fields(self, **kwargs):
"""If we are exporting for the purposes of generating a 'bom-import' template, there are some fields which we are not interested in."""
fields = super().get_fields(**kwargs)
is_importing = getattr(self, 'is_importing', False)
include_pricing = getattr(self, 'include_pricing', False)
to_remove = ['metadata']
if is_importing or not include_pricing:
# Remove pricing fields in this instance
to_remove += [
'sub_part__pricing__overall_min',
'sub_part__pricing__overall_max',
]
if is_importing:
to_remove += [
'level',
'part',
'part__IPN',
'part__name',
'sub_part__name',
'sub_part__description',
'sub_part__assembly',
]
idx = 0
while idx < len(fields):
if fields[idx].attribute in to_remove:
del fields[idx]
else:
idx += 1
return fields
class BomItemAdmin(ImportExportModelAdmin):
@admin.register(models.BomItem)
class BomItemAdmin(admin.ModelAdmin):
"""Admin class for the BomItem model."""
resource_class = BomItemResource
list_display = ('part', 'sub_part', 'quantity')
search_fields = (
@ -509,72 +106,32 @@ class BomItemAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'sub_part')
class ParameterTemplateResource(InvenTreeResource):
"""Class for managing ParameterTemplate import/export."""
# The following fields will be converted from None to ''
CONVERT_NULL_FIELDS = ['choices', 'units']
class Meta:
"""Metaclass options."""
model = models.PartParameterTemplate
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = ['metadata']
class ParameterTemplateAdmin(ImportExportModelAdmin):
@admin.register(models.PartParameterTemplate)
class ParameterTemplateAdmin(admin.ModelAdmin):
"""Admin class for the PartParameterTemplate model."""
resource_class = ParameterTemplateResource
list_display = ('name', 'units')
search_fields = ('name', 'units')
class ParameterResource(InvenTreeResource):
"""Class for managing PartParameter data import/export."""
class Meta:
"""Metaclass options."""
model = models.PartParameter
skip_unchanged = True
report_skipped = False
clean_model_instance = True
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
part_name = Field(attribute='part__name', readonly=True)
template = Field(
attribute='template',
widget=widgets.ForeignKeyWidget(models.PartParameterTemplate),
)
template_name = Field(attribute='template__name', readonly=True)
class ParameterAdmin(ImportExportModelAdmin):
@admin.register(models.PartParameter)
class ParameterAdmin(admin.ModelAdmin):
"""Admin class for the PartParameter model."""
resource_class = ParameterResource
list_display = ('part', 'template', 'data')
autocomplete_fields = ('part', 'template')
@admin.register(models.PartCategoryParameterTemplate)
class PartCategoryParameterAdmin(admin.ModelAdmin):
"""Admin class for the PartCategoryParameterTemplate model."""
autocomplete_fields = ('category', 'parameter_template')
@admin.register(models.PartSellPriceBreak)
class PartSellPriceBreakAdmin(admin.ModelAdmin):
"""Admin class for the PartSellPriceBreak model."""
@ -586,6 +143,7 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
list_display = ('part', 'quantity', 'price')
@admin.register(models.PartInternalPriceBreak)
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
"""Admin class for the PartInternalPriceBreak model."""
@ -597,18 +155,3 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin):
list_display = ('part', 'quantity', 'price')
autocomplete_fields = ('part',)
admin.site.register(models.Part, PartAdmin)
admin.site.register(models.PartCategory, PartCategoryAdmin)
admin.site.register(models.PartRelated, PartRelatedAdmin)
admin.site.register(models.BomItem, BomItemAdmin)
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
admin.site.register(models.PartParameter, ParameterAdmin)
admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.PartPricing, PartPricingAdmin)
admin.site.register(models.PartStocktake, PartStocktakeAdmin)
admin.site.register(models.PartStocktakeReport, PartStocktakeReportAdmin)

View File

@ -46,7 +46,6 @@ from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
from stock.models import StockLocation
from . import serializers as part_serializers
from . import views
from .models import (
BomItem,
BomItemSubstitute,
@ -2225,12 +2224,6 @@ part_api_urls = [
),
]),
),
# BOM template
path(
'bom_template/',
views.BomUploadTemplate.as_view(),
name='api-bom-upload-template',
),
path(
'<int:pk>/',
include([
@ -2262,8 +2255,6 @@ part_api_urls = [
),
# Part pricing
path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'),
# BOM download
path('bom-download/', views.BomDownload.as_view(), name='api-bom-download'),
# Part detail endpoint
path('', PartDetail.as_view(), name='api-part-detail'),
]),

View File

@ -3,41 +3,13 @@
Primarily BOM upload tools.
"""
from collections import OrderedDict
from typing import Optional
from django.utils.translation import gettext as _
from .models import Part
from company.models import ManufacturerPart, SupplierPart
from InvenTree.helpers import DownloadFile, GetExportFormats, normalize, str2bool
from .admin import BomItemResource
from .models import BomItem, BomItemSubstitute, Part
def IsValidBOMFormat(fmt):
"""Test if a file format specifier is in the valid list of BOM file formats."""
return fmt.strip().lower() in GetExportFormats()
def MakeBomTemplate(fmt):
"""Generate a Bill of Materials upload template file (for user download)."""
fmt = fmt.strip().lower()
if not IsValidBOMFormat(fmt):
fmt = 'csv'
# Create an "empty" queryset, essentially.
# This will then export just the row headers!
query = BomItem.objects.filter(pk=None)
dataset = BomItemResource().export(queryset=query, importing=True)
data = dataset.export(fmt)
filename = 'InvenTree_BOM_Template.' + fmt
return DownloadFile(data, filename)
# TODO: 2024-12-17 - This entire file is to be removed
# TODO: Ref: https://github.com/inventree/InvenTree/pull/8685
# TODO: To be removed as part of https://github.com/inventree/InvenTree/issues/8686
def ExportBom(
@ -66,6 +38,8 @@ def ExportBom(
Returns:
StreamingHttpResponse: Response that can be passed to the endpoint
"""
# TODO: All this will be pruned!!!
"""
parameter_data = str2bool(kwargs.get('parameter_data', False))
stock_data = str2bool(kwargs.get('stock_data', False))
supplier_data = str2bool(kwargs.get('supplier_data', False))
@ -73,9 +47,6 @@ def ExportBom(
pricing_data = str2bool(kwargs.get('pricing_data', False))
substitute_part_data = str2bool(kwargs.get('substitute_part_data', False))
if not IsValidBOMFormat(fmt):
fmt = 'csv'
bom_items = []
uids = []
@ -114,7 +85,7 @@ def ExportBom(
pass
if substitute_part_data:
"""If requested, add extra columns for all substitute part numbers associated with each line item."""
# If requested, add extra columns for all substitute part numbers associated with each line item.
col_index = 0
substitute_cols = {}
@ -122,7 +93,7 @@ def ExportBom(
for bom_item in bom_items:
substitutes = BomItemSubstitute.objects.filter(bom_item=bom_item)
for s_idx, substitute in enumerate(substitutes):
"""Create substitute part IPN column"""
# Create substitute part IPN column.
name = f'{_("Substitute IPN")}{s_idx + 1}'
value = substitute.part.IPN
try:
@ -130,7 +101,7 @@ def ExportBom(
except KeyError:
substitute_cols[name] = {col_index: value}
"""Create substitute part name column"""
# Create substitute part name column.
name = f'{_("Substitute Part")}{s_idx + 1}'
value = substitute.part.name
try:
@ -138,7 +109,7 @@ def ExportBom(
except KeyError:
substitute_cols[name] = {col_index: value}
"""Create substitute part description column"""
# Create substitute part description column.
name = f'{_("Substitute Description")}{s_idx + 1}'
value = substitute.part.description
try:
@ -152,8 +123,8 @@ def ExportBom(
add_columns_to_dataset(substitute_cols, len(bom_items))
if parameter_data:
"""If requested, add extra columns for each PartParameter associated with each line item."""
# If requested, add extra columns for each PartParameter associated with each line item.
3
parameter_cols = {}
for b_idx, bom_item in enumerate(bom_items):
@ -177,7 +148,7 @@ def ExportBom(
add_columns_to_dataset(parameter_cols_ordered, len(bom_items))
if stock_data:
"""If requested, add extra columns for stock data associated with each line item."""
# If requested, add extra columns for stock data associated with each line item.
stock_headers = [
_('Default Location'),
@ -223,7 +194,7 @@ def ExportBom(
add_columns_to_dataset(stock_cols, len(bom_items))
if manufacturer_data or supplier_data:
"""If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item."""
# If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item.
# Keep track of the supplier parts we have already exported
supplier_parts_used = set()
@ -329,3 +300,4 @@ def ExportBom(
filename = f'{part.full_name}_BOM.{fmt}'
return DownloadFile(data, filename)
"""

View File

@ -1,172 +0,0 @@
"""Unit testing for BOM export functionality."""
import csv
from django.urls import reverse
import part.models
from InvenTree.settings import BASE_DIR
from InvenTree.unit_test import InvenTreeTestCase
class BomExportTest(InvenTreeTestCase):
"""Class for performing unit testing of BOM export functionality."""
fixtures = ['category', 'part', 'location', 'bom']
roles = 'all'
def setUp(self):
"""Perform test setup functions."""
super().setUp()
part.models.Part.objects.rebuild()
self.url = reverse('api-bom-download', kwargs={'pk': 100})
def test_bom_template(self):
"""Test that the BOM template can be downloaded from the server."""
url = reverse('api-bom-upload-template')
# Download an XLS template
response = self.client.get(url, data={'format': 'xlsx'})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers['Content-Disposition'],
'attachment; filename="InvenTree_BOM_Template.xlsx"',
)
# Return a simple CSV template
response = self.client.get(url, data={'format': 'csv'})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers['Content-Disposition'],
'attachment; filename="InvenTree_BOM_Template.csv"',
)
filename = BASE_DIR / '_testfolder' / '_tmp.csv'
with open(filename, 'wb') as f:
f.write(response.getvalue())
with open(filename, encoding='utf-8') as f:
reader = csv.reader(f, delimiter=',')
for line in reader:
headers = line
break
expected = [
'Part ID',
'Part IPN',
'Quantity',
'Reference',
'Note',
'optional',
'overage',
'inherited',
'allow_variants',
]
# Ensure all the expected headers are in the provided file
for header in expected:
self.assertIn(header, headers)
def test_export_csv(self):
"""Test BOM download in CSV format."""
params = {
'format': 'csv',
'cascade': True,
'parameter_data': True,
'stock_data': True,
'supplier_data': True,
'manufacturer_data': True,
}
response = self.client.get(self.url, data=params)
self.assertEqual(response.status_code, 200)
content = response.headers['Content-Disposition']
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"')
filename = BASE_DIR / '_testfolder' / '_tmp.csv'
with open(filename, 'wb') as f:
f.write(response.getvalue())
# Read the file
with open(filename, encoding='utf-8') as f:
reader = csv.reader(f, delimiter=',')
for line in reader:
headers = line
break
expected = [
'BOM Level',
'BOM Item ID',
'Parent ID',
'Parent IPN',
'Parent Name',
'Part ID',
'Part IPN',
'Part Revision',
'Part Name',
'Description',
'Assembly',
'Quantity',
'optional',
'consumable',
'overage',
'Reference',
'Note',
'inherited',
'allow_variants',
'Default Location',
'Total Stock',
'Available Stock',
'On Order',
]
for header in expected:
self.assertIn(header, headers)
for header in headers:
self.assertIn(header, expected)
def test_export_xlsx(self):
"""Test BOM download in XLSX format."""
params = {
'format': 'xlsx',
'cascade': True,
'parameter_data': True,
'stock_data': True,
'supplier_data': True,
'manufacturer_data': True,
}
response = self.client.get(self.url, data=params)
self.assertEqual(response.status_code, 200)
content = response.headers['Content-Disposition']
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.xlsx"')
def test_export_json(self):
"""Test BOM download in JSON format."""
params = {
'format': 'json',
'cascade': True,
'parameter_data': True,
'stock_data': True,
'supplier_data': True,
'manufacturer_data': True,
}
response = self.client.get(self.url, data=params)
self.assertEqual(response.status_code, 200)
content = response.headers['Content-Disposition']
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.json"')

View File

@ -1,263 +0,0 @@
"""Unit testing for BOM upload / import functionality."""
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
import tablib
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
class BomUploadTest(InvenTreeAPITestCase):
"""Test BOM file upload API endpoint."""
roles = ['part.add', 'part.change']
@classmethod
def setUpTestData(cls):
"""Create BOM data as part of setup routine."""
super().setUpTestData()
Part.objects.rebuild()
cls.part = Part.objects.create(
name='Assembly',
description='An assembled part',
assembly=True,
component=False,
)
parts = []
for i in range(10):
parts.append(
Part(
name=f'Component {i}',
IPN=f'CMP_{i}',
description='A subcomponent that can be used in a BOM',
component=True,
assembly=False,
lft=0,
rght=0,
level=0,
tree_id=0,
)
)
Part.objects.bulk_create(parts)
def post_bom(
self,
filename,
file_data,
clear_existing=None,
expected_code=None,
content_type='text/plain',
):
"""Helper function for submitting a BOM file."""
bom_file = SimpleUploadedFile(filename, file_data, content_type=content_type)
if clear_existing is None:
clear_existing = False
response = self.post(
reverse('api-bom-import-upload'),
data={'data_file': bom_file},
expected_code=expected_code,
format='multipart',
)
return response
def test_missing_file(self):
"""POST without a file."""
response = self.post(
reverse('api-bom-import-upload'), data={}, expected_code=400
)
self.assertIn('No file was submitted', str(response.data['data_file']))
def test_unsupported_file(self):
"""POST with an unsupported file type."""
response = self.post_bom('sample.txt', b'hello world', expected_code=400)
self.assertIn('Unsupported file format', str(response.data['data_file']))
def test_broken_file(self):
"""Test upload with broken (corrupted) files."""
response = self.post_bom('sample.csv', b'', expected_code=400)
self.assertIn('The submitted file is empty', str(response.data['data_file']))
response = self.post_bom(
'test.xls',
b'hello world',
expected_code=400,
content_type='application/xls',
)
self.assertIn(
'Unsupported format, or corrupt file', str(response.data['data_file'])
)
def test_missing_rows(self):
"""Test upload of an invalid file (without data rows)."""
dataset = tablib.Dataset()
dataset.headers = ['apple', 'banana']
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
content_type='text/csv',
expected_code=400,
)
self.assertIn('No data rows found in file', str(response.data))
# Try again, with an .xlsx file
response = self.post_bom(
'bom.xlsx', dataset.xlsx, content_type='application/xlsx', expected_code=400
)
self.assertIn('No data rows found in file', str(response.data))
def test_missing_columns(self):
"""Upload extracted data, but with missing columns."""
url = reverse('api-bom-import-extract')
rows = [['1', 'test'], ['2', 'test']]
# Post without columns
response = self.post(url, {}, expected_code=400)
self.assertIn('This field is required', str(response.data['rows']))
self.assertIn('This field is required', str(response.data['columns']))
response = self.post(
url, {'rows': rows, 'columns': ['part', 'reference']}, expected_code=400
)
self.assertIn("Missing required column: 'quantity'", str(response.data))
response = self.post(
url, {'rows': rows, 'columns': ['quantity', 'reference']}, expected_code=400
)
self.assertIn('No part column specified', str(response.data))
self.post(
url, {'rows': rows, 'columns': ['quantity', 'part']}, expected_code=201
)
def test_invalid_data(self):
"""Upload data which contains errors."""
dataset = tablib.Dataset()
# Only these headers are strictly necessary
dataset.headers = ['part_id', 'quantity']
components = Part.objects.filter(component=True)
for idx, cmp in enumerate(components):
if idx == 5:
cmp.component = False
cmp.save()
dataset.append([cmp.pk, idx])
url = reverse('api-bom-import-extract')
response = self.post(url, {'columns': dataset.headers, 'rows': list(dataset)})
rows = response.data['rows']
# Returned data must be the same as the original dataset
self.assertEqual(len(rows), len(dataset))
for idx, row in enumerate(rows):
data = row['data']
cmp = components[idx]
# Should have guessed the correct part
data['part'] = cmp.pk
# Check some specific error messages
self.assertEqual(
rows[0]['data']['errors']['quantity'], 'Quantity must be greater than zero'
)
self.assertEqual(
rows[5]['data']['errors']['part'], 'Part is not designated as a component'
)
def test_part_guess(self):
"""Test part 'guessing' when PK values are not supplied."""
dataset = tablib.Dataset()
# Should be able to 'guess' the part from the name
dataset.headers = ['part_name', 'quantity']
components = Part.objects.filter(component=True)
for component in components:
dataset.append([component.name, 10])
url = reverse('api-bom-import-extract')
response = self.post(
url, {'columns': dataset.headers, 'rows': list(dataset)}, expected_code=201
)
rows = response.data['rows']
self.assertEqual(len(rows), 10)
for idx in range(10):
self.assertEqual(rows[idx]['data']['part'], components[idx].pk)
# Should also be able to 'guess' part by the IPN value
dataset = tablib.Dataset()
dataset.headers = ['part_ipn', 'quantity']
for component in components:
dataset.append([component.IPN, 10])
response = self.post(
url, {'columns': dataset.headers, 'rows': list(dataset)}, expected_code=201
)
rows = response.data['rows']
self.assertEqual(len(rows), 10)
for idx in range(10):
self.assertEqual(rows[idx]['data']['part'], components[idx].pk)
def test_levels(self):
"""Test that multi-level BOMs are correctly handled during upload."""
url = reverse('api-bom-import-extract')
dataset = tablib.Dataset()
dataset.headers = ['level', 'part', 'quantity']
components = Part.objects.filter(component=True)
for idx, cmp in enumerate(components):
dataset.append([idx % 3, cmp.pk, 2])
response = self.post(
url, {'rows': list(dataset), 'columns': dataset.headers}, expected_code=201
)
rows = response.data['rows']
# Only parts at index 1, 4, 7 should have been returned
self.assertEqual(len(response.data['rows']), 3)
# Check the returned PK values
self.assertEqual(rows[0]['data']['part'], components[1].pk)
self.assertEqual(rows[1]['data']['part'], components[4].pk)
self.assertEqual(rows[2]['data']['part'], components[7].pk)

View File

@ -1,27 +0,0 @@
"""Unit tests for Part Views (see views.py)."""
from django.urls import reverse
from InvenTree.unit_test import InvenTreeTestCase
class PartViewTestCase(InvenTreeTestCase):
"""Base class for unit testing the various Part views."""
fixtures = ['category', 'part', 'bom', 'location', 'company', 'supplier_part']
roles = 'all'
superuser = True
class PartDetailTest(PartViewTestCase):
"""Unit tests for the PartDetail view."""
def test_bom_download(self):
"""Test downloading a BOM for a valid part."""
response = self.client.get(
reverse('api-bom-download', args=(1,)),
headers={'x-requested-with': 'XMLHttpRequest'},
)
self.assertEqual(response.status_code, 200)
self.assertIn('streaming_content', dir(response))

View File

@ -1,85 +0,0 @@
"""Django views for interacting with Part app."""
from django.shortcuts import get_object_or_404
from InvenTree.helpers import str2bool
from InvenTree.views import AjaxView
from .bom import ExportBom, IsValidBOMFormat, MakeBomTemplate
from .models import Part
class BomUploadTemplate(AjaxView):
"""Provide a BOM upload template file for download.
- Generates a template file in the provided format e.g. ?format=csv
"""
def get(self, request, *args, **kwargs):
"""Perform a GET request to download the 'BOM upload' template."""
export_format = request.GET.get('format', 'csv')
return MakeBomTemplate(export_format)
class BomDownload(AjaxView):
"""Provide raw download of a BOM file.
- File format should be passed as a query param e.g. ?format=csv
"""
role_required = 'part.view'
model = Part
def get(self, request, *args, **kwargs):
"""Perform GET request to download BOM data."""
part = get_object_or_404(Part, pk=self.kwargs['pk'])
export_format = request.GET.get('format', 'csv')
cascade = str2bool(request.GET.get('cascade', False))
parameter_data = str2bool(request.GET.get('parameter_data', False))
substitute_part_data = str2bool(request.GET.get('substitute_part_data', False))
stock_data = str2bool(request.GET.get('stock_data', False))
supplier_data = str2bool(request.GET.get('supplier_data', False))
manufacturer_data = str2bool(request.GET.get('manufacturer_data', False))
pricing_data = str2bool(request.GET.get('pricing_data', False))
levels = request.GET.get('levels', None)
if levels is not None:
try:
levels = int(levels)
if levels <= 0:
levels = None
except ValueError:
levels = None
if not IsValidBOMFormat(export_format):
export_format = 'csv'
return ExportBom(
part,
fmt=export_format,
cascade=cascade,
max_levels=levels,
parameter_data=parameter_data,
stock_data=stock_data,
supplier_data=supplier_data,
manufacturer_data=manufacturer_data,
pricing_data=pricing_data,
substitute_part_data=substitute_part_data,
)
def get_data(self):
"""Return a custom message."""
return {'info': 'Exported BOM'}

View File

@ -2,17 +2,6 @@
from django.contrib import admin
from django.db.models import Count
from django.utils.translation import gettext_lazy as _
from import_export import widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from build.models import Build
from company.models import Company, SupplierPart
from InvenTree.admin import InvenTreeResource
from order.models import PurchaseOrder, SalesOrder
from part.models import Part
from .models import (
StockItem,
@ -23,61 +12,6 @@ from .models import (
)
class LocationResource(InvenTreeResource):
"""Class for managing StockLocation data import/export."""
class Meta:
"""Metaclass options."""
model = StockLocation
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
# Exclude MPTT internal model fields
'lft',
'rght',
'tree_id',
'level',
'metadata',
'barcode_data',
'barcode_hash',
'owner',
'icon',
]
id = Field(
attribute='id', column_name=_('Location ID'), widget=widgets.IntegerWidget()
)
name = Field(attribute='name', column_name=_('Location Name'))
description = Field(attribute='description', column_name=_('Description'))
parent = Field(
attribute='parent',
column_name=_('Parent ID'),
widget=widgets.ForeignKeyWidget(StockLocation),
)
parent_name = Field(
attribute='parent__name', column_name=_('Parent Name'), readonly=True
)
pathstring = Field(attribute='pathstring', column_name=_('Location Path'))
# Calculated fields
items = Field(
attribute='item_count',
column_name=_('Stock Items'),
widget=widgets.IntegerWidget(),
readonly=True,
)
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
"""Rebuild after import to keep tree intact."""
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the StockLocation tree(s)
StockLocation.objects.rebuild()
class LocationInline(admin.TabularInline):
"""Inline for sub-locations."""
@ -85,11 +19,9 @@ class LocationInline(admin.TabularInline):
@admin.register(StockLocation)
class LocationAdmin(ImportExportModelAdmin):
class LocationAdmin(admin.ModelAdmin):
"""Admin class for Location."""
resource_class = LocationResource
list_display = ('name', 'pathstring', 'description')
search_fields = ('name', 'description')
@ -119,168 +51,10 @@ class LocationTypeAdmin(admin.ModelAdmin):
return obj.location_count
class StockItemResource(InvenTreeResource):
"""Class for managing StockItem data import/export."""
class Meta:
"""Metaclass options."""
model = StockItem
skip_unchanged = True
report_skipped = False
clean_model_instance = True
exclude = [
# Exclude MPTT internal model fields
'lft',
'rght',
'tree_id',
'level',
# Exclude internal fields
'serial_int',
'metadata',
'barcode_hash',
'barcode_data',
'owner',
'status_custom_key',
]
id = Field(
attribute='pk', column_name=_('Stock Item ID'), widget=widgets.IntegerWidget()
)
part = Field(
attribute='part',
column_name=_('Part ID'),
widget=widgets.ForeignKeyWidget(Part),
)
part_name = Field(
attribute='part__full_name', column_name=_('Part Name'), readonly=True
)
quantity = Field(
attribute='quantity', column_name=_('Quantity'), widget=widgets.DecimalWidget()
)
serial = Field(attribute='serial', column_name=_('Serial'))
batch = Field(attribute='batch', column_name=_('Batch'))
status_label = Field(
attribute='status_label', column_name=_('Status'), readonly=True
)
status = Field(
attribute='status', column_name=_('Status Code'), widget=widgets.IntegerWidget()
)
location = Field(
attribute='location',
column_name=_('Location ID'),
widget=widgets.ForeignKeyWidget(StockLocation),
)
location_name = Field(
attribute='location__name', column_name=_('Location Name'), readonly=True
)
supplier_part = Field(
attribute='supplier_part',
column_name=_('Supplier Part ID'),
widget=widgets.ForeignKeyWidget(SupplierPart),
)
supplier_part_sku = Field(
attribute='supplier_part__SKU',
column_name=_('Supplier Part SKU'),
readonly=True,
)
supplier = Field(
attribute='supplier_part__supplier__id',
column_name=_('Supplier ID'),
readonly=True,
widget=widgets.IntegerWidget(),
)
supplier_name = Field(
attribute='supplier_part__supplier__name',
column_name=_('Supplier Name'),
readonly=True,
)
customer = Field(
attribute='customer',
column_name=_('Customer ID'),
widget=widgets.ForeignKeyWidget(Company),
)
belongs_to = Field(
attribute='belongs_to',
column_name=_('Installed In'),
widget=widgets.ForeignKeyWidget(StockItem),
)
build = Field(
attribute='build',
column_name=_('Build ID'),
widget=widgets.ForeignKeyWidget(Build),
)
parent = Field(
attribute='parent',
column_name=_('Parent ID'),
widget=widgets.ForeignKeyWidget(StockItem),
)
sales_order = Field(
attribute='sales_order',
column_name=_('Sales Order ID'),
widget=widgets.ForeignKeyWidget(SalesOrder),
)
purchase_order = Field(
attribute='purchase_order',
column_name=_('Purchase Order ID'),
widget=widgets.ForeignKeyWidget(PurchaseOrder),
)
packaging = Field(attribute='packaging', column_name=_('Packaging'))
link = Field(attribute='link', column_name=_('Link'))
notes = Field(attribute='notes', column_name=_('Notes'))
# Status fields (note that IntegerWidget exports better to excel than BooleanWidget)
is_building = Field(
attribute='is_building',
column_name=_('Building'),
widget=widgets.BooleanWidget(),
)
review_needed = Field(
attribute='review_needed',
column_name=_('Review Needed'),
widget=widgets.BooleanWidget(),
)
delete_on_deplete = Field(
attribute='delete_on_deplete',
column_name=_('Delete on Deplete'),
widget=widgets.BooleanWidget(),
)
# Date management
updated = Field(
attribute='updated', column_name=_('Last Updated'), widget=widgets.DateWidget()
)
stocktake_date = Field(
attribute='stocktake_date',
column_name=_('Stocktake'),
widget=widgets.DateWidget(),
)
expiry_date = Field(
attribute='expiry_date',
column_name=_('Expiry Date'),
widget=widgets.DateWidget(),
)
def dehydrate_purchase_price(self, item):
"""Render purchase pric as float."""
if item.purchase_price is not None:
return float(item.purchase_price.amount)
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
"""Rebuild after import to keep tree intact."""
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the StockItem tree(s)
StockItem.objects.rebuild()
@admin.register(StockItem)
class StockItemAdmin(ImportExportModelAdmin):
class StockItemAdmin(admin.ModelAdmin):
"""Admin class for StockItem."""
resource_class = StockItemResource
list_display = ('part', 'quantity', 'location', 'status', 'updated')
# A list of search fields which can be used for lookup on matching 'autocomplete' fields
@ -302,7 +76,7 @@ class StockItemAdmin(ImportExportModelAdmin):
@admin.register(StockItemTracking)
class StockTrackingAdmin(ImportExportModelAdmin):
class StockTrackingAdmin(admin.ModelAdmin):
"""Admin class for StockTracking."""
list_display = ('item', 'date', 'label')

View File

@ -1,13 +1,13 @@
"""DRF API definition for the 'users' app."""
import datetime
import logging
from django.contrib.auth import get_user, login
from django.contrib.auth.models import Group, User
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView
import structlog
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from rest_framework import exceptions, permissions
from rest_framework.generics import DestroyAPIView
@ -39,7 +39,7 @@ from users.serializers import (
RoleSerializer,
)
logger = logging.getLogger('inventree')
logger = structlog.get_logger('inventree')
class OwnerList(ListAPI):

View File

@ -10,7 +10,6 @@ django-error-report-2 # Error report viewer for the admin inte
django-filter # Extended filtering options
django-flags # Feature flags
django-ical # iCal export for calendar views
django-import-export<4.0 # Data import / export for admin interface # FIXED 2024-06-26 see https://github.com/inventree/InvenTree/pull/7521
django-maintenance-mode # Shut down application while reloading etc.
django-markdownify # Markdown rendering
django-mptt # Modified Preorder Tree Traversal

View File

@ -385,9 +385,7 @@ cssselect2==0.7.0 \
defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via
# odfpy
# python3-openid
# via python3-openid
deprecated==1.2.15 \
--hash=sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320 \
--hash=sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d
@ -396,10 +394,6 @@ deprecated==1.2.15 \
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-semantic-conventions
diff-match-patch==20241021 \
--hash=sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782 \
--hash=sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073
# via django-import-export
django==4.2.17 \
--hash=sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0 \
--hash=sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc
@ -412,7 +406,6 @@ django==4.2.17 \
# django-filter
# django-flags
# django-ical
# django-import-export
# django-js-asset
# django-markdownify
# django-money
@ -463,10 +456,6 @@ django-ical==1.9.2 \
--hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \
--hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70
# via -r src/backend/requirements.in
django-import-export==3.2.0 \
--hash=sha256:1d3f2cb2ee3cca0386ed60651fa1623be989f130d9fbdf98a67f7dc3a94b8a37 \
--hash=sha256:38fd7b9439b9e3aa1a4747421c1087a5bc194e915a28d795fb8429a5f8028f2d
# via -r src/backend/requirements.in
django-ipware==7.0.1 \
--hash=sha256:d9ec43d2bf7cdf216fed8d494a084deb5761a54860a53b2e74346a4f384cff47 \
--hash=sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709
@ -1000,9 +989,6 @@ oauthlib==3.2.2 \
--hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \
--hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918
# via requests-oauthlib
odfpy==1.4.1 \
--hash=sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec
# via tablib
openpyxl==3.1.5 \
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
@ -1593,12 +1579,10 @@ structlog==24.4.0 \
--hash=sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610 \
--hash=sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4
# via django-structlog
tablib[html, ods, xls, xlsx, yaml]==3.7.0 \
--hash=sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b \
--hash=sha256:f9db84ed398df5109bd69c11d46613d16cc572fb9ad3213f10d95e2b5f12c18e
# via
# -r src/backend/requirements.in
# django-import-export
tablib[xls, xlsx, yaml]==3.5.0 \
--hash=sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9 \
--hash=sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33
# via -r src/backend/requirements.in
tinycss2==1.4.0 \
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
--hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289