diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml
index a074753380..70a32afa79 100644
--- a/.github/workflows/sponsors.yml
+++ b/.github/workflows/sponsors.yml
@@ -6,6 +6,8 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
+ if: github.repository_owner == 'Inventree'
+
steps:
- name: Checkout Code
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py
index 2d4fb95024..a526cf152e 100644
--- a/InvenTree/InvenTree/metadata.py
+++ b/InvenTree/InvenTree/metadata.py
@@ -10,6 +10,7 @@ from rest_framework.utils import model_meta
import InvenTree.permissions
import users.models
from InvenTree.helpers import str2bool
+from InvenTree.serializers import DependentField
logger = logging.getLogger('inventree')
@@ -242,6 +243,10 @@ class InvenTreeMetadata(SimpleMetadata):
We take the regular DRF metadata and add our own unique flavor
"""
+ # Try to add the child property to the dependent field to be used by the super call
+ if self.label_lookup[field] == 'dependent field':
+ field.get_child(raise_exception=True)
+
# Run super method first
field_info = super().get_field_info(field)
@@ -275,4 +280,11 @@ class InvenTreeMetadata(SimpleMetadata):
else:
field_info['api_url'] = model.get_api_url()
+ # Add more metadata about dependent fields
+ if field_info['type'] == 'dependent field':
+ field_info['depends_on'] = field.depends_on
+
return field_info
+
+
+InvenTreeMetadata.label_lookup[DependentField] = "dependent field"
diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py
index 1e2ab59515..96956ec427 100644
--- a/InvenTree/InvenTree/serializers.py
+++ b/InvenTree/InvenTree/serializers.py
@@ -2,6 +2,7 @@
import os
from collections import OrderedDict
+from copy import deepcopy
from decimal import Decimal
from django.conf import settings
@@ -94,6 +95,93 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField):
super().__init__(*args, **kwargs)
+class DependentField(serializers.Field):
+ """A dependent field can be used to dynamically return child fields based on the value of other fields."""
+ child = None
+
+ def __init__(self, *args, depends_on, field_serializer, **kwargs):
+ """A dependent field can be used to dynamically return child fields based on the value of other fields.
+
+ Example:
+ This example adds two fields. If the client selects integer, an integer field will be shown, but if he
+ selects char, an char field will be shown. For any other value, nothing will be shown.
+
+ class TestSerializer(serializers.Serializer):
+ select_type = serializers.ChoiceField(choices=[
+ ("integer", "Integer"),
+ ("char", "Char"),
+ ])
+ my_field = DependentField(depends_on=["select_type"], field_serializer="get_my_field")
+
+ def get_my_field(self, fields):
+ if fields["select_type"] == "integer":
+ return serializers.IntegerField()
+ if fields["select_type"] == "char":
+ return serializers.CharField()
+ """
+ super().__init__(*args, **kwargs)
+
+ self.depends_on = depends_on
+ self.field_serializer = field_serializer
+
+ def get_child(self, raise_exception=False):
+ """This method tries to extract the child based on the provided data in the request by the client."""
+ data = deepcopy(self.context["request"].data)
+
+ def visit_parent(node):
+ """Recursively extract the data for the parent field/serializer in reverse."""
+ nonlocal data
+
+ if node.parent:
+ visit_parent(node.parent)
+
+ # only do for composite fields and stop right before the current field
+ if hasattr(node, "child") and node is not self and isinstance(data, dict):
+ data = data.get(node.field_name, None)
+ visit_parent(self)
+
+ # ensure that data is a dictionary and that a parent exists
+ if not isinstance(data, dict) or self.parent is None:
+ return
+
+ # check if the request data contains the dependent fields, otherwise skip getting the child
+ for f in self.depends_on:
+ if not data.get(f, None):
+ return
+
+ # partially validate the data for options requests that set raise_exception while calling .get_child(...)
+ if raise_exception:
+ validation_data = {k: v for k, v in data.items() if k in self.depends_on}
+ serializer = self.parent.__class__(context=self.context, data=validation_data, partial=True)
+ serializer.is_valid(raise_exception=raise_exception)
+
+ # try to get the field serializer
+ field_serializer = getattr(self.parent, self.field_serializer)
+ child = field_serializer(data)
+
+ if not child:
+ return
+
+ self.child = child
+ self.child.bind(field_name='', parent=self)
+
+ def to_internal_value(self, data):
+ """This method tries to convert the data to an internal representation based on the defined to_internal_value method on the child."""
+ self.get_child()
+ if self.child:
+ return self.child.to_internal_value(data)
+
+ return None
+
+ def to_representation(self, value):
+ """This method tries to convert the data to representation based on the defined to_representation method on the child."""
+ self.get_child()
+ if self.child:
+ return self.child.to_representation(value)
+
+ return None
+
+
class InvenTreeModelSerializer(serializers.ModelSerializer):
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 69ab8decf0..e825d21ec9 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1874,6 +1874,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
]
},
+ 'DISPLAY_FULL_NAMES': {
+ 'name': _('Display Users full names'),
+ 'description': _('Display Users full names instead of usernames'),
+ 'default': False,
+ 'validator': bool
+ }
}
typ = 'inventree'
diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py
index 0371b2220f..a76b8019e7 100644
--- a/InvenTree/label/api.py
+++ b/InvenTree/label/api.py
@@ -159,7 +159,8 @@ class LabelPrintMixin(LabelFilterMixin):
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(self.request)
- serializer = plugin.get_printing_options_serializer(self.request)
+ kwargs.setdefault('context', self.get_serializer_context())
+ serializer = plugin.get_printing_options_serializer(self.request, *args, **kwargs)
# if no serializer is defined, return an empty serializer
if not serializer:
@@ -226,7 +227,7 @@ class LabelPrintMixin(LabelFilterMixin):
raise ValidationError('Label has invalid dimensions')
# if the plugin returns a serializer, validate the data
- if serializer := plugin.get_printing_options_serializer(request, data=request.data):
+ if serializer := plugin.get_printing_options_serializer(request, data=request.data, context=self.get_serializer_context()):
serializer.is_valid(raise_exception=True)
# At this point, we offload the label(s) to the selected plugin.
diff --git a/InvenTree/templates/InvenTree/settings/global.html b/InvenTree/templates/InvenTree/settings/global.html
index 8161b5d7fd..6554faf9e6 100644
--- a/InvenTree/templates/InvenTree/settings/global.html
+++ b/InvenTree/templates/InvenTree/settings/global.html
@@ -17,6 +17,7 @@
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_RESTRICT_ABOUT" icon="fa-info-circle" %}
+ {% include "InvenTree/settings/setting.html" with key="DISPLAY_FULL_NAMES" icon="fa-font" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_UPDATE_CHECK_INTERVAL" icon="fa-calendar-alt" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index 69ab8cbd89..652c744e19 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -298,7 +298,7 @@ function constructDeleteForm(fields, options) {
* - closeText: Text for the "close" button
* - fields: list of fields to display, with the following options
* - filters: API query filters
- * - onEdit: callback when field is edited
+ * - onEdit: callback or array of callbacks which get fired when field is edited
* - secondary: Define a secondary modal form for this field
* - label: Specify custom label
* - help_text: Specify custom help_text
@@ -493,6 +493,32 @@ function constructFormBody(fields, options) {
html += options.header_html;
}
+ // process every field by recursively walking down nested fields
+ const processField = (name, field, optionsField) => {
+ if (typeof optionsField !== "object") return;
+
+ if (field.type === "nested object" && optionsField.children) {
+ for (const [k, v] of Object.entries(field.children)) {
+ processField(`${name}__${k}`, v, optionsField.children[k]);
+ }
+ }
+
+ if (field.type === "dependent field") {
+ if(field.child) {
+ // copy child attribute from parameters to options
+ optionsField.child = field.child;
+
+ processField(name, field.child, optionsField.child);
+ } else {
+ delete optionsField.child;
+ }
+ }
+ }
+
+ for (const [k,v] of Object.entries(fields)) {
+ processField(k, v, options.fields[k]);
+ }
+
// Client must provide set of fields to be displayed,
// otherwise *all* fields will be displayed
var displayed_fields = options.fields || fields;
@@ -599,14 +625,6 @@ function constructFormBody(fields, options) {
var field = fields[field_name];
- switch (field.type) {
- // Skip field types which are simply not supported
- case 'nested object':
- continue;
- default:
- break;
- }
-
html += constructField(field_name, field, options);
}
@@ -810,7 +828,7 @@ function insertSecondaryButtons(options) {
/*
* Extract all specified form values as a single object
*/
-function extractFormData(fields, options) {
+function extractFormData(fields, options, includeLocal = true) {
var data = {};
@@ -823,6 +841,7 @@ function extractFormData(fields, options) {
if (!field) continue;
if (field.type == 'candy') continue;
+ if (!includeLocal && field.localOnly) continue;
data[name] = getFormFieldValue(name, field, options);
}
@@ -1031,6 +1050,17 @@ function updateFieldValue(name, value, field, options) {
}
// TODO - Specify an actual value!
break;
+ case 'nested object':
+ for (const [k, v] of Object.entries(value)) {
+ if (!(k in field.children)) continue;
+ updateFieldValue(`${name}__${k}`, v, field.children[k], options);
+ }
+ break;
+ case 'dependent field':
+ if (field.child) {
+ updateFieldValue(name, value, field.child, options);
+ }
+ break;
case 'file upload':
case 'image upload':
break;
@@ -1165,6 +1195,17 @@ function getFormFieldValue(name, field={}, options={}) {
case 'email':
value = sanitizeInputString(el.val());
break;
+ case 'nested object':
+ value = {};
+ for (const [name, subField] of Object.entries(field.children)) {
+ value[name] = getFormFieldValue(subField.name, subField, options);
+ }
+ break;
+ case 'dependent field':
+ if(!field.child) return undefined;
+
+ value = getFormFieldValue(name, field.child, options);
+ break;
default:
value = el.val();
break;
@@ -1449,19 +1490,28 @@ function handleFormErrors(errors, fields={}, options={}) {
var field = fields[field_name] || {};
var field_errors = errors[field_name];
- if ((field.type == 'nested object') && ('children' in field)) {
+ // for nested objects with children and dependent fields with a child defined, extract nested errors
+ if (((field.type == 'nested object') && ('children' in field)) || ((field.type == 'dependent field') && ('child' in field))) {
// Handle multi-level nested errors
+ const handleNestedError = (parent_name, sub_field_errors) => {
+ for (const sub_field in sub_field_errors) {
+ const sub_sub_field_name = `${parent_name}__${sub_field}`;
+ const sub_sub_field_errors = sub_field_errors[sub_field];
- for (var sub_field in field_errors) {
- var sub_field_name = `${field_name}__${sub_field}`;
- var sub_field_errors = field_errors[sub_field];
+ if (!first_error_field && sub_sub_field_errors && isFieldVisible(sub_sub_field_name, options)) {
+ first_error_field = sub_sub_field_name;
+ }
- if (!first_error_field && sub_field_errors && isFieldVisible(sub_field_name, options)) {
- first_error_field = sub_field_name;
+ // if the error is an object, its a nested object, recursively handle the errors
+ if (typeof sub_sub_field_errors === "object" && !Array.isArray(sub_sub_field_errors)) {
+ handleNestedError(sub_sub_field_name, sub_sub_field_errors)
+ } else {
+ addFieldErrorMessage(sub_sub_field_name, sub_sub_field_errors, options);
+ }
}
-
- addFieldErrorMessage(sub_field_name, sub_field_errors, options);
}
+
+ handleNestedError(field_name, field_errors);
} else if ((field.type == 'field') && ('child' in field)) {
// This is a "nested" array field
handleNestedArrayErrors(errors, field_name, options);
@@ -1556,7 +1606,7 @@ function addFieldCallbacks(fields, options) {
var field = fields[name];
- if (!field || !field.onEdit) continue;
+ if (!field || field.type === "candy") continue;
addFieldCallback(name, field, options);
}
@@ -1564,15 +1614,34 @@ function addFieldCallbacks(fields, options) {
function addFieldCallback(name, field, options) {
+ const el = getFormFieldElement(name, options);
- var el = getFormFieldElement(name, options);
+ if (field.onEdit) {
+ el.change(function() {
- el.change(function() {
+ var value = getFormFieldValue(name, field, options);
+ let onEditHandlers = field.onEdit;
- var value = getFormFieldValue(name, field, options);
+ if (!Array.isArray(onEditHandlers)) {
+ onEditHandlers = [onEditHandlers];
+ }
- field.onEdit(value, name, field, options);
- });
+ for (const onEdit of onEditHandlers) {
+ onEdit(value, name, field, options);
+ }
+ });
+ }
+
+ // attach field callback for nested fields
+ if(field.type === "nested object") {
+ for (const [c_name, c_field] of Object.entries(field.children)) {
+ addFieldCallback(`${name}__${c_name}`, c_field, options);
+ }
+ }
+
+ if(field.type === "dependent field" && field.child) {
+ addFieldCallback(name, field.child, options);
+ }
}
@@ -1727,16 +1796,32 @@ function initializeRelatedFields(fields, options={}) {
if (!field || field.hidden) continue;
- switch (field.type) {
- case 'related field':
- initializeRelatedField(field, fields, options);
- break;
- case 'choice':
- initializeChoiceField(field, fields, options);
- break;
- default:
- break;
+ initializeRelatedFieldsRecursively(field, fields, options);
+ }
+}
+
+function initializeRelatedFieldsRecursively(field, fields, options) {
+ switch (field.type) {
+ case 'related field':
+ initializeRelatedField(field, fields, options);
+ break;
+ case 'choice':
+ initializeChoiceField(field, fields, options);
+ break;
+ case 'nested object':
+ for (const [c_name, c_field] of Object.entries(field.children)) {
+ if(!c_field.name) c_field.name = `${field.name}__${c_name}`;
+ initializeRelatedFieldsRecursively(c_field, field.children, options);
}
+ break;
+ case 'dependent field':
+ if (field.child) {
+ if(!field.child.name) field.child.name = field.name;
+ initializeRelatedFieldsRecursively(field.child, fields, options);
+ }
+ break;
+ default:
+ break;
}
}
@@ -2346,7 +2431,7 @@ function constructField(name, parameters, options={}) {
html += ``;
// Add a label
- if (!options.hideLabels) {
+ if (!options.hideLabels && parameters.type !== "nested object" && parameters.type !== "dependent field") {
html += constructLabel(name, parameters);
}
@@ -2501,6 +2586,12 @@ function constructInput(name, parameters, options={}) {
case 'raw':
func = constructRawInput;
break;
+ case 'nested object':
+ func = constructNestedObject;
+ break;
+ case 'dependent field':
+ func = constructDependentField;
+ break;
default:
// Unsupported field type!
break;
@@ -2780,6 +2871,129 @@ function constructRawInput(name, parameters) {
}
+/*
+ * Construct a nested object input
+ */
+function constructNestedObject(name, parameters, options) {
+ let html = `
+
";
+
+ return html;
+}
+
+function getFieldByNestedPath(name, fields) {
+ if (typeof name === "string") {
+ name = name.split("__");
+ }
+
+ if (name.length === 0) return fields;
+
+ if (fields.type === "nested object") fields = fields.children;
+
+ if (!(name[0] in fields)) return null;
+ let field = fields[name[0]];
+
+ if (field.type === "dependent field" && field.child) {
+ field = field.child;
+ }
+
+ return getFieldByNestedPath(name.slice(1), field);
+}
+
+/*
+ * Construct a dependent field input
+ */
+function constructDependentField(name, parameters, options) {
+ // add onEdit handler to all fields this dependent field depends on
+ for (let d_field_name of parameters.depends_on) {
+ const d_field = getFieldByNestedPath([...name.split("__").slice(0, -1), d_field_name], options.fields);
+ if (!d_field) continue;
+
+ const onEdit = (value, name, field, options) => {
+ if(value === undefined) return;
+
+ // extract the current form data to include in OPTIONS request
+ const data = extractFormData(options.fields, options, false)
+
+ $.ajax({
+ url: options.url,
+ type: "OPTIONS",
+ data: JSON.stringify(data),
+ contentType: "application/json",
+ dataType: "json",
+ accepts: { json: "application/json" },
+ success: (res) => {
+ const fields = res.actions[options.method];
+
+ // merge already entered values in the newly constructed form
+ options.data = extractFormData(options.fields, options);
+
+ // remove old submit handlers
+ $(options.modal).off('click', '#modal-form-submit');
+
+ if (options.method === "POST") {
+ constructCreateForm(fields, options);
+ }
+
+ if (options.method === "PUT" || options.method === "PATCH") {
+ constructChangeForm(fields, options);
+ }
+
+ if (options.method === "DELETE") {
+ constructDeleteForm(fields, options);
+ }
+ },
+ error: (xhr) => showApiError(xhr, options.url)
+ });
+ }
+
+ // attach on edit handler
+ const originalOnEdit = d_field.onEdit;
+ d_field.onEdit = [onEdit];
+
+ if(typeof originalOnEdit === "function") {
+ d_field.onEdit.push(originalOnEdit);
+ } else if (Array.isArray(originalOnEdit)) {
+ // push old onEdit handlers, but omit the old
+ d_field.onEdit.push(...originalOnEdit.filter(h => h !== d_field._currentDependentFieldOnEdit));
+ }
+
+ // track current onEdit handler function
+ d_field._currentDependentFieldOnEdit = onEdit;
+ }
+
+ // child is not specified already, return a dummy div with id so no errors can happen
+ if (!parameters.child) {
+ return `
`;
+ }
+
+ // copy label to child if not already provided
+ if(!parameters.child.label) {
+ parameters.child.label = parameters.label;
+ }
+
+ // construct the provided child field
+ return constructField(name, parameters.child, options);
+}
/*
* Construct a 'help text' div based on the field parameters
diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js
index 57ee46e919..a9a75f0f56 100644
--- a/InvenTree/templates/js/translated/label.js
+++ b/InvenTree/templates/js/translated/label.js
@@ -137,6 +137,11 @@ function printLabels(options) {
// update form
updateForm(formOptions);
+
+ // workaround to fix a bug where one cannot scroll after changing the plugin
+ // without opening and closing the select box again manually
+ $("#id__plugin").select2("open");
+ $("#id__plugin").select2("close");
}
const printingFormOptions = {
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 19bc91f611..4cd01439ed 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -6,7 +6,7 @@ import logging
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group, Permission
+from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
@@ -21,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import Token as AuthToken
+import common.models as common_models
import InvenTree.helpers
import InvenTree.models
from InvenTree.ready import canAppAccessDatabase
@@ -28,6 +29,22 @@ from InvenTree.ready import canAppAccessDatabase
logger = logging.getLogger("inventree")
+# OVERRIDE START
+# Overrides Django User model __str__ with a custom function to be able to change
+# string representation of a user
+def user_model_str(self):
+ """Function to override the default Django User __str__"""
+
+ if common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES'):
+ if self.first_name or self.last_name:
+ return f'{self.first_name} {self.last_name}'
+ return self.username
+
+
+User.add_to_class("__str__", user_model_str) # Overriding User.__str__
+# OVERRIDE END
+
+
def default_token():
"""Generate a default value for the token"""
return ApiToken.generate_key()
@@ -785,10 +802,16 @@ class Owner(models.Model):
def __str__(self):
"""Defines the owner string representation."""
- return f'{self.owner} ({self.owner_type.name})'
+ if self.owner_type.name == 'user' and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES'):
+ display_name = self.owner.get_full_name()
+ else:
+ display_name = str(self.owner)
+ return f'{display_name} ({self.owner_type.name})'
def name(self):
"""Return the 'name' of this owner."""
+ if self.owner_type.name == 'user' and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES'):
+ return self.owner.get_full_name()
return str(self.owner)
def label(self):