mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-06 20:11:37 +00:00
Merge branch 'master' into pui-plugins
This commit is contained in:
.devcontainer
.github
.gitignore.pkgr.yml.pre-commit-config.yaml.vscode
CONTRIBUTING.mdREADME.mdcodecov.ymlcontrib
docs
docs
assets
images
barcodes
build
demo.mddevelop
extend
order
part
releases
report
settings
start
stock
src
backend
InvenTree
InvenTree
api.pyapi_version.pyapps.pyconversion.pyexceptions.pyexchange.pyfields.pyforms.pyhelpers.pyhelpers_mixin.pyhelpers_model.pylocales.pystatus_codes.pytasks.py
management
metadata.pymiddleware.pymodels.pyready.pyserializers.pysettings.pysocial_auth_urls.pysso.pystatic
css
script
inventree
tabler-icons
templatetags
test_api.pytest_middleware.pytest_sso.pytests.pytracing.pyunit_test.pyurls.pyvalidators.pyversion.pyviews.pybuild
api.py
migrations
0021_auto_20201020_0908_squashed_0026_auto_20201023_1228.py0022_buildorderattachment.py0027_auto_20210404_2016.py0033_auto_20211128_0151.py0051_delete_buildorderattachment.py
models.pyserializers.pystatus_codes.pytemplates
test_api.pytest_build.pytest_migrations.pytests.pycommon
admin.pyapi.pycurrency.pyforms.pyicons.py
migrations
0018_projectcode.py0020_customunit.py0023_auto_20240602_1332.py0025_attachment.py0026_auto_20240608_1238.py0027_alter_customunit_symbol.py0028_colortheme_user_obj.py
models.pyserializers.pysettings.pytest_migrations.pytest_views.pytests.pyvalidators.pycompany
admin.pyapi.pytest_api.pytest_migrations.py
config_template.yamlmigrations
0001_initial.py0023_auto_20200808_0715.py0032_auto_20210403_1837.py0034_manufacturerpart.py0038_manufacturerpartparameter.py0041_alter_company_options.py0043_manufacturerpartattachment.py0054_companyattachment.py0066_auto_20230616_2059.py0070_remove_manufacturerpartattachment_manufacturer_part_and_more.py0071_manufacturerpart_notes_supplierpart_notes.py
models.pyserializers.pytemplates
company
generic
states
importer
__init__.pyadmin.pyapi.pyapps.py
migrations
0001_initial.py0002_dataimportsession_field_overrides.py0003_dataimportsession_field_filters.py__init__.py
mixins.pymodels.pyoperations.pyregistry.pyserializers.pystatus_codes.pytasks.pytest_data
tests.pyvalidators.pylocale
ar
LC_MESSAGES
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
et
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
ro
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
uk
LC_MESSAGES
vi
LC_MESSAGES
zh
LC_MESSAGES
zh_Hans
LC_MESSAGES
machine
order
api.pytest_api.py
migrations
0001_initial.py0016_purchaseorderattachment.py0020_auto_20200420_0940.py0024_salesorderallocation.py0028_auto_20200423_0956.py0044_auto_20210404_2016.py0053_auto_20211128_0151.py0053_salesordershipment.py0064_purchaseorderextraline_salesorderextraline.py0081_auto_20230314_0725.py0083_returnorderextraline.py0084_auto_20230321_1111.py0085_auto_20230322_1056.py0087_alter_salesorder_status.py0099_alter_salesorder_status.py0100_remove_returnorderattachment_order_and_more.py
models.pyserializers.pystatus_codes.pytemplates
order
part
admin.pyapi.pybom.pyfilters.py
migrations
0014_partparameter.py0015_auto_20190820_0251.py0032_auto_20200322_0453.py0040_parttesttemplate.py0049_partsellpricebreak.py0053_partcategoryparametertemplate.py0064_auto_20210404_2016.py0075_auto_20211128_0151.py0124_delete_partattachment.py0125_part_locked.py0126_part_revision_of.py0127_remove_partcategory_icon_partcategory__icon.py
models.pyserializers.pystocktake.pytemplates
test_api.pytest_bom_export.pytest_bom_item.pytest_category.pytest_param.pytest_part.pytest_pricing.pyplugin
api.py
base
barcodes
event
icons
integration
label
locate
builtin
barcodes
labels
mixins
registry.pysamples
staticfiles.pytemplatetags
test_api.pytest_plugin.pyurls.pyreport
script
stock
admin.pyapi.pyfilters.pygenerators.py
migrations
0001_initial.py0036_stockitemattachment.py0040_stockitemtestresult.py0059_auto_20210404_2016.py0070_auto_20211128_0151.py0111_delete_stockitemattachment.py0112_alter_stocklocation_custom_icon_and_more.py
models.pyserializers.pytemplates
test_api.pytests.pytemplates
users
frontend
.linguircpackage.jsonplaywright.config.ts
src
App.tsxmain.tsxrouter.tsx
components
Boundary.tsxDashboardItemProxy.tsx
buttons
ActionButton.tsxAdminButton.tsxButtonMenu.tsxCopyButton.tsxPrimaryActionButton.tsxPrintingActions.tsxYesNoButton.tsx
details
editors
errors
ClientError.tsxGenericErrorPage.tsxNotAuthenticated.tsxNotFound.tsxPermissionDenied.tsxServerError.tsx
forms
images
importer
items
modals
nav
BreadcrumbList.tsxHeader.tsxInstanceDetail.tsxNavigationTree.tsxNotificationDrawer.tsxPageDetail.tsxPanelGroup.tsxSearchDrawer.tsx
render
settings
contexts
defaults
enums
forms
BuildForms.tsxCompanyForms.tsxImporterForms.tsxPartForms.tsxPurchaseOrderForms.tsxReturnOrderForms.tsxSalesOrderForms.tsxStockForms.tsx
functions
hooks
locales
ar
bg
cs
da
de
el
en
es-mx
es
et
fa
fi
fr
he
hi
hu
id
it
ja
ko
lv
nl
no
pl
pseudo-LOCALE
pt-br
pt
ro
ru
sk
sl
sr
sv
th
tr
uk
vi
zh-hans
zh-hant
zh
pages
Auth
ErrorPage.tsxIndex
NotFound.tsxNotifications.tsxbuild
company
part
purchasing
sales
stock
states
tables
ColumnRenderers.tsxColumnSelect.tsxDownloadAction.tsxFilter.tsxFilterSelectDrawer.tsxInvenTreeTable.tsxRowActions.tsxUploadAction.tsx
bom
build
BuildAllocatedStockTable.tsxBuildLineTable.tsxBuildOrderTable.tsxBuildOrderTestTable.tsxBuildOutputTable.tsx
company
general
machine
part
ParametricPartTable.tsxPartCategoryTable.tsxPartCategoryTemplateTable.tsxPartParameterTable.tsxPartParameterTemplateTable.tsxPartPurchaseOrdersTable.tsxPartTable.tsxPartTestTemplateTable.tsxPartThumbTable.tsxPartVariantTable.tsx
purchasing
sales
ReturnOrderLineItemTable.tsxSalesOrderAllocationTable.tsxSalesOrderLineItemTable.tsxSalesOrderShipmentTable.tsxSalesOrderTable.tsx
settings
CustomUnitsTable.tsxErrorTable.tsxFailedTasksTable.tsxGroupTable.tsxImportSessionTable.tsxPendingTasksTable.tsxProjectCodeTable.tsxTemplateTable.tsxUserTable.tsx
stock
tests
baseFixtures.tslogin.tsmodals.spec.ts
yarn.lockpages
pui_basic.spec.tspui_general.spec.tspui_printing.spec.tspui_settings.spec.tspui_stock.spec.tspui_tables.spec.ts@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.urls import include, path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_q.models import OrmQ
|
||||
@@ -21,9 +20,7 @@ from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.version
|
||||
import users.models
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import ListCreateAPI
|
||||
from InvenTree.permissions import RolePermission
|
||||
from InvenTree.templatetags.inventree_extras import plugins_info
|
||||
from part.models import Part
|
||||
from plugin.serializers import MetadataSerializer
|
||||
@@ -311,8 +308,26 @@ class BulkDeleteMixin:
|
||||
- Speed (single API call and DB query)
|
||||
"""
|
||||
|
||||
def validate_delete(self, queryset, request) -> None:
|
||||
"""Perform validation right before deletion.
|
||||
|
||||
Arguments:
|
||||
queryset: The queryset to be deleted
|
||||
request: The request object
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
ValidationError: If the deletion should not proceed
|
||||
"""
|
||||
pass
|
||||
|
||||
def filter_delete_queryset(self, queryset, request):
|
||||
"""Provide custom filtering for the queryset *before* it is deleted."""
|
||||
"""Provide custom filtering for the queryset *before* it is deleted.
|
||||
|
||||
The default implementation does nothing, just returns the queryset.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
@@ -371,6 +386,9 @@ class BulkDeleteMixin:
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
|
||||
# Run a final validation step (should raise an error if the deletion should not proceed)
|
||||
self.validate_delete(queryset, request)
|
||||
|
||||
n_deleted = queryset.count()
|
||||
queryset.delete()
|
||||
|
||||
@@ -383,58 +401,6 @@ class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI):
|
||||
...
|
||||
|
||||
|
||||
class APIDownloadMixin:
|
||||
"""Mixin for enabling a LIST endpoint to be downloaded a file.
|
||||
|
||||
To download the data, add the ?export=<fmt> to the query string.
|
||||
|
||||
The implementing class must provided a download_queryset method,
|
||||
e.g.
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
dataset = StockItemResource().export(queryset=queryset)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
|
||||
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
|
||||
date=datetime.now().strftime("%d-%b-%Y"),
|
||||
fmt=export_format
|
||||
)
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Generic handler for a download request."""
|
||||
export_format = request.query_params.get('export', None)
|
||||
|
||||
if export_format and export_format in ['csv', 'tsv', 'xls', 'xlsx']:
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
return self.download_queryset(queryset, export_format)
|
||||
# Default to the parent class implementation
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
"""This function must be implemented to provide a downloadFile request."""
|
||||
raise NotImplementedError('download_queryset method not implemented!')
|
||||
|
||||
|
||||
class AttachmentMixin:
|
||||
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, RolePermission]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = ['attachment', 'comment', 'link']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save the user information when a file is uploaded."""
|
||||
attachment = serializer.save()
|
||||
attachment.user = self.request.user
|
||||
attachment.save()
|
||||
|
||||
|
||||
class APISearchViewSerializer(serializers.Serializer):
|
||||
"""Serializer for the APISearchView."""
|
||||
|
||||
|
@@ -1,11 +1,115 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 206
|
||||
INVENTREE_API_VERSION = 236
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v236 - 2024-08-10 : https://github.com/inventree/InvenTree/pull/7844
|
||||
- Adds "supplier_name" to the PurchaseOrder API serializer
|
||||
|
||||
v235 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7837
|
||||
- Adds "on_order" quantity to SalesOrderLineItem serializer
|
||||
- Adds "building" quantity to SalesOrderLineItem serializer
|
||||
|
||||
v234 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7829
|
||||
- Fixes bug in the plugin metadata endpoint
|
||||
|
||||
v233 - 2024-08-04 : https://github.com/inventree/InvenTree/pull/7807
|
||||
- Adds new endpoints for managing state of build orders
|
||||
- Adds new endpoints for managing state of purchase orders
|
||||
- Adds new endpoints for managing state of sales orders
|
||||
- Adds new endpoints for managing state of return orders
|
||||
|
||||
v232 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7793
|
||||
- Allow ordering of SalesOrderShipment API by 'shipment_date' and 'delivery_date'
|
||||
|
||||
v231 - 2024-08-03 : https://github.com/inventree/InvenTree/pull/7794
|
||||
- Optimize BuildItem and BuildLine serializers to improve API efficiency
|
||||
|
||||
v230 - 2024-05-05 : https://github.com/inventree/InvenTree/pull/7164
|
||||
- Adds test statistics endpoint
|
||||
|
||||
v229 - 2024-07-31 : https://github.com/inventree/InvenTree/pull/7775
|
||||
- Add extra exportable fields to the BomItem serializer
|
||||
|
||||
v228 - 2024-07-18 : https://github.com/inventree/InvenTree/pull/7684
|
||||
- Adds "icon" field to the PartCategory.path and StockLocation.path API
|
||||
- Adds icon packages API endpoint
|
||||
|
||||
v227 - 2024-07-19 : https://github.com/inventree/InvenTree/pull/7693/
|
||||
- Adds endpoints to list and revoke the tokens issued to the current user
|
||||
|
||||
v226 - 2024-07-15 : https://github.com/inventree/InvenTree/pull/7648
|
||||
- Adds barcode generation API endpoint
|
||||
|
||||
v225 - 2024-07-17 : https://github.com/inventree/InvenTree/pull/7671
|
||||
- Adds "filters" field to DataImportSession API
|
||||
|
||||
v224 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7667
|
||||
- Add notes field to ManufacturerPart and SupplierPart API endpoints
|
||||
|
||||
v223 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7649
|
||||
- Allow adjustment of "packaging" field when receiving items against a purchase order
|
||||
|
||||
v222 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7635
|
||||
- Adjust the BomItem API endpoint to improve data import process
|
||||
|
||||
v221 - 2024-07-13 : https://github.com/inventree/InvenTree/pull/7636
|
||||
- Adds missing fields from StockItemBriefSerializer
|
||||
- Adds missing fields from PartBriefSerializer
|
||||
- Adds extra exportable fields to BuildItemSerializer
|
||||
|
||||
v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585
|
||||
- Adds "revision_of" field to Part serializer
|
||||
- Adds new API filters for "revision" status
|
||||
|
||||
v219 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7611
|
||||
- Adds new fields to the BuildItem API endpoints
|
||||
- Adds new ordering / filtering options to the BuildItem API endpoints
|
||||
|
||||
v218 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7619
|
||||
- Adds "can_build" field to the BomItem API
|
||||
|
||||
v217 - 2024-07-09 : https://github.com/inventree/InvenTree/pull/7599
|
||||
- Fixes bug in "project_code" field for order API endpoints
|
||||
|
||||
v216 - 2024-07-08 : https://github.com/inventree/InvenTree/pull/7595
|
||||
- Moves API endpoint for contenttype lookup by model name
|
||||
|
||||
v215 - 2024-07-09 : https://github.com/inventree/InvenTree/pull/7591
|
||||
- Adds additional fields to the BuildLine serializer
|
||||
|
||||
v214 - 2024-07-08 : https://github.com/inventree/InvenTree/pull/7587
|
||||
- Adds "default_location_detail" field to the Part API
|
||||
|
||||
v213 - 2024-07-06 : https://github.com/inventree/InvenTree/pull/7527
|
||||
- Adds 'locked' field to Part API
|
||||
|
||||
v212 - 2024-07-06 : https://github.com/inventree/InvenTree/pull/7562
|
||||
- Makes API generation more robust (no functional changes)
|
||||
|
||||
v211 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/6911
|
||||
- Adds API endpoints for managing data import and export
|
||||
|
||||
v210 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/7518
|
||||
- Adds translateable text to User API fields
|
||||
|
||||
v209 - 2024-06-26 : https://github.com/inventree/InvenTree/pull/7514
|
||||
- Add "top_level" filter to PartCategory API endpoint
|
||||
- Add "top_level" filter to StockLocation API endpoint
|
||||
|
||||
v208 - 2024-06-19 : https://github.com/inventree/InvenTree/pull/7479
|
||||
- Adds documentation for the user roles API endpoint (no functional changes)
|
||||
|
||||
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
|
||||
- Moves all "Attachment" models into a single table
|
||||
- All "Attachment" operations are now performed at /api/attachment/
|
||||
- Add permissions information to /api/user/roles/ endpoint
|
||||
|
||||
v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
|
||||
- Adds "choices" field to the PartTestTemplate model
|
||||
|
||||
@@ -111,7 +215,7 @@ v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
|
||||
- Adds "external_stock" field to the Part API endpoint
|
||||
- Adds "external_stock" field to the BomItem API endpoint
|
||||
- Adds "external_stock" field to the BuildLine API endpoint
|
||||
- Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
|
||||
- Stock quantities represented in the BuildLine API endpoint are now filtered by Build.source_location
|
||||
|
||||
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
|
||||
- Adds "subcategoies" count to PartCategoryTree serializer
|
||||
|
@@ -11,6 +11,8 @@ from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError, OperationalError
|
||||
|
||||
from allauth.socialaccount.signals import social_account_updated
|
||||
|
||||
import InvenTree.conversion
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
@@ -70,6 +72,12 @@ class InvenTreeConfig(AppConfig):
|
||||
self.add_user_on_startup()
|
||||
self.add_user_from_file()
|
||||
|
||||
# register event receiver and connect signal for SSO group sync. The connected signal is
|
||||
# used for account updates whereas the receiver is used for the initial account creation.
|
||||
from InvenTree import sso
|
||||
|
||||
social_account_updated.connect(sso.ensure_sso_groups)
|
||||
|
||||
def remove_obsolete_tasks(self):
|
||||
"""Delete any obsolete scheduled tasks in the database."""
|
||||
obsolete = [
|
||||
|
@@ -153,7 +153,7 @@ def convert_physical_value(value: str, unit: str = None, strip_units=True):
|
||||
if unit:
|
||||
try:
|
||||
valid = unit in ureg
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
valid = False
|
||||
|
||||
if not valid:
|
||||
@@ -196,7 +196,7 @@ def convert_physical_value(value: str, unit: str = None, strip_units=True):
|
||||
try:
|
||||
value = convert_value(attempt, unit)
|
||||
break
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
value = None
|
||||
|
||||
if value is None:
|
||||
|
@@ -9,7 +9,6 @@ import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db.utils import IntegrityError, OperationalError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import rest_framework.views as drfviews
|
||||
|
@@ -8,6 +8,7 @@ from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
|
||||
from common.currency import currency_code_default, currency_codes
|
||||
from common.settings import get_global_setting
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
||||
|
||||
def get_rates(self, **kwargs) -> dict:
|
||||
"""Set the requested currency codes and get rates."""
|
||||
from common.models import InvenTreeSetting
|
||||
from plugin import registry
|
||||
|
||||
base_currency = kwargs.get('base_currency', currency_code_default())
|
||||
symbols = kwargs.get('symbols', currency_codes())
|
||||
|
||||
# Find the selected exchange rate plugin
|
||||
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
|
||||
slug = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False)
|
||||
|
||||
if slug:
|
||||
plugin = registry.get_plugin(slug)
|
||||
|
@@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
"""Override default validation behaviour for this field type."""
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', True, cache=False)
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||
|
||||
if not strict_urls and data is not empty and '://' not in data:
|
||||
# Validate as if there were a schema provided
|
||||
|
@@ -15,6 +15,7 @@ from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from allauth_2fa.adapter import OTPAdapter
|
||||
from allauth_2fa.forms import TOTPDeviceForm
|
||||
from allauth_2fa.utils import user_has_valid_totp_device
|
||||
from crispy_forms.bootstrap import AppendedText, PrependedAppendedText, PrependedText
|
||||
from crispy_forms.helper import FormHelper
|
||||
@@ -190,7 +191,7 @@ class CustomSignupForm(SignupForm):
|
||||
|
||||
# check for two password fields
|
||||
if not get_global_setting('LOGIN_SIGNUP_PWD_TWICE'):
|
||||
self.fields.pop('password2')
|
||||
self.fields.pop('password2', None)
|
||||
|
||||
# reorder fields
|
||||
set_form_field_order(
|
||||
@@ -211,6 +212,16 @@ class CustomSignupForm(SignupForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class CustomTOTPDeviceForm(TOTPDeviceForm):
|
||||
"""Ensure that db registration is enabled."""
|
||||
|
||||
def __init__(self, user, metadata=None, **kwargs):
|
||||
"""Override to check if registration is open."""
|
||||
if not settings.MFA_ENABLED:
|
||||
raise forms.ValidationError(_('MFA Registration is disabled.'))
|
||||
super().__init__(user, metadata, **kwargs)
|
||||
|
||||
|
||||
def registration_enabled():
|
||||
"""Determine whether user registration is enabled."""
|
||||
if get_global_setting('LOGIN_ENABLE_REG') or InvenTree.sso.registration_enabled():
|
||||
@@ -269,7 +280,9 @@ class RegistratonMixin:
|
||||
|
||||
# Check if a default group is set in settings
|
||||
start_group = get_global_setting('SIGNUP_GROUP')
|
||||
if start_group:
|
||||
if (
|
||||
start_group and user.groups.count() == 0
|
||||
): # check that no group has been added through SSO group sync
|
||||
try:
|
||||
group = Group.objects.get(id=start_group)
|
||||
user.groups.add(group)
|
||||
|
@@ -3,7 +3,6 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
@@ -27,7 +26,6 @@ from bleach import clean
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
import InvenTree.version
|
||||
from common.currency import currency_code_default
|
||||
|
||||
from .settings import MEDIA_URL, STATIC_URL
|
||||
@@ -396,41 +394,9 @@ def WrapWithQuotes(text, quote='"'):
|
||||
return text
|
||||
|
||||
|
||||
def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
|
||||
"""Generate a string for a barcode. Adds some global InvenTree parameters.
|
||||
|
||||
Args:
|
||||
cls_name: string describing the object type e.g. 'StockItem'
|
||||
object_pk (int): ID (Primary Key) of the object in the database
|
||||
object_data: Python dict object containing extra data which will be rendered to string (must only contain stringable values)
|
||||
|
||||
Returns:
|
||||
json string of the supplied data plus some other data
|
||||
"""
|
||||
if object_data is None:
|
||||
object_data = {}
|
||||
|
||||
brief = kwargs.get('brief', True)
|
||||
|
||||
data = {}
|
||||
|
||||
if brief:
|
||||
data[cls_name] = object_pk
|
||||
else:
|
||||
data['tool'] = 'InvenTree'
|
||||
data['version'] = InvenTree.version.inventreeVersion()
|
||||
data['instance'] = InvenTree.version.inventreeInstanceName()
|
||||
|
||||
# Ensure PK is included
|
||||
object_data['id'] = object_pk
|
||||
data[cls_name] = object_data
|
||||
|
||||
return str(json.dumps(data, sort_keys=True))
|
||||
|
||||
|
||||
def GetExportFormats():
|
||||
"""Return a list of allowable file formats for exporting data."""
|
||||
return ['csv', 'tsv', 'xls', 'xlsx', 'json', 'yaml']
|
||||
"""Return a list of allowable file formats for importing or exporting tabular data."""
|
||||
return ['csv', 'xlsx', 'tsv', 'json']
|
||||
|
||||
|
||||
def DownloadFile(
|
||||
|
@@ -2,8 +2,10 @@
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from plugin import registry as plg_registry
|
||||
|
||||
@@ -104,3 +106,37 @@ class ClassProviderMixin:
|
||||
except ValueError:
|
||||
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
|
||||
return False
|
||||
|
||||
|
||||
def get_shared_class_instance_state_mixin(get_state_key: Callable[[type], str]):
|
||||
"""Get a mixin class that provides shared state for classes across the main application and worker.
|
||||
|
||||
Arguments:
|
||||
get_state_key: A function that returns the key for the shared state when given a class instance.
|
||||
"""
|
||||
|
||||
class SharedClassStateMixinClass:
|
||||
"""Mixin to provide shared state for classes across the main application and worker."""
|
||||
|
||||
def set_shared_state(self, key: str, value: Any):
|
||||
"""Set a shared state value for this machine.
|
||||
|
||||
Arguments:
|
||||
key: The key for the shared state
|
||||
value: The value to set
|
||||
"""
|
||||
cache.set(self._get_key(key), value, timeout=None)
|
||||
|
||||
def get_shared_state(self, key: str, default=None):
|
||||
"""Get a shared state value for this machine.
|
||||
|
||||
Arguments:
|
||||
key: The key for the shared state
|
||||
"""
|
||||
return cache.get(self._get_key(key)) or default
|
||||
|
||||
def _get_key(self, key: str):
|
||||
"""Get the key for this class instance."""
|
||||
return f'{get_state_key(self)}:{key}'
|
||||
|
||||
return SharedClassStateMixinClass
|
||||
|
@@ -15,9 +15,6 @@ from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
import InvenTree
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.version
|
||||
from common.notifications import (
|
||||
InvenTreeNotificationBodies,
|
||||
NotificationBody,
|
||||
@@ -39,8 +36,6 @@ def get_base_url(request=None):
|
||||
3. If settings.SITE_URL is set (e.g. in the Django settings), use that
|
||||
4. If the InvenTree setting INVENTREE_BASE_URL is set, use that
|
||||
"""
|
||||
import common.models
|
||||
|
||||
# Check if a request is provided
|
||||
if request:
|
||||
return request.build_absolute_uri('/')
|
||||
@@ -107,8 +102,6 @@ def download_image_from_url(remote_url, timeout=2.5):
|
||||
ValueError: Server responded with invalid 'Content-Length' value
|
||||
TypeError: Response is not a valid image
|
||||
"""
|
||||
import common.models
|
||||
|
||||
# Check that the provided URL at least looks valid
|
||||
validator = URLValidator()
|
||||
validator(remote_url)
|
||||
@@ -206,8 +199,6 @@ def render_currency(
|
||||
max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting.
|
||||
include_symbol: If True, include the currency symbol in the output
|
||||
"""
|
||||
import common.models
|
||||
|
||||
if money in [None, '']:
|
||||
return '-'
|
||||
|
||||
@@ -252,7 +243,7 @@ def render_currency(
|
||||
|
||||
|
||||
def getModelsWithMixin(mixin_class) -> list:
|
||||
"""Return a list of models that inherit from the given mixin class.
|
||||
"""Return a list of database models that inherit from the given mixin class.
|
||||
|
||||
Args:
|
||||
mixin_class: The mixin class to search for
|
||||
@@ -331,9 +322,7 @@ def notify_users(
|
||||
'instance': instance,
|
||||
'name': content.name.format(**content_context),
|
||||
'message': content.message.format(**content_context),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
instance.get_absolute_url()
|
||||
),
|
||||
'link': construct_absolute_url(instance.get_absolute_url()),
|
||||
'template': {'subject': content.name.format(**content_context)},
|
||||
}
|
||||
|
||||
|
@@ -15,6 +15,7 @@ Additionally, update the following files with the new locale code:
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
LOCALES = [
|
||||
('ar', _('Arabic')),
|
||||
('bg', _('Bulgarian')),
|
||||
('cs', _('Czech')),
|
||||
('da', _('Danish')),
|
||||
@@ -23,6 +24,7 @@ LOCALES = [
|
||||
('en', _('English')),
|
||||
('es', _('Spanish')),
|
||||
('es-mx', _('Spanish (Mexican)')),
|
||||
('et', _('Estonian')),
|
||||
('fa', _('Farsi / Persian')),
|
||||
('fi', _('Finnish')),
|
||||
('fr', _('French')),
|
||||
|
@@ -0,0 +1,53 @@
|
||||
"""Custom management command to export settings definitions.
|
||||
|
||||
This is used to generate a JSON file which contains all of the settings,
|
||||
so that they can be introspected by the InvenTree documentation system.
|
||||
|
||||
This in turn allows settings to be documented in the InvenTree documentation,
|
||||
without having to manually duplicate the information in multiple places.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Extract settings information, and export to a JSON file."""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add custom arguments for this command."""
|
||||
parser.add_argument(
|
||||
'filename', type=str, help='Output filename for settings definitions'
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Export settings information to a JSON file."""
|
||||
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
||||
|
||||
settings = {'global': {}, 'user': {}}
|
||||
|
||||
# Global settings
|
||||
for key, setting in InvenTreeSetting.SETTINGS.items():
|
||||
settings['global'][key] = {
|
||||
'name': str(setting['name']),
|
||||
'description': str(setting['description']),
|
||||
'default': str(InvenTreeSetting.get_setting_default(key)),
|
||||
'units': str(setting.get('units', '')),
|
||||
}
|
||||
|
||||
# User settings
|
||||
for key, setting in InvenTreeUserSetting.SETTINGS.items():
|
||||
settings['user'][key] = {
|
||||
'name': str(setting['name']),
|
||||
'description': str(setting['description']),
|
||||
'default': str(InvenTreeUserSetting.get_setting_default(key)),
|
||||
'units': str(setting.get('units', '')),
|
||||
}
|
||||
|
||||
filename = kwargs.get('filename', 'inventree_settings.json')
|
||||
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(settings, f, indent=4)
|
||||
|
||||
print(f"Exported InvenTree settings definitions to '{filename}'")
|
@@ -0,0 +1,192 @@
|
||||
"""Custom management command to migrate the old FontAwesome icons."""
|
||||
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import models
|
||||
|
||||
from common.icons import validate_icon
|
||||
from part.models import PartCategory
|
||||
from stock.models import StockLocation, StockLocationType
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Generate an icon map from the FontAwesome library to the new icon library."""
|
||||
|
||||
help = """Helper command to migrate the old FontAwesome icons to the new icon library."""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add the arguments."""
|
||||
parser.add_argument(
|
||||
'--output-file',
|
||||
type=str,
|
||||
help='Path to file to write generated icon map to',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--input-file', type=str, help='Path to file to read icon map from'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--include-items',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Include referenced inventree items in the output icon map (optional)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--import-now',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='CAUTION: If this flag is set, the icon map will be imported and the database will be touched',
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Generate an icon map from the FontAwesome library to the new icon library."""
|
||||
# Check for invalid combinations of arguments
|
||||
if kwargs['output_file'] and kwargs['input_file']:
|
||||
raise CommandError('Cannot specify both --input-file and --output-file')
|
||||
|
||||
if not kwargs['output_file'] and not kwargs['input_file']:
|
||||
raise CommandError('Must specify either --input-file or --output-file')
|
||||
|
||||
if kwargs['include_items'] and not kwargs['output_file']:
|
||||
raise CommandError(
|
||||
'--include-items can only be used with an --output-file specified'
|
||||
)
|
||||
|
||||
if kwargs['output_file'] and kwargs['import_now']:
|
||||
raise CommandError(
|
||||
'--import-now can only be used with an --input-file specified'
|
||||
)
|
||||
|
||||
ICON_MODELS = [
|
||||
(StockLocation, 'custom_icon'),
|
||||
(StockLocationType, 'icon'),
|
||||
(PartCategory, '_icon'),
|
||||
]
|
||||
|
||||
def get_model_items_with_icons(model: models.Model, icon_field: str):
|
||||
"""Return a list of models with icon fields."""
|
||||
return model.objects.exclude(**{f'{icon_field}__isnull': True}).exclude(**{
|
||||
f'{icon_field}__exact': ''
|
||||
})
|
||||
|
||||
# Generate output icon map file
|
||||
if kwargs['output_file']:
|
||||
icons = {}
|
||||
|
||||
for model, icon_name in ICON_MODELS:
|
||||
self.stdout.write(
|
||||
f'Processing model {model.__name__} with icon field {icon_name}'
|
||||
)
|
||||
|
||||
items = get_model_items_with_icons(model, icon_name)
|
||||
|
||||
for item in items:
|
||||
icon = getattr(item, icon_name)
|
||||
|
||||
try:
|
||||
validate_icon(icon)
|
||||
continue # Skip if the icon is already valid
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
if icon not in icons:
|
||||
icons[icon] = {
|
||||
**({'items': []} if kwargs['include_items'] else {}),
|
||||
'new_icon': '',
|
||||
}
|
||||
|
||||
if kwargs['include_items']:
|
||||
icons[icon]['items'].append({
|
||||
'model': model.__name__.lower(),
|
||||
'id': item.id, # type: ignore
|
||||
})
|
||||
|
||||
self.stdout.write(f'Writing icon map for {len(icons.keys())} icons')
|
||||
with open(kwargs['output_file'], 'w') as f:
|
||||
json.dump(icons, f, indent=2)
|
||||
|
||||
self.stdout.write(f'Icon map written to {kwargs["output_file"]}')
|
||||
|
||||
# Import icon map file
|
||||
if kwargs['input_file']:
|
||||
with open(kwargs['input_file'], 'r') as f:
|
||||
icons = json.load(f)
|
||||
|
||||
self.stdout.write(f'Loaded icon map for {len(icons.keys())} icons')
|
||||
|
||||
self.stdout.write('Verifying icon map')
|
||||
has_errors = False
|
||||
|
||||
# Verify that all new icons are valid icons
|
||||
for old_icon, data in icons.items():
|
||||
try:
|
||||
validate_icon(data.get('new_icon', ''))
|
||||
except ValidationError:
|
||||
self.stdout.write(
|
||||
f'[ERR] Invalid icon: "{old_icon}" -> "{data.get("new_icon", "")}'
|
||||
)
|
||||
has_errors = True
|
||||
|
||||
# Verify that all required items are provided in the icon map
|
||||
for model, icon_name in ICON_MODELS:
|
||||
self.stdout.write(
|
||||
f'Processing model {model.__name__} with icon field {icon_name}'
|
||||
)
|
||||
items = get_model_items_with_icons(model, icon_name)
|
||||
|
||||
for item in items:
|
||||
icon = getattr(item, icon_name)
|
||||
|
||||
try:
|
||||
validate_icon(icon)
|
||||
continue # Skip if the icon is already valid
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
if icon not in icons:
|
||||
self.stdout.write(
|
||||
f' [ERR] Icon "{icon}" not found in icon map'
|
||||
)
|
||||
has_errors = True
|
||||
|
||||
# If there are errors, stop here
|
||||
if has_errors:
|
||||
self.stdout.write(
|
||||
'[ERR] Icon map has errors, please fix them before continuing with importing'
|
||||
)
|
||||
return
|
||||
|
||||
# Import the icon map into the database if the flag is set
|
||||
if kwargs['import_now']:
|
||||
self.stdout.write('Start importing icons and updating database...')
|
||||
cnt = 0
|
||||
|
||||
for model, icon_name in ICON_MODELS:
|
||||
self.stdout.write(
|
||||
f'Processing model {model.__name__} with icon field {icon_name}'
|
||||
)
|
||||
items = get_model_items_with_icons(model, icon_name)
|
||||
|
||||
for item in items:
|
||||
icon = getattr(item, icon_name)
|
||||
|
||||
try:
|
||||
validate_icon(icon)
|
||||
continue # Skip if the icon is already valid
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
setattr(item, icon_name, icons[icon]['new_icon'])
|
||||
cnt += 1
|
||||
item.save()
|
||||
|
||||
self.stdout.write(
|
||||
f'Icon map successfully imported - changed {cnt} items'
|
||||
)
|
||||
self.stdout.write('Icons are now migrated')
|
||||
else:
|
||||
self.stdout.write('Icon map is valid and ready to be imported')
|
||||
self.stdout.write(
|
||||
'Run the command with --import-now to import the icon map and update the database'
|
||||
)
|
@@ -115,9 +115,14 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
return metadata
|
||||
|
||||
def override_value(self, field_name, field_value, model_value):
|
||||
def override_value(self, field_name: str, field_key: str, field_value, model_value):
|
||||
"""Override a value on the serializer with a matching value for the model.
|
||||
|
||||
Often, the serializer field will point to an underlying model field,
|
||||
which contains extra information (which is translated already).
|
||||
|
||||
Rather than duplicating this information in the serializer, we can extract it from the model.
|
||||
|
||||
This is used to override the serializer values with model values,
|
||||
if (and *only* if) the model value should take precedence.
|
||||
|
||||
@@ -125,17 +130,28 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
- field_value is None
|
||||
- model_value is callable, and field_value is not (this indicates that the model value is translated)
|
||||
- model_value is not a string, and field_value is a string (this indicates that the model value is translated)
|
||||
|
||||
Arguments:
|
||||
- field_name: The name of the field
|
||||
- field_key: The property key to override
|
||||
- field_value: The value of the field (if available)
|
||||
- model_value: The equivalent value of the model (if available)
|
||||
"""
|
||||
if model_value and not field_value:
|
||||
if field_value is None and model_value is not None:
|
||||
return model_value
|
||||
|
||||
if field_value and not model_value:
|
||||
if model_value is None and field_value is not None:
|
||||
return field_value
|
||||
|
||||
# Callable values will be evaluated later
|
||||
if callable(model_value) and not callable(field_value):
|
||||
return model_value
|
||||
|
||||
if type(model_value) is not str and type(field_value) is str:
|
||||
if callable(field_value) and not callable(model_value):
|
||||
return field_value
|
||||
|
||||
# Prioritize translated text over raw string values
|
||||
if type(field_value) is str and type(model_value) is not str:
|
||||
return model_value
|
||||
|
||||
return field_value
|
||||
@@ -144,6 +160,8 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
"""Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value."""
|
||||
self.serializer = serializer
|
||||
|
||||
request = getattr(self, 'request', None)
|
||||
|
||||
serializer_info = super().get_serializer_info(serializer)
|
||||
|
||||
# Look for any dynamic fields which were not available when the serializer was instantiated
|
||||
@@ -153,12 +171,19 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
# Already know about this one
|
||||
continue
|
||||
|
||||
if hasattr(serializer, field_name):
|
||||
field = getattr(serializer, field_name)
|
||||
if field := getattr(serializer, field_name, None):
|
||||
serializer_info[field_name] = self.get_field_info(field)
|
||||
|
||||
model_class = None
|
||||
|
||||
# Extract read_only_fields and write_only_fields from the Meta class (if available)
|
||||
if meta := getattr(serializer, 'Meta', None):
|
||||
read_only_fields = getattr(meta, 'read_only_fields', [])
|
||||
write_only_fields = getattr(meta, 'write_only_fields', [])
|
||||
else:
|
||||
read_only_fields = []
|
||||
write_only_fields = []
|
||||
|
||||
# Attributes to copy extra attributes from the model to the field (if they don't exist)
|
||||
# Note that the attributes may be named differently on the underlying model!
|
||||
extra_attributes = {
|
||||
@@ -172,16 +197,20 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
model_fields = model_meta.get_field_info(model_class)
|
||||
|
||||
model_default_func = getattr(model_class, 'api_defaults', None)
|
||||
|
||||
if model_default_func:
|
||||
model_default_values = model_class.api_defaults(self.request)
|
||||
if model_default_func := getattr(model_class, 'api_defaults', None):
|
||||
model_default_values = model_default_func(request=request) or {}
|
||||
else:
|
||||
model_default_values = {}
|
||||
|
||||
# Iterate through simple fields
|
||||
for name, field in model_fields.fields.items():
|
||||
if name in serializer_info.keys():
|
||||
if name in read_only_fields:
|
||||
serializer_info[name]['read_only'] = True
|
||||
|
||||
if name in write_only_fields:
|
||||
serializer_info[name]['write_only'] = True
|
||||
|
||||
if field.has_default():
|
||||
default = field.default
|
||||
|
||||
@@ -197,10 +226,12 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
serializer_info[name]['default'] = model_default_values[name]
|
||||
|
||||
for field_key, model_key in extra_attributes.items():
|
||||
field_value = serializer_info[name].get(field_key, None)
|
||||
field_value = getattr(serializer.fields[name], field_key, None)
|
||||
model_value = getattr(field, model_key, None)
|
||||
|
||||
if value := self.override_value(name, field_value, model_value):
|
||||
if value := self.override_value(
|
||||
name, field_key, field_value, model_value
|
||||
):
|
||||
serializer_info[name][field_key] = value
|
||||
|
||||
# Iterate through relations
|
||||
@@ -213,6 +244,12 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
# Ignore reverse relations
|
||||
continue
|
||||
|
||||
if name in read_only_fields:
|
||||
serializer_info[name]['read_only'] = True
|
||||
|
||||
if name in write_only_fields:
|
||||
serializer_info[name]['write_only'] = True
|
||||
|
||||
# Extract and provide the "limit_choices_to" filters
|
||||
# This is used to automatically filter AJAX requests
|
||||
serializer_info[name]['filters'] = (
|
||||
@@ -220,10 +257,12 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
)
|
||||
|
||||
for field_key, model_key in extra_attributes.items():
|
||||
field_value = serializer_info[name].get(field_key, None)
|
||||
field_value = getattr(serializer.fields[name], field_key, None)
|
||||
model_value = getattr(relation.model_field, model_key, None)
|
||||
|
||||
if value := self.override_value(name, field_value, model_value):
|
||||
if value := self.override_value(
|
||||
name, field_key, field_value, model_value
|
||||
):
|
||||
serializer_info[name][field_key] = value
|
||||
|
||||
if name in model_default_values:
|
||||
@@ -241,7 +280,8 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
if instance is None and model_class is not None:
|
||||
# Attempt to find the instance based on kwargs lookup
|
||||
kwargs = getattr(self.view, 'kwargs', None)
|
||||
view = getattr(self, 'view', None)
|
||||
kwargs = getattr(view, 'kwargs', None) if view else None
|
||||
|
||||
if kwargs:
|
||||
pk = None
|
||||
@@ -298,8 +338,10 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
# Force non-nullable fields to read as "required"
|
||||
# (even if there is a default value!)
|
||||
if not field.allow_null and not (
|
||||
hasattr(field, 'allow_blank') and field.allow_blank
|
||||
if (
|
||||
'required' not in field_info
|
||||
and not field.allow_null
|
||||
and not (hasattr(field, 'allow_blank') and field.allow_blank)
|
||||
):
|
||||
field_info['required'] = True
|
||||
|
||||
@@ -326,8 +368,11 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
field_info['api_url'] = '/api/user/'
|
||||
elif field_info['model'] == 'contenttype':
|
||||
field_info['api_url'] = '/api/contenttype/'
|
||||
else:
|
||||
elif hasattr(model, 'get_api_url'):
|
||||
field_info['api_url'] = model.get_api_url()
|
||||
else:
|
||||
logger.warning("'get_api_url' method not defined for %s", model)
|
||||
field_info['api_url'] = getattr(model, 'api_url', None)
|
||||
|
||||
# Handle custom 'primary key' field
|
||||
field_info['pk_field'] = getattr(field, 'pk_field', 'pk') or 'pk'
|
||||
|
@@ -12,6 +12,7 @@ from django.urls import Resolver404, include, path, resolve, reverse_lazy
|
||||
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.urls import frontendpatterns
|
||||
from users.models import ApiToken
|
||||
|
||||
@@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):
|
||||
|
||||
def require_2fa(self, request):
|
||||
"""Use setting to check if MFA should be enforced for frontend page."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
try:
|
||||
if url_matcher.resolve(request.path[1:]):
|
||||
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
|
||||
return get_global_setting('LOGIN_ENFORCE_MFA')
|
||||
except Resolver404:
|
||||
pass
|
||||
return False
|
||||
|
@@ -1,13 +1,9 @@
|
||||
"""Generic models which provide extra functionality over base Django model types."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
@@ -20,11 +16,11 @@ from error_report.models import Error
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_model
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -97,7 +93,7 @@ class PluginValidationMixin(DiffMixin):
|
||||
return
|
||||
except ValidationError as exc:
|
||||
raise exc
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
# Log the exception to the database
|
||||
import InvenTree.exceptions
|
||||
|
||||
@@ -218,12 +214,15 @@ class MetadataMixin(models.Model):
|
||||
self.save()
|
||||
|
||||
|
||||
class DataImportMixin(object):
|
||||
class DataImportMixin:
|
||||
"""Model mixin class which provides support for 'data import' functionality.
|
||||
|
||||
Models which implement this mixin should provide information on the fields available for import
|
||||
"""
|
||||
|
||||
# TODO: This mixin should be removed after https://github.com/inventree/InvenTree/pull/6911 is implemented
|
||||
# TODO: This approach to data import functionality is *outdated*
|
||||
|
||||
# Define a map of fields available for import
|
||||
IMPORT_FIELDS = {}
|
||||
|
||||
@@ -304,10 +303,7 @@ class ReferenceIndexingMixin(models.Model):
|
||||
if cls.REFERENCE_PATTERN_SETTING is None:
|
||||
return ''
|
||||
|
||||
# import at function level to prevent cyclic imports
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
return InvenTreeSetting.get_setting(
|
||||
return common.settings.get_global_setting(
|
||||
cls.REFERENCE_PATTERN_SETTING, create=False
|
||||
).strip()
|
||||
|
||||
@@ -503,207 +499,71 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
|
||||
abstract = True
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
|
||||
|
||||
Args:
|
||||
instance: Instance of a PartAttachment object
|
||||
filename: name of uploaded file
|
||||
|
||||
Returns:
|
||||
path to store file, format: '<subdir>/<id>/filename'
|
||||
"""
|
||||
# Construct a path to store a file attachment for a given model type
|
||||
return os.path.join(instance.getSubdir(), filename)
|
||||
|
||||
|
||||
class InvenTreeAttachment(InvenTreeModel):
|
||||
class InvenTreeAttachmentMixin:
|
||||
"""Provides an abstracted class for managing file attachments.
|
||||
|
||||
An attachment can be either an uploaded file, or an external URL
|
||||
Links the implementing model to the common.models.Attachment table,
|
||||
and provides the following methods:
|
||||
|
||||
Attributes:
|
||||
attachment: Upload file
|
||||
link: External URL
|
||||
comment: String descriptor for the attachment
|
||||
user: User associated with file upload
|
||||
upload_date: Date the file was uploaded
|
||||
- attachments: Return a queryset containing all attachments for this model
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options. Abstract ensures no database table is created."""
|
||||
def delete(self):
|
||||
"""Handle the deletion of a model instance.
|
||||
|
||||
abstract = True
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory under which attachments should be stored.
|
||||
|
||||
Note: Re-implement this for each subclass of InvenTreeAttachment
|
||||
Before deleting the model instance, delete any associated attachments.
|
||||
"""
|
||||
return 'attachments'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Provide better validation error."""
|
||||
# Either 'attachment' or 'link' must be specified!
|
||||
if not self.attachment and not self.link:
|
||||
raise ValidationError({
|
||||
'attachment': _('Missing file'),
|
||||
'link': _('Missing external link'),
|
||||
})
|
||||
|
||||
if self.attachment and self.attachment.name.lower().endswith('.svg'):
|
||||
self.attachment.file.file = self.clean_svg(self.attachment)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean_svg(self, field):
|
||||
"""Sanitize SVG file before saving."""
|
||||
cleaned = sanitize_svg(field.file.read())
|
||||
return BytesIO(bytes(cleaned, 'utf8'))
|
||||
|
||||
def __str__(self):
|
||||
"""Human name for attachment."""
|
||||
if self.attachment is not None:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return str(self.link)
|
||||
|
||||
attachment = models.FileField(
|
||||
upload_to=rename_attachment,
|
||||
verbose_name=_('Attachment'),
|
||||
help_text=_('Select file to attach'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Link'),
|
||||
help_text=_('Link to external URL'),
|
||||
)
|
||||
|
||||
comment = models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name=_('Comment'),
|
||||
help_text=_('File comment'),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User'),
|
||||
)
|
||||
|
||||
upload_date = models.DateField(
|
||||
auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')
|
||||
)
|
||||
self.attachments.all().delete()
|
||||
super().delete()
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
"""Base name/path for attachment."""
|
||||
if self.attachment:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return None
|
||||
def attachments(self):
|
||||
"""Return a queryset containing all attachments for this model."""
|
||||
return self.attachments_for_model().filter(model_id=self.pk)
|
||||
|
||||
@basename.setter
|
||||
def basename(self, fn):
|
||||
"""Function to rename the attachment file.
|
||||
@classmethod
|
||||
def check_attachment_permission(cls, permission, user) -> bool:
|
||||
"""Check if the user has permission to perform the specified action on the attachment.
|
||||
|
||||
- Filename cannot be empty
|
||||
- Filename cannot contain illegal characters
|
||||
- Filename must specify an extension
|
||||
- Filename cannot match an existing file
|
||||
The default implementation runs a permission check against *this* model class,
|
||||
but this can be overridden in the implementing class if required.
|
||||
|
||||
Arguments:
|
||||
permission: The permission to check (add / change / view / delete)
|
||||
user: The user to check against
|
||||
|
||||
Returns:
|
||||
bool: True if the user has permission, False otherwise
|
||||
"""
|
||||
fn = fn.strip()
|
||||
perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}'
|
||||
return user.has_perm(perm)
|
||||
|
||||
if len(fn) == 0:
|
||||
raise ValidationError(_('Filename must not be empty'))
|
||||
def attachments_for_model(self):
|
||||
"""Return all attachments for this model class."""
|
||||
from common.models import Attachment
|
||||
|
||||
attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir())
|
||||
old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name)
|
||||
new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve()
|
||||
model_type = self.__class__.__name__.lower()
|
||||
|
||||
# Check that there are no directory tricks going on...
|
||||
if new_file.parent != attachment_dir:
|
||||
logger.error(
|
||||
"Attempted to rename attachment outside valid directory: '%s'", new_file
|
||||
)
|
||||
raise ValidationError(_('Invalid attachment directory'))
|
||||
return Attachment.objects.filter(model_type=model_type)
|
||||
|
||||
# Ignore further checks if the filename is not actually being renamed
|
||||
if new_file == old_file:
|
||||
return
|
||||
def create_attachment(self, attachment=None, link=None, comment='', **kwargs):
|
||||
"""Create an attachment / link for this model."""
|
||||
from common.models import Attachment
|
||||
|
||||
forbidden = [
|
||||
"'",
|
||||
'"',
|
||||
'#',
|
||||
'@',
|
||||
'!',
|
||||
'&',
|
||||
'^',
|
||||
'<',
|
||||
'>',
|
||||
':',
|
||||
';',
|
||||
'/',
|
||||
'\\',
|
||||
'|',
|
||||
'?',
|
||||
'*',
|
||||
'%',
|
||||
'~',
|
||||
'`',
|
||||
]
|
||||
kwargs['attachment'] = attachment
|
||||
kwargs['link'] = link
|
||||
kwargs['comment'] = comment
|
||||
kwargs['model_type'] = self.__class__.__name__.lower()
|
||||
kwargs['model_id'] = self.pk
|
||||
|
||||
for c in forbidden:
|
||||
if c in fn:
|
||||
raise ValidationError(_(f"Filename contains illegal character '{c}'"))
|
||||
|
||||
if len(fn.split('.')) < 2:
|
||||
raise ValidationError(_('Filename missing extension'))
|
||||
|
||||
if not old_file.exists():
|
||||
logger.error(
|
||||
"Trying to rename attachment '%s' which does not exist", old_file
|
||||
)
|
||||
return
|
||||
|
||||
if new_file.exists():
|
||||
raise ValidationError(_('Attachment with this filename already exists'))
|
||||
|
||||
try:
|
||||
os.rename(old_file, new_file)
|
||||
self.attachment.name = os.path.join(self.getSubdir(), fn)
|
||||
self.save()
|
||||
except Exception:
|
||||
raise ValidationError(_('Error renaming file'))
|
||||
|
||||
def fully_qualified_url(self):
|
||||
"""Return a 'fully qualified' URL for this attachment.
|
||||
|
||||
- If the attachment is a link to an external resource, return the link
|
||||
- If the attachment is an uploaded file, return the fully qualified media URL
|
||||
"""
|
||||
if self.link:
|
||||
return self.link
|
||||
|
||||
if self.attachment:
|
||||
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
|
||||
return InvenTree.helpers_model.construct_absolute_url(media_url)
|
||||
|
||||
return ''
|
||||
Attachment.objects.create(**kwargs)
|
||||
|
||||
|
||||
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
|
||||
"""Provides an abstracted self-referencing tree model for data categories.
|
||||
|
||||
- Each Category has one parent Category, which can be blank (for a top-level Category).
|
||||
- Each Category can have zero-or-more child Categor(y/ies)
|
||||
- Each Category can have zero-or-more child Category(y/ies)
|
||||
|
||||
Attributes:
|
||||
name: brief name
|
||||
@@ -715,6 +575,9 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
|
||||
# e.g. for StockLocation, this value is 'location'
|
||||
ITEM_PARENT_KEY = None
|
||||
|
||||
# Extra fields to include in the get_path result. E.g. icon
|
||||
EXTRA_PATH_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties."""
|
||||
|
||||
@@ -920,7 +783,7 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
|
||||
on_delete=models.DO_NOTHING,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('parent'),
|
||||
verbose_name='parent',
|
||||
related_name='children',
|
||||
)
|
||||
|
||||
@@ -1008,7 +871,14 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
|
||||
name: <name>,
|
||||
}
|
||||
"""
|
||||
return [{'pk': item.pk, 'name': item.name} for item in self.path]
|
||||
return [
|
||||
{
|
||||
'pk': item.pk,
|
||||
'name': item.name,
|
||||
**{k: getattr(item, k, None) for k in self.EXTRA_PATH_FIELDS},
|
||||
}
|
||||
for item in self.path
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of a category is the full path to that category."""
|
||||
@@ -1072,6 +942,8 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
|
||||
- barcode_data : Raw data associated with an assigned barcode
|
||||
- barcode_hash : A 'hash' of the assigned barcode data used to improve matching
|
||||
|
||||
The barcode_model_type_code() classmethod must be implemented in the model class.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@@ -1102,11 +974,25 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
# By default, use the name of the class
|
||||
return cls.__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
r"""Return a 'short' code for the model type.
|
||||
|
||||
This is used to generate a efficient QR code for the model type.
|
||||
It is expected to match this pattern: [0-9A-Z $%*+-.\/:]{2}
|
||||
|
||||
Note: Due to the shape constrains (45**2=2025 different allowed codes)
|
||||
this needs to be explicitly implemented in the model class to avoid collisions.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
'barcode_model_type_code() must be implemented in the model class'
|
||||
)
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a QR code for this model instance."""
|
||||
return InvenTree.helpers.MakeBarcode(
|
||||
self.__class__.barcode_model_type(), self.pk, **kwargs
|
||||
)
|
||||
from plugin.base.barcodes.helper import generate_barcode
|
||||
|
||||
return generate_barcode(self)
|
||||
|
||||
def format_matched_response(self):
|
||||
"""Format a standard response for a matched barcode."""
|
||||
@@ -1124,7 +1010,7 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
@property
|
||||
def barcode(self):
|
||||
"""Format a minimal barcode string (e.g. for label printing)."""
|
||||
return self.format_barcode(brief=True)
|
||||
return self.format_barcode()
|
||||
|
||||
@classmethod
|
||||
def lookup_barcode(cls, barcode_hash):
|
||||
|
@@ -115,6 +115,7 @@ def canAppAccessDatabase(
|
||||
'makemessages',
|
||||
'compilemessages',
|
||||
'spectactular',
|
||||
'collectstatic',
|
||||
]
|
||||
|
||||
if not allow_shell:
|
||||
@@ -125,7 +126,7 @@ def canAppAccessDatabase(
|
||||
excluded_commands.append('test')
|
||||
|
||||
if not allow_plugins:
|
||||
excluded_commands.extend(['collectstatic', 'collectplugins'])
|
||||
excluded_commands.extend(['collectplugins'])
|
||||
|
||||
for cmd in excluded_commands:
|
||||
if cmd in sys.argv:
|
||||
|
@@ -404,6 +404,17 @@ class UserSerializer(InvenTreeModelSerializer):
|
||||
|
||||
read_only_fields = ['username']
|
||||
|
||||
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
|
||||
first_name = serializers.CharField(
|
||||
label=_('First Name'), help_text=_('First name of the user')
|
||||
)
|
||||
last_name = serializers.CharField(
|
||||
label=_('Last Name'), help_text=_('Last name of the user')
|
||||
)
|
||||
email = serializers.EmailField(
|
||||
label=_('Email'), help_text=_('Email address of the user')
|
||||
)
|
||||
|
||||
|
||||
class ExendedUserSerializer(UserSerializer):
|
||||
"""Serializer for a User with a bit more info."""
|
||||
@@ -424,6 +435,16 @@ class ExendedUserSerializer(UserSerializer):
|
||||
|
||||
read_only_fields = UserSerializer.Meta.read_only_fields + ['groups']
|
||||
|
||||
is_staff = serializers.BooleanField(
|
||||
label=_('Staff'), help_text=_('Does this user have staff permissions')
|
||||
)
|
||||
is_superuser = serializers.BooleanField(
|
||||
label=_('Superuser'), help_text=_('Is this user a superuser')
|
||||
)
|
||||
is_active = serializers.BooleanField(
|
||||
label=_('Active'), help_text=_('Is this user account active')
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Expanded validation for changing user role."""
|
||||
# Check if is_staff or is_superuser is in attrs
|
||||
@@ -509,43 +530,6 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
||||
|
||||
|
||||
class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
||||
"""Special case of an InvenTreeModelSerializer, which handles an "attachment" model.
|
||||
|
||||
The only real addition here is that we support "renaming" of the attachment file.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def attachment_fields(extra_fields=None):
|
||||
"""Default set of fields for an attachment serializer."""
|
||||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
if extra_fields:
|
||||
fields += extra_fields
|
||||
|
||||
return fields
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=False)
|
||||
|
||||
# The 'filename' field must be present in the serializer
|
||||
filename = serializers.CharField(
|
||||
label=_('Filename'), required=False, source='basename', allow_blank=False
|
||||
)
|
||||
|
||||
upload_date = serializers.DateField(read_only=True)
|
||||
|
||||
|
||||
class InvenTreeImageSerializerField(serializers.ImageField):
|
||||
"""Custom image serializer.
|
||||
|
||||
@@ -872,7 +856,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
|
||||
|
||||
remote_image = serializers.URLField(
|
||||
required=False,
|
||||
allow_blank=False,
|
||||
allow_blank=True,
|
||||
write_only=True,
|
||||
label=_('Remote Image'),
|
||||
help_text=_('URL of remote image file'),
|
||||
|
@@ -18,7 +18,6 @@ import django.conf.locale
|
||||
import django.core.exceptions
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pytz
|
||||
from dotenv import load_dotenv
|
||||
@@ -198,6 +197,7 @@ INSTALLED_APPS = [
|
||||
'stock.apps.StockConfig',
|
||||
'users.apps.UsersConfig',
|
||||
'machine.apps.MachineConfig',
|
||||
'importer.apps.ImporterConfig',
|
||||
'web',
|
||||
'generic',
|
||||
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
|
||||
@@ -296,10 +296,11 @@ ADMIN_SHELL_IMPORT_MODELS = False
|
||||
if (
|
||||
DEBUG
|
||||
and INVENTREE_ADMIN_ENABLED
|
||||
and not TESTING
|
||||
and get_boolean_setting('INVENTREE_DEBUG_SHELL', 'debug_shell', False)
|
||||
): # noqa
|
||||
try:
|
||||
import django_admin_shell
|
||||
import django_admin_shell # noqa: F401
|
||||
|
||||
INSTALLED_APPS.append('django_admin_shell')
|
||||
ADMIN_SHELL_ENABLE = True
|
||||
@@ -1208,6 +1209,9 @@ ACCOUNT_FORMS = {
|
||||
'reset_password_from_key': 'allauth.account.forms.ResetPasswordKeyForm',
|
||||
'disconnect': 'allauth.socialaccount.forms.DisconnectForm',
|
||||
}
|
||||
ALLAUTH_2FA_FORMS = {'setup': 'InvenTree.forms.CustomTOTPDeviceForm'}
|
||||
# Determine if multi-factor authentication is enabled for this server (default = True)
|
||||
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
|
||||
|
||||
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
|
||||
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
|
||||
@@ -1272,7 +1276,7 @@ PLUGIN_TESTING_SETUP = get_setting(
|
||||
) # Load plugins from setup hooks in testing?
|
||||
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
PLUGIN_RETRY = get_setting(
|
||||
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 5
|
||||
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
|
||||
) # How often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
|
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import NoReverseMatch, include, path, reverse
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
@@ -177,7 +178,9 @@ class SocialProviderListView(ListAPI):
|
||||
data = {
|
||||
'sso_enabled': InvenTree.sso.login_enabled(),
|
||||
'sso_registration': InvenTree.sso.registration_enabled(),
|
||||
'mfa_required': get_global_setting('LOGIN_ENFORCE_MFA'),
|
||||
'mfa_required': settings.MFA_ENABLED
|
||||
and get_global_setting('LOGIN_ENFORCE_MFA'),
|
||||
'mfa_enabled': settings.MFA_ENABLED,
|
||||
'providers': provider_list,
|
||||
'registration_enabled': get_global_setting('LOGIN_ENABLE_REG'),
|
||||
'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'),
|
||||
|
@@ -1,7 +1,14 @@
|
||||
"""Helper functions for Single Sign On functionality."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
@@ -75,3 +82,55 @@ def registration_enabled() -> bool:
|
||||
def auto_registration_enabled() -> bool:
|
||||
"""Return True if SSO auto-registration is enabled."""
|
||||
return str2bool(get_global_setting('LOGIN_SIGNUP_SSO_AUTO'))
|
||||
|
||||
|
||||
def ensure_sso_groups(sender, sociallogin: SocialLogin, **kwargs):
|
||||
"""Sync groups from IdP each time a SSO user logs on.
|
||||
|
||||
This event listener is registered in the apps ready method.
|
||||
"""
|
||||
if not get_global_setting('LOGIN_ENABLE_SSO_GROUP_SYNC'):
|
||||
return
|
||||
|
||||
group_key = get_global_setting('SSO_GROUP_KEY')
|
||||
group_map = json.loads(get_global_setting('SSO_GROUP_MAP'))
|
||||
# map SSO groups to InvenTree groups
|
||||
group_names = []
|
||||
for sso_group in sociallogin.account.extra_data.get(group_key, []):
|
||||
if mapped_name := group_map.get(sso_group):
|
||||
group_names.append(mapped_name)
|
||||
|
||||
# ensure user has groups
|
||||
user = sociallogin.account.user
|
||||
for group_name in group_names:
|
||||
try:
|
||||
user.groups.get(name=group_name)
|
||||
except Group.DoesNotExist:
|
||||
# user not in group yet
|
||||
try:
|
||||
group = Group.objects.get(name=group_name)
|
||||
except Group.DoesNotExist:
|
||||
logger.info(f'Creating group {group_name} as it did not exist')
|
||||
group = Group(name=group_name)
|
||||
group.save()
|
||||
logger.info(f'Adding group {group_name} to user {user}')
|
||||
user.groups.add(group)
|
||||
|
||||
# remove groups not listed by SSO if not disabled
|
||||
if get_global_setting('SSO_REMOVE_GROUPS'):
|
||||
for group in user.groups.all():
|
||||
if not group.name in group_names:
|
||||
logger.info(f'Removing group {group.name} from {user}')
|
||||
user.groups.remove(group)
|
||||
|
||||
|
||||
@receiver(post_save, sender=SocialAccount)
|
||||
def on_social_account_created(sender, instance: SocialAccount, created: bool, **kwargs):
|
||||
"""Sync SSO groups when new SocialAccount is added.
|
||||
|
||||
Since the allauth `social_account_added` signal is not sent for some reason, this
|
||||
signal is simulated using post_save signals. The issue has been reported as
|
||||
https://github.com/pennersr/django-allauth/issues/3834
|
||||
"""
|
||||
if created:
|
||||
ensure_sso_groups(None, SocialLogin(account=instance))
|
||||
|
@@ -1101,3 +1101,19 @@ a {
|
||||
.large-treeview-icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.api-icon {
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
/* Better font rendering */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.test-statistics-table-total-row {
|
||||
font-weight: bold;
|
||||
border-top-style: double;
|
||||
}
|
||||
|
@@ -60,10 +60,6 @@ function exportFormatOptions() {
|
||||
value: 'tsv',
|
||||
display_name: 'TSV',
|
||||
},
|
||||
{
|
||||
value: 'xls',
|
||||
display_name: 'XLS',
|
||||
},
|
||||
{
|
||||
value: 'xlsx',
|
||||
display_name: 'XLSX',
|
||||
|
21
src/backend/InvenTree/InvenTree/static/tabler-icons/LICENSE
Normal file
21
src/backend/InvenTree/InvenTree/static/tabler-icons/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2024 Paweł Kuna
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,6 @@ This file remains here for backwards compatibility,
|
||||
as external plugins may import status codes from this file.
|
||||
"""
|
||||
|
||||
from build.status_codes import *
|
||||
from order.status_codes import *
|
||||
from stock.status_codes import *
|
||||
from build.status_codes import * # noqa: F403
|
||||
from order.status_codes import * # noqa: F403
|
||||
from stock.status_codes import * # noqa: F403
|
||||
|
@@ -118,7 +118,7 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
|
||||
if last_success:
|
||||
threshold = datetime.now() - timedelta(days=n_days)
|
||||
|
||||
if last_success > threshold:
|
||||
if last_success.date() > threshold.date():
|
||||
logger.info(
|
||||
"Last successful run for '%s' was too recent - skipping task", task_name
|
||||
)
|
||||
@@ -256,8 +256,8 @@ def offload_task(
|
||||
_func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
log_error('InvenTree.offload_task')
|
||||
raise_warning(f"WARNING: '{taskname}' not started due to {str(exc)}")
|
||||
return False
|
||||
raise_warning(f"WARNING: '{taskname}' failed due to {str(exc)}")
|
||||
raise exc
|
||||
|
||||
# Finally, task either completed successfully or was offloaded
|
||||
return True
|
||||
|
@@ -438,9 +438,9 @@ def progress_bar(val, max_val, *args, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def get_color_theme_css(username):
|
||||
def get_color_theme_css(user):
|
||||
"""Return the custom theme .css file for the selected user."""
|
||||
user_theme_name = get_user_color_theme(username)
|
||||
user_theme_name = get_user_color_theme(user)
|
||||
# Build path to CSS sheet
|
||||
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
|
||||
|
||||
@@ -451,12 +451,18 @@ def get_color_theme_css(username):
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def get_user_color_theme(username):
|
||||
def get_user_color_theme(user):
|
||||
"""Get current user color theme."""
|
||||
from common.models import ColorTheme
|
||||
|
||||
try:
|
||||
user_theme = ColorTheme.objects.filter(user=username).get()
|
||||
if not user.is_authenticated:
|
||||
return 'default'
|
||||
except Exception:
|
||||
return 'default'
|
||||
|
||||
try:
|
||||
user_theme = ColorTheme.objects.filter(user_obj=user).get()
|
||||
user_theme_name = user_theme.name
|
||||
if not user_theme_name or not ColorTheme.is_valid_choice(user_theme):
|
||||
user_theme_name = 'default'
|
||||
|
@@ -62,6 +62,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
"""Tests for the InvenTree API."""
|
||||
|
||||
fixtures = ['location', 'category', 'part', 'stock']
|
||||
roles = ['part.view']
|
||||
token = None
|
||||
auto_login = False
|
||||
|
||||
@@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
|
||||
# Now log in!
|
||||
self.basicAuth()
|
||||
self.assignRole('part.view')
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
@@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
|
||||
|
||||
role_names = roles.keys()
|
||||
|
||||
# By default, 'view' permissions are provided
|
||||
# By default, no permissions are provided
|
||||
for rule in RuleSet.RULESET_NAMES:
|
||||
self.assertIn(rule, role_names)
|
||||
|
||||
self.assertIn('view', roles[rule])
|
||||
if roles[rule] is None:
|
||||
continue
|
||||
|
||||
if rule == 'part':
|
||||
self.assertIn('view', roles[rule])
|
||||
else:
|
||||
self.assertNotIn('view', roles[rule])
|
||||
self.assertNotIn('add', roles[rule])
|
||||
self.assertNotIn('change', roles[rule])
|
||||
self.assertNotIn('delete', roles[rule])
|
||||
@@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
|
||||
'order',
|
||||
'sales_order',
|
||||
]
|
||||
roles = ['build.view', 'part.view']
|
||||
|
||||
def test_empty(self):
|
||||
"""Test empty request."""
|
||||
@@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
|
||||
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['purchaseorder'],
|
||||
{'error': 'User does not have permission to view this model'},
|
||||
)
|
||||
|
||||
# Add permissions and try again
|
||||
self.assignRole('purchase_order.view')
|
||||
self.assignRole('sales_order.view')
|
||||
response = self.post(
|
||||
reverse('api-search'),
|
||||
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['purchaseorder']['count'], 1)
|
||||
self.assertEqual(response.data['salesorder']['count'], 0)
|
||||
|
@@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
|
||||
def test_error_exceptions(self):
|
||||
"""Test that ignored errors are not logged."""
|
||||
self.assignRole('part.view')
|
||||
|
||||
def check(excpected_nbr=0):
|
||||
# Check that errors are empty
|
||||
|
121
src/backend/InvenTree/InvenTree/test_sso.py
Normal file
121
src/backend/InvenTree/InvenTree/test_sso.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Test the sso module functionality."""
|
||||
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.test import override_settings
|
||||
from django.test.testcases import TransactionTestCase
|
||||
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree import sso
|
||||
from InvenTree.forms import RegistratonMixin
|
||||
|
||||
|
||||
class Dummy:
|
||||
"""Simulate super class of RegistratonMixin."""
|
||||
|
||||
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):
|
||||
"""Mocked implementation of the RegistrationMixin."""
|
||||
|
||||
|
||||
class TestSsoGroupSync(TransactionTestCase):
|
||||
"""Tests for the SSO group sync feature."""
|
||||
|
||||
def setUp(self):
|
||||
"""Construct sociallogin object for test cases."""
|
||||
# configure SSO
|
||||
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_GROUP_SYNC', True)
|
||||
InvenTreeSetting.set_setting('SSO_GROUP_KEY', 'groups')
|
||||
InvenTreeSetting.set_setting(
|
||||
'SSO_GROUP_MAP', '{"idp_group": "inventree_group"}'
|
||||
)
|
||||
# configure sociallogin
|
||||
extra_data = {'groups': ['idp_group']}
|
||||
self.group = Group(name='inventree_group')
|
||||
self.group.save()
|
||||
# ensure default group exists
|
||||
user = User(username='testuser', first_name='Test', last_name='User')
|
||||
user.save()
|
||||
account = SocialAccount(user=user, extra_data=extra_data)
|
||||
self.sociallogin = SocialLogin(account=account)
|
||||
|
||||
def test_group_added_to_user(self):
|
||||
"""Check that a new SSO group is added to the user."""
|
||||
user: User = self.sociallogin.account.user
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
def test_group_already_exists(self):
|
||||
"""Check that existing SSO group is not modified."""
|
||||
user: User = self.sociallogin.account.user
|
||||
user.groups.add(self.group)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
@override_settings(SSO_REMOVE_GROUPS=True)
|
||||
def test_remove_non_sso_group(self):
|
||||
"""Check that any group not provided by IDP is removed."""
|
||||
user: User = self.sociallogin.account.user
|
||||
# group must be saved to database first
|
||||
group = Group(name='local_group')
|
||||
group.save()
|
||||
user.groups.add(group)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'local_group')
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
def test_override_default_group_with_sso_group(self):
|
||||
"""The default group should be overridden if SSO groups are available."""
|
||||
user: User = self.sociallogin.account.user
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
Group(id=42, name='default_group').save()
|
||||
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
MockRegistrationMixin().save_user(None, user, None)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
def test_default_group_without_sso_group(self):
|
||||
"""If no SSO group is specified, the default group should be applied."""
|
||||
self.sociallogin.account.extra_data = {}
|
||||
user: User = self.sociallogin.account.user
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
Group(id=42, name='default_group').save()
|
||||
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
MockRegistrationMixin().save_user(None, user, None)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'default_group')
|
||||
|
||||
@override_settings(SSO_REMOVE_GROUPS=True)
|
||||
def test_remove_groups_overrides_default_group(self):
|
||||
"""If no SSO group is specified, the default group should not be added if SSO_REMOVE_GROUPS=True."""
|
||||
user: User = self.sociallogin.account.user
|
||||
self.sociallogin.account.extra_data = {}
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
Group(id=42, name='default_group').save()
|
||||
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
MockRegistrationMixin().save_user(None, user, None)
|
||||
# second ensure_sso_groups will be called by signal if social account changes
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
|
||||
def test_sso_group_created_if_not_exists(self):
|
||||
"""If the mapped group does not exist, a new group with the same name should be created."""
|
||||
self.group.delete()
|
||||
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)
|
@@ -1,6 +1,5 @@
|
||||
"""Test general functions and helpers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
@@ -789,33 +788,6 @@ class TestIncrement(TestCase):
|
||||
self.assertEqual(result, b)
|
||||
|
||||
|
||||
class TestMakeBarcode(TestCase):
|
||||
"""Tests for barcode string creation."""
|
||||
|
||||
def test_barcode_extended(self):
|
||||
"""Test creation of barcode with extended data."""
|
||||
bc = helpers.MakeBarcode(
|
||||
'part', 3, {'id': 3, 'url': 'www.google.com'}, brief=False
|
||||
)
|
||||
|
||||
self.assertIn('part', bc)
|
||||
self.assertIn('tool', bc)
|
||||
self.assertIn('"tool": "InvenTree"', bc)
|
||||
|
||||
data = json.loads(bc)
|
||||
|
||||
self.assertEqual(data['part']['id'], 3)
|
||||
self.assertEqual(data['part']['url'], 'www.google.com')
|
||||
|
||||
def test_barcode_brief(self):
|
||||
"""Test creation of simple barcode."""
|
||||
bc = helpers.MakeBarcode('stockitem', 7)
|
||||
|
||||
data = json.loads(bc)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data['stockitem'], 7)
|
||||
|
||||
|
||||
class TestDownloadFile(TestCase):
|
||||
"""Tests for DownloadFile."""
|
||||
|
||||
|
@@ -22,9 +22,6 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExport
|
||||
import InvenTree.ready
|
||||
from InvenTree.version import inventreeVersion
|
||||
|
||||
# Logger configuration
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def setup_tracing(
|
||||
endpoint: str,
|
||||
@@ -46,6 +43,9 @@ def setup_tracing(
|
||||
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
|
||||
return
|
||||
|
||||
# Logger configuration
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
if resources_input is None:
|
||||
resources_input = {}
|
||||
if auth is None:
|
||||
|
@@ -84,6 +84,9 @@ def getNewestMigrationFile(app, exclude_extension=True):
|
||||
newest_num = num
|
||||
newest_file = f
|
||||
|
||||
if not newest_file: # pragma: no cover
|
||||
return newest_file
|
||||
|
||||
if exclude_extension:
|
||||
newest_file = newest_file.replace('.py', '')
|
||||
|
||||
@@ -152,6 +155,17 @@ class UserMixin:
|
||||
"""Lougout current user."""
|
||||
self.client.logout()
|
||||
|
||||
@classmethod
|
||||
def clearRoles(cls):
|
||||
"""Remove all user roles from the registered user."""
|
||||
for ruleset in cls.group.rule_sets.all():
|
||||
ruleset.can_view = False
|
||||
ruleset.can_change = False
|
||||
ruleset.can_delete = False
|
||||
ruleset.can_add = False
|
||||
|
||||
ruleset.save()
|
||||
|
||||
@classmethod
|
||||
def assignRole(cls, role=None, assign_all: bool = False, group=None):
|
||||
"""Set the user roles for the registered user.
|
||||
@@ -190,7 +204,8 @@ class UserMixin:
|
||||
ruleset.can_add = True
|
||||
|
||||
ruleset.save()
|
||||
break
|
||||
if not assign_all:
|
||||
break
|
||||
|
||||
|
||||
class PluginMixin:
|
||||
@@ -229,7 +244,7 @@ class ExchangeRateMixin:
|
||||
|
||||
|
||||
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
|
||||
"""Testcase with user setup buildin."""
|
||||
"""Testcase with user setup build in."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -267,7 +282,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
|
||||
) # pragma: no cover
|
||||
|
||||
if verbose:
|
||||
if verbose or n >= value:
|
||||
msg = '\r\n%s' % json.dumps(
|
||||
context.captured_queries, indent=4
|
||||
) # pragma: no cover
|
||||
@@ -296,7 +311,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
if hasattr(response, 'content'):
|
||||
print('content:', response.content)
|
||||
|
||||
self.assertEqual(expected_code, response.status_code)
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
def getActions(self, url):
|
||||
"""Return a dict of the 'actions' available at a given endpoint.
|
||||
@@ -314,17 +329,17 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
expected_code = kwargs.pop('expected_code', None)
|
||||
|
||||
kwargs['format'] = kwargs.get('format', 'json')
|
||||
|
||||
max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT)
|
||||
max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME)
|
||||
expected_code = kwargs.pop('expected_code', None)
|
||||
max_queries = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT)
|
||||
max_query_time = kwargs.pop('max_query_time', self.MAX_QUERY_TIME)
|
||||
|
||||
t1 = time.time()
|
||||
|
||||
with self.assertNumQueriesLessThan(max_queries, url=url):
|
||||
response = method(url, data, **kwargs)
|
||||
|
||||
t2 = time.time()
|
||||
dt = t2 - t1
|
||||
|
||||
@@ -401,12 +416,12 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
# Extract filename
|
||||
disposition = response.headers['Content-Disposition']
|
||||
|
||||
result = re.search(r'attachment; filename="([\w.]+)"', disposition)
|
||||
result = re.search(r'attachment; filename="([\w\d\-.]+)"', disposition)
|
||||
|
||||
fn = result.groups()[0]
|
||||
|
||||
if expected_fn is not None:
|
||||
self.assertEqual(expected_fn, fn)
|
||||
self.assertRegex(fn, expected_fn)
|
||||
|
||||
if decode:
|
||||
# Decode data and return as StringIO file object
|
||||
|
@@ -21,6 +21,7 @@ from sesame.views import LoginView
|
||||
import build.api
|
||||
import common.api
|
||||
import company.api
|
||||
import importer.api
|
||||
import machine.api
|
||||
import order.api
|
||||
import part.api
|
||||
@@ -34,6 +35,7 @@ from company.urls import company_urls, manufacturer_part_urls, supplier_part_url
|
||||
from order.urls import order_urls
|
||||
from part.urls import part_urls
|
||||
from plugin.urls import get_plugin_urls
|
||||
from stock.api import test_statistics_api_urls
|
||||
from stock.urls import stock_urls
|
||||
from web.urls import api_urls as web_api_urls
|
||||
from web.urls import urlpatterns as platform_urls
|
||||
@@ -80,11 +82,19 @@ admin.site.site_header = 'InvenTree Admin'
|
||||
|
||||
apipatterns = [
|
||||
# Global search
|
||||
path('admin/', include(common.api.admin_api_urls)),
|
||||
path('bom/', include(part.api.bom_api_urls)),
|
||||
path('build/', include(build.api.build_api_urls)),
|
||||
path('company/', include(company.api.company_api_urls)),
|
||||
path('importer/', include(importer.api.importer_api_urls)),
|
||||
path('label/', include(report.api.label_api_urls)),
|
||||
path('machine/', include(machine.api.machine_api_urls)),
|
||||
path('order/', include(order.api.order_api_urls)),
|
||||
path('part/', include(part.api.part_api_urls)),
|
||||
path('report/', include(report.api.report_api_urls)),
|
||||
path('search/', APISearchView.as_view(), name='api-search'),
|
||||
path('settings/', include(common.api.settings_api_urls)),
|
||||
path('part/', include(part.api.part_api_urls)),
|
||||
path('bom/', include(part.api.bom_api_urls)),
|
||||
path('company/', include(company.api.company_api_urls)),
|
||||
path('stock/', include(stock.api.stock_api_urls)),
|
||||
path(
|
||||
'generate/',
|
||||
include([
|
||||
@@ -100,14 +110,8 @@ apipatterns = [
|
||||
),
|
||||
]),
|
||||
),
|
||||
path('stock/', include(stock.api.stock_api_urls)),
|
||||
path('build/', include(build.api.build_api_urls)),
|
||||
path('order/', include(order.api.order_api_urls)),
|
||||
path('label/', include(report.api.label_api_urls)),
|
||||
path('report/', include(report.api.report_api_urls)),
|
||||
path('machine/', include(machine.api.machine_api_urls)),
|
||||
path('test-statistics/', include(test_statistics_api_urls)),
|
||||
path('user/', include(users.api.user_urls)),
|
||||
path('admin/', include(common.api.admin_api_urls)),
|
||||
path('web/', include(web_api_urls)),
|
||||
# Plugin endpoints
|
||||
path('', include(plugin.api.plugin_api_urls)),
|
||||
|
@@ -13,6 +13,7 @@ from jinja2 import Template
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import InvenTree.conversion
|
||||
from common.settings import get_global_setting
|
||||
|
||||
|
||||
def validate_physical_units(unit):
|
||||
@@ -63,14 +64,10 @@ class AllowedURLValidator(validators.URLValidator):
|
||||
|
||||
def __call__(self, value):
|
||||
"""Validate the URL."""
|
||||
import common.models
|
||||
|
||||
self.schemes = allowable_url_schemes()
|
||||
|
||||
# Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix)
|
||||
strict_urls = common.models.InvenTreeSetting.get_setting(
|
||||
'INVENTREE_STRICT_URLS', True, cache=False
|
||||
)
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||
|
||||
if not strict_urls:
|
||||
# Allow URLs which do not have a provided schema
|
||||
|
@@ -3,6 +3,7 @@
|
||||
Provides information on the current InvenTree version
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
@@ -14,20 +15,29 @@ from datetime import timedelta as td
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
from dulwich.repo import NotGitRepository, Repo
|
||||
|
||||
from common.settings import get_global_setting
|
||||
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '0.16.0 dev'
|
||||
INVENTREE_SW_VERSION = '0.17.0 dev'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
# Discover git
|
||||
try:
|
||||
from dulwich.repo import Repo
|
||||
|
||||
main_repo = Repo(pathlib.Path(__file__).parent.parent.parent.parent.parent)
|
||||
main_commit = main_repo[main_repo.head()]
|
||||
except (NotGitRepository, FileNotFoundError):
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
logger.warning(
|
||||
'Warning: Dulwich module not found, git information will not be available.'
|
||||
)
|
||||
main_repo = None
|
||||
main_commit = None
|
||||
except Exception:
|
||||
main_repo = None
|
||||
main_commit = None
|
||||
|
||||
|
||||
@@ -53,13 +63,17 @@ def checkMinPythonVersion():
|
||||
|
||||
def inventreeInstanceName():
|
||||
"""Returns the InstanceName settings for the current database."""
|
||||
return get_global_setting('INVENTREE_INSTANCE', '')
|
||||
from common.settings import get_global_setting
|
||||
|
||||
return get_global_setting('INVENTREE_INSTANCE')
|
||||
|
||||
|
||||
def inventreeInstanceTitle():
|
||||
"""Returns the InstanceTitle for the current database."""
|
||||
if get_global_setting('INVENTREE_INSTANCE_TITLE', False):
|
||||
return get_global_setting('INVENTREE_INSTANCE', 'InvenTree')
|
||||
from common.settings import get_global_setting
|
||||
|
||||
if get_global_setting('INVENTREE_INSTANCE_TITLE'):
|
||||
return get_global_setting('INVENTREE_INSTANCE')
|
||||
|
||||
return 'InvenTree'
|
||||
|
||||
@@ -103,7 +117,7 @@ def inventreeDocUrl():
|
||||
|
||||
def inventreeAppUrl():
|
||||
"""Return URL for InvenTree app site."""
|
||||
return f'https://docs.inventree.org/app/'
|
||||
return 'https://docs.inventree.org/app/'
|
||||
|
||||
|
||||
def inventreeCreditsUrl():
|
||||
@@ -121,6 +135,8 @@ def isInvenTreeUpToDate():
|
||||
|
||||
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
|
||||
"""
|
||||
from common.settings import get_global_setting
|
||||
|
||||
latest = get_global_setting(
|
||||
'_INVENTREE_LATEST_VERSION', backup_value=None, create=False
|
||||
)
|
||||
|
@@ -614,7 +614,7 @@ class AppearanceSelectView(RedirectView):
|
||||
"""Get current user color theme."""
|
||||
try:
|
||||
user_theme = common_models.ColorTheme.objects.filter(
|
||||
user=self.request.user
|
||||
user_obj=self.request.user
|
||||
).get()
|
||||
except common_models.ColorTheme.DoesNotExist:
|
||||
user_theme = None
|
||||
@@ -631,7 +631,7 @@ class AppearanceSelectView(RedirectView):
|
||||
# Create theme entry if user did not select one yet
|
||||
if not user_theme:
|
||||
user_theme = common_models.ColorTheme()
|
||||
user_theme.user = request.user
|
||||
user_theme.user_obj = request.user
|
||||
|
||||
if theme:
|
||||
try:
|
||||
|
@@ -8,19 +8,20 @@ from django.contrib.auth.models import User
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from importer.mixins import DataExportViewMixin
|
||||
|
||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||
from generic.states.api import StatusView
|
||||
from InvenTree.helpers import str2bool, isNull, DownloadFile
|
||||
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, BuildOrderAttachment
|
||||
from build.models import Build, BuildLine, BuildItem
|
||||
import part.models
|
||||
from users.models import Owner
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
|
||||
@@ -125,7 +126,7 @@ class BuildMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildList(APIDownloadMixin, BuildMixin, ListCreateAPI):
|
||||
class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of Build objects.
|
||||
|
||||
- GET: Return list of objects (with filters)
|
||||
@@ -176,15 +177,6 @@ class BuildList(APIDownloadMixin, BuildMixin, ListCreateAPI):
|
||||
|
||||
return queryset
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
"""Download the queryset data as a file."""
|
||||
dataset = build.admin.BuildResource().export(queryset=queryset)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
filename = f"InvenTree_BuildOrders.{export_format}"
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom query filtering for the BuildList endpoint."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
@@ -351,7 +343,7 @@ class BuildLineEndpoint:
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildLineList(BuildLineEndpoint, ListCreateAPI):
|
||||
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildLine objects"""
|
||||
|
||||
filterset_class = BuildLineFilter
|
||||
@@ -367,6 +359,8 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
|
||||
'unit_quantity',
|
||||
'available_stock',
|
||||
'trackable',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
@@ -376,6 +370,8 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
|
||||
'consumable': 'bom_item__consumable',
|
||||
'optional': 'bom_item__optional',
|
||||
'trackable': 'bom_item__sub_part__trackable',
|
||||
'allow_variants': 'bom_item__allow_variants',
|
||||
'inherited': 'bom_item__inherited',
|
||||
}
|
||||
|
||||
search_fields = [
|
||||
@@ -474,9 +470,19 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for marking a build as finished (completed)."""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildCompleteSerializer
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for 'automatically' allocating stock against a build order.
|
||||
@@ -488,7 +494,6 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildAutoAllocationSerializer
|
||||
|
||||
|
||||
@@ -504,10 +509,22 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildAllocationSerializer
|
||||
|
||||
|
||||
class BuildIssue(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for issuing a BuildOrder."""
|
||||
|
||||
queryset = Build.objects.all()
|
||||
serializer_class = build.serializers.BuildIssueSerializer
|
||||
|
||||
|
||||
class BuildHold(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for placing a BuildOrder on hold."""
|
||||
|
||||
queryset = Build.objects.all()
|
||||
serializer_class = build.serializers.BuildHoldSerializer
|
||||
|
||||
class BuildCancel(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for cancelling a BuildOrder."""
|
||||
|
||||
@@ -553,15 +570,17 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
return queryset.filter(install_into=None)
|
||||
|
||||
|
||||
class BuildItemList(ListCreateAPI):
|
||||
class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildItem objects.
|
||||
|
||||
- GET: Return list of objects
|
||||
- POST: Create a new BuildItem object
|
||||
"""
|
||||
|
||||
queryset = BuildItem.objects.all()
|
||||
serializer_class = build.serializers.BuildItemSerializer
|
||||
filterset_class = BuildItemFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Returns a BuildItemSerializer instance based on the request."""
|
||||
@@ -577,16 +596,25 @@ class BuildItemList(ListCreateAPI):
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method, to allow filtering by stock_item.part."""
|
||||
queryset = BuildItem.objects.all()
|
||||
"""Override the queryset method, to perform custom prefetch."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
queryset = queryset.select_related(
|
||||
'build_line',
|
||||
'build_line__build',
|
||||
'build_line__bom_item',
|
||||
'build_line__bom_item__part',
|
||||
'build_line__bom_item__sub_part',
|
||||
'install_into',
|
||||
'stock_item',
|
||||
'stock_item__location',
|
||||
'stock_item__part',
|
||||
'stock_item__supplier_part__part',
|
||||
'stock_item__supplier_part__supplier',
|
||||
'stock_item__supplier_part__manufacturer_part',
|
||||
'stock_item__supplier_part__manufacturer_part__manufacturer',
|
||||
).prefetch_related(
|
||||
'stock_item__location__tags',
|
||||
)
|
||||
|
||||
return queryset
|
||||
@@ -609,37 +637,30 @@ class BuildItemList(ListCreateAPI):
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
ordering_fields = [
|
||||
'part',
|
||||
'sku',
|
||||
'quantity',
|
||||
'location',
|
||||
'reference',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'part': 'stock_item__part__name',
|
||||
'sku': 'stock_item__supplier_part__SKU',
|
||||
'location': 'stock_item__location__name',
|
||||
'reference': 'build_line__bom_item__reference',
|
||||
}
|
||||
|
||||
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
||||
|
||||
queryset = BuildOrderAttachment.objects.all()
|
||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||
|
||||
filterset_fields = [
|
||||
'build',
|
||||
search_fields = [
|
||||
'stock_item__supplier_part__SKU',
|
||||
'stock_item__part__name',
|
||||
'build_line__bom_item__reference',
|
||||
]
|
||||
|
||||
|
||||
class BuildAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for a BuildOrderAttachment object."""
|
||||
|
||||
queryset = BuildOrderAttachment.objects.all()
|
||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||
|
||||
|
||||
build_api_urls = [
|
||||
|
||||
# Attachments
|
||||
path('attachment/', include([
|
||||
path('<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
|
||||
path('', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||
])),
|
||||
|
||||
# Build lines
|
||||
path('line/', include([
|
||||
path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
|
||||
@@ -663,6 +684,8 @@ build_api_urls = [
|
||||
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'),
|
||||
|
4
src/backend/InvenTree/build/migrations/0021_auto_20201020_0908_squashed_0026_auto_20201023_1228.py
4
src/backend/InvenTree/build/migrations/0021_auto_20201020_0908_squashed_0026_auto_20201023_1228.py
@@ -5,6 +5,8 @@ from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import mptt.fields
|
||||
|
||||
from build.status_codes import BuildStatus
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -40,7 +42,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
name='status',
|
||||
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Production'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'),
|
||||
field=models.PositiveIntegerField(choices=BuildStatus.items(), default=BuildStatus.PENDING.value, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
|
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
|
||||
name='BuildOrderAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
|
||||
('attachment', models.FileField(help_text='Select file to attach', upload_to='attachments')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100)),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True)),
|
||||
('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='build.Build')),
|
||||
|
@@ -65,7 +65,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(help_text='Select file to attach', upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
|
@@ -20,6 +20,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='buildorderattachment',
|
||||
name='attachment',
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'),
|
||||
field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment'),
|
||||
),
|
||||
]
|
||||
|
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.2.12 on 2024-06-09 09:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0050_auto_20240508_0138'),
|
||||
('common', '0026_auto_20240608_1238'),
|
||||
('company', '0069_company_active'),
|
||||
('order', '0099_alter_salesorder_status'),
|
||||
('part', '0123_parttesttemplate_choices'),
|
||||
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='BuildOrderAttachment',
|
||||
),
|
||||
]
|
@@ -2,7 +2,6 @@
|
||||
|
||||
import decimal
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from django.conf import settings
|
||||
|
||||
@@ -26,6 +25,7 @@ from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from stock.status_codes import StockStatus, StockHistoryCode
|
||||
|
||||
from build.validators import generate_next_build_reference, validate_build_order_reference
|
||||
from generic.states import StateTransitionMixin
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
@@ -50,11 +50,13 @@ logger = logging.getLogger('inventree')
|
||||
|
||||
class Build(
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
InvenTree.models.ReferenceIndexingMixin,
|
||||
StateTransitionMixin,
|
||||
MPTTModel):
|
||||
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||
|
||||
@@ -103,7 +105,7 @@ class Build(
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def api_defaults(cls, request):
|
||||
def api_defaults(cls, request=None):
|
||||
"""Return default values for this model when issuing an API OPTIONS request."""
|
||||
defaults = {
|
||||
'reference': generate_next_build_reference(),
|
||||
@@ -114,13 +116,42 @@ class Build(
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return "BO"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom save method for the BuildOrder model"""
|
||||
self.validate_reference_field(self.reference)
|
||||
self.reference_int = self.rebuild_reference_field(self.reference)
|
||||
|
||||
# Check part when initially creating the build order
|
||||
if not self.pk or self.has_field_changed('part'):
|
||||
if get_global_setting('BUILDORDER_REQUIRE_VALID_BOM'):
|
||||
# Check that the BOM is valid
|
||||
if not self.part.is_bom_valid():
|
||||
raise ValidationError({
|
||||
'part': _('Assembly BOM has not been validated')
|
||||
})
|
||||
|
||||
if get_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART'):
|
||||
# Check that the part is active
|
||||
if not self.part.active:
|
||||
raise ValidationError({
|
||||
'part': _('Build order cannot be created for an inactive part')
|
||||
})
|
||||
|
||||
if get_global_setting('BUILDORDER_REQUIRE_LOCKED_PART'):
|
||||
# Check that the part is locked
|
||||
if not self.part.locked:
|
||||
raise ValidationError({
|
||||
'part': _('Build order cannot be created for an unlocked part')
|
||||
})
|
||||
|
||||
# On first save (i.e. creation), run some extra checks
|
||||
if self.pk is None:
|
||||
|
||||
# Set the destination location (if not specified)
|
||||
if not self.destination:
|
||||
self.destination = self.part.get_default_location()
|
||||
@@ -364,9 +395,9 @@ class Build(
|
||||
def sub_builds(self, cascade=True):
|
||||
"""Return all Build Order objects under this one."""
|
||||
if cascade:
|
||||
return Build.objects.filter(parent=self.pk)
|
||||
descendants = self.get_descendants(include_self=True)
|
||||
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
|
||||
return self.get_descendants(include_self=False)
|
||||
else:
|
||||
return self.get_children()
|
||||
|
||||
def sub_build_count(self, cascade=True):
|
||||
"""Return the number of sub builds under this one.
|
||||
@@ -376,6 +407,11 @@ class Build(
|
||||
"""
|
||||
return self.sub_builds(cascade=cascade).count()
|
||||
|
||||
@property
|
||||
def has_open_child_builds(self):
|
||||
"""Return True if this build order has any open child builds."""
|
||||
return self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists()
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Returns true if this build is "overdue".
|
||||
@@ -544,6 +580,13 @@ class Build(
|
||||
- Completed count must meet the required quantity
|
||||
- Untracked parts must be allocated
|
||||
"""
|
||||
|
||||
if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds:
|
||||
return False
|
||||
|
||||
if self.status != BuildStatus.PRODUCTION.value:
|
||||
return False
|
||||
|
||||
if self.incomplete_count > 0:
|
||||
return False
|
||||
|
||||
@@ -572,8 +615,22 @@ class Build(
|
||||
def complete_build(self, user, trim_allocated_stock=False):
|
||||
"""Mark this build as complete."""
|
||||
|
||||
return self.handle_transition(
|
||||
self.status, BuildStatus.COMPLETE.value, self, self._action_complete, user=user, trim_allocated_stock=trim_allocated_stock
|
||||
)
|
||||
|
||||
def _action_complete(self, *args, **kwargs):
|
||||
"""Action to be taken when a build is completed."""
|
||||
|
||||
import build.tasks
|
||||
|
||||
trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
|
||||
user = kwargs.pop('user', None)
|
||||
|
||||
# Prevent completion if there are open child builds
|
||||
if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds:
|
||||
return
|
||||
|
||||
if self.incomplete_count > 0:
|
||||
return
|
||||
|
||||
@@ -635,6 +692,59 @@ class Build(
|
||||
target_exclude=[user],
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def issue_build(self):
|
||||
"""Mark the Build as IN PRODUCTION.
|
||||
|
||||
Args:
|
||||
user: The user who is issuing the build
|
||||
"""
|
||||
return self.handle_transition(
|
||||
self.status, BuildStatus.PENDING.value, self, self._action_issue
|
||||
)
|
||||
|
||||
@property
|
||||
def can_issue(self):
|
||||
"""Returns True if this BuildOrder can be issued."""
|
||||
return self.status in [
|
||||
BuildStatus.PENDING.value,
|
||||
BuildStatus.ON_HOLD.value,
|
||||
]
|
||||
|
||||
def _action_issue(self, *args, **kwargs):
|
||||
"""Perform the action to mark this order as PRODUCTION."""
|
||||
|
||||
if self.can_issue:
|
||||
self.status = BuildStatus.PRODUCTION.value
|
||||
self.save()
|
||||
|
||||
trigger_event('build.issued', id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def hold_build(self):
|
||||
"""Mark the Build as ON HOLD."""
|
||||
|
||||
return self.handle_transition(
|
||||
self.status, BuildStatus.ON_HOLD.value, self, self._action_hold
|
||||
)
|
||||
|
||||
@property
|
||||
def can_hold(self):
|
||||
"""Returns True if this BuildOrder can be placed on hold"""
|
||||
return self.status in [
|
||||
BuildStatus.PENDING.value,
|
||||
BuildStatus.PRODUCTION.value,
|
||||
]
|
||||
|
||||
def _action_hold(self, *args, **kwargs):
|
||||
"""Action to be taken when a build is placed on hold."""
|
||||
|
||||
if self.can_hold:
|
||||
self.status = BuildStatus.ON_HOLD.value
|
||||
self.save()
|
||||
|
||||
trigger_event('build.hold', id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_build(self, user, **kwargs):
|
||||
"""Mark the Build as CANCELLED.
|
||||
@@ -644,8 +754,17 @@ class Build(
|
||||
- Save the Build object
|
||||
"""
|
||||
|
||||
return self.handle_transition(
|
||||
self.status, BuildStatus.CANCELLED.value, self, self._action_cancel, user=user, **kwargs
|
||||
)
|
||||
|
||||
def _action_cancel(self, *args, **kwargs):
|
||||
"""Action to be taken when a build is cancelled."""
|
||||
|
||||
import build.tasks
|
||||
|
||||
user = kwargs.pop('user', None)
|
||||
|
||||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
||||
|
||||
@@ -867,7 +986,10 @@ class Build(
|
||||
items_to_save = []
|
||||
items_to_delete = []
|
||||
|
||||
for build_line in self.untracked_line_items:
|
||||
lines = self.untracked_line_items
|
||||
lines = lines.prefetch_related('allocations')
|
||||
|
||||
for build_line in lines:
|
||||
|
||||
reduce_by = build_line.allocated_quantity() - build_line.quantity
|
||||
|
||||
@@ -1246,7 +1368,7 @@ class Build(
|
||||
@property
|
||||
def is_complete(self):
|
||||
"""Returns True if the build status is COMPLETE."""
|
||||
return self.status == BuildStatus.COMPLETE
|
||||
return self.status == BuildStatus.COMPLETE.value
|
||||
|
||||
@transaction.atomic
|
||||
def create_build_line_items(self, prevent_duplicates=True):
|
||||
@@ -1322,16 +1444,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
instance.update_build_line_items()
|
||||
|
||||
|
||||
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a BuildOrder object."""
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the media file subdirectory for storing BuildOrder attachments"""
|
||||
return os.path.join('bo_files', str(self.build.id))
|
||||
|
||||
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
|
||||
|
||||
|
||||
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
|
||||
"""A BuildLine object links a BOMItem to a Build.
|
||||
|
||||
|
@@ -1,5 +1,7 @@
|
||||
"""JSON serializers for Build API."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -13,8 +15,7 @@ from django.db.models.functions import Coalesce
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import UserSerializer
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
|
||||
@@ -22,18 +23,22 @@ from stock.status_codes import StockStatus
|
||||
|
||||
from stock.generators import generate_batch_code
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
from stock.serializers import StockItemSerializerBrief, LocationBriefSerializer
|
||||
|
||||
import common.models
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
from common.settings import get_global_setting
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
import company.serializers
|
||||
import part.filters
|
||||
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
|
||||
import part.serializers as part_serializers
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
|
||||
from .models import Build, BuildLine, BuildItem
|
||||
from .status_codes import BuildStatus
|
||||
|
||||
|
||||
class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializes a Build object."""
|
||||
|
||||
class Meta:
|
||||
@@ -51,8 +56,10 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
'destination',
|
||||
'parent',
|
||||
'part',
|
||||
'part_name',
|
||||
'part_detail',
|
||||
'project_code',
|
||||
'project_code_label',
|
||||
'project_code_detail',
|
||||
'overdue',
|
||||
'reference',
|
||||
@@ -83,7 +90,9 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
part_detail = part_serializers.PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name'))
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
@@ -95,6 +104,8 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
|
||||
barcode_hash = serializers.CharField(read_only=True)
|
||||
|
||||
project_code_label = serializers.CharField(source='project_code.code', read_only=True, label=_('Project Code Label'))
|
||||
|
||||
project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True)
|
||||
|
||||
@staticmethod
|
||||
@@ -125,7 +136,7 @@ class BuildSerializer(NotesFieldMixin, InvenTreeModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
@@ -202,7 +213,7 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Enter quantity for build output'),
|
||||
@@ -249,7 +260,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Enter quantity for build output'),
|
||||
@@ -588,6 +599,33 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class BuildIssueSerializer(serializers.Serializer):
|
||||
"""DRF serializer for issuing a build order."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
fields = []
|
||||
|
||||
def save(self):
|
||||
"""Issue the specified build order"""
|
||||
build = self.context['build']
|
||||
build.issue_build()
|
||||
|
||||
|
||||
class BuildHoldSerializer(serializers.Serializer):
|
||||
"""DRF serializer for placing a BuildOrder on hold."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
fields = []
|
||||
|
||||
def save(self):
|
||||
"""Place the specified build on hold."""
|
||||
build = self.context['build']
|
||||
|
||||
build.hold_build()
|
||||
|
||||
|
||||
class BuildCancelSerializer(serializers.Serializer):
|
||||
"""DRF serializer class for cancelling an active BuildOrder"""
|
||||
|
||||
@@ -728,6 +766,12 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""Perform validation of this serializer prior to saving"""
|
||||
build = self.context['build']
|
||||
|
||||
if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and build.has_open_child_builds:
|
||||
raise ValidationError(_("Build order has open child build orders"))
|
||||
|
||||
if build.status != BuildStatus.PRODUCTION.value:
|
||||
raise ValidationError(_("Build order must be in production state"))
|
||||
|
||||
if build.incomplete_count > 0:
|
||||
raise ValidationError(_("Build order has incomplete outputs"))
|
||||
|
||||
@@ -857,7 +901,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
min_value=Decimal(0),
|
||||
required=True
|
||||
)
|
||||
|
||||
@@ -1050,8 +1094,26 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
raise ValidationError(_("Failed to start auto-allocation task"))
|
||||
|
||||
|
||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
"""Serializes a BuildItem object."""
|
||||
class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializes a BuildItem object, which is an allocation of a stock item against a build order."""
|
||||
|
||||
# These fields are only used for data export
|
||||
export_only_fields = [
|
||||
'bom_part_id',
|
||||
'bom_part_name',
|
||||
'build_reference',
|
||||
'sku',
|
||||
'mpn',
|
||||
'location_name',
|
||||
'part_id',
|
||||
'part_name',
|
||||
'part_ipn',
|
||||
'part_description',
|
||||
'available_quantity',
|
||||
'item_batch_code',
|
||||
'item_serial',
|
||||
'item_packaging',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
@@ -1063,23 +1125,33 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
'install_into',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'location',
|
||||
|
||||
# Detail fields, can be included or excluded
|
||||
'build_detail',
|
||||
'location_detail',
|
||||
'part_detail',
|
||||
'stock_item_detail',
|
||||
'build_detail',
|
||||
'supplier_part_detail',
|
||||
|
||||
# The following fields are only used for data export
|
||||
'bom_reference',
|
||||
'bom_part_id',
|
||||
'bom_part_name',
|
||||
'build_reference',
|
||||
'location_name',
|
||||
'mpn',
|
||||
'sku',
|
||||
'part_id',
|
||||
'part_name',
|
||||
'part_ipn',
|
||||
'part_description',
|
||||
'available_quantity',
|
||||
'item_batch_code',
|
||||
'item_serial_number',
|
||||
'item_packaging',
|
||||
]
|
||||
|
||||
# Annotated fields
|
||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
# Extra (optional) detail fields
|
||||
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
||||
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Determine which extra details fields should be included"""
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
@@ -1090,21 +1162,65 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
if not location_detail:
|
||||
self.fields.pop('location_detail')
|
||||
self.fields.pop('location_detail', None)
|
||||
|
||||
if not stock_detail:
|
||||
self.fields.pop('stock_item_detail')
|
||||
self.fields.pop('stock_item_detail', None)
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail')
|
||||
self.fields.pop('build_detail', None)
|
||||
|
||||
# Export-only fields
|
||||
sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True)
|
||||
mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True)
|
||||
location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True)
|
||||
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
|
||||
bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True)
|
||||
item_packaging = serializers.CharField(source='stock_item.packaging', label=_('Packaging'), read_only=True)
|
||||
|
||||
# Part detail fields
|
||||
part_id = serializers.PrimaryKeyRelatedField(source='stock_item.part', label=_('Part ID'), many=False, read_only=True)
|
||||
part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True)
|
||||
part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True)
|
||||
part_description = serializers.CharField(source='stock_item.part.description', label=_('Part Description'), read_only=True)
|
||||
|
||||
# BOM Item Part ID (it may be different to the allocated part)
|
||||
bom_part_id = serializers.PrimaryKeyRelatedField(source='build_line.bom_item.sub_part', label=_('BOM Part ID'), many=False, read_only=True)
|
||||
bom_part_name = serializers.CharField(source='build_line.bom_item.sub_part.name', label=_('BOM Part Name'), read_only=True)
|
||||
|
||||
item_batch_code = serializers.CharField(source='stock_item.batch', label=_('Batch Code'), read_only=True)
|
||||
item_serial_number = serializers.CharField(source='stock_item.serial', label=_('Serial Number'), read_only=True)
|
||||
|
||||
# Annotated fields
|
||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
# Extra (optional) detail fields
|
||||
part_detail = part_serializers.PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True)
|
||||
location_detail = LocationBriefSerializer(source='stock_item.location', read_only=True)
|
||||
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
|
||||
supplier_part_detail = company.serializers.SupplierPartSerializer(source='stock_item.supplier_part', many=False, read_only=True, brief=True)
|
||||
|
||||
quantity = InvenTreeDecimalField(label=_('Allocated Quantity'))
|
||||
available_quantity = InvenTreeDecimalField(source='stock_item.quantity', read_only=True, label=_('Available Quantity'))
|
||||
|
||||
|
||||
class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for a BuildItem object."""
|
||||
|
||||
export_exclude_fields = [
|
||||
'allocations',
|
||||
]
|
||||
|
||||
export_only_fields = [
|
||||
'part_description',
|
||||
'part_category_name',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
|
||||
@@ -1118,6 +1234,20 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
'quantity',
|
||||
'allocations',
|
||||
|
||||
# BOM item detail fields
|
||||
'reference',
|
||||
'consumable',
|
||||
'optional',
|
||||
'trackable',
|
||||
'inherited',
|
||||
'allow_variants',
|
||||
|
||||
# Part detail fields
|
||||
'part',
|
||||
'part_name',
|
||||
'part_IPN',
|
||||
'part_category_id',
|
||||
|
||||
# Annotated fields
|
||||
'allocated',
|
||||
'in_production',
|
||||
@@ -1127,6 +1257,10 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
'available_variant_stock',
|
||||
'total_available_stock',
|
||||
'external_stock',
|
||||
|
||||
# Extra fields only for data export
|
||||
'part_description',
|
||||
'part_category_name',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
@@ -1135,13 +1269,30 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
'allocations',
|
||||
]
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
# Part info fields
|
||||
part = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part', label=_('Part'), many=False, read_only=True)
|
||||
part_name = serializers.CharField(source='bom_item.sub_part.name', label=_('Part Name'), read_only=True)
|
||||
part_IPN = serializers.CharField(source='bom_item.sub_part.IPN', label=_('Part IPN'), read_only=True)
|
||||
|
||||
part_description = serializers.CharField(source='bom_item.sub_part.description', label=_('Part Description'), read_only=True)
|
||||
part_category_id = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part.category', label=_('Part Category ID'), read_only=True)
|
||||
part_category_name = serializers.CharField(source='bom_item.sub_part.category.name', label=_('Part Category Name'), read_only=True)
|
||||
|
||||
# BOM item info fields
|
||||
reference = serializers.CharField(source='bom_item.reference', label=_('Reference'), read_only=True)
|
||||
consumable = serializers.BooleanField(source='bom_item.consumable', label=_('Consumable'), read_only=True)
|
||||
optional = serializers.BooleanField(source='bom_item.optional', label=_('Optional'), read_only=True)
|
||||
trackable = serializers.BooleanField(source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True)
|
||||
inherited = serializers.BooleanField(source='bom_item.inherited', label=_('Inherited'), read_only=True)
|
||||
allow_variants = serializers.BooleanField(source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True)
|
||||
|
||||
quantity = serializers.FloatField(label=_('Quantity'))
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
|
||||
|
||||
# Foreign key fields
|
||||
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
|
||||
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||
bom_item_detail = part_serializers.BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
|
||||
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||
allocations = BuildItemSerializer(many=True, read_only=True)
|
||||
|
||||
# Annotated (calculated) fields
|
||||
@@ -1165,10 +1316,10 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
read_only=True
|
||||
)
|
||||
|
||||
available_substitute_stock = serializers.FloatField(read_only=True)
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
total_available_stock = serializers.FloatField(read_only=True)
|
||||
external_stock = serializers.FloatField(read_only=True)
|
||||
available_substitute_stock = serializers.FloatField(read_only=True, label=_('Available Substitute Stock'))
|
||||
available_variant_stock = serializers.FloatField(read_only=True, label=_('Available Variant Stock'))
|
||||
total_available_stock = serializers.FloatField(read_only=True, label=_('Total Available Stock'))
|
||||
external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset, build=None):
|
||||
@@ -1187,16 +1338,20 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
|
||||
"""
|
||||
queryset = queryset.select_related(
|
||||
'build', 'bom_item',
|
||||
'build',
|
||||
'bom_item',
|
||||
'bom_item__part',
|
||||
'bom_item__part__pricing_data',
|
||||
'bom_item__sub_part',
|
||||
'bom_item__sub_part__pricing_data',
|
||||
)
|
||||
|
||||
# Pre-fetch related fields
|
||||
queryset = queryset.prefetch_related(
|
||||
'bom_item__sub_part',
|
||||
'bom_item__sub_part__tags',
|
||||
'bom_item__sub_part__stock_items',
|
||||
'bom_item__sub_part__stock_items__allocations',
|
||||
'bom_item__sub_part__stock_items__sales_order_allocations',
|
||||
'bom_item__sub_part__tags',
|
||||
|
||||
'bom_item__substitutes',
|
||||
'bom_item__substitutes__part__stock_items',
|
||||
@@ -1208,6 +1363,11 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
'allocations__stock_item__part',
|
||||
'allocations__stock_item__location',
|
||||
'allocations__stock_item__location__tags',
|
||||
'allocations__stock_item__supplier_part',
|
||||
'allocations__stock_item__supplier_part__part',
|
||||
'allocations__stock_item__supplier_part__supplier',
|
||||
'allocations__stock_item__supplier_part__manufacturer_part',
|
||||
'allocations__stock_item__supplier_part__manufacturer_part__manufacturer',
|
||||
)
|
||||
|
||||
# Annotate the "allocated" quantity
|
||||
@@ -1311,15 +1471,3 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for a BuildAttachment."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass"""
|
||||
model = BuildOrderAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'build',
|
||||
])
|
||||
|
@@ -9,7 +9,8 @@ class BuildStatus(StatusCode):
|
||||
"""Build status codes."""
|
||||
|
||||
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
|
||||
PRODUCTION = 20, _('Production'), 'primary' # BuildOrder is in production
|
||||
PRODUCTION = 20, _('Production'), 'primary' # Build is in production
|
||||
ON_HOLD = 25, _('On Hold'), 'warning' # Build is on hold
|
||||
CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
|
||||
COMPLETE = 40, _('Complete'), 'success' # Build is complete
|
||||
|
||||
@@ -17,4 +18,8 @@ class BuildStatus(StatusCode):
|
||||
class BuildStatusGroups:
|
||||
"""Groups for BuildStatus codes."""
|
||||
|
||||
ACTIVE_CODES = [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value]
|
||||
ACTIVE_CODES = [
|
||||
BuildStatus.PENDING.value,
|
||||
BuildStatus.ON_HOLD.value,
|
||||
BuildStatus.PRODUCTION.value,
|
||||
]
|
||||
|
@@ -69,22 +69,30 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
|
||||
{% if build.is_active %}
|
||||
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if roles.build.add %}
|
||||
<li><a class='dropdown-item' href='#' id='build-duplicate'><span class='fas fa-clone'></span> {% trans "Duplicate Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if build.can_hold %}
|
||||
<li><a class='dropdown-item' href='#' id='build-hold'><span class='fas fa-hand-paper icon-yellow'></span> {% trans "Hold Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if build.is_active %}
|
||||
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||
<li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Build" %}</a>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if build.active %}
|
||||
{% if build.can_issue %}
|
||||
<button id='build-issue' title='{% trans "Isueue Build" %}' class='btn btn-primary'>
|
||||
<span class='fas fa-paper-plane'></span> {% trans "Issue Build" %}
|
||||
</button>
|
||||
{% elif build.active %}
|
||||
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
|
||||
<span class='fas fa-check-circle'></span> {% trans "Complete Build" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock actions %}
|
||||
|
||||
@@ -244,6 +252,31 @@ src="{% static 'img/blank_image.png' %}"
|
||||
);
|
||||
});
|
||||
|
||||
$('#build-hold').click(function() {
|
||||
holdOrder(
|
||||
'{% url "api-build-hold" build.pk %}',
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#build-issue').click(function() {
|
||||
constructForm('{% url "api-build-issue" build.pk %}', {
|
||||
method: 'POST',
|
||||
title: '{% trans "Issue Build Order" %}',
|
||||
confirm: true,
|
||||
preFormContent: `
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "Issue this Build Order?" %}
|
||||
</div>
|
||||
`,
|
||||
onSuccess: function(response) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#build-complete").on('click', function() {
|
||||
completeBuildOrder({{ build.pk }});
|
||||
});
|
||||
@@ -277,7 +310,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Build Order QR Code" escape %}',
|
||||
'{"build": {{ build.pk }} }'
|
||||
'{{ build.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -298,6 +331,12 @@ src="{% static 'img/blank_image.png' %}"
|
||||
build: {{ build.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
{% if build.part.trackable > 0 %}
|
||||
onPanelLoad("test-statistics", function() {
|
||||
prepareTestStatisticsTable('build', '{% url "api-test-statistics-by-build" build.pk %}')
|
||||
});
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
@@ -174,7 +174,7 @@
|
||||
<div class='panel panel-hidden' id='panel-allocate'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Allocate Stock to Build" %}</h4>
|
||||
<h4>{% trans "Build Order Line Items" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.build.add and build.active %}
|
||||
@@ -231,6 +231,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-allocated'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Allocated Stock" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='build-allocated-stock-toolbar'>
|
||||
{% include "filter_list.html" with id='buildorderallocatedstock' %}
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='allocated-stock-table' data-toolbar='#build-allocated-stock-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-consumed'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
@@ -255,6 +267,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-test-statistics'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Build test statistics" %}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
<div id='teststatistics-button-toolbar'>
|
||||
{% include "filter_list.html" with id="buildteststatistics" %}
|
||||
</div>
|
||||
{% include "test_statistics_table.html" with prefix="build-" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-attachments'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
@@ -290,6 +317,10 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad('allocated', function() {
|
||||
loadBuildOrderAllocatedStockTable($('#allocated-stock-table'), {{ build.pk }});
|
||||
});
|
||||
|
||||
onPanelLoad('consumed', function() {
|
||||
loadStockTable($('#consumed-stock-table'), {
|
||||
filterTarget: '#filter-list-consumed-stock',
|
||||
@@ -326,18 +357,7 @@ onPanelLoad('children', function() {
|
||||
});
|
||||
|
||||
onPanelLoad('attachments', function() {
|
||||
|
||||
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
|
||||
filters: {
|
||||
build: {{ build.pk }},
|
||||
},
|
||||
fields: {
|
||||
build: {
|
||||
value: {{ build.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('build', {{ build.pk }});
|
||||
});
|
||||
|
||||
onPanelLoad('notes', function() {
|
||||
|
@@ -5,17 +5,25 @@
|
||||
{% trans "Build Order Details" as text %}
|
||||
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
|
||||
{% if build.is_active %}
|
||||
{% trans "Allocate Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
|
||||
{% trans "Line Items" as text %}
|
||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-list-ol" %}
|
||||
{% trans "Incomplete Outputs" as text %}
|
||||
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
|
||||
{% endif %}
|
||||
{% trans "Completed Outputs" as text %}
|
||||
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
|
||||
{% if build.is_active %}
|
||||
{% trans "Allocated Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='allocated' text=text icon="fa-list" %}
|
||||
{% endif %}
|
||||
{% trans "Consumed Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
|
||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %}
|
||||
{% trans "Child Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
||||
{% if build.part.trackable %}
|
||||
{% trans "Test Statistics" as text %}
|
||||
{% include "sidebar_item.html" with label='test-statistics' text=text icon="fa-chart-line" %}
|
||||
{% endif %}
|
||||
{% trans "Attachments" as text %}
|
||||
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
||||
{% trans "Notes" as text %}
|
||||
|
@@ -564,16 +564,16 @@ class BuildTest(BuildAPITest):
|
||||
def test_download_build_orders(self):
|
||||
"""Test that we can download a list of build orders via the API"""
|
||||
required_cols = [
|
||||
'reference',
|
||||
'status',
|
||||
'completed',
|
||||
'batch',
|
||||
'notes',
|
||||
'title',
|
||||
'part',
|
||||
'part_name',
|
||||
'id',
|
||||
'quantity',
|
||||
'Reference',
|
||||
'Build Status',
|
||||
'Completed items',
|
||||
'Batch Code',
|
||||
'Notes',
|
||||
'Description',
|
||||
'Part',
|
||||
'Part Name',
|
||||
'ID',
|
||||
'Quantity',
|
||||
]
|
||||
|
||||
excluded_cols = [
|
||||
@@ -597,13 +597,13 @@ class BuildTest(BuildAPITest):
|
||||
|
||||
for row in data:
|
||||
|
||||
build = Build.objects.get(pk=row['id'])
|
||||
build = Build.objects.get(pk=row['ID'])
|
||||
|
||||
self.assertEqual(str(build.part.pk), row['part'])
|
||||
self.assertEqual(build.part.full_name, row['part_name'])
|
||||
self.assertEqual(str(build.part.pk), row['Part'])
|
||||
self.assertEqual(build.part.name, row['Part Name'])
|
||||
|
||||
self.assertEqual(build.reference, row['reference'])
|
||||
self.assertEqual(build.title, row['title'])
|
||||
self.assertEqual(build.reference, row['Reference'])
|
||||
self.assertEqual(build.title, row['Description'])
|
||||
|
||||
|
||||
class BuildAllocationTest(BuildAPITest):
|
||||
@@ -1015,7 +1015,7 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
'accept_overallocated': 'trim',
|
||||
},
|
||||
expected_code=201,
|
||||
max_query_count=550, # TODO: Come back and refactor this
|
||||
max_query_count=600, # TODO: Come back and refactor this
|
||||
)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
|
@@ -15,6 +15,7 @@ import common.models
|
||||
from common.settings import set_global_setting
|
||||
import build.tasks
|
||||
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 stock.models import StockItem, StockItemTestResult
|
||||
from users.models import Owner
|
||||
@@ -175,6 +176,7 @@ class BuildTestBase(TestCase):
|
||||
part=cls.assembly,
|
||||
quantity=10,
|
||||
issued_by=get_user_model().objects.get(pk=1),
|
||||
status=BuildStatus.PENDING,
|
||||
)
|
||||
|
||||
# Create some BuildLine items we can use later on
|
||||
@@ -321,6 +323,10 @@ class BuildTest(BuildTestBase):
|
||||
# Build is PENDING
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||
|
||||
self.assertTrue(self.build.is_active)
|
||||
self.assertTrue(self.build.can_hold)
|
||||
self.assertTrue(self.build.can_issue)
|
||||
|
||||
# Build has two build outputs
|
||||
self.assertEqual(self.build.output_count, 2)
|
||||
|
||||
@@ -470,6 +476,11 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
def test_overallocation_and_trim(self):
|
||||
"""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,
|
||||
@@ -516,6 +527,7 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
n = StockItem.objects.filter(consumed_by=self.build).count()
|
||||
@@ -583,6 +595,8 @@ class BuildTest(BuildTestBase):
|
||||
self.stock_2_1.quantity = 30
|
||||
self.stock_2_1.save()
|
||||
|
||||
self.build.issue_build()
|
||||
|
||||
# Allocate non-tracked parts
|
||||
self.allocate_stock(
|
||||
None,
|
||||
|
@@ -19,7 +19,6 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
name='Widget',
|
||||
description='Buildable Part',
|
||||
active=True,
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
@@ -61,7 +60,6 @@ class TestReferenceMigration(MigratorTestCase):
|
||||
part = Part.objects.create(
|
||||
name='Part',
|
||||
description='A test part',
|
||||
level=0, lft=0, rght=0, tree_id=0,
|
||||
)
|
||||
|
||||
Build = self.old_state.apps.get_model('build', 'build')
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Basic unit tests for the BuildOrder app"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -9,8 +10,10 @@ from datetime import datetime, timedelta
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
from .models import Build
|
||||
from part.models import Part, BomItem
|
||||
from stock.models import StockItem
|
||||
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from build.status_codes import BuildStatus
|
||||
|
||||
|
||||
@@ -88,6 +91,79 @@ class BuildTestSimple(InvenTreeTestCase):
|
||||
|
||||
self.assertEqual(build.status, BuildStatus.CANCELLED)
|
||||
|
||||
def test_build_create(self):
|
||||
"""Test creation of build orders via API."""
|
||||
|
||||
n = Build.objects.count()
|
||||
|
||||
# Find an assembly part
|
||||
assembly = Part.objects.filter(assembly=True).first()
|
||||
|
||||
assembly.active = True
|
||||
assembly.locked = False
|
||||
assembly.save()
|
||||
|
||||
self.assertEqual(assembly.get_bom_items().count(), 0)
|
||||
|
||||
# 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
|
||||
)
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
# The assembly has a BOM, and is now *invalid*
|
||||
self.assertGreater(assembly.get_bom_items().count(), 0)
|
||||
self.assertFalse(assembly.is_bom_valid())
|
||||
|
||||
# Create a build for an assembly with an *invalid* BOM
|
||||
set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', False)
|
||||
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', True)
|
||||
set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', False)
|
||||
|
||||
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9990')
|
||||
bo.save()
|
||||
|
||||
# Now, require a *valid* BOM
|
||||
set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', True)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9991')
|
||||
|
||||
# Now, validate the BOM, and try again
|
||||
assembly.validate_bom(None)
|
||||
self.assertTrue(assembly.is_bom_valid())
|
||||
|
||||
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9992')
|
||||
|
||||
# Now, try and create a build for an inactive assembly
|
||||
assembly.active = False
|
||||
assembly.save()
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9993')
|
||||
|
||||
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', False)
|
||||
Build.objects.create(part=assembly, quantity=10, reference='BO-9994')
|
||||
|
||||
# Check that the "locked" requirement works
|
||||
set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', True)
|
||||
with self.assertRaises(ValidationError):
|
||||
Build.objects.create(part=assembly, quantity=10, reference='BO-9995')
|
||||
|
||||
assembly.locked = True
|
||||
assembly.save()
|
||||
|
||||
Build.objects.create(part=assembly, quantity=10, reference='BO-9996')
|
||||
|
||||
# Check that expected quantity of new builds is created
|
||||
self.assertEqual(Build.objects.count(), n + 4)
|
||||
|
||||
class TestBuildViews(InvenTreeTestCase):
|
||||
"""Tests for Build app views."""
|
||||
|
@@ -5,6 +5,34 @@ from django.contrib import admin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
import common.models
|
||||
import common.validators
|
||||
|
||||
|
||||
@admin.register(common.models.Attachment)
|
||||
class AttachmentAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Attachment objects."""
|
||||
|
||||
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
||||
"""Provide custom choices for 'model_type' field."""
|
||||
if db_field.name == 'model_type':
|
||||
db_field.choices = common.validators.attachment_model_options()
|
||||
|
||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||
|
||||
list_display = (
|
||||
'model_type',
|
||||
'model_id',
|
||||
'attachment',
|
||||
'link',
|
||||
'upload_user',
|
||||
'upload_date',
|
||||
)
|
||||
|
||||
list_filter = ['model_type', 'upload_user']
|
||||
|
||||
readonly_fields = ['file_size', 'upload_date', 'upload_user']
|
||||
|
||||
search_fields = ('content_type', 'comment')
|
||||
|
||||
|
||||
@admin.register(common.models.ProjectCode)
|
||||
@@ -16,6 +44,7 @@ class ProjectCodeAdmin(ImportExportModelAdmin):
|
||||
search_fields = ('code', 'description')
|
||||
|
||||
|
||||
@admin.register(common.models.InvenTreeSetting)
|
||||
class SettingsAdmin(ImportExportModelAdmin):
|
||||
"""Admin settings for InvenTreeSetting."""
|
||||
|
||||
@@ -28,6 +57,7 @@ class SettingsAdmin(ImportExportModelAdmin):
|
||||
return []
|
||||
|
||||
|
||||
@admin.register(common.models.InvenTreeUserSetting)
|
||||
class UserSettingsAdmin(ImportExportModelAdmin):
|
||||
"""Admin settings for InvenTreeUserSetting."""
|
||||
|
||||
@@ -40,18 +70,21 @@ class UserSettingsAdmin(ImportExportModelAdmin):
|
||||
return []
|
||||
|
||||
|
||||
@admin.register(common.models.WebhookEndpoint)
|
||||
class WebhookAdmin(ImportExportModelAdmin):
|
||||
"""Admin settings for Webhook."""
|
||||
|
||||
list_display = ('endpoint_id', 'name', 'active', 'user')
|
||||
|
||||
|
||||
@admin.register(common.models.NotificationEntry)
|
||||
class NotificationEntryAdmin(admin.ModelAdmin):
|
||||
"""Admin settings for NotificationEntry."""
|
||||
|
||||
list_display = ('key', 'uid', 'updated')
|
||||
|
||||
|
||||
@admin.register(common.models.NotificationMessage)
|
||||
class NotificationMessageAdmin(admin.ModelAdmin):
|
||||
"""Admin settings for NotificationMessage."""
|
||||
|
||||
@@ -70,16 +103,11 @@ class NotificationMessageAdmin(admin.ModelAdmin):
|
||||
search_fields = ('name', 'category', 'message')
|
||||
|
||||
|
||||
@admin.register(common.models.NewsFeedEntry)
|
||||
class NewsFeedEntryAdmin(admin.ModelAdmin):
|
||||
"""Admin settings for NewsFeedEntry."""
|
||||
|
||||
list_display = ('title', 'author', 'published', 'summary')
|
||||
|
||||
|
||||
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
|
||||
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
|
||||
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
|
||||
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
|
||||
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
|
||||
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
|
||||
admin.site.register(common.models.NewsFeedEntry, NewsFeedEntryAdmin)
|
||||
|
@@ -4,26 +4,32 @@ import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.http.response import HttpResponse
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
import django_q.models
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from django_q.tasks import async_task
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from error_report.models import Error
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import common.models
|
||||
import common.serializers
|
||||
from common.icons import get_icon_packs
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.api import AllStatusViews, StatusView
|
||||
from importer.mixins import DataExportViewMixin
|
||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||
from InvenTree.config import CONFIG_LOOKUPS
|
||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||
@@ -491,7 +497,7 @@ class NotesImageList(ListCreateAPI):
|
||||
image.save()
|
||||
|
||||
|
||||
class ProjectCodeList(ListCreateAPI):
|
||||
class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
|
||||
"""List view for all project codes."""
|
||||
|
||||
queryset = common.models.ProjectCode.objects.all()
|
||||
@@ -512,7 +518,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
|
||||
|
||||
class CustomUnitList(ListCreateAPI):
|
||||
class CustomUnitList(DataExportViewMixin, ListCreateAPI):
|
||||
"""List view for custom units."""
|
||||
|
||||
queryset = common.models.CustomUnit.objects.all()
|
||||
@@ -674,6 +680,83 @@ class ContentTypeModelDetail(ContentTypeDetail):
|
||||
raise NotFound()
|
||||
|
||||
|
||||
class AttachmentFilter(rest_filters.FilterSet):
|
||||
"""Filterset for the AttachmentList API endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = common.models.Attachment
|
||||
fields = ['model_type', 'model_id', 'upload_user']
|
||||
|
||||
is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
|
||||
|
||||
def filter_is_link(self, queryset, name, value):
|
||||
"""Filter attachments based on whether they are a link or not."""
|
||||
if value:
|
||||
return queryset.exclude(link=None).exclude(link='')
|
||||
return queryset.filter(Q(link=None) | Q(link='')).distinct()
|
||||
|
||||
is_file = rest_filters.BooleanFilter(label=_('Is File'), method='filter_is_file')
|
||||
|
||||
def filter_is_file(self, queryset, name, value):
|
||||
"""Filter attachments based on whether they are a file or not."""
|
||||
if value:
|
||||
return queryset.exclude(attachment=None).exclude(attachment='')
|
||||
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
|
||||
|
||||
|
||||
class AttachmentList(ListCreateAPI):
|
||||
"""List API endpoint for Attachment objects."""
|
||||
|
||||
queryset = common.models.Attachment.objects.all()
|
||||
serializer_class = common.serializers.AttachmentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
filterset_class = AttachmentFilter
|
||||
|
||||
ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size']
|
||||
search_fields = ['comment', 'model_id', 'model_type']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save the user information when a file is uploaded."""
|
||||
attachment = serializer.save()
|
||||
attachment.upload_user = self.request.user
|
||||
attachment.save()
|
||||
|
||||
|
||||
class AttachmentDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail API endpoint for Attachment objects."""
|
||||
|
||||
queryset = common.models.Attachment.objects.all()
|
||||
serializer_class = common.serializers.AttachmentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Check user permissions before deleting an attachment."""
|
||||
attachment = self.get_object()
|
||||
|
||||
if not attachment.check_permission('delete', request.user):
|
||||
raise PermissionDenied(
|
||||
_('User does not have permission to delete this attachment')
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
@method_decorator(cache_control(public=True, max_age=86400), name='dispatch')
|
||||
class IconList(ListAPI):
|
||||
"""List view for available icon packages."""
|
||||
|
||||
serializer_class = common.serializers.IconPackageSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return a list of all available icon packages."""
|
||||
return get_icon_packs().values()
|
||||
|
||||
|
||||
settings_api_urls = [
|
||||
# User settings
|
||||
path(
|
||||
@@ -742,6 +825,25 @@ common_api_urls = [
|
||||
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
|
||||
]),
|
||||
),
|
||||
# Attachments
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(),
|
||||
{'model': common.models.Attachment},
|
||||
name='api-attachment-metadata',
|
||||
),
|
||||
path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
|
||||
]),
|
||||
),
|
||||
path('', AttachmentList.as_view(), name='api-attachment-list'),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'error-report/',
|
||||
include([
|
||||
@@ -862,13 +964,15 @@ common_api_urls = [
|
||||
'<int:pk>/', ContentTypeDetail.as_view(), name='api-contenttype-detail'
|
||||
),
|
||||
path(
|
||||
'<str:model>/',
|
||||
'model/<str:model>/',
|
||||
ContentTypeModelDetail.as_view(),
|
||||
name='api-contenttype-detail-modelname',
|
||||
),
|
||||
path('', ContentTypeList.as_view(), name='api-contenttype-list'),
|
||||
]),
|
||||
),
|
||||
# Icons
|
||||
path('icons/', IconList.as_view(), name='api-icon-list'),
|
||||
]
|
||||
|
||||
admin_api_urls = [
|
||||
|
@@ -28,9 +28,7 @@ def currency_code_default():
|
||||
return cached_value
|
||||
|
||||
try:
|
||||
code = get_global_setting(
|
||||
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
|
||||
)
|
||||
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
|
||||
except Exception: # pragma: no cover
|
||||
# Database may not yet be ready, no need to throw an error here
|
||||
code = ''
|
||||
@@ -61,7 +59,9 @@ def currency_codes() -> list:
|
||||
"""Returns the current currency codes."""
|
||||
from common.settings import get_global_setting
|
||||
|
||||
codes = get_global_setting('CURRENCY_CODES', '', create=False).strip()
|
||||
codes = get_global_setting(
|
||||
'CURRENCY_CODES', create=False, enviroment_key='INVENTREE_CURRENCY_CODES'
|
||||
).strip()
|
||||
|
||||
if not codes:
|
||||
codes = currency_codes_default_list()
|
||||
|
@@ -51,6 +51,9 @@ class MatchFieldForm(forms.Form):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not file_manager: # pragma: no cover
|
||||
return
|
||||
|
||||
# Setup FileManager
|
||||
file_manager.setup()
|
||||
# Get columns
|
||||
@@ -87,6 +90,9 @@ class MatchItemForm(forms.Form):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not file_manager: # pragma: no cover
|
||||
return
|
||||
|
||||
# Setup FileManager
|
||||
file_manager.setup()
|
||||
|
||||
|
114
src/backend/InvenTree/common/icons.py
Normal file
114
src/backend/InvenTree/common/icons.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Icon utilities for InvenTree."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.templatetags.static import static
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
_icon_packs = None
|
||||
|
||||
|
||||
class Icon(TypedDict):
|
||||
"""Dict type for an icon.
|
||||
|
||||
Attributes:
|
||||
name: The name of the icon.
|
||||
category: The category of the icon.
|
||||
tags: A list of tags for the icon (used for search).
|
||||
variants: A dictionary of variants for the icon, where the key is the variant name and the value is the variant's unicode hex character.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
tags: list[str]
|
||||
variants: dict[str, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class IconPack:
|
||||
"""Dataclass for an icon pack.
|
||||
|
||||
Attributes:
|
||||
name: The name of the icon pack.
|
||||
prefix: The prefix used for the icon pack.
|
||||
fonts: A dictionary of different font file formats for the icon pack, where the key is the css format and the value a path to the font file.
|
||||
icons: A dictionary of icons in the icon pack, where the key is the icon name and the value is a dictionary of the icon's variants.
|
||||
"""
|
||||
|
||||
name: str
|
||||
prefix: str
|
||||
fonts: dict[str, str]
|
||||
icons: dict[str, Icon]
|
||||
|
||||
|
||||
def get_icon_packs():
|
||||
"""Return a dictionary of available icon packs including their icons."""
|
||||
global _icon_packs
|
||||
|
||||
if _icon_packs is None:
|
||||
tabler_icons_path = Path(__file__).parent.parent.joinpath(
|
||||
'InvenTree/static/tabler-icons/icons.json'
|
||||
)
|
||||
with open(tabler_icons_path, 'r') as tabler_icons_file:
|
||||
tabler_icons = json.load(tabler_icons_file)
|
||||
|
||||
icon_packs = [
|
||||
IconPack(
|
||||
name='Tabler Icons',
|
||||
prefix='ti',
|
||||
fonts={
|
||||
'woff2': static('tabler-icons/tabler-icons.woff2'),
|
||||
'woff': static('tabler-icons/tabler-icons.woff'),
|
||||
'truetype': static('tabler-icons/tabler-icons.ttf'),
|
||||
},
|
||||
icons=tabler_icons,
|
||||
)
|
||||
]
|
||||
|
||||
from plugin import registry
|
||||
|
||||
for plugin in registry.with_mixin('icon_pack', active=True):
|
||||
try:
|
||||
icon_packs.extend(plugin.icon_packs())
|
||||
except Exception as e:
|
||||
logger.warning('Error loading icon pack from plugin %s: %s', plugin, e)
|
||||
|
||||
_icon_packs = {pack.prefix: pack for pack in icon_packs}
|
||||
|
||||
return _icon_packs
|
||||
|
||||
|
||||
def reload_icon_packs():
|
||||
"""Reload the icon packs."""
|
||||
global _icon_packs
|
||||
_icon_packs = None
|
||||
get_icon_packs()
|
||||
|
||||
|
||||
def validate_icon(icon: str):
|
||||
"""Validate an icon string in the format pack:name:variant."""
|
||||
try:
|
||||
pack, name, variant = icon.split(':')
|
||||
except ValueError:
|
||||
raise ValidationError(
|
||||
f'Invalid icon format: {icon}, expected: pack:name:variant'
|
||||
)
|
||||
|
||||
packs = get_icon_packs()
|
||||
|
||||
if pack not in packs:
|
||||
raise ValidationError(f'Invalid icon pack: {pack}')
|
||||
|
||||
if name not in packs[pack].icons:
|
||||
raise ValidationError(f'Invalid icon name: {name}')
|
||||
|
||||
if variant not in packs[pack].icons[name]['variants']:
|
||||
raise ValidationError(f'Invalid icon variant: {variant}')
|
||||
|
||||
return packs[pack], packs[pack].icons[name], variant
|
@@ -17,5 +17,8 @@ class Migration(migrations.Migration):
|
||||
('code', models.CharField(help_text='Unique project code', max_length=50, unique=True, verbose_name='Project Code')),
|
||||
('description', models.CharField(blank=True, help_text='Project description', max_length=200, verbose_name='Description')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Project Code',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@@ -18,5 +18,8 @@ class Migration(migrations.Migration):
|
||||
('symbol', models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, unique=True, verbose_name='Symbol')),
|
||||
('definition', models.CharField(help_text='Unit definition', max_length=50, verbose_name='Definition')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Custom Unit',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@@ -1,5 +1,6 @@
|
||||
# Generated by Django 4.2.12 on 2024-06-02 13:32
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
@@ -47,16 +48,20 @@ def set_currencies(apps, schema_editor):
|
||||
return
|
||||
|
||||
value = ','.join(valid_codes)
|
||||
print(f"Found existing currency codes:", value)
|
||||
|
||||
if not settings.TESTING:
|
||||
print(f"Found existing currency codes:", value)
|
||||
|
||||
setting = InvenTreeSetting.objects.filter(key=key).first()
|
||||
|
||||
if setting:
|
||||
print(f"- Updating existing setting for currency codes")
|
||||
if not settings.TESTING:
|
||||
print(f"- Updating existing setting for currency codes")
|
||||
setting.value = value
|
||||
setting.save()
|
||||
else:
|
||||
print(f"- Creating new setting for currency codes")
|
||||
if not settings.TESTING:
|
||||
print(f"- Creating new setting for currency codes")
|
||||
setting = InvenTreeSetting(key=key, value=value)
|
||||
setting.save()
|
||||
|
||||
|
43
src/backend/InvenTree/common/migrations/0025_attachment.py
Normal file
43
src/backend/InvenTree/common/migrations/0025_attachment.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.12 on 2024-06-08 12:37
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
|
||||
import common.models
|
||||
import common.validators
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('common', '0024_notesimage_model_id_notesimage_model_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Attachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('model_id', models.PositiveIntegerField()),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=common.models.rename_attachment, verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='Attachment comment', max_length=250, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, help_text='Date the file was uploaded', null=True, verbose_name='Upload date')),
|
||||
('file_size', models.PositiveIntegerField(default=0, help_text='File size in bytes', verbose_name='File size')),
|
||||
('model_type', models.CharField(help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_attachment_model_type])),
|
||||
('upload_user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
|
||||
('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'))
|
||||
],
|
||||
bases=(InvenTree.models.PluginValidationMixin, models.Model),
|
||||
options={
|
||||
'verbose_name': 'Attachment',
|
||||
}
|
||||
),
|
||||
]
|
@@ -0,0 +1,122 @@
|
||||
# Generated by Django 4.2.12 on 2024-06-08 12:38
|
||||
|
||||
from django.db import migrations
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
|
||||
def get_legacy_models():
|
||||
"""Return a set of legacy attachment models."""
|
||||
|
||||
# Legacy attachment types to convert:
|
||||
# app_label, table name, target model, model ref
|
||||
return [
|
||||
('build', 'BuildOrderAttachment', 'build', 'build'),
|
||||
('company', 'CompanyAttachment', 'company', 'company'),
|
||||
('company', 'ManufacturerPartAttachment', 'manufacturerpart', 'manufacturer_part'),
|
||||
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
|
||||
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
|
||||
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
|
||||
('part', 'PartAttachment', 'part', 'part'),
|
||||
('stock', 'StockItemAttachment', 'stockitem', 'stock_item')
|
||||
]
|
||||
|
||||
|
||||
def update_attachments(apps, schema_editor):
|
||||
"""Migrate any existing attachment models to the new attachment table."""
|
||||
|
||||
Attachment = apps.get_model('common', 'attachment')
|
||||
|
||||
N = 0
|
||||
|
||||
for app, model, target_model, model_ref in get_legacy_models():
|
||||
LegacyAttachmentModel = apps.get_model(app, model)
|
||||
|
||||
if LegacyAttachmentModel.objects.count() == 0:
|
||||
continue
|
||||
|
||||
to_create = []
|
||||
|
||||
for attachment in LegacyAttachmentModel.objects.all():
|
||||
|
||||
# Find the size of the file (if exists)
|
||||
if attachment.attachment and default_storage.exists(attachment.attachment.name):
|
||||
try:
|
||||
file_size = default_storage.size(attachment.attachment.name)
|
||||
except NotImplementedError:
|
||||
file_size = 0
|
||||
else:
|
||||
file_size = 0
|
||||
|
||||
to_create.append(
|
||||
Attachment(
|
||||
model_type=target_model,
|
||||
model_id=getattr(attachment, model_ref).pk,
|
||||
attachment=attachment.attachment,
|
||||
link=attachment.link,
|
||||
comment=attachment.comment,
|
||||
upload_date=attachment.upload_date,
|
||||
upload_user=attachment.user,
|
||||
file_size=file_size
|
||||
)
|
||||
)
|
||||
|
||||
if len(to_create) > 0:
|
||||
print(f"Migrating {len(to_create)} attachments for the legacy '{model}' model.")
|
||||
Attachment.objects.bulk_create(to_create)
|
||||
|
||||
N += len(to_create)
|
||||
|
||||
# Check the correct number of Attachment objects has been created
|
||||
assert(N == Attachment.objects.count())
|
||||
|
||||
|
||||
def reverse_attachments(apps, schema_editor):
|
||||
"""Reverse data migration, and map new Attachment model back to legacy models."""
|
||||
|
||||
Attachment = apps.get_model('common', 'attachment')
|
||||
|
||||
N = 0
|
||||
|
||||
for app, model, target_model, model_ref in get_legacy_models():
|
||||
LegacyAttachmentModel = apps.get_model(app, model)
|
||||
|
||||
to_create = []
|
||||
|
||||
for attachment in Attachment.objects.filter(model_type=target_model):
|
||||
|
||||
TargetModel = apps.get_model(app, target_model)
|
||||
|
||||
data = {
|
||||
'attachment': attachment.attachment,
|
||||
'link': attachment.link,
|
||||
'comment': attachment.comment,
|
||||
'upload_date': attachment.upload_date,
|
||||
'user': attachment.upload_user,
|
||||
model_ref: TargetModel.objects.get(pk=attachment.model_id)
|
||||
}
|
||||
|
||||
to_create.append(LegacyAttachmentModel(**data))
|
||||
|
||||
if len(to_create) > 0:
|
||||
print(f"Reversing {len(to_create)} attachments for the legacy '{model}' model.")
|
||||
LegacyAttachmentModel.objects.bulk_create(to_create)
|
||||
|
||||
N += len(to_create)
|
||||
|
||||
# Check the correct number of LegacyAttachmentModel objects has been created
|
||||
assert(N == Attachment.objects.count())
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0050_auto_20240508_0138'),
|
||||
('common', '0025_attachment'),
|
||||
('company', '0069_company_active'),
|
||||
('order', '0099_alter_salesorder_status'),
|
||||
('part', '0123_parttesttemplate_choices'),
|
||||
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_attachments, reverse_code=reverse_attachments),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.12 on 2024-07-04 10:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0026_auto_20240608_1238'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customunit',
|
||||
name='symbol',
|
||||
field=models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, verbose_name='Symbol'),
|
||||
),
|
||||
]
|
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.2.12 on 2024-07-04 10:23
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_userthemes(apps, schema_editor):
|
||||
"""Mgrate text-based user references to ForeignKey references."""
|
||||
ColorTheme = apps.get_model("common", "ColorTheme")
|
||||
User = apps.get_model(settings.AUTH_USER_MODEL)
|
||||
|
||||
for theme in ColorTheme.objects.all():
|
||||
try:
|
||||
theme.user_obj = User.objects.get(username=theme.user)
|
||||
theme.save()
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("common", "0027_alter_customunit_symbol"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="colortheme",
|
||||
name="user_obj",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_userthemes, migrations.RunPython.noop),
|
||||
]
|
@@ -9,9 +9,11 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from secrets import compare_digest
|
||||
from typing import Any, Callable, TypedDict, Union
|
||||
|
||||
@@ -23,6 +25,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
@@ -35,6 +38,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
import build.validators
|
||||
import common.currency
|
||||
@@ -46,12 +50,25 @@ import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
import order.validators
|
||||
import plugin.base.barcodes.helper
|
||||
import report.helpers
|
||||
import users.models
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
from plugin import registry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import NotRequired
|
||||
else:
|
||||
|
||||
class NotRequired: # pragma: no cover
|
||||
"""NotRequired type helper is only supported with Python 3.11+."""
|
||||
|
||||
def __class_getitem__(cls, item):
|
||||
"""Return the item."""
|
||||
return item
|
||||
|
||||
|
||||
class MetaMixin(models.Model):
|
||||
"""A base class for InvenTree models to include shared meta fields.
|
||||
@@ -112,6 +129,11 @@ class BaseURLValidator(URLValidator):
|
||||
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A ProjectCode is a unique identifier for a project."""
|
||||
|
||||
class Meta:
|
||||
"""Class options for the ProjectCode model."""
|
||||
|
||||
verbose_name = _('Project Code')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL for this model."""
|
||||
@@ -549,25 +571,25 @@ class BaseInvenTreeSetting(models.Model):
|
||||
"""
|
||||
key = str(key).strip().upper()
|
||||
|
||||
filters = {
|
||||
'key__iexact': key,
|
||||
# Optionally filter by other keys
|
||||
**cls.get_filters(**kwargs),
|
||||
}
|
||||
|
||||
# Unless otherwise specified, attempt to create the setting
|
||||
create = kwargs.pop('create', True)
|
||||
|
||||
# Specify if cache lookup should be performed
|
||||
do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED)
|
||||
|
||||
# Prevent saving to the database during data import
|
||||
if InvenTree.ready.isImportingData():
|
||||
create = False
|
||||
do_cache = False
|
||||
filters = {
|
||||
'key__iexact': key,
|
||||
# Optionally filter by other keys
|
||||
**cls.get_filters(**kwargs),
|
||||
}
|
||||
|
||||
# Prevent saving to the database during migrations
|
||||
if InvenTree.ready.isRunningMigrations():
|
||||
# Prevent saving to the database during certain operations
|
||||
if (
|
||||
InvenTree.ready.isImportingData()
|
||||
or InvenTree.ready.isRunningMigrations()
|
||||
or InvenTree.ready.isRebuildingData()
|
||||
or InvenTree.ready.isRunningBackup()
|
||||
):
|
||||
create = False
|
||||
do_cache = False
|
||||
|
||||
@@ -594,33 +616,21 @@ class BaseInvenTreeSetting(models.Model):
|
||||
setting = None
|
||||
|
||||
# Setting does not exist! (Try to create it)
|
||||
if not setting:
|
||||
# Prevent creation of new settings objects when importing data
|
||||
if (
|
||||
InvenTree.ready.isImportingData()
|
||||
or not InvenTree.ready.canAppAccessDatabase(
|
||||
allow_test=True, allow_shell=True
|
||||
)
|
||||
):
|
||||
create = False
|
||||
if not setting and create:
|
||||
# Attempt to create a new settings object
|
||||
default_value = cls.get_setting_default(key, **kwargs)
|
||||
setting = cls(key=key, value=default_value, **kwargs)
|
||||
|
||||
if create:
|
||||
# Attempt to create a new settings object
|
||||
|
||||
default_value = cls.get_setting_default(key, **kwargs)
|
||||
|
||||
setting = cls(key=key, value=default_value, **kwargs)
|
||||
|
||||
try:
|
||||
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
||||
with transaction.atomic():
|
||||
setting.save(**kwargs)
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
# It might be the case that the database isn't created yet
|
||||
pass
|
||||
except ValidationError:
|
||||
# The setting failed validation - might be due to duplicate keys
|
||||
pass
|
||||
try:
|
||||
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
||||
with transaction.atomic():
|
||||
setting.save(**kwargs)
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
# It might be the case that the database isn't created yet
|
||||
pass
|
||||
except ValidationError:
|
||||
# The setting failed validation - might be due to duplicate keys
|
||||
pass
|
||||
|
||||
if setting and do_cache:
|
||||
# Cache this setting object
|
||||
@@ -694,6 +704,15 @@ class BaseInvenTreeSetting(models.Model):
|
||||
if change_user is not None and not change_user.is_staff:
|
||||
return
|
||||
|
||||
# Do not write to the database under certain conditions
|
||||
if (
|
||||
InvenTree.ready.isImportingData()
|
||||
or InvenTree.ready.isRunningMigrations()
|
||||
or InvenTree.ready.isRebuildingData()
|
||||
or InvenTree.ready.isRunningBackup()
|
||||
):
|
||||
return
|
||||
|
||||
attempts = int(kwargs.get('attempts', 3))
|
||||
|
||||
filters = {
|
||||
@@ -1161,7 +1180,7 @@ class InvenTreeSettingsKeyType(SettingsKeyType):
|
||||
requires_restart: If True, a server restart is required after changing the setting
|
||||
"""
|
||||
|
||||
requires_restart: bool
|
||||
requires_restart: NotRequired[bool]
|
||||
|
||||
|
||||
class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
@@ -1390,12 +1409,30 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'BARCODE_SHOW_TEXT': {
|
||||
'name': _('Barcode Show Data'),
|
||||
'description': _('Display barcode data in browser as text'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BARCODE_GENERATION_PLUGIN': {
|
||||
'name': _('Barcode Generation Plugin'),
|
||||
'description': _('Plugin to use for internal barcode data generation'),
|
||||
'choices': plugin.base.barcodes.helper.barcode_plugins,
|
||||
'default': 'inventreebarcode',
|
||||
},
|
||||
'PART_ENABLE_REVISION': {
|
||||
'name': _('Part Revisions'),
|
||||
'description': _('Enable revision field for Part'),
|
||||
'validator': bool,
|
||||
'default': True,
|
||||
},
|
||||
'PART_REVISION_ASSEMBLY_ONLY': {
|
||||
'name': _('Assembly Revision Only'),
|
||||
'description': _('Only allow revisions for assembly parts'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
'PART_ALLOW_DELETE_FROM_ASSEMBLY': {
|
||||
'name': _('Allow Deletion from Assembly'),
|
||||
'description': _('Allow deletion of parts which are used in an assembly'),
|
||||
@@ -1521,6 +1558,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'name': _('Part Category Default Icon'),
|
||||
'description': _('Part category default icon (empty means no icon)'),
|
||||
'default': '',
|
||||
'validator': common.validators.validate_icon,
|
||||
},
|
||||
'PART_PARAMETER_ENFORCE_UNITS': {
|
||||
'name': _('Enforce Parameter Units'),
|
||||
@@ -1742,6 +1780,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'name': _('Stock Location Default Icon'),
|
||||
'description': _('Stock location default icon (empty means no icon)'),
|
||||
'default': '',
|
||||
'validator': common.validators.validate_icon,
|
||||
},
|
||||
'STOCK_SHOW_INSTALLED_ITEMS': {
|
||||
'name': _('Show Installed Stock Items'),
|
||||
@@ -1779,6 +1818,34 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_REQUIRE_ACTIVE_PART': {
|
||||
'name': _('Require Active Part'),
|
||||
'description': _('Prevent build order creation for inactive parts'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_REQUIRE_LOCKED_PART': {
|
||||
'name': _('Require Locked Part'),
|
||||
'description': _('Prevent build order creation for unlocked parts'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_REQUIRE_VALID_BOM': {
|
||||
'name': _('Require Valid BOM'),
|
||||
'description': _(
|
||||
'Prevent build order creation unless BOM has been validated'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_REQUIRE_CLOSED_CHILDS': {
|
||||
'name': _('Require Closed Child Orders'),
|
||||
'description': _(
|
||||
'Prevent build order completion until all child orders are closed'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
|
||||
'name': _('Block Until Tests Pass'),
|
||||
'description': _(
|
||||
@@ -1908,6 +1975,38 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'LOGIN_ENABLE_SSO_GROUP_SYNC': {
|
||||
'name': _('Enable SSO group sync'),
|
||||
'description': _(
|
||||
'Enable synchronizing InvenTree groups with groups provided by the IdP'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SSO_GROUP_KEY': {
|
||||
'name': _('SSO group key'),
|
||||
'description': _(
|
||||
'The name of the groups claim attribute provided by the IdP'
|
||||
),
|
||||
'default': 'groups',
|
||||
'validator': str,
|
||||
},
|
||||
'SSO_GROUP_MAP': {
|
||||
'name': _('SSO group map'),
|
||||
'description': _(
|
||||
'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.'
|
||||
),
|
||||
'validator': json.loads,
|
||||
'default': '{}',
|
||||
},
|
||||
'SSO_REMOVE_GROUPS': {
|
||||
'name': _('Remove groups outside of SSO'),
|
||||
'description': _(
|
||||
'Whether groups assigned to the user should be removed if they are not backend by the IdP. Disabling this setting might cause security issues'
|
||||
),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'LOGIN_MAIL_REQUIRED': {
|
||||
'name': _('Email required'),
|
||||
'description': _('Require user to supply mail on signup'),
|
||||
@@ -1944,7 +2043,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
'SIGNUP_GROUP': {
|
||||
'name': _('Group on signup'),
|
||||
'description': _('Group to which new users are assigned on registration'),
|
||||
'description': _(
|
||||
'Group to which new users are assigned on registration. If SSO group sync is enabled, this group is only set if no group can be assigned from the IdP.'
|
||||
),
|
||||
'default': '',
|
||||
'choices': settings_group_options,
|
||||
},
|
||||
@@ -2425,36 +2526,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'validator': [int, MinValueValidator(0)],
|
||||
'default': 100,
|
||||
},
|
||||
'DEFAULT_PART_LABEL_TEMPLATE': {
|
||||
'name': _('Default part label template'),
|
||||
'description': _('The part label template to be automatically selected'),
|
||||
'validator': [int],
|
||||
'default': '',
|
||||
},
|
||||
'DEFAULT_ITEM_LABEL_TEMPLATE': {
|
||||
'name': _('Default stock item template'),
|
||||
'description': _(
|
||||
'The stock item label template to be automatically selected'
|
||||
),
|
||||
'validator': [int],
|
||||
'default': '',
|
||||
},
|
||||
'DEFAULT_LOCATION_LABEL_TEMPLATE': {
|
||||
'name': _('Default stock location label template'),
|
||||
'description': _(
|
||||
'The stock location label template to be automatically selected'
|
||||
),
|
||||
'validator': [int],
|
||||
'default': '',
|
||||
},
|
||||
'DEFAULT_LINE_LABEL_TEMPLATE': {
|
||||
'name': _('Default build line label template'),
|
||||
'description': _(
|
||||
'The build line label template to be automatically selected'
|
||||
),
|
||||
'validator': [int],
|
||||
'default': '',
|
||||
},
|
||||
'NOTIFICATION_ERROR_REPORT': {
|
||||
'name': _('Receive error reports'),
|
||||
'description': _('Receive notifications for system errors'),
|
||||
@@ -2542,18 +2613,27 @@ class ColorTheme(models.Model):
|
||||
name = models.CharField(max_length=20, default='', blank=True)
|
||||
|
||||
user = models.CharField(max_length=150, unique=True)
|
||||
user_obj = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
||||
@classmethod
|
||||
def get_color_themes_choices(cls):
|
||||
"""Get all color themes from static folder."""
|
||||
if not django_settings.STATIC_COLOR_THEMES_DIR.exists():
|
||||
logger.error('Theme directory does not exist')
|
||||
color_theme_dir = (
|
||||
django_settings.STATIC_COLOR_THEMES_DIR
|
||||
if django_settings.STATIC_COLOR_THEMES_DIR.exists()
|
||||
else django_settings.BASE_DIR.joinpath(
|
||||
'InvenTree', 'static', 'css', 'color-themes'
|
||||
)
|
||||
)
|
||||
|
||||
if not color_theme_dir.exists():
|
||||
logger.error(f'Theme directory "{color_theme_dir}" does not exist')
|
||||
return []
|
||||
|
||||
# Get files list from css/color-themes/ folder
|
||||
files_list = []
|
||||
|
||||
for file in django_settings.STATIC_COLOR_THEMES_DIR.iterdir():
|
||||
for file in color_theme_dir.iterdir():
|
||||
files_list.append([file.stem, file.suffix])
|
||||
|
||||
# Get color themes choices (CSS sheets)
|
||||
@@ -2992,6 +3072,11 @@ class CustomUnit(models.Model):
|
||||
https://pint.readthedocs.io/en/stable/advanced/defining.html
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Class meta options."""
|
||||
|
||||
verbose_name = _('Custom Unit')
|
||||
|
||||
def fmt_string(self):
|
||||
"""Construct a unit definition string e.g. 'dog_year = 52 * day = dy'."""
|
||||
fmt = f'{self.name} = {self.definition}'
|
||||
@@ -3001,6 +3086,18 @@ class CustomUnit(models.Model):
|
||||
|
||||
return fmt
|
||||
|
||||
def validate_unique(self, exclude=None) -> None:
|
||||
"""Ensure that the custom unit is unique."""
|
||||
super().validate_unique(exclude)
|
||||
|
||||
if self.symbol:
|
||||
if (
|
||||
CustomUnit.objects.filter(symbol=self.symbol)
|
||||
.exclude(pk=self.pk)
|
||||
.exists()
|
||||
):
|
||||
raise ValidationError({'symbol': _('Unit symbol must be unique')})
|
||||
|
||||
def clean(self):
|
||||
"""Validate that the provided custom unit is indeed valid."""
|
||||
super().clean()
|
||||
@@ -3042,7 +3139,6 @@ class CustomUnit(models.Model):
|
||||
max_length=10,
|
||||
verbose_name=_('Symbol'),
|
||||
help_text=_('Optional unit symbol'),
|
||||
unique=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@@ -3062,3 +3158,184 @@ def after_custom_unit_updated(sender, instance, **kwargs):
|
||||
from InvenTree.conversion import reload_unit_registry
|
||||
|
||||
reload_unit_registry()
|
||||
|
||||
|
||||
def rename_attachment(instance, filename):
|
||||
"""Callback function to rename an uploaded attachment file.
|
||||
|
||||
Arguments:
|
||||
- instance: The Attachment instance
|
||||
- filename: The original filename of the uploaded file
|
||||
|
||||
Returns:
|
||||
- The new filename for the uploaded file, e.g. 'attachments/<model_type>/<model_id>/<filename>'
|
||||
"""
|
||||
# Remove any illegal characters from the filename
|
||||
illegal_chars = '\'"\\`~#|!@#$%^&*()[]{}<>?;:+=,'
|
||||
|
||||
for c in illegal_chars:
|
||||
filename = filename.replace(c, '')
|
||||
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Generate a new filename for the attachment
|
||||
return os.path.join(
|
||||
'attachments', str(instance.model_type), str(instance.model_id), filename
|
||||
)
|
||||
|
||||
|
||||
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""Class which represents an uploaded file attachment.
|
||||
|
||||
An attachment can be either an uploaded file, or an external URL.
|
||||
|
||||
Attributes:
|
||||
attachment: The uploaded file
|
||||
url: An external URL
|
||||
comment: A comment or description for the attachment
|
||||
user: The user who uploaded the attachment
|
||||
upload_date: The date the attachment was uploaded
|
||||
file_size: The size of the uploaded file
|
||||
metadata: Arbitrary metadata for the attachment (inherit from MetadataMixin)
|
||||
tags: Tags for the attachment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
verbose_name = _('Attachment')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom 'save' method for the Attachment model.
|
||||
|
||||
- Record the file size of the uploaded attachment (if applicable)
|
||||
- Ensure that the 'content_type' and 'object_id' fields are set
|
||||
- Run extra validations
|
||||
"""
|
||||
# Either 'attachment' or 'link' must be specified!
|
||||
if not self.attachment and not self.link:
|
||||
raise ValidationError({
|
||||
'attachment': _('Missing file'),
|
||||
'link': _('Missing external link'),
|
||||
})
|
||||
|
||||
if self.attachment:
|
||||
if self.attachment.name.lower().endswith('.svg'):
|
||||
self.attachment.file.file = self.clean_svg(self.attachment)
|
||||
else:
|
||||
self.file_size = 0
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update file size
|
||||
if self.file_size == 0 and self.attachment:
|
||||
# Get file size
|
||||
if default_storage.exists(self.attachment.name):
|
||||
try:
|
||||
self.file_size = default_storage.size(self.attachment.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.file_size != 0:
|
||||
super().save()
|
||||
|
||||
def clean_svg(self, field):
|
||||
"""Sanitize SVG file before saving."""
|
||||
cleaned = sanitize_svg(field.file.read())
|
||||
return BytesIO(bytes(cleaned, 'utf8'))
|
||||
|
||||
def __str__(self):
|
||||
"""Human name for attachment."""
|
||||
if self.attachment is not None:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return str(self.link)
|
||||
|
||||
model_type = models.CharField(
|
||||
max_length=100,
|
||||
validators=[common.validators.validate_attachment_model_type],
|
||||
help_text=_('Target model type for this image'),
|
||||
)
|
||||
|
||||
model_id = models.PositiveIntegerField()
|
||||
|
||||
attachment = models.FileField(
|
||||
upload_to=rename_attachment,
|
||||
verbose_name=_('Attachment'),
|
||||
help_text=_('Select file to attach'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Link'),
|
||||
help_text=_('Link to external URL'),
|
||||
)
|
||||
|
||||
comment = models.CharField(
|
||||
blank=True,
|
||||
max_length=250,
|
||||
verbose_name=_('Comment'),
|
||||
help_text=_('Attachment comment'),
|
||||
)
|
||||
|
||||
upload_user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User'),
|
||||
)
|
||||
|
||||
upload_date = models.DateField(
|
||||
auto_now_add=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Upload date'),
|
||||
help_text=_('Date the file was uploaded'),
|
||||
)
|
||||
|
||||
file_size = models.PositiveIntegerField(
|
||||
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
|
||||
)
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
"""Base name/path for attachment."""
|
||||
if self.attachment:
|
||||
return os.path.basename(self.attachment.name)
|
||||
return None
|
||||
|
||||
def fully_qualified_url(self):
|
||||
"""Return a 'fully qualified' URL for this attachment.
|
||||
|
||||
- If the attachment is a link to an external resource, return the link
|
||||
- If the attachment is an uploaded file, return the fully qualified media URL
|
||||
"""
|
||||
if self.link:
|
||||
return self.link
|
||||
|
||||
if self.attachment:
|
||||
import InvenTree.helpers_model
|
||||
|
||||
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
|
||||
return InvenTree.helpers_model.construct_absolute_url(media_url)
|
||||
|
||||
return ''
|
||||
|
||||
def check_permission(self, permission, user):
|
||||
"""Check if the user has the required permission for this attachment."""
|
||||
from InvenTree.models import InvenTreeAttachmentMixin
|
||||
|
||||
model_class = common.validators.attachment_model_class_from_label(
|
||||
self.model_type
|
||||
)
|
||||
|
||||
if not issubclass(model_class, InvenTreeAttachmentMixin):
|
||||
raise ValidationError(_('Invalid model type specified for attachment'))
|
||||
|
||||
return model_class.check_attachment_permission(permission, user)
|
||||
|
@@ -9,13 +9,20 @@ import django_q.models
|
||||
from error_report.models import Error
|
||||
from flags.state import flag_state
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.models as common_models
|
||||
import common.validators
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.helpers import get_objectreference
|
||||
from InvenTree.helpers_model import construct_absolute_url
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializerField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from plugin import registry as plugin_registry
|
||||
from users.serializers import OwnerSerializer
|
||||
@@ -288,7 +295,8 @@ class NotesImageSerializer(InvenTreeModelSerializer):
|
||||
image = InvenTreeImageSerializerField(required=True)
|
||||
|
||||
|
||||
class ProjectCodeSerializer(InvenTreeModelSerializer):
|
||||
@register_importer()
|
||||
class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for the ProjectCode model."""
|
||||
|
||||
class Meta:
|
||||
@@ -336,7 +344,8 @@ class ContentTypeSerializer(serializers.Serializer):
|
||||
return obj.app_label in plugin_registry.installed_apps
|
||||
|
||||
|
||||
class CustomUnitSerializer(InvenTreeModelSerializer):
|
||||
@register_importer()
|
||||
class CustomUnitSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""DRF serializer for CustomUnit model."""
|
||||
|
||||
class Meta:
|
||||
@@ -474,3 +483,103 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
|
||||
pk = serializers.CharField(source='id', read_only=True)
|
||||
|
||||
result = serializers.CharField()
|
||||
|
||||
|
||||
class AttachmentSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer class for the Attachment model."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
model = common_models.Attachment
|
||||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'upload_user',
|
||||
'user_detail',
|
||||
'file_size',
|
||||
'model_type',
|
||||
'model_id',
|
||||
'tags',
|
||||
]
|
||||
|
||||
read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override the model_type field to provide dynamic choices."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if len(self.fields['model_type'].choices) == 0:
|
||||
self.fields[
|
||||
'model_type'
|
||||
].choices = common.validators.attachment_model_options()
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
user_detail = UserSerializer(source='upload_user', read_only=True, many=False)
|
||||
|
||||
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
|
||||
|
||||
# The 'filename' field must be present in the serializer
|
||||
filename = serializers.CharField(
|
||||
label=_('Filename'), required=False, source='basename', allow_blank=False
|
||||
)
|
||||
|
||||
upload_date = serializers.DateField(read_only=True)
|
||||
|
||||
# Note: The choices are overridden at run-time on class initialization
|
||||
model_type = serializers.ChoiceField(
|
||||
label=_('Model Type'),
|
||||
choices=common.validators.attachment_model_options(),
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
allow_null=False,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Override the save method to handle the model_type field."""
|
||||
from InvenTree.models import InvenTreeAttachmentMixin
|
||||
|
||||
model_type = self.validated_data.get('model_type', None)
|
||||
|
||||
# Ensure that the user has permission to attach files to the specified model
|
||||
user = self.context.get('request').user
|
||||
|
||||
target_model_class = common.validators.attachment_model_class_from_label(
|
||||
model_type
|
||||
)
|
||||
|
||||
if not issubclass(target_model_class, InvenTreeAttachmentMixin):
|
||||
raise PermissionDenied(_('Invalid model type specified for attachment'))
|
||||
|
||||
# Check that the user has the required permissions to attach files to the target model
|
||||
if not target_model_class.check_attachment_permission('change', user):
|
||||
raise PermissionDenied(
|
||||
_(
|
||||
'User does not have permission to create or edit attachments for this model'
|
||||
)
|
||||
)
|
||||
|
||||
return super().save()
|
||||
|
||||
|
||||
class IconSerializer(serializers.Serializer):
|
||||
"""Serializer for an icon."""
|
||||
|
||||
name = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
tags = serializers.ListField(child=serializers.CharField())
|
||||
variants = serializers.DictField(child=serializers.CharField())
|
||||
|
||||
|
||||
class IconPackageSerializer(serializers.Serializer):
|
||||
"""Serializer for a list of icons."""
|
||||
|
||||
name = serializers.CharField()
|
||||
prefix = serializers.CharField()
|
||||
fonts = serializers.DictField(child=serializers.CharField())
|
||||
icons = serializers.DictField(child=IconSerializer())
|
||||
|
@@ -1,11 +1,19 @@
|
||||
"""User-configurable settings for the common app."""
|
||||
|
||||
from os import environ
|
||||
|
||||
def get_global_setting(key, backup_value=None, **kwargs):
|
||||
|
||||
def get_global_setting(key, backup_value=None, enviroment_key=None, **kwargs):
|
||||
"""Return the value of a global setting using the provided key."""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
kwargs['backup_value'] = backup_value
|
||||
if enviroment_key:
|
||||
value = environ.get(enviroment_key)
|
||||
if value:
|
||||
return value
|
||||
|
||||
if backup_value is not None:
|
||||
kwargs['backup_value'] = backup_value
|
||||
|
||||
return InvenTreeSetting.get_setting(key, **kwargs)
|
||||
|
||||
@@ -25,7 +33,9 @@ def get_user_setting(key, user, backup_value=None, **kwargs):
|
||||
from common.models import InvenTreeUserSetting
|
||||
|
||||
kwargs['user'] = user
|
||||
kwargs['backup_value'] = backup_value
|
||||
|
||||
if backup_value is not None:
|
||||
kwargs['backup_value'] = backup_value
|
||||
|
||||
return InvenTreeUserSetting.get_setting(key, **kwargs)
|
||||
|
||||
|
210
src/backend/InvenTree/common/test_migrations.py
Normal file
210
src/backend/InvenTree/common/test_migrations.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Data migration unit tests for the 'common' app."""
|
||||
|
||||
import io
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||
|
||||
from InvenTree import unit_test
|
||||
|
||||
|
||||
def get_legacy_models():
|
||||
"""Return a set of legacy attachment models."""
|
||||
# Legacy attachment types to convert:
|
||||
# app_label, table name, target model, model ref
|
||||
return [
|
||||
('build', 'BuildOrderAttachment', 'build', 'build'),
|
||||
('company', 'CompanyAttachment', 'company', 'company'),
|
||||
(
|
||||
'company',
|
||||
'ManufacturerPartAttachment',
|
||||
'manufacturerpart',
|
||||
'manufacturer_part',
|
||||
),
|
||||
('order', 'PurchaseOrderAttachment', 'purchaseorder', 'order'),
|
||||
('order', 'SalesOrderAttachment', 'salesorder', 'order'),
|
||||
('order', 'ReturnOrderAttachment', 'returnorder', 'order'),
|
||||
('part', 'PartAttachment', 'part', 'part'),
|
||||
('stock', 'StockItemAttachment', 'stockitem', 'stock_item'),
|
||||
]
|
||||
|
||||
|
||||
def generate_attachment():
|
||||
"""Generate a file attachment object for test upload."""
|
||||
file_object = io.StringIO('Some dummy data')
|
||||
file_object.seek(0)
|
||||
|
||||
return ContentFile(file_object.getvalue(), 'test.txt')
|
||||
|
||||
|
||||
class TestForwardMigrations(MigratorTestCase):
|
||||
"""Test entire schema migration sequence for the common app."""
|
||||
|
||||
migrate_from = ('common', '0024_notesimage_model_id_notesimage_model_type')
|
||||
migrate_to = ('common', unit_test.getNewestMigrationFile('common'))
|
||||
|
||||
def prepare(self):
|
||||
"""Create initial data.
|
||||
|
||||
Legacy attachment model types are:
|
||||
- BuildOrderAttachment
|
||||
- CompanyAttachment
|
||||
- ManufacturerPartAttachment
|
||||
- PurchaseOrderAttachment
|
||||
- SalesOrderAttachment
|
||||
- ReturnOrderAttachment
|
||||
- PartAttachment
|
||||
- StockItemAttachment
|
||||
"""
|
||||
# Dummy MPPT data
|
||||
tree = {'tree_id': 0, 'level': 0, 'lft': 0, 'rght': 0}
|
||||
|
||||
# BuildOrderAttachment
|
||||
Part = self.old_state.apps.get_model('part', 'Part')
|
||||
Build = self.old_state.apps.get_model('build', 'Build')
|
||||
|
||||
part = Part.objects.create(
|
||||
name='Test Part',
|
||||
description='Test Part Description',
|
||||
active=True,
|
||||
assembly=True,
|
||||
purchaseable=True,
|
||||
**tree,
|
||||
)
|
||||
|
||||
build = Build.objects.create(part=part, title='Test Build', quantity=10, **tree)
|
||||
|
||||
PartAttachment = self.old_state.apps.get_model('part', 'PartAttachment')
|
||||
PartAttachment.objects.create(
|
||||
part=part, attachment=generate_attachment(), comment='Test file attachment'
|
||||
)
|
||||
PartAttachment.objects.create(
|
||||
part=part, link='http://example.com', comment='Test link attachment'
|
||||
)
|
||||
self.assertEqual(PartAttachment.objects.count(), 2)
|
||||
|
||||
BuildOrderAttachment = self.old_state.apps.get_model(
|
||||
'build', 'BuildOrderAttachment'
|
||||
)
|
||||
BuildOrderAttachment.objects.create(
|
||||
build=build, link='http://example.com', comment='Test comment'
|
||||
)
|
||||
BuildOrderAttachment.objects.create(
|
||||
build=build, attachment=generate_attachment(), comment='a test file'
|
||||
)
|
||||
self.assertEqual(BuildOrderAttachment.objects.count(), 2)
|
||||
|
||||
StockItem = self.old_state.apps.get_model('stock', 'StockItem')
|
||||
StockItemAttachment = self.old_state.apps.get_model(
|
||||
'stock', 'StockItemAttachment'
|
||||
)
|
||||
|
||||
item = StockItem.objects.create(part=part, quantity=10, **tree)
|
||||
|
||||
StockItemAttachment.objects.create(
|
||||
stock_item=item,
|
||||
attachment=generate_attachment(),
|
||||
comment='Test file attachment',
|
||||
)
|
||||
StockItemAttachment.objects.create(
|
||||
stock_item=item, link='http://example.com', comment='Test link attachment'
|
||||
)
|
||||
self.assertEqual(StockItemAttachment.objects.count(), 2)
|
||||
|
||||
Company = self.old_state.apps.get_model('company', 'Company')
|
||||
CompanyAttachment = self.old_state.apps.get_model(
|
||||
'company', 'CompanyAttachment'
|
||||
)
|
||||
|
||||
company = Company.objects.create(
|
||||
name='Test Company',
|
||||
description='Test Company Description',
|
||||
is_customer=True,
|
||||
is_manufacturer=True,
|
||||
is_supplier=True,
|
||||
)
|
||||
|
||||
CompanyAttachment.objects.create(
|
||||
company=company,
|
||||
attachment=generate_attachment(),
|
||||
comment='Test file attachment',
|
||||
)
|
||||
CompanyAttachment.objects.create(
|
||||
company=company, link='http://example.com', comment='Test link attachment'
|
||||
)
|
||||
self.assertEqual(CompanyAttachment.objects.count(), 2)
|
||||
|
||||
PurchaseOrder = self.old_state.apps.get_model('order', 'PurchaseOrder')
|
||||
PurchaseOrderAttachment = self.old_state.apps.get_model(
|
||||
'order', 'PurchaseOrderAttachment'
|
||||
)
|
||||
|
||||
po = PurchaseOrder.objects.create(
|
||||
reference='PO-12345',
|
||||
supplier=company,
|
||||
description='Test Purchase Order Description',
|
||||
)
|
||||
|
||||
PurchaseOrderAttachment.objects.create(
|
||||
order=po, attachment=generate_attachment(), comment='Test file attachment'
|
||||
)
|
||||
PurchaseOrderAttachment.objects.create(
|
||||
order=po, link='http://example.com', comment='Test link attachment'
|
||||
)
|
||||
self.assertEqual(PurchaseOrderAttachment.objects.count(), 2)
|
||||
|
||||
SalesOrder = self.old_state.apps.get_model('order', 'SalesOrder')
|
||||
SalesOrderAttachment = self.old_state.apps.get_model(
|
||||
'order', 'SalesOrderAttachment'
|
||||
)
|
||||
|
||||
so = SalesOrder.objects.create(
|
||||
reference='SO-12345',
|
||||
customer=company,
|
||||
description='Test Sales Order Description',
|
||||
)
|
||||
|
||||
SalesOrderAttachment.objects.create(
|
||||
order=so, attachment=generate_attachment(), comment='Test file attachment'
|
||||
)
|
||||
SalesOrderAttachment.objects.create(
|
||||
order=so, link='http://example.com', comment='Test link attachment'
|
||||
)
|
||||
self.assertEqual(SalesOrderAttachment.objects.count(), 2)
|
||||
|
||||
ReturnOrder = self.old_state.apps.get_model('order', 'ReturnOrder')
|
||||
ReturnOrderAttachment = self.old_state.apps.get_model(
|
||||
'order', 'ReturnOrderAttachment'
|
||||
)
|
||||
|
||||
ro = ReturnOrder.objects.create(
|
||||
reference='RO-12345',
|
||||
customer=company,
|
||||
description='Test Return Order Description',
|
||||
)
|
||||
|
||||
ReturnOrderAttachment.objects.create(
|
||||
order=ro, attachment=generate_attachment(), comment='Test file attachment'
|
||||
)
|
||||
ReturnOrderAttachment.objects.create(
|
||||
order=ro, link='http://example.com', comment='Test link attachment'
|
||||
)
|
||||
self.assertEqual(ReturnOrderAttachment.objects.count(), 2)
|
||||
|
||||
def test_items_exist(self):
|
||||
"""Test to ensure that the attachments are correctly migrated."""
|
||||
Attachment = self.new_state.apps.get_model('common', 'Attachment')
|
||||
|
||||
self.assertEqual(Attachment.objects.count(), 14)
|
||||
|
||||
for model in [
|
||||
'build',
|
||||
'company',
|
||||
'purchaseorder',
|
||||
'returnorder',
|
||||
'salesorder',
|
||||
'part',
|
||||
'stockitem',
|
||||
]:
|
||||
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)
|
@@ -1 +0,0 @@
|
||||
"""Unit tests for the views associated with the 'common' app."""
|
@@ -11,6 +11,8 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.test.utils import override_settings
|
||||
@@ -18,14 +20,17 @@ from django.urls import reverse
|
||||
|
||||
import PIL
|
||||
|
||||
import common.validators
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
|
||||
from part.models import Part
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting
|
||||
|
||||
from .api import WebhookView
|
||||
from .models import (
|
||||
Attachment,
|
||||
ColorTheme,
|
||||
CustomUnit,
|
||||
InvenTreeSetting,
|
||||
@@ -41,6 +46,131 @@ from .models import (
|
||||
CONTENT_TYPE_JSON = 'application/json'
|
||||
|
||||
|
||||
class AttachmentTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the 'Attachment' model."""
|
||||
|
||||
fixtures = ['part', 'category', 'location']
|
||||
|
||||
def generate_file(self, fn: str):
|
||||
"""Generate an attachment file object."""
|
||||
file_object = io.StringIO('Some dummy data')
|
||||
file_object.seek(0)
|
||||
|
||||
return ContentFile(file_object.getvalue(), fn)
|
||||
|
||||
def test_filename_validation(self):
|
||||
"""Test that the filename validation works as expected.
|
||||
|
||||
The django file-upload mechanism should sanitize filenames correctly.
|
||||
"""
|
||||
part = Part.objects.first()
|
||||
|
||||
filenames = {
|
||||
'test.txt': 'test.txt',
|
||||
'r####at.mp4': 'rat.mp4',
|
||||
'../../../win32.dll': 'win32.dll',
|
||||
'ABC!@#$%^&&&&&&&)-XYZ-(**&&&\\/QqQ.sqlite': 'QqQ.sqlite',
|
||||
'/var/log/inventree.log': 'inventree.log',
|
||||
'c:\\Users\\admin\\passwd.txt': 'cUsersadminpasswd.txt',
|
||||
'8&&&8.txt': '88.txt',
|
||||
}
|
||||
|
||||
for fn, expected in filenames.items():
|
||||
attachment = Attachment.objects.create(
|
||||
attachment=self.generate_file(fn),
|
||||
comment=f'Testing filename: {fn}',
|
||||
model_type='part',
|
||||
model_id=part.pk,
|
||||
)
|
||||
|
||||
expected_path = f'attachments/part/{part.pk}/{expected}'
|
||||
self.assertEqual(attachment.attachment.name, expected_path)
|
||||
self.assertEqual(attachment.file_size, 15)
|
||||
|
||||
self.assertEqual(part.attachments.count(), len(filenames.keys()))
|
||||
|
||||
# Delete any attachments after the test is completed
|
||||
for attachment in part.attachments.all():
|
||||
path = attachment.attachment.name
|
||||
attachment.delete()
|
||||
|
||||
# Remove uploaded files to prevent them sticking around
|
||||
if default_storage.exists(path):
|
||||
default_storage.delete(path)
|
||||
|
||||
self.assertEqual(
|
||||
Attachment.objects.filter(model_type='part', model_id=part.pk).count(), 0
|
||||
)
|
||||
|
||||
def test_mixin(self):
|
||||
"""Test that the mixin class works as expected."""
|
||||
part = Part.objects.first()
|
||||
|
||||
self.assertEqual(part.attachments.count(), 0)
|
||||
|
||||
part.create_attachment(
|
||||
attachment=self.generate_file('test.txt'), comment='Hello world'
|
||||
)
|
||||
|
||||
self.assertEqual(part.attachments.count(), 1)
|
||||
|
||||
attachment = part.attachments.first()
|
||||
|
||||
self.assertEqual(attachment.comment, 'Hello world')
|
||||
self.assertIn(f'attachments/part/{part.pk}/test', attachment.attachment.name)
|
||||
|
||||
def test_upload_via_api(self):
|
||||
"""Test that we can upload attachments via the API."""
|
||||
part = Part.objects.first()
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
data = {
|
||||
'model_type': 'part',
|
||||
'model_id': part.pk,
|
||||
'link': 'https://www.google.com',
|
||||
'comment': 'Some appropriate comment',
|
||||
}
|
||||
|
||||
# Start without appropriate permissions
|
||||
# User must have 'part.change' to upload an attachment against a Part instance
|
||||
self.logout()
|
||||
self.user.is_staff = False
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
self.clearRoles()
|
||||
|
||||
# Check without login (401)
|
||||
response = self.post(url, data, expected_code=401)
|
||||
|
||||
self.login()
|
||||
|
||||
response = self.post(url, data, expected_code=403)
|
||||
|
||||
self.assertIn(
|
||||
'User does not have permission to create or edit attachments for this model',
|
||||
str(response.data['detail']),
|
||||
)
|
||||
|
||||
# Add the required permission
|
||||
self.assignRole('part.change')
|
||||
|
||||
# Upload should now work!
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Try to delete the attachment via API (should fail)
|
||||
attachment = part.attachments.first()
|
||||
url = reverse('api-attachment-detail', kwargs={'pk': attachment.pk})
|
||||
response = self.delete(url, expected_code=403)
|
||||
self.assertIn(
|
||||
'User does not have permission to delete this attachment',
|
||||
str(response.data['detail']),
|
||||
)
|
||||
|
||||
# Assign 'delete' permission to 'part' model
|
||||
self.assignRole('part.delete')
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
|
||||
class SettingsTest(InvenTreeTestCase):
|
||||
"""Tests for the 'settings' model."""
|
||||
|
||||
@@ -277,8 +407,6 @@ class SettingsTest(InvenTreeTestCase):
|
||||
@override_settings(SITE_URL=None, PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True)
|
||||
def test_defaults(self):
|
||||
"""Populate the settings with default values."""
|
||||
N = len(InvenTreeSetting.SETTINGS.keys())
|
||||
|
||||
for key in InvenTreeSetting.SETTINGS.keys():
|
||||
value = InvenTreeSetting.get_setting_default(key)
|
||||
|
||||
@@ -973,7 +1101,6 @@ class CommonTest(InvenTreeAPITestCase):
|
||||
|
||||
def test_restart_flag(self):
|
||||
"""Test that the restart flag is reset on start."""
|
||||
import common.models
|
||||
from plugin import registry
|
||||
|
||||
# set flag true
|
||||
@@ -1070,20 +1197,10 @@ class ColorThemeTest(TestCase):
|
||||
def test_choices(self):
|
||||
"""Test that default choices are returned."""
|
||||
result = ColorTheme.get_color_themes_choices()
|
||||
|
||||
# skip due to directories not being set up
|
||||
if not result:
|
||||
return # pragma: no cover
|
||||
self.assertIn(('default', 'Default'), result)
|
||||
|
||||
def test_valid_choice(self):
|
||||
"""Check that is_valid_choice works correctly."""
|
||||
result = ColorTheme.get_color_themes_choices()
|
||||
|
||||
# skip due to directories not being set up
|
||||
if not result:
|
||||
return # pragma: no cover
|
||||
|
||||
# check wrong reference
|
||||
self.assertFalse(ColorTheme.is_valid_choice('abcdd'))
|
||||
|
||||
@@ -1247,7 +1364,7 @@ class ProjectCodesTest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'project code with this Project Code already exists',
|
||||
'Project Code with this Project Code already exists',
|
||||
str(response.data['code']),
|
||||
)
|
||||
|
||||
@@ -1405,3 +1522,44 @@ class ContentTypeAPITest(InvenTreeAPITestCase):
|
||||
reverse('api-contenttype-detail-modelname', kwargs={'model': None}),
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
|
||||
class IconAPITest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the Icons API."""
|
||||
|
||||
def test_list(self):
|
||||
"""Test API list functionality."""
|
||||
response = self.get(reverse('api-icon-list'), expected_code=200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
self.assertEqual(response.data[0]['prefix'], 'ti')
|
||||
self.assertEqual(response.data[0]['name'], 'Tabler Icons')
|
||||
for font_format in ['woff2', 'woff', 'truetype']:
|
||||
self.assertIn(font_format, response.data[0]['fonts'])
|
||||
|
||||
self.assertGreater(len(response.data[0]['icons']), 1000)
|
||||
|
||||
|
||||
class ValidatorsTest(TestCase):
|
||||
"""Unit tests for the custom validators."""
|
||||
|
||||
def test_validate_icon(self):
|
||||
"""Test the validate_icon function."""
|
||||
common.validators.validate_icon('')
|
||||
common.validators.validate_icon(None)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
common.validators.validate_icon('invalid')
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
common.validators.validate_icon('my:package:non-existing')
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
common.validators.validate_icon(
|
||||
'ti:my-non-existing-icon:non-existing-variant'
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
common.validators.validate_icon('ti:package:non-existing-variant')
|
||||
|
||||
common.validators.validate_icon('ti:package:outline')
|
||||
|
@@ -1,13 +1,53 @@
|
||||
"""Validation helpers for common models."""
|
||||
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.icons
|
||||
from common.settings import get_global_setting
|
||||
|
||||
|
||||
def attachment_model_types():
|
||||
"""Return a list of valid attachment model choices."""
|
||||
import InvenTree.models
|
||||
|
||||
return list(
|
||||
InvenTree.helpers_model.getModelsWithMixin(
|
||||
InvenTree.models.InvenTreeAttachmentMixin
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def attachment_model_options():
|
||||
"""Return a list of options for models which support attachments."""
|
||||
return [
|
||||
(model.__name__.lower(), model._meta.verbose_name)
|
||||
for model in attachment_model_types()
|
||||
]
|
||||
|
||||
|
||||
def attachment_model_class_from_label(label: str):
|
||||
"""Return the model class for the given label."""
|
||||
if not label:
|
||||
raise ValidationError(_('No attachment model type provided'))
|
||||
|
||||
for model in attachment_model_types():
|
||||
if model.__name__.lower() == label.lower():
|
||||
return model
|
||||
|
||||
raise ValidationError(_('Invalid attachment model type') + f": '{label}'")
|
||||
|
||||
|
||||
def validate_attachment_model_type(value):
|
||||
"""Ensure that the provided attachment model is valid."""
|
||||
model_names = [el[0] for el in attachment_model_options()]
|
||||
if value not in model_names:
|
||||
raise ValidationError('Model type does not support attachments')
|
||||
|
||||
|
||||
def validate_notes_model_type(value):
|
||||
"""Ensure that the provided model type is valid.
|
||||
|
||||
@@ -65,3 +105,11 @@ def validate_email_domains(setting):
|
||||
raise ValidationError(_('An empty domain is not allowed.'))
|
||||
if not re.match(r'^@[a-zA-Z0-9\.\-_]+$', domain):
|
||||
raise ValidationError(_(f'Invalid domain name: {domain}'))
|
||||
|
||||
|
||||
def validate_icon(name: Union[str, None]):
|
||||
"""Validate the provided icon name, and ignore if empty."""
|
||||
if name == '' or name is None:
|
||||
return
|
||||
|
||||
common.icons.validate_icon(name)
|
||||
|
@@ -6,6 +6,8 @@ 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
|
||||
|
||||
@@ -14,7 +16,6 @@ from .models import (
|
||||
Company,
|
||||
Contact,
|
||||
ManufacturerPart,
|
||||
ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter,
|
||||
SupplierPart,
|
||||
SupplierPriceBreak,
|
||||
@@ -34,9 +35,10 @@ class CompanyResource(InvenTreeResource):
|
||||
|
||||
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(ImportExportModelAdmin):
|
||||
class CompanyAdmin(importer.admin.DataExportAdmin, ImportExportModelAdmin):
|
||||
"""Admin class for the Company model."""
|
||||
|
||||
serializer_class = company.serializers.CompanySerializer
|
||||
resource_class = CompanyResource
|
||||
|
||||
list_display = ('name', 'website', 'contact')
|
||||
@@ -120,15 +122,6 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('part', 'manufacturer')
|
||||
|
||||
|
||||
@admin.register(ManufacturerPartAttachment)
|
||||
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for ManufacturerPartAttachment model."""
|
||||
|
||||
list_display = ('manufacturer_part', 'attachment', 'comment')
|
||||
|
||||
autocomplete_fields = ('manufacturer_part',)
|
||||
|
||||
|
||||
class ManufacturerPartParameterResource(InvenTreeResource):
|
||||
"""Class for managing ManufacturerPartParameter data import/export."""
|
||||
|
||||
|
@@ -7,32 +7,25 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
import part.models
|
||||
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
)
|
||||
from importer.mixins import DataExportViewMixin
|
||||
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
|
||||
from .models import (
|
||||
Address,
|
||||
Company,
|
||||
CompanyAttachment,
|
||||
Contact,
|
||||
ManufacturerPart,
|
||||
ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter,
|
||||
SupplierPart,
|
||||
SupplierPriceBreak,
|
||||
)
|
||||
from .serializers import (
|
||||
AddressSerializer,
|
||||
CompanyAttachmentSerializer,
|
||||
CompanySerializer,
|
||||
ContactSerializer,
|
||||
ManufacturerPartAttachmentSerializer,
|
||||
ManufacturerPartParameterSerializer,
|
||||
ManufacturerPartSerializer,
|
||||
SupplierPartSerializer,
|
||||
@@ -40,7 +33,7 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
class CompanyList(ListCreateAPI):
|
||||
class CompanyList(DataExportViewMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of Company objects.
|
||||
|
||||
Provides two methods:
|
||||
@@ -88,23 +81,7 @@ class CompanyDetail(RetrieveUpdateDestroyAPI):
|
||||
return queryset
|
||||
|
||||
|
||||
class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a CompanyAttachment."""
|
||||
|
||||
queryset = CompanyAttachment.objects.all()
|
||||
serializer_class = CompanyAttachmentSerializer
|
||||
|
||||
filterset_fields = ['company']
|
||||
|
||||
|
||||
class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for CompanyAttachment model."""
|
||||
|
||||
queryset = CompanyAttachment.objects.all()
|
||||
serializer_class = CompanyAttachmentSerializer
|
||||
|
||||
|
||||
class ContactList(ListCreateDestroyAPIView):
|
||||
class ContactList(DataExportViewMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of Company model."""
|
||||
|
||||
queryset = Contact.objects.all()
|
||||
@@ -128,7 +105,7 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = ContactSerializer
|
||||
|
||||
|
||||
class AddressList(ListCreateDestroyAPIView):
|
||||
class AddressList(DataExportViewMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of Address model."""
|
||||
|
||||
queryset = Address.objects.all()
|
||||
@@ -169,7 +146,7 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartList(ListCreateDestroyAPIView):
|
||||
class ManufacturerPartList(DataExportViewMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of ManufacturerPart object.
|
||||
|
||||
- GET: Return list of ManufacturerPart objects
|
||||
@@ -227,22 +204,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = ManufacturerPartSerializer
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing, creating and bulk deleting a ManufacturerPartAttachment (file upload)."""
|
||||
|
||||
queryset = ManufacturerPartAttachment.objects.all()
|
||||
serializer_class = ManufacturerPartAttachmentSerializer
|
||||
|
||||
filterset_fields = ['manufacturer_part']
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpooint for ManufacturerPartAttachment model."""
|
||||
|
||||
queryset = ManufacturerPartAttachment.objects.all()
|
||||
serializer_class = ManufacturerPartAttachmentSerializer
|
||||
|
||||
|
||||
class ManufacturerPartParameterFilter(rest_filters.FilterSet):
|
||||
"""Custom filterset for the ManufacturerPartParameterList API endpoint."""
|
||||
|
||||
@@ -333,7 +294,7 @@ class SupplierPartFilter(rest_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class SupplierPartList(ListCreateDestroyAPIView):
|
||||
class SupplierPartList(DataExportViewMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of SupplierPart object.
|
||||
|
||||
- GET: Return list of SupplierPart objects
|
||||
@@ -509,22 +470,6 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
|
||||
manufacturer_part_api_urls = [
|
||||
# Base URL for ManufacturerPartAttachment API endpoints
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
ManufacturerPartAttachmentDetail.as_view(),
|
||||
name='api-manufacturer-part-attachment-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
ManufacturerPartAttachmentList.as_view(),
|
||||
name='api-manufacturer-part-attachment-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'parameter/',
|
||||
include([
|
||||
@@ -611,19 +556,6 @@ company_api_urls = [
|
||||
path('', CompanyDetail.as_view(), name='api-company-detail'),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'attachment/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
CompanyAttachmentDetail.as_view(),
|
||||
name='api-company-attachment-detail',
|
||||
),
|
||||
path(
|
||||
'', CompanyAttachmentList.as_view(), name='api-company-attachment-list'
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'contact/',
|
||||
include([
|
||||
|
@@ -31,6 +31,9 @@ class Migration(migrations.Migration):
|
||||
('is_customer', models.BooleanField(default=False, help_text='Do you sell items to this company?')),
|
||||
('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Company',
|
||||
}
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Contact',
|
||||
@@ -41,6 +44,9 @@ class Migration(migrations.Migration):
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('role', models.CharField(blank=True, max_length=100)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Contact',
|
||||
}
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SupplierPart',
|
||||
@@ -60,6 +66,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'db_table': 'part_supplierpart',
|
||||
'verbose_name': 'Supplier Part',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -71,6 +78,7 @@ class Migration(migrations.Migration):
|
||||
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricebreaks', to='company.SupplierPart')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Supplier Price Break',
|
||||
'db_table': 'part_supplierpricebreak',
|
||||
},
|
||||
),
|
||||
|
@@ -12,6 +12,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='company',
|
||||
options={'ordering': ['name']},
|
||||
options={'ordering': ['name'], 'verbose_name': 'Company'},
|
||||
),
|
||||
]
|
||||
|
@@ -23,17 +23,17 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='is_customer',
|
||||
field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='is customer'),
|
||||
field=models.BooleanField(default=False, help_text='Do you sell items to this company?', verbose_name='Is customer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='is_manufacturer',
|
||||
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='is manufacturer'),
|
||||
field=models.BooleanField(default=False, help_text='Does this company manufacture parts?', verbose_name='Is manufacturer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
name='is_supplier',
|
||||
field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='is supplier'),
|
||||
field=models.BooleanField(default=True, help_text='Do you purchase items from this company?', verbose_name='Is supplier'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='company',
|
||||
|
@@ -22,6 +22,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'unique_together': {('part', 'manufacturer', 'MPN')},
|
||||
'verbose_name': 'Manufacturer Part',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@@ -21,6 +21,7 @@ class Migration(migrations.Migration):
|
||||
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Manufacturer Part Parameter',
|
||||
'unique_together': {('manufacturer_part', 'name')},
|
||||
},
|
||||
),
|
||||
|
@@ -12,6 +12,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='company',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'Companies'},
|
||||
options={'ordering': ['name'], 'verbose_name': 'Company', 'verbose_name_plural': 'Companies'},
|
||||
),
|
||||
]
|
||||
|
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
name='ManufacturerPartAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
|
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
name='CompanyAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to='attachments', verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
|
@@ -12,7 +12,10 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='address',
|
||||
options={'verbose_name_plural': 'Addresses'},
|
||||
options={
|
||||
'verbose_name': 'Address',
|
||||
'verbose_name_plural': 'Addresses'
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='address',
|
||||
|
24
src/backend/InvenTree/company/migrations/0070_remove_manufacturerpartattachment_manufacturer_part_and_more.py
Normal file
24
src/backend/InvenTree/company/migrations/0070_remove_manufacturerpartattachment_manufacturer_part_and_more.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.12 on 2024-06-09 09:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0050_auto_20240508_0138'),
|
||||
('common', '0026_auto_20240608_1238'),
|
||||
('company', '0069_company_active'),
|
||||
('order', '0099_alter_salesorder_status'),
|
||||
('part', '0123_parttesttemplate_choices'),
|
||||
('stock', '0110_alter_stockitemtestresult_finished_datetime_and_more')
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='CompanyAttachment',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='ManufacturerPartAttachment',
|
||||
),
|
||||
]
|
24
src/backend/InvenTree/company/migrations/0071_manufacturerpart_notes_supplierpart_notes.py
Normal file
24
src/backend/InvenTree/company/migrations/0071_manufacturerpart_notes_supplierpart_notes.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.11 on 2024-07-16 12:58
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0070_remove_manufacturerpartattachment_manufacturer_part_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='manufacturerpart',
|
||||
name='notes',
|
||||
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supplierpart',
|
||||
name='notes',
|
||||
field=InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Markdown notes (optional)', max_length=50000, null=True, verbose_name='Notes'),
|
||||
),
|
||||
]
|
@@ -60,7 +60,9 @@ def rename_company_image(instance, filename):
|
||||
|
||||
|
||||
class Company(
|
||||
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.InvenTreeMetadataModel,
|
||||
):
|
||||
"""A Company object represents an external company.
|
||||
|
||||
@@ -95,7 +97,8 @@ class Company(
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
|
||||
]
|
||||
verbose_name_plural = 'Companies'
|
||||
verbose_name = _('Company')
|
||||
verbose_name_plural = _('Companies')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
@@ -162,19 +165,19 @@ class Company(
|
||||
|
||||
is_customer = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('is customer'),
|
||||
verbose_name=_('Is customer'),
|
||||
help_text=_('Do you sell items to this company?'),
|
||||
)
|
||||
|
||||
is_supplier = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('is supplier'),
|
||||
verbose_name=_('Is supplier'),
|
||||
help_text=_('Do you purchase items from this company?'),
|
||||
)
|
||||
|
||||
is_manufacturer = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('is manufacturer'),
|
||||
verbose_name=_('Is manufacturer'),
|
||||
help_text=_('Does this company manufacture parts?'),
|
||||
)
|
||||
|
||||
@@ -255,26 +258,6 @@ class Company(
|
||||
).distinct()
|
||||
|
||||
|
||||
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file or URL attachments against a Company object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with this model."""
|
||||
return reverse('api-company-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory where these attachments are uploaded."""
|
||||
return os.path.join('company_files', str(self.company.pk))
|
||||
|
||||
company = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Company'),
|
||||
related_name='attachments',
|
||||
)
|
||||
|
||||
|
||||
class Contact(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
|
||||
|
||||
@@ -286,6 +269,11 @@ class Contact(InvenTree.models.InvenTreeMetadataModel):
|
||||
role: position in company
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options."""
|
||||
|
||||
verbose_name = _('Contact')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the Contcat model."""
|
||||
@@ -323,7 +311,8 @@ class Address(InvenTree.models.InvenTreeModel):
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options."""
|
||||
|
||||
verbose_name_plural = 'Addresses'
|
||||
verbose_name = _('Address')
|
||||
verbose_name_plural = _('Addresses')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Custom init function."""
|
||||
@@ -460,7 +449,10 @@ class Address(InvenTree.models.InvenTreeModel):
|
||||
|
||||
|
||||
class ManufacturerPart(
|
||||
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.InvenTreeMetadataModel,
|
||||
):
|
||||
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
|
||||
|
||||
@@ -475,6 +467,7 @@ class ManufacturerPart(
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options."""
|
||||
|
||||
verbose_name = _('Manufacturer Part')
|
||||
unique_together = ('part', 'manufacturer', 'MPN')
|
||||
|
||||
@staticmethod
|
||||
@@ -482,6 +475,11 @@ class ManufacturerPart(
|
||||
"""Return the API URL associated with the ManufacturerPart instance."""
|
||||
return reverse('api-manufacturer-part-list')
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'MP'
|
||||
|
||||
part = models.ForeignKey(
|
||||
'part.Part',
|
||||
on_delete=models.CASCADE,
|
||||
@@ -563,26 +561,6 @@ class ManufacturerPart(
|
||||
return s
|
||||
|
||||
|
||||
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a ManufacturerPart object."""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the ManufacturerPartAttachment model."""
|
||||
return reverse('api-manufacturer-part-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Return the subdirectory where attachment files for the ManufacturerPart model are located."""
|
||||
return os.path.join('manufacturer_part_files', str(self.manufacturer_part.id))
|
||||
|
||||
manufacturer_part = models.ForeignKey(
|
||||
ManufacturerPart,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Manufacturer Part'),
|
||||
related_name='attachments',
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
|
||||
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
||||
|
||||
@@ -594,6 +572,7 @@ class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options."""
|
||||
|
||||
verbose_name = _('Manufacturer Part Parameter')
|
||||
unique_together = ('manufacturer_part', 'name')
|
||||
|
||||
@staticmethod
|
||||
@@ -651,6 +630,7 @@ class SupplierPartManager(models.Manager):
|
||||
class SupplierPart(
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
common.models.MetaMixin,
|
||||
InvenTree.models.InvenTreeModel,
|
||||
):
|
||||
@@ -679,6 +659,8 @@ class SupplierPart(
|
||||
|
||||
unique_together = ('part', 'supplier', 'SKU')
|
||||
|
||||
verbose_name = _('Supplier Part')
|
||||
|
||||
# This model was moved from the 'Part' app
|
||||
db_table = 'part_supplierpart'
|
||||
|
||||
@@ -701,6 +683,11 @@ class SupplierPart(
|
||||
"""Return custom API filters for this particular instance."""
|
||||
return {'manufacturer_part': {'part': self.part.pk}}
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SP'
|
||||
|
||||
def clean(self):
|
||||
"""Custom clean action for the SupplierPart model.
|
||||
|
||||
@@ -1037,6 +1024,7 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
class Meta:
|
||||
"""Metaclass defines extra model options."""
|
||||
|
||||
verbose_name = _('Supplier Price Break')
|
||||
unique_together = ('part', 'quantity')
|
||||
|
||||
# This model was moved from the 'Part' app
|
||||
|
@@ -10,8 +10,10 @@ from sql_util.utils import SubqueryCount
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import part.filters
|
||||
import part.serializers as part_serializers
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializer,
|
||||
InvenTreeCurrencySerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeImageSerializerField,
|
||||
@@ -21,15 +23,12 @@ from InvenTree.serializers import (
|
||||
NotesFieldMixin,
|
||||
RemoteImageMixin,
|
||||
)
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import (
|
||||
Address,
|
||||
Company,
|
||||
CompanyAttachment,
|
||||
Contact,
|
||||
ManufacturerPart,
|
||||
ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter,
|
||||
SupplierPart,
|
||||
SupplierPriceBreak,
|
||||
@@ -59,7 +58,8 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
|
||||
class AddressSerializer(InvenTreeModelSerializer):
|
||||
@register_importer()
|
||||
class AddressSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for the Address Model."""
|
||||
|
||||
class Meta:
|
||||
@@ -103,9 +103,19 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSerializer):
|
||||
@register_importer()
|
||||
class CompanySerializer(
|
||||
DataImportExportSerializerMixin,
|
||||
NotesFieldMixin,
|
||||
RemoteImageMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for Company object (full detail)."""
|
||||
|
||||
export_exclude_fields = ['url', 'primary_address']
|
||||
|
||||
import_exclude_fields = ['image']
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@@ -186,28 +196,25 @@ class CompanySerializer(NotesFieldMixin, RemoteImageMixin, InvenTreeModelSeriali
|
||||
return self.instance
|
||||
|
||||
|
||||
class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the CompanyAttachment class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines serializer options."""
|
||||
|
||||
model = CompanyAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields(['company'])
|
||||
|
||||
|
||||
class ContactSerializer(InvenTreeModelSerializer):
|
||||
@register_importer()
|
||||
class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer class for the Contact model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = Contact
|
||||
fields = ['pk', 'company', 'name', 'phone', 'email', 'role']
|
||||
fields = ['pk', 'company', 'company_name', 'name', 'phone', 'email', 'role']
|
||||
|
||||
company_name = serializers.CharField(
|
||||
label=_('Company Name'), source='company.name', read_only=True
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||
@register_importer()
|
||||
class ManufacturerPartSerializer(
|
||||
DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
|
||||
):
|
||||
"""Serializer for ManufacturerPart object."""
|
||||
|
||||
class Meta:
|
||||
@@ -225,6 +232,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||
'MPN',
|
||||
'link',
|
||||
'barcode_hash',
|
||||
'notes',
|
||||
'tags',
|
||||
]
|
||||
|
||||
@@ -239,15 +247,17 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
self.fields.pop('manufacturer_detail', None)
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
self.fields.pop('pretty_name', None)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
part_detail = part_serializers.PartBriefSerializer(
|
||||
source='part', many=False, read_only=True
|
||||
)
|
||||
|
||||
manufacturer_detail = CompanyBriefSerializer(
|
||||
source='manufacturer', many=False, read_only=True
|
||||
@@ -260,18 +270,10 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the ManufacturerPartAttachment class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = ManufacturerPartAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields(['manufacturer_part'])
|
||||
|
||||
|
||||
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
@register_importer()
|
||||
class ManufacturerPartParameterSerializer(
|
||||
DataImportExportSerializerMixin, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for the ManufacturerPartParameter model."""
|
||||
|
||||
class Meta:
|
||||
@@ -295,14 +297,17 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not man_detail:
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
self.fields.pop('manufacturer_part_detail', None)
|
||||
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(
|
||||
source='manufacturer_part', many=False, read_only=True
|
||||
)
|
||||
|
||||
|
||||
class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
@register_importer()
|
||||
class SupplierPartSerializer(
|
||||
DataImportExportSerializerMixin, InvenTreeTagModelSerializer, NotesFieldMixin
|
||||
):
|
||||
"""Serializer for SupplierPart object."""
|
||||
|
||||
class Meta:
|
||||
@@ -336,6 +341,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
'supplier_detail',
|
||||
'url',
|
||||
'updated',
|
||||
'notes',
|
||||
'tags',
|
||||
]
|
||||
|
||||
@@ -366,17 +372,22 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
if supplier_detail is not True:
|
||||
self.fields.pop('supplier_detail')
|
||||
self.fields.pop('supplier_detail', None)
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
self.fields.pop('manufacturer_detail', None)
|
||||
self.fields.pop('manufacturer_part_detail', None)
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
if brief or prettify is not True:
|
||||
self.fields.pop('pretty_name', None)
|
||||
|
||||
if brief:
|
||||
self.fields.pop('tags')
|
||||
self.fields.pop('available')
|
||||
self.fields.pop('availability_updated')
|
||||
|
||||
# Annotated field showing total in-stock quantity
|
||||
in_stock = serializers.FloatField(read_only=True, label=_('In Stock'))
|
||||
@@ -385,7 +396,9 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
|
||||
pack_quantity_native = serializers.FloatField(read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
part_detail = part_serializers.PartBriefSerializer(
|
||||
source='part', many=False, read_only=True
|
||||
)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(
|
||||
source='supplier', many=False, read_only=True
|
||||
@@ -460,7 +473,10 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
return supplier_part
|
||||
|
||||
|
||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
@register_importer()
|
||||
class SupplierPriceBreakSerializer(
|
||||
DataImportExportSerializerMixin, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for SupplierPriceBreak object."""
|
||||
|
||||
class Meta:
|
||||
@@ -487,10 +503,10 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not supplier_detail:
|
||||
self.fields.pop('supplier_detail')
|
||||
self.fields.pop('supplier_detail', None)
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
|
@@ -244,17 +244,7 @@
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad("attachments", function() {
|
||||
loadAttachmentTable('{% url "api-company-attachment-list" %}', {
|
||||
filters: {
|
||||
company: {{ company.pk }},
|
||||
},
|
||||
fields: {
|
||||
company: {
|
||||
value: {{ company.pk }},
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
});
|
||||
loadAttachmentTable('company', {{ company.pk }});
|
||||
});
|
||||
|
||||
// Callback function when the 'contacts' panel is loaded
|
||||
|
@@ -171,23 +171,42 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-manufacturer-part-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Manufacturer Part Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<textarea id='manufacturer-part-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock page_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad("attachments", function() {
|
||||
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
|
||||
filters: {
|
||||
manufacturer_part: {{ part.pk }},
|
||||
},
|
||||
fields: {
|
||||
manufacturer_part: {
|
||||
value: {{ part.pk }},
|
||||
hidden: true
|
||||
}
|
||||
// Load the "notes" tab
|
||||
onPanelLoad('manufacturer-part-notes', function() {
|
||||
|
||||
setupNotesField(
|
||||
'manufacturer-part-notes',
|
||||
'{% url "api-manufacturer-part-detail" part.pk %}',
|
||||
{
|
||||
model_type: "manufacturerpart",
|
||||
model_id: {{ part.pk }},
|
||||
editable: {% js_bool roles.purchase_order.change %},
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad("attachments", function() {
|
||||
loadAttachmentTable('manufacturerpart', {{ part.pk }});
|
||||
});
|
||||
|
||||
$('#parameter-create').click(function() {
|
||||
|
@@ -8,3 +8,5 @@
|
||||
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
|
||||
{% trans "Attachments" as text %}
|
||||
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
|
||||
{% trans "Notes" as text %}
|
||||
{% include "sidebar_item.html" with label="manufacturer-part-notes" text=text icon="fa-clipboard" %}
|
||||
|
@@ -264,17 +264,46 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-supplier-part-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Supplier Part Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<textarea id='supplier-part-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock page_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
// Load the "notes" tab
|
||||
onPanelLoad('supplier-part-notes', function() {
|
||||
|
||||
setupNotesField(
|
||||
'supplier-part-notes',
|
||||
'{% url "api-supplier-part-detail" part.pk %}',
|
||||
{
|
||||
model_type: "supplierpart",
|
||||
model_id: {{ part.pk }},
|
||||
editable: {% js_bool roles.purchase_order.change %},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% if barcodes %}
|
||||
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Supplier Part QR Code" escape %}',
|
||||
'{"supplierpart": {{ part.pk }} }'
|
||||
'{{ part.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
@@ -8,3 +8,5 @@
|
||||
{% include "sidebar_item.html" with label='purchase-orders' text=text icon="fa-shopping-cart" %}
|
||||
{% trans "Supplier Part Pricing" as text %}
|
||||
{% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %}
|
||||
{% trans "Notes" as text %}
|
||||
{% include "sidebar_item.html" with label="supplier-part-notes" text=text icon="fa-clipboard" %}
|
||||
|
@@ -57,22 +57,20 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
def test_company_detail(self):
|
||||
"""Tests for the Company detail endpoint."""
|
||||
url = reverse('api-company-detail', kwargs={'pk': self.acme.pk})
|
||||
response = self.get(url)
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertIn('name', response.data.keys())
|
||||
self.assertEqual(response.data['name'], 'ACME')
|
||||
|
||||
# Change the name of the company
|
||||
# Note we should not have the correct permissions (yet)
|
||||
data = response.data
|
||||
response = self.client.patch(url, data, format='json', expected_code=400)
|
||||
|
||||
self.assignRole('company.change')
|
||||
|
||||
# Update the name and set the currency to a valid value
|
||||
data['name'] = 'ACMOO'
|
||||
data['currency'] = 'NZD'
|
||||
|
||||
response = self.client.patch(url, data, format='json', expected_code=200)
|
||||
response = self.patch(url, data, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['name'], 'ACMOO')
|
||||
self.assertEqual(response.data['currency'], 'NZD')
|
||||
@@ -162,7 +160,7 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
class ContactTest(InvenTreeAPITestCase):
|
||||
"""Tests for the Contact models."""
|
||||
|
||||
roles = []
|
||||
roles = ['purchase_order.view']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -268,7 +266,7 @@ class ContactTest(InvenTreeAPITestCase):
|
||||
class AddressTest(InvenTreeAPITestCase):
|
||||
"""Test cases for Address API endpoints."""
|
||||
|
||||
roles = []
|
||||
roles = ['purchase_order.view']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@@ -45,14 +45,7 @@ class TestManufacturerField(MigratorTestCase):
|
||||
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||
|
||||
# Create an initial part
|
||||
part = Part.objects.create(
|
||||
name='Screw',
|
||||
description='A single screw',
|
||||
level=0,
|
||||
tree_id=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
)
|
||||
part = Part.objects.create(name='Screw', description='A single screw')
|
||||
|
||||
# Create a company to act as the supplier
|
||||
supplier = Company.objects.create(
|
||||
|
@@ -11,6 +11,8 @@
|
||||
# Note: Database configuration options can also be specified from environmental variables,
|
||||
# with the prefix INVENTREE_DB_
|
||||
# e.g INVENTREE_DB_NAME / INVENTREE_DB_USER / INVENTREE_DB_PASSWORD
|
||||
# Do not change this section if you are using the package - use `inventree config` instead
|
||||
# TO MAINTAINERS: Do not change database strings
|
||||
database:
|
||||
# --- Available options: ---
|
||||
# ENGINE: Database engine. Selection from:
|
||||
@@ -26,7 +28,7 @@ database:
|
||||
# Set debug to False to run in production mode, or use the environment variable INVENTREE_DEBUG
|
||||
debug: True
|
||||
|
||||
# Set to False to disable the admin interfac, or use the environment variable INVENTREE_ADMIN_ENABLED
|
||||
# Set to False to disable the admin interface, or use the environment variable INVENTREE_ADMIN_ENABLED
|
||||
#admin_enabled: True
|
||||
|
||||
# Set the admin URL, or use the environment variable INVENTREE_ADMIN_URL
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user