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:
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
40
src/backend/InvenTree/InvenTree/auth_override_views.py
Normal file
40
src/backend/InvenTree/InvenTree/auth_override_views.py
Normal 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
|
@ -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."""
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
@ -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), {})
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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']
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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'
|
||||
|
@ -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
@ -24,6 +24,4 @@ class BuildStatusGroups:
|
||||
BuildStatus.PRODUCTION.value,
|
||||
]
|
||||
|
||||
COMPLETE = [
|
||||
BuildStatus.COMPLETE.value,
|
||||
]
|
||||
COMPLETE = [BuildStatus.COMPLETE.value]
|
||||
|
@ -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
@ -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))
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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']
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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'),
|
||||
]),
|
||||
|
@ -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)
|
||||
"""
|
||||
|
@ -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"')
|
@ -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)
|
@ -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))
|
@ -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'}
|
@ -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')
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user