Coverage for src/backend/InvenTree/InvenTree/fields.py: 79%
133 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Custom fields used in InvenTree."""
3import sys
4from decimal import Decimal
6from django import forms
7from django.core.exceptions import ValidationError
8from django.db import models
9from django.utils.translation import gettext_lazy as _
11from djmoney.forms.fields import MoneyField
12from djmoney.models.fields import MoneyField as ModelMoneyField
13from djmoney.models.validators import MinMoneyValidator
14from rest_framework.fields import URLField as RestURLField
15from rest_framework.fields import empty
17import InvenTree.helpers
18import InvenTree.ready
19from common.currency import currency_code_default
20from common.settings import get_global_setting
22from .validators import AllowedURLValidator, allowable_url_schemes
25class InvenTreeRestURLField(RestURLField):
26 """Custom field for DRF with custom scheme validators."""
28 def __init__(self, **kwargs):
29 """Update schemes."""
30 # Enforce 'max length' parameter in form validation
31 if 'max_length' not in kwargs:
32 kwargs['max_length'] = 200
34 super().__init__(**kwargs)
35 self.validators[-1].schemes = allowable_url_schemes()
37 def run_validation(self, data=empty):
38 """Override default validation behavior for this field type."""
39 strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
41 if not strict_urls and data is not empty and data is not None: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true
42 data = str(data).strip()
43 if data and '://' not in data:
44 # Validate as if there were a schema provided
45 data = 'http://' + data
47 return super().run_validation(data=data)
50class InvenTreeURLField(models.URLField):
51 """Custom URL field which has custom scheme validators."""
53 default_validators = [AllowedURLValidator()]
55 def __init__(self, **kwargs):
56 """Initialization method for InvenTreeURLField."""
57 # Max length for InvenTreeURLField is set to 2000
58 kwargs['max_length'] = 2000
59 super().__init__(**kwargs)
62def money_kwargs(**kwargs):
63 """Returns the database settings for MoneyFields."""
64 from common.currency import currency_code_mappings
66 # Default values (if not specified)
67 if 'max_digits' not in kwargs:
68 kwargs['max_digits'] = 19
70 if 'decimal_places' not in kwargs:
71 kwargs['decimal_places'] = 6
73 if 'currency_choices' not in kwargs:
74 # Pass the function itself (not the evaluated result) so that the
75 # available currency options are resolved dynamically.
76 kwargs['currency_choices'] = currency_code_mappings
78 if InvenTree.ready.isRunningMigrations(): 78 ↛ 81line 78 didn't jump to line 81 because the condition on line 78 was never true
79 # During migrations, avoid setting a default currency
80 # This prevents issues related to early evaluation of the default currency value
81 kwargs['default_currency'] = ''
82 else:
83 # Override default currency with a callable function
84 # This ensures that the default currency is always up-to-date
85 kwargs['default_currency'] = currency_code_default
87 return kwargs
90class InvenTreeModelMoneyField(ModelMoneyField):
91 """Custom MoneyField for clean migrations while using dynamic currency settings."""
93 def __init__(self, **kwargs):
94 """Overwrite default values and validators."""
95 # detect if creating migration
96 if 'migrate' in sys.argv or 'makemigrations' in sys.argv: 96 ↛ 98line 96 didn't jump to line 98 because the condition on line 96 was never true
97 # remove currency information for a clean migration
98 kwargs['default_currency'] = ''
99 kwargs['currency_choices'] = []
101 kwargs = money_kwargs(**kwargs)
103 # Set a minimum value validator
104 validators = kwargs.get('validators', [])
106 allow_negative = kwargs.pop('allow_negative', False)
108 # If no validators are provided, add some "standard" ones
109 if len(validators) == 0 and not allow_negative:
110 validators.append(MinMoneyValidator(0))
112 kwargs['validators'] = validators
114 super().__init__(**kwargs)
116 def formfield(self, **kwargs):
117 """Override form class to use own function."""
118 kwargs['form_class'] = InvenTreeMoneyField
119 return super().formfield(**kwargs)
121 def to_python(self, value):
122 """Convert value to python type."""
123 value = super().to_python(value)
124 return round_decimal(value, self.decimal_places)
126 def prepare_value(self, value):
127 """Override the 'prepare_value' method, to remove trailing zeros when displaying.
129 Why? It looks nice!
130 """
131 return round_decimal(value, self.decimal_places, normalize=True)
134class InvenTreeMoneyField(MoneyField):
135 """Custom MoneyField for clean migrations while using dynamic currency settings."""
137 def __init__(self, *args, **kwargs):
138 """Override initial values with the real info from database."""
139 kwargs = money_kwargs(**kwargs)
140 super().__init__(*args, **kwargs)
143class DatePickerFormField(forms.DateField):
144 """Custom date-picker field."""
146 def __init__(self, **kwargs):
147 """Set up custom values."""
148 help_text = kwargs.get('help_text', _('Enter date'))
149 label = kwargs.get('label')
150 required = kwargs.get('required', False)
151 initial = kwargs.get('initial')
153 widget = forms.DateInput(attrs={'type': 'date'})
155 forms.DateField.__init__(
156 self,
157 required=required,
158 initial=initial,
159 help_text=help_text,
160 widget=widget,
161 label=label,
162 )
165def round_decimal(value, places, normalize=False):
166 """Round value to the specified number of places."""
167 if type(value) in [Decimal, float]:
168 try:
169 value = round(value, places)
170 except Exception:
171 raise ValidationError(_('Invalid decimal value') + f' ({value})')
173 if normalize: 173 ↛ 175line 173 didn't jump to line 175 because the condition on line 173 was never true
174 # Remove any trailing zeroes
175 value = InvenTree.helpers.normalize(value)
177 return value
180class RoundingDecimalFormField(forms.DecimalField):
181 """Custom FormField that automatically rounds inputs."""
183 def to_python(self, value):
184 """Convert value to python type."""
185 value = super().to_python(value)
186 return round_decimal(value, self.decimal_places)
188 def prepare_value(self, value):
189 """Override the 'prepare_value' method, to remove trailing zeros when displaying.
191 Why? It looks nice!
192 """
193 return round_decimal(value, self.decimal_places, normalize=True)
196class RoundingDecimalField(models.DecimalField):
197 """Custom Field that automatically rounds inputs."""
199 def to_python(self, value):
200 """Convert value to python type."""
201 value = super().to_python(value)
202 return round_decimal(value, self.decimal_places)
204 def formfield(self, **kwargs):
205 """Return a Field instance for this field."""
206 kwargs['form_class'] = RoundingDecimalFormField
208 return super().formfield(**kwargs)
211class InvenTreeNotesField(models.TextField):
212 """Custom implementation of a 'notes' field."""
214 # Maximum character limit for the various 'notes' fields
215 NOTES_MAX_LENGTH = 50000
217 def __init__(self, **kwargs):
218 """Configure default initial values for this field."""
219 kwargs['max_length'] = self.NOTES_MAX_LENGTH
220 kwargs['verbose_name'] = _('Notes')
221 kwargs['blank'] = True
222 kwargs['null'] = True
224 super().__init__(**kwargs)
227class InvenTreeOutputOption:
228 """Represents an available output option with description, flag name, and default value."""
230 DEFAULT_DESCRIPTIONS = {
231 'part_detail': 'Include detailed information about the related part in the response',
232 'item_detail': 'Include detailed information about the item in the response',
233 'order_detail': 'Include detailed information about the sales order in the response',
234 'location_detail': 'Include detailed information about the stock location in the response',
235 'customer_detail': 'Include detailed information about the customer in the response',
236 'supplier_detail': 'Include detailed information about the supplier in the response',
237 }
239 def __init__(self, flag: str, default=False, description: str = ''):
240 """Initialize the output option."""
241 self.flag = flag
242 self.default = default
244 if description is None or description == '':
245 self.description = self.DEFAULT_DESCRIPTIONS.get(flag, '')
246 else:
247 self.description = description
250class OutputConfiguration:
251 """Holds all available output options for a view.
253 This class is responsible for converting incoming query parameters from an API request
254 into a dictionary of boolean flags, which can then be applied to serializers.
255 """
257 OPTIONS: list[InvenTreeOutputOption] = []
259 def __init_subclass__(cls, **kwargs):
260 """Validates that subclass defines OPTIONS attribute with correct type."""
261 super().__init_subclass__(**kwargs)
263 options = cls.OPTIONS
264 # Type validation - ensure it's a list
265 if not isinstance(options, list): 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 raise TypeError(
267 f"Class {cls.__name__} 'OPTIONS' must be a list, got {type(options).__name__}"
268 )
270 # Type validation - Ensure list contains InvenTreeOutputOption instances
271 for i, option in enumerate(options):
272 if not isinstance(option, InvenTreeOutputOption): 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 raise TypeError(
274 f"Class {cls.__name__} 'OPTIONS[{i}]' must be an instance of InvenTreeOutputOption, "
275 f'got {type(option).__name__}'
276 )
278 @classmethod
279 def format_params(cls, params: dict) -> dict[str, bool]:
280 """Convert query parameters into a dictionary of output flags with boolean values."""
281 result = {}
282 for option in cls.OPTIONS:
283 value = params.get(option.flag, option.default)
284 result[option.flag] = InvenTree.helpers.str2bool(value)
285 return result