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

1"""Generic implementation of status for InvenTree models.""" 

2 

3import enum 

4import logging 

5import re 

6from enum import Enum 

7from typing import Optional 

8 

9logger = logging.getLogger('inventree') 

10 

11 

12class BaseEnum(enum.IntEnum): # noqa: PLW1641 

13 """An `Enum` capabile of having its members have docstrings. 

14 

15 Based on https://stackoverflow.com/questions/19330460/how-do-i-put-docstrings-on-enums 

16 """ 

17 

18 def __new__(cls, *args): 

19 """Assign values on creation.""" 

20 obj = object.__new__(cls) 

21 obj._value_ = args[0] 

22 return obj 

23 

24 def __int__(self): 

25 """Return an integer representation of the value.""" 

26 return self.value 

27 

28 def __str__(self): 

29 """Return a string representation of the value.""" 

30 return str(self.value) 

31 

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 

36 

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 

39 

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 

42 

43 return super().__eq__(obj) 

44 

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 

50 

51 

52class StatusCode(BaseEnum): 

53 """Base class for representing a set of StatusCodes. 

54 

55 Use enum syntax to define the status codes, e.g. 

56 ```python 

57 PENDING = 10, _("Pending"), 'secondary' 

58 ``` 

59 

60 The values of the status can be accessed with `StatusCode.PENDING.value`. 

61 

62 Additionally there are helpers to access all additional attributes `text`, `label`, `color`. 

63 """ 

64 

65 def __new__(cls, *args): 

66 """Define object out of args.""" 

67 obj = int.__new__(cls) 

68 obj._value_ = args[0] 

69 

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 

77 

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 ) 

86 

87 # Set color value as string 

88 obj.color = obj.color.value 

89 obj.color_class = obj.color 

90 

91 return obj 

92 

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 

100 

101 value = getattr(cls, d, None) 

102 

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) 

108 

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 

113 

114 try: 

115 return InvenTreeCustomUserStateModel.objects.filter( 

116 reference_status=cls.__name__ 

117 ) 

118 except Exception: 

119 return None 

120 

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

127 

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

132 

133 if key is None: 

134 return elements 

135 

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 

140 

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 

148 

149 return f"<span class='badge rounded-pill bg-{item.color}'>{item.label}</span>" 

150 

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 

157 

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

163 

164 @classmethod 

165 def items(cls, custom=False): 

166 """All status code items.""" 

167 data = [(x.value, x.label) for x in cls.values()] 

168 

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 

175 

176 return data 

177 

178 @classmethod 

179 def keys(cls, custom=True): 

180 """All status code keys.""" 

181 return [el[0] for el in cls.items(custom=custom)] 

182 

183 @classmethod 

184 def labels(cls, custom=True): 

185 """All status code labels.""" 

186 return [el[1] for el in cls.items(custom=custom)] 

187 

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

192 

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 

200 

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 

208 

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 } 

216 

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 

230 

231 return data 

232 

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

237 

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) 

242 

243 ret = {x['name']: x['key'] for x in data.values()} 

244 

245 ret['list'] = list(data.values()) 

246 

247 return ret 

248 

249 

250class ColorEnum(Enum): 

251 """Enum for color values.""" 

252 

253 primary = 'primary' 

254 secondary = 'secondary' 

255 success = 'success' 

256 danger = 'danger' 

257 warning = 'warning' 

258 info = 'info' 

259 dark = 'dark' 

260 

261 

262class StatusCodeMixin: 

263 """Mixin class which handles custom 'status' fields. 

264 

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 

267 

268 This mixin assumes that the implementing class has a 'status' field, 

269 which must be an instance of the InvenTreeCustomStatusModelField class. 

270 """ 

271 

272 STATUS_CLASS = None 

273 STATUS_FIELD = 'status' 

274 

275 @property 

276 def status_class(self): 

277 """Return the status class associated with this model.""" 

278 return self.STATUS_CLASS 

279 

280 def save(self, *args, **kwargs): 

281 """Custom save method for StatusCodeMixin. 

282 

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 ) 

290 

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) 

294 

295 super().save(*args, **kwargs) 

296 

297 def get_status(self) -> int: 

298 """Return the status code for this object.""" 

299 return getattr(self, self.STATUS_FIELD) 

300 

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) 

304 

305 def compare_status(self, status: int) -> bool: 

306 """Determine if the current status matches the provided status code. 

307 

308 Arguments: 

309 status: The status code to compare against 

310 

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 

319 

320 if status == self.get_status(): 

321 return True 

322 

323 return status is not None and status == self.get_custom_status() 

324 

325 def set_status(self, status: int, custom_values=None) -> bool: 

326 """Set the status code for this object. 

327 

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

334 

335 base_values = self.status_class.values() 

336 

337 custom_value_set = ( 

338 self.status_class.custom_values() 

339 if custom_values is None 

340 else custom_values 

341 ) 

342 

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 

349 

350 custom_field = f'{self.STATUS_FIELD}_custom_key' 

351 

352 result = False 

353 

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 

367 

368 if not result: 

369 logger.warning(f'Failed to set status {status} for class {self.__class__}') 

370 

371 return result