Coverage for src/backend/InvenTree/generic/states/states.py: 54%
197 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"""Generic implementation of status for InvenTree models."""
3import enum
4import logging
5import re
6from enum import Enum
7from typing import Optional
9logger = logging.getLogger('inventree')
12class BaseEnum(enum.IntEnum): # noqa: PLW1641
13 """An `Enum` capabile of having its members have docstrings.
15 Based on https://stackoverflow.com/questions/19330460/how-do-i-put-docstrings-on-enums
16 """
18 def __new__(cls, *args):
19 """Assign values on creation."""
20 obj = object.__new__(cls)
21 obj._value_ = args[0]
22 return obj
24 def __int__(self):
25 """Return an integer representation of the value."""
26 return self.value
28 def __str__(self):
29 """Return a string representation of the value."""
30 return str(self.value)
32 def __eq__(self, obj):
33 """Override equality operator to allow comparison with int."""
34 if type(obj) is int: 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 return self.value == obj
37 if isinstance(obj, BaseEnum): 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true
38 return self.value == obj.value
40 if hasattr(obj, 'value'): 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 return self.value == obj.value
43 return super().__eq__(obj)
45 def __ne__(self, obj):
46 """Override inequality operator to allow comparison with int."""
47 if type(self) is type(obj):
48 return super().__ne__(obj)
49 return self.value != obj
52class StatusCode(BaseEnum):
53 """Base class for representing a set of StatusCodes.
55 Use enum syntax to define the status codes, e.g.
56 ```python
57 PENDING = 10, _("Pending"), 'secondary'
58 ```
60 The values of the status can be accessed with `StatusCode.PENDING.value`.
62 Additionally there are helpers to access all additional attributes `text`, `label`, `color`.
63 """
65 def __new__(cls, *args):
66 """Define object out of args."""
67 obj = int.__new__(cls)
68 obj._value_ = args[0]
70 # Normal item definition
71 if len(args) == 1: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 obj.label = args[0]
73 obj.color = ColorEnum.secondary
74 else:
75 obj.label = args[1]
76 obj.color = args[2] if len(args) > 2 else ColorEnum.secondary
78 # Ensure color is a valid value
79 if isinstance(obj.color, str): 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 try:
81 obj.color = ColorEnum(obj.color)
82 except ValueError:
83 raise ValueError(
84 f"Invalid color value '{obj.color}' for status '{obj.label}'"
85 )
87 # Set color value as string
88 obj.color = obj.color.value
89 obj.color_class = obj.color
91 return obj
93 @classmethod
94 def _is_element(cls, d):
95 """Check if the supplied value is a valid status code."""
96 if d.startswith('_'): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 return False
98 if d != d.upper(): 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 return False
101 value = getattr(cls, d, None)
103 if value is None: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 return False
105 if callable(value): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 return False
107 return isinstance(value.value, int)
109 @classmethod
110 def custom_queryset(cls):
111 """Return a queryset of all custom values for this status class."""
112 from common.models import InvenTreeCustomUserStateModel
114 try:
115 return InvenTreeCustomUserStateModel.objects.filter(
116 reference_status=cls.__name__
117 )
118 except Exception:
119 return None
121 @classmethod
122 def custom_values(cls):
123 """Return all user-defined custom values for this status class."""
124 if query := cls.custom_queryset(): 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 return list(query)
126 return []
128 @classmethod
129 def values(cls, key=None):
130 """Return a dict representation containing all required information."""
131 elements = [itm for itm in cls if cls._is_element(itm.name)]
133 if key is None:
134 return elements
136 ret = [itm for itm in elements if itm.value == key]
137 if ret: 137 ↛ 139line 137 didn't jump to line 139 because the condition on line 137 was always true
138 return ret[0]
139 return None
141 @classmethod
142 def render(cls, key, large=False):
143 """Render the value as a HTML label."""
144 # If the key cannot be found, pass it back
145 item = cls.values(key)
146 if item is None:
147 return key
149 return f"<span class='badge rounded-pill bg-{item.color}'>{item.label}</span>"
151 @classmethod
152 def tag(cls):
153 """Return tag for this status code."""
154 # Return the tag if it is defined
155 if hasattr(cls, '_TAG') and bool(cls._TAG):
156 return cls._TAG.value
158 # Try to find a default tag
159 # Remove `Status` from the class name
160 ref_name = cls.__name__.removesuffix('Status')
161 # Convert to snake case
162 return re.sub(r'(?<!^)(?=[A-Z])', '_', ref_name).lower()
164 @classmethod
165 def items(cls, custom=False):
166 """All status code items."""
167 data = [(x.value, x.label) for x in cls.values()]
169 if custom:
170 try:
171 for item in cls.custom_values(): 171 ↛ 172line 171 didn't jump to line 172 because the loop on line 171 never started
172 data.append((item.key, item.label))
173 except Exception:
174 pass
176 return data
178 @classmethod
179 def keys(cls, custom=True):
180 """All status code keys."""
181 return [el[0] for el in cls.items(custom=custom)]
183 @classmethod
184 def labels(cls, custom=True):
185 """All status code labels."""
186 return [el[1] for el in cls.items(custom=custom)]
188 @classmethod
189 def names(cls):
190 """Return a map of all 'names' of status codes in this class."""
191 return {x.name: x.value for x in cls.values()}
193 @classmethod
194 def text(cls, key):
195 """Text for supplied status code."""
196 filtered = cls.values(key)
197 if filtered is None:
198 return key
199 return filtered.label
201 @classmethod
202 def label(cls, key):
203 """Return the status code label associated with the provided value."""
204 filtered = cls.values(key)
205 if filtered is None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 return key
207 return filtered.label
209 @classmethod
210 def dict(cls, key=None, custom=True):
211 """Return a dict representation containing all required information."""
212 data = {
213 x.name: {'color': x.color, 'key': x.value, 'label': x.label, 'name': x.name}
214 for x in cls.values(key)
215 }
217 if custom:
218 try:
219 for item in cls.custom_values(): 219 ↛ 220line 219 didn't jump to line 220 because the loop on line 219 never started
220 if item.name not in data:
221 data[item.name] = {
222 'color': item.color,
223 'key': item.key,
224 'label': item.label,
225 'name': item.name,
226 'custom': True,
227 }
228 except Exception:
229 pass
231 return data
233 @classmethod
234 def list(cls, custom=True):
235 """Return the StatusCode options as a list of mapped key / value items."""
236 return list(cls.dict(custom=custom).values())
238 @classmethod
239 def template_context(cls, custom=True):
240 """Return a dict representation containing all required information for templates."""
241 data = cls.dict(custom=custom)
243 ret = {x['name']: x['key'] for x in data.values()}
245 ret['list'] = list(data.values())
247 return ret
250class ColorEnum(Enum):
251 """Enum for color values."""
253 primary = 'primary'
254 secondary = 'secondary'
255 success = 'success'
256 danger = 'danger'
257 warning = 'warning'
258 info = 'info'
259 dark = 'dark'
262class StatusCodeMixin:
263 """Mixin class which handles custom 'status' fields.
265 - Implements a 'set_stutus' method which can be used to set the status of an object
266 - Implements a 'get_status' method which can be used to retrieve the status of an object
268 This mixin assumes that the implementing class has a 'status' field,
269 which must be an instance of the InvenTreeCustomStatusModelField class.
270 """
272 STATUS_CLASS = None
273 STATUS_FIELD = 'status'
275 @property
276 def status_class(self):
277 """Return the status class associated with this model."""
278 return self.STATUS_CLASS
280 def save(self, *args, **kwargs):
281 """Custom save method for StatusCodeMixin.
283 - Ensure custom status code values are correctly updated
284 """
285 if self.status_class: 285 ↛ 295line 285 didn't jump to line 295 because the condition on line 285 was always true
286 # Check that the current 'logical key' actually matches the current status code
287 custom_values = self.status_class.custom_queryset().filter(
288 logical_key=self.get_status(), key=self.get_custom_status()
289 )
291 if not custom_values.exists(): 291 ↛ 295line 291 didn't jump to line 295 because the condition on line 291 was always true
292 # No match - null out the custom value
293 setattr(self, f'{self.STATUS_FIELD}_custom_key', None)
295 super().save(*args, **kwargs)
297 def get_status(self) -> int:
298 """Return the status code for this object."""
299 return getattr(self, self.STATUS_FIELD)
301 def get_custom_status(self) -> Optional[int]:
302 """Return the custom status code for this object."""
303 return getattr(self, f'{self.STATUS_FIELD}_custom_key', None)
305 def compare_status(self, status: int) -> bool:
306 """Determine if the current status matches the provided status code.
308 Arguments:
309 status: The status code to compare against
311 Returns:
312 True if the status matches, False otherwise.
313 """
314 try:
315 status = int(status)
316 except (ValueError, TypeError):
317 # Value cannot be converted to integer - so it cannot match
318 return False
320 if status == self.get_status():
321 return True
323 return status is not None and status == self.get_custom_status()
325 def set_status(self, status: int, custom_values=None) -> bool:
326 """Set the status code for this object.
328 Arguments:
329 status: The status code to set
330 custom_values: Optional list of custom values to consider (can be used to avoid DB queries)
331 """
332 if not self.status_class:
333 raise NotImplementedError('Status class not defined')
335 base_values = self.status_class.values()
337 custom_value_set = (
338 self.status_class.custom_values()
339 if custom_values is None
340 else custom_values
341 )
343 # The status must be an integer
344 try:
345 status = int(status)
346 except (ValueError, TypeError):
347 logger.warning(f'Invalid status value {status} for class {self.__class__}')
348 return False
350 custom_field = f'{self.STATUS_FIELD}_custom_key'
352 result = False
354 if status in base_values:
355 # Set the status to a 'base' value
356 setattr(self, self.STATUS_FIELD, status)
357 setattr(self, custom_field, None)
358 result = True
359 else:
360 for item in custom_value_set:
361 if item.key == status:
362 # Set the status to a 'custom' value
363 setattr(self, self.STATUS_FIELD, item.logical_key)
364 setattr(self, custom_field, item.key)
365 result = True
366 break
368 if not result:
369 logger.warning(f'Failed to set status {status} for class {self.__class__}')
371 return result