2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-06 20:11:37 +00:00
This commit is contained in:
Matthias Mair
2023-11-19 13:34:26 +01:00
9 changed files with 390 additions and 38 deletions

View File

@@ -6,6 +6,8 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
if: github.repository_owner == 'Inventree'
steps:
- name: Checkout Code
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

View File

@@ -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"

View File

@@ -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."""

View File

@@ -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'

View File

@@ -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.

View File

@@ -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" %}
<tr><td colspan='5'></td></tr>
{% 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" %}

View File

@@ -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 += `<div id='div_id_${field_name}' class='${form_classes}' ${hover_title} ${css}>`;
// 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 = `
<div id="div_id_${name}" class='panel form-panel' style="margin-bottom: 0; padding-bottom: 0;">
<div class='panel-heading form-panel-heading'>
<div>
<h6 style='display: inline;'>${parameters.label}</h6>
</div>
</div>
<div class='panel-content form-panel-content' id="id_${name}">
`;
parameters.field_names = [];
for (const [key, field] of Object.entries(parameters.children)) {
const subFieldName = `${name}__${key}`;
field.name = subFieldName;
parameters.field_names.push(subFieldName);
html += constructField(subFieldName, field, options);
}
html += "</div></div>";
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 `<div id="id_${name}" hidden></div>`;
}
// 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

View File

@@ -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 = {

View File

@@ -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):