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

1"""Custom fields used in InvenTree.""" 

2 

3import sys 

4from decimal import Decimal 

5 

6from django import forms 

7from django.core.exceptions import ValidationError 

8from django.db import models 

9from django.utils.translation import gettext_lazy as _ 

10 

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 

16 

17import InvenTree.helpers 

18import InvenTree.ready 

19from common.currency import currency_code_default 

20from common.settings import get_global_setting 

21 

22from .validators import AllowedURLValidator, allowable_url_schemes 

23 

24 

25class InvenTreeRestURLField(RestURLField): 

26 """Custom field for DRF with custom scheme validators.""" 

27 

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 

33 

34 super().__init__(**kwargs) 

35 self.validators[-1].schemes = allowable_url_schemes() 

36 

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) 

40 

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 

46 

47 return super().run_validation(data=data) 

48 

49 

50class InvenTreeURLField(models.URLField): 

51 """Custom URL field which has custom scheme validators.""" 

52 

53 default_validators = [AllowedURLValidator()] 

54 

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) 

60 

61 

62def money_kwargs(**kwargs): 

63 """Returns the database settings for MoneyFields.""" 

64 from common.currency import currency_code_mappings 

65 

66 # Default values (if not specified) 

67 if 'max_digits' not in kwargs: 

68 kwargs['max_digits'] = 19 

69 

70 if 'decimal_places' not in kwargs: 

71 kwargs['decimal_places'] = 6 

72 

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 

77 

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 

86 

87 return kwargs 

88 

89 

90class InvenTreeModelMoneyField(ModelMoneyField): 

91 """Custom MoneyField for clean migrations while using dynamic currency settings.""" 

92 

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'] = [] 

100 

101 kwargs = money_kwargs(**kwargs) 

102 

103 # Set a minimum value validator 

104 validators = kwargs.get('validators', []) 

105 

106 allow_negative = kwargs.pop('allow_negative', False) 

107 

108 # If no validators are provided, add some "standard" ones 

109 if len(validators) == 0 and not allow_negative: 

110 validators.append(MinMoneyValidator(0)) 

111 

112 kwargs['validators'] = validators 

113 

114 super().__init__(**kwargs) 

115 

116 def formfield(self, **kwargs): 

117 """Override form class to use own function.""" 

118 kwargs['form_class'] = InvenTreeMoneyField 

119 return super().formfield(**kwargs) 

120 

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) 

125 

126 def prepare_value(self, value): 

127 """Override the 'prepare_value' method, to remove trailing zeros when displaying. 

128 

129 Why? It looks nice! 

130 """ 

131 return round_decimal(value, self.decimal_places, normalize=True) 

132 

133 

134class InvenTreeMoneyField(MoneyField): 

135 """Custom MoneyField for clean migrations while using dynamic currency settings.""" 

136 

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) 

141 

142 

143class DatePickerFormField(forms.DateField): 

144 """Custom date-picker field.""" 

145 

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

152 

153 widget = forms.DateInput(attrs={'type': 'date'}) 

154 

155 forms.DateField.__init__( 

156 self, 

157 required=required, 

158 initial=initial, 

159 help_text=help_text, 

160 widget=widget, 

161 label=label, 

162 ) 

163 

164 

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})') 

172 

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) 

176 

177 return value 

178 

179 

180class RoundingDecimalFormField(forms.DecimalField): 

181 """Custom FormField that automatically rounds inputs.""" 

182 

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) 

187 

188 def prepare_value(self, value): 

189 """Override the 'prepare_value' method, to remove trailing zeros when displaying. 

190 

191 Why? It looks nice! 

192 """ 

193 return round_decimal(value, self.decimal_places, normalize=True) 

194 

195 

196class RoundingDecimalField(models.DecimalField): 

197 """Custom Field that automatically rounds inputs.""" 

198 

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) 

203 

204 def formfield(self, **kwargs): 

205 """Return a Field instance for this field.""" 

206 kwargs['form_class'] = RoundingDecimalFormField 

207 

208 return super().formfield(**kwargs) 

209 

210 

211class InvenTreeNotesField(models.TextField): 

212 """Custom implementation of a 'notes' field.""" 

213 

214 # Maximum character limit for the various 'notes' fields 

215 NOTES_MAX_LENGTH = 50000 

216 

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 

223 

224 super().__init__(**kwargs) 

225 

226 

227class InvenTreeOutputOption: 

228 """Represents an available output option with description, flag name, and default value.""" 

229 

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 } 

238 

239 def __init__(self, flag: str, default=False, description: str = ''): 

240 """Initialize the output option.""" 

241 self.flag = flag 

242 self.default = default 

243 

244 if description is None or description == '': 

245 self.description = self.DEFAULT_DESCRIPTIONS.get(flag, '') 

246 else: 

247 self.description = description 

248 

249 

250class OutputConfiguration: 

251 """Holds all available output options for a view. 

252 

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

256 

257 OPTIONS: list[InvenTreeOutputOption] = [] 

258 

259 def __init_subclass__(cls, **kwargs): 

260 """Validates that subclass defines OPTIONS attribute with correct type.""" 

261 super().__init_subclass__(**kwargs) 

262 

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 ) 

269 

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 ) 

277 

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