Coverage for src/backend/InvenTree/InvenTree/models.py: 52%

645 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""Generic models which provide extra functionality over base Django model types.""" 

2 

3from collections.abc import Callable 

4from datetime import datetime 

5from string import Formatter 

6from typing import Any, Optional 

7 

8from django.contrib.auth import get_user_model 

9from django.contrib.contenttypes.fields import GenericRelation 

10from django.contrib.contenttypes.models import ContentType 

11from django.core.exceptions import ValidationError 

12from django.db import models, transaction 

13from django.db.models import QuerySet 

14from django.db.models.signals import post_save 

15from django.db.transaction import TransactionManagementError 

16from django.dispatch import receiver 

17from django.urls import resolve, reverse 

18from django.urls.exceptions import NoReverseMatch 

19from django.utils.translation import gettext_lazy as _ 

20 

21import structlog 

22from django_q.models import Task 

23from error_report.models import Error 

24from mptt.exceptions import InvalidMove 

25from mptt.models import MPTTModel, TreeForeignKey 

26from rest_framework.exceptions import PermissionDenied 

27from stdimage.models import StdImageField 

28from taggit.managers import TaggableManager 

29 

30import common.settings 

31import InvenTree.exceptions 

32import InvenTree.fields 

33import InvenTree.format 

34import InvenTree.helpers 

35import InvenTree.helpers_model 

36import InvenTree.sentry 

37 

38logger = structlog.get_logger('inventree') 

39 

40 

41class DiffMixin: 

42 """Mixin which can be used to determine which fields have changed, compared to the instance saved to the database.""" 

43 

44 def get_db_instance(self): 

45 """Return the instance of the object saved in the database. 

46 

47 Returns: 

48 object: Instance of the object saved in the database 

49 """ 

50 if self.pk: 

51 try: 

52 return self.__class__.objects.get(pk=self.pk) 

53 except self.__class__.DoesNotExist: 

54 pass 

55 

56 return None 

57 

58 def get_field_deltas(self): 

59 """Return a dict of field deltas. 

60 

61 Compares the current instance with the instance saved in the database, 

62 and returns a dict of fields which have changed. 

63 

64 Returns: 

65 dict: Dict of field deltas 

66 """ 

67 db_instance = self.get_db_instance() 

68 

69 if db_instance is None: 

70 return {} 

71 

72 deltas = {} 

73 

74 for field in self._meta.fields: 

75 if field.name == 'id': 

76 continue 

77 

78 if getattr(self, field.name) != getattr(db_instance, field.name): 

79 deltas[field.name] = { 

80 'old': getattr(db_instance, field.name), 

81 'new': getattr(self, field.name), 

82 } 

83 

84 return deltas 

85 

86 def has_field_changed(self, field_name): 

87 """Determine if a particular field has changed.""" 

88 return field_name in self.get_field_deltas() 

89 

90 

91class PluginValidationMixin(DiffMixin): 

92 """Mixin class which exposes the model instance to plugin validation. 

93 

94 Any model class which inherits from this mixin will be exposed to the plugin validation system. 

95 """ 

96 

97 def should_plugin_validate(self): 

98 """Return True if this model instance should be validated by plugins. 

99 

100 The default implementation returns True, but this can be overridden in the implementing class if required. 

101 """ 

102 from InvenTree.ready import isReadOnlyCommand 

103 

104 # Prevent plugin validation when importing or exporting data 

105 return not isReadOnlyCommand() 

106 

107 def run_plugin_validation(self): 

108 """Throw this model against the plugin validation interface.""" 

109 from plugin import PluginMixinEnum, registry 

110 

111 if not self.should_plugin_validate(): 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 return 

113 

114 deltas = self.get_field_deltas() 

115 

116 for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): 116 ↛ 117line 116 didn't jump to line 117 because the loop on line 116 never started

117 try: 

118 if plugin.validate_model_instance(self, deltas=deltas) is True: 

119 return 

120 except ValidationError as exc: 

121 raise exc 

122 except Exception: 

123 # Log the exception to the database 

124 import InvenTree.exceptions 

125 

126 InvenTree.exceptions.log_error( 

127 'validate_model_instance', plugin=plugin.slug 

128 ) 

129 raise ValidationError(_('Error running plugin validation')) 

130 

131 def full_clean(self, *args, **kwargs): 

132 """Run plugin validation on full model clean. 

133 

134 Note that plugin validation is performed *after* super.full_clean() 

135 """ 

136 super().full_clean(*args, **kwargs) 

137 self.run_plugin_validation() 

138 

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

140 """Run plugin validation on model save. 

141 

142 Note that plugin validation is performed *before* super.save() 

143 """ 

144 self.run_plugin_validation() 

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

146 

147 def delete(self, *args, **kwargs): 

148 """Run plugin validation on model delete. 

149 

150 Allows plugins to prevent model instances from being deleted. 

151 

152 Note: Each plugin may raise a ValidationError to prevent deletion. 

153 """ 

154 from InvenTree.exceptions import log_error 

155 from plugin import PluginMixinEnum, registry 

156 

157 if self.should_plugin_validate(): 157 ↛ 168line 157 didn't jump to line 168 because the condition on line 157 was always true

158 for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): 158 ↛ 159line 158 didn't jump to line 159 because the loop on line 158 never started

159 try: 

160 plugin.validate_model_deletion(self) 

161 except ValidationError as e: 

162 # Plugin might raise a ValidationError to prevent deletion 

163 raise e 

164 except Exception: 

165 log_error('validate_model_deletion', plugin=plugin.slug) 

166 continue 

167 

168 super().delete(*args, **kwargs) 

169 

170 

171class MetadataMixin(models.Model): 

172 """Model mixin class which adds a JSON metadata field to a model, for use by any (and all) plugins. 

173 

174 The intent of this mixin is to provide a metadata field on a model instance, 

175 for plugins to read / modify as required, to store any extra information. 

176 

177 The assumptions for models implementing this mixin are: 

178 

179 - The internal InvenTree business logic will make no use of this field 

180 - Multiple plugins may read / write to this metadata field, and not assume they have sole rights 

181 """ 

182 

183 class Meta: 

184 """Meta for MetadataMixin.""" 

185 

186 abstract = True 

187 

188 def save(self, force_insert=False, force_update=False, *args, **kwargs): 

189 """Save the model instance, and perform validation on the metadata field.""" 

190 self.validate_metadata() 

191 if len(args) > 0: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 raise TypeError( 

193 'save() takes no positional arguments anymore' 

194 ) # pragma: no cover 

195 super().save(force_insert=force_insert, force_update=force_update, **kwargs) 

196 

197 def clean(self, *args, **kwargs): 

198 """Perform model validation on the metadata field.""" 

199 super().clean() 

200 

201 self.validate_metadata() 

202 

203 def validate_metadata(self): 

204 """Validate the metadata field.""" 

205 # Ensure that the 'metadata' field is a valid dict object 

206 if self.metadata is None: 

207 self.metadata = {} 

208 

209 if type(self.metadata) is not dict: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 raise ValidationError({ 

211 'metadata': _('Metadata must be a python dict object') 

212 }) 

213 

214 metadata = models.JSONField( 

215 blank=True, 

216 null=True, 

217 verbose_name=_('Plugin Metadata'), 

218 help_text=_('JSON metadata field, for use by external plugins'), 

219 ) 

220 

221 def get_metadata(self, key: str, backup_value=None): 

222 """Finds metadata for this model instance, using the provided key for lookup. 

223 

224 Args: 

225 key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used 

226 backup_value: Value that should be used if no value is found 

227 

228 Returns: 

229 Python dict object containing requested metadata. If no matching metadata is found, returns None 

230 """ 

231 if self.metadata is None: 

232 return backup_value 

233 

234 return self.metadata.get(key, backup_value) 

235 

236 def set_metadata( 

237 self, key: str, data, commit: bool = True, overwrite: bool = False 

238 ): 

239 """Save the provided metadata under the provided key. 

240 

241 Args: 

242 key (str): Key for saving metadata 

243 data (Any): Data object to save - must be able to be rendered as a JSON string 

244 commit (bool, optional): If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted. Defaults to True. 

245 overwrite (bool): If true, delete existing metadata before adding new value 

246 """ 

247 if overwrite or self.metadata is None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 self.metadata = {} 

249 

250 self.metadata[key] = data 

251 

252 if commit: 252 ↛ exitline 252 didn't return from function 'set_metadata' because the condition on line 252 was always true

253 self.save() 

254 

255 

256class ReferenceIndexingMixin(models.Model): 

257 """A mixin for keeping track of numerical copies of the "reference" field. 

258 

259 Here, we attempt to convert a "reference" field value (char) to an integer, 

260 for performing fast natural sorting. 

261 

262 This requires extra database space (due to the extra table column), 

263 but is required as not all supported database backends provide equivalent casting. 

264 

265 This mixin adds a field named 'reference_int'. 

266 

267 - If the 'reference' field can be cast to an integer, it is stored here 

268 - If the 'reference' field *starts* with an integer, it is stored here 

269 - Otherwise, we store zero 

270 """ 

271 

272 # Name of the global setting which defines the required reference pattern for this model 

273 REFERENCE_PATTERN_SETTING = None 

274 

275 class Meta: 

276 """Metaclass options. Abstract ensures no database table is created.""" 

277 

278 abstract = True 

279 

280 @classmethod 

281 def get_reference_pattern(cls): 

282 """Returns the reference pattern associated with this model. 

283 

284 This is defined by a global setting object, specified by the REFERENCE_PATTERN_SETTING attribute 

285 """ 

286 # By default, we return an empty string 

287 if cls.REFERENCE_PATTERN_SETTING is None: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true

288 return '' 

289 

290 return common.settings.get_global_setting( 

291 cls.REFERENCE_PATTERN_SETTING, create=False 

292 ).strip() 

293 

294 @classmethod 

295 def get_reference_context(cls): 

296 """Generate context data for generating the 'reference' field for this class. 

297 

298 - Returns a python dict object which contains the context data for formatting the reference string. 

299 - The default implementation provides some default context information 

300 - The '?' key is required to accept our wildcard-with-default syntax {?:default} 

301 """ 

302 return {'ref': cls.get_next_reference(), 'date': datetime.now(), '?': '?'} 

303 

304 @classmethod 

305 def get_most_recent_item(cls): 

306 """Return the item which is 'most recent'. 

307 

308 In practice, this means the item with the highest reference value 

309 """ 

310 query = cls.objects.all().order_by('-reference_int', '-pk') 

311 

312 if query.exists(): 

313 return query.first() 

314 return None 

315 

316 @classmethod 

317 def get_next_reference(cls): 

318 """Return the next available reference value for this particular class.""" 

319 # Find the "most recent" item 

320 latest = cls.get_most_recent_item() 

321 

322 if not latest: 

323 # No existing items 

324 return 1 

325 

326 reference = latest.reference.strip 

327 

328 try: 

329 reference = InvenTree.format.extract_named_group( 

330 'ref', reference, cls.get_reference_pattern() 

331 ) 

332 except Exception: 

333 # If reference cannot be extracted using the pattern, try just the integer value 

334 reference = str(latest.reference_int) 

335 

336 # Attempt to perform 'intelligent' incrementing of the reference field 

337 incremented = InvenTree.helpers.increment(reference) 

338 

339 try: 

340 incremented = int(incremented) 

341 except ValueError: 

342 pass 

343 

344 return incremented 

345 

346 @classmethod 

347 def generate_reference(cls): 

348 """Generate the next 'reference' field based on specified pattern.""" 

349 

350 # Based on https://stackoverflow.com/a/57570269/14488558 

351 class ReferenceFormatter(Formatter): 

352 def format_field(self, value, format_spec): 

353 if isinstance(value, str) and value == '?': 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true

354 value = format_spec 

355 format_spec = '' 

356 return super().format_field(value, format_spec) 

357 

358 ref_ptn = cls.get_reference_pattern() 

359 ctx = cls.get_reference_context() 

360 fmt = ReferenceFormatter() 

361 

362 reference = None 

363 

364 attempts = set() 

365 

366 while reference is None: 366 ↛ 390line 366 didn't jump to line 390 because the condition on line 366 was always true

367 try: 

368 ref = fmt.format(ref_ptn, **ctx) 

369 

370 if ref in attempts: 370 ↛ 372line 370 didn't jump to line 372 because the condition on line 370 was never true

371 # We are stuck in a loop! 

372 reference = ref 

373 break 

374 else: 

375 attempts.add(ref) 

376 

377 if cls.objects.filter(reference=ref).exists(): 377 ↛ 379line 377 didn't jump to line 379 because the condition on line 377 was never true

378 # Handle case where we have duplicated an existing reference 

379 ctx['ref'] = InvenTree.helpers.increment(ctx['ref']) 

380 else: 

381 # We have found an 'unused' reference 

382 reference = ref 

383 break 

384 

385 except Exception: 

386 # If anything goes wrong, return the most recent reference 

387 recent = cls.get_most_recent_item() 

388 reference = recent.reference if recent else '' 

389 

390 return reference 

391 

392 @classmethod 

393 def validate_reference_pattern(cls, pattern): 

394 """Ensure that the provided pattern is valid.""" 

395 ctx = cls.get_reference_context() 

396 

397 try: 

398 info = InvenTree.format.parse_format_string(pattern) 

399 except Exception as exc: 

400 raise ValidationError({ 

401 'value': _('Improperly formatted pattern') + ': ' + str(exc) 

402 }) 

403 

404 # Check that only 'allowed' keys are provided 

405 for key in info: 

406 if key not in ctx: 

407 raise ValidationError({ 

408 'value': _('Unknown format key specified') + f": '{key}'" 

409 }) 

410 

411 # Check that the 'ref' variable is specified 

412 if 'ref' not in info: 

413 raise ValidationError({ 

414 'value': _('Missing required format key') + ": 'ref'" 

415 }) 

416 

417 @classmethod 

418 def validate_reference_field(cls, value): 

419 """Check that the provided 'reference' value matches the requisite pattern.""" 

420 pattern = cls.get_reference_pattern() 

421 

422 value = str(value).strip() 

423 

424 if len(value) == 0: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 raise ValidationError(_('Reference field cannot be empty')) 

426 

427 # An 'empty' pattern means no further validation is required 

428 if not pattern: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true

429 return 

430 

431 if not InvenTree.format.validate_string(value, pattern): 

432 raise ValidationError( 

433 _('Reference must match required pattern') + ': ' + pattern 

434 ) 

435 

436 # Check that the reference field can be rebuild 

437 return cls.rebuild_reference_field(value, validate=True) 

438 

439 @classmethod 

440 def rebuild_reference_field(cls, reference, validate=False): 

441 """Extract integer out of reference for sorting. 

442 

443 If the 'integer' portion is buried somewhere 'within' the reference, 

444 we can first try to extract it using the pattern. 

445 

446 Example: 

447 reference - BO-123-ABC 

448 pattern - BO-{ref}-??? 

449 extracted - 123 

450 

451 If we cannot extract using the pattern for some reason, fallback to the entire reference 

452 """ 

453 try: 

454 # Extract named group based on provided pattern 

455 reference = InvenTree.format.extract_named_group( 

456 'ref', reference, cls.get_reference_pattern() 

457 ) 

458 except Exception: 

459 pass 

460 

461 reference_int = InvenTree.helpers.extract_int(reference) 

462 

463 if validate and reference_int > models.BigIntegerField.MAX_BIGINT: 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true

464 raise ValidationError({'reference': _('Reference number is too large')}) 

465 

466 return reference_int 

467 

468 reference_int = models.BigIntegerField(default=0) 

469 

470 

471class ContentTypeMixin: 

472 """Mixin class which supports retrieval of the ContentType for a model instance.""" 

473 

474 @classmethod 

475 def get_content_type(cls): 

476 """Return the ContentType object associated with this model.""" 

477 from django.contrib.contenttypes.models import ContentType 

478 

479 return ContentType.objects.get_for_model(cls) 

480 

481 

482class InvenTreeModel(ContentTypeMixin, PluginValidationMixin, models.Model): 

483 """Base class for InvenTree models, which provides some common functionality. 

484 

485 Includes the following mixins by default: 

486 

487 - PluginValidationMixin: Provides a hook for plugins to validate model instances 

488 """ 

489 

490 class Meta: 

491 """Metaclass options.""" 

492 

493 abstract = True 

494 

495 

496class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel): 

497 """Base class for an InvenTree model which includes a metadata field.""" 

498 

499 class Meta: 

500 """Metaclass options.""" 

501 

502 abstract = True 

503 

504 

505class InvenTreePermissionCheckMixin: 

506 """Provides an abstracted class for managing permissions against related fields.""" 

507 

508 @classmethod 

509 def check_related_permission(cls, permission, user) -> bool: 

510 """Check if the user has permission to perform the specified action on the attachment. 

511 

512 The default implementation runs a permission check against *this* model class, 

513 but this can be overridden in the implementing class if required. 

514 

515 Arguments: 

516 permission: The permission to check (add / change / view / delete) 

517 user: The user to check against 

518 

519 Returns: 

520 bool: True if the user has permission, False otherwise 

521 """ 

522 perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}' 

523 return user.has_perm(perm) 

524 

525 

526class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model): 

527 """Provides an abstracted class for managing parameters. 

528 

529 Links the implementing model to the common.models.Parameter table, 

530 and provides the following methods: 

531 """ 

532 

533 class Meta: 

534 """Metaclass options for InvenTreeParameterMixin.""" 

535 

536 abstract = True 

537 

538 # Define a reverse relation to the Parameter model 

539 parameters_list = GenericRelation( 

540 'common.Parameter', content_type_field='model_type', object_id_field='model_id' 

541 ) 

542 

543 @staticmethod 

544 def annotate_parameters(queryset: QuerySet) -> QuerySet: 

545 """Annotate a queryset with pre-fetched parameters. 

546 

547 Args: 

548 queryset: Queryset to annotate 

549 

550 Returns: 

551 Annotated queryset 

552 """ 

553 return queryset.prefetch_related( 

554 'parameters_list', 

555 'parameters_list__model_type', 

556 'parameters_list__updated_by', 

557 'parameters_list__template', 

558 'parameters_list__template__model_type', 

559 ) 

560 

561 @property 

562 def parameters(self) -> QuerySet: 

563 """Return a QuerySet containing all the Parameter instances for this model. 

564 

565 This will return pre-fetched data if available (i.e. in a serializer context). 

566 """ 

567 # Check the query cache for pre-fetched parameters 

568 if cache := getattr(self, '_prefetched_objects_cache', None): 

569 if 'parameters_list' in cache: 569 ↛ 572line 569 didn't jump to line 572 because the condition on line 569 was always true

570 return cache['parameters_list'] 

571 

572 return self.parameters_list.all().prefetch_related('template') 

573 

574 def delete(self, *args, **kwargs): 

575 """Handle the deletion of a model instance. 

576 

577 Before deleting the model instance, delete any associated parameters. 

578 """ 

579 self.parameters_list.all().delete() 

580 super().delete(*args, **kwargs) 

581 

582 @transaction.atomic 

583 def copy_parameters_from(self, other, clear=True, **kwargs): 

584 """Copy all parameters from another model instance. 

585 

586 Arguments: 

587 other: The other model instance to copy parameters from 

588 clear: If True, clear existing parameters before copying 

589 **kwargs: Additional keyword arguments to pass to the Parameter constructor 

590 """ 

591 import common.models 

592 

593 if clear: 

594 self.parameters_list.all().delete() 

595 

596 parameters = [] 

597 

598 content_type = ContentType.objects.get_for_model(self.__class__) 

599 

600 template_ids = [parameter.template.pk for parameter in other.parameters.all()] 

601 

602 # Remove all conflicting parameters first 

603 self.parameters_list.filter(template__pk__in=template_ids).delete() 

604 

605 for parameter in other.parameters.all(): 

606 parameter.pk = None 

607 parameter.model_id = self.pk 

608 parameter.model_type = content_type 

609 

610 parameters.append(parameter) 

611 

612 if len(parameters) > 0: 

613 common.models.Parameter.objects.bulk_create(parameters, batch_size=250) 

614 

615 def get_parameter(self, name: str): 

616 """Return a Parameter instance for the given parameter name. 

617 

618 Args: 

619 name: Name of the parameter template 

620 

621 Returns: 

622 Parameter instance if found, else None 

623 """ 

624 return self.parameters_list.filter(template__name=name).first() 

625 

626 def get_parameters(self) -> QuerySet: 

627 """Return all Parameter instances for this model.""" 

628 return ( 

629 self.parameters_list 

630 .all() 

631 .prefetch_related('template', 'model_type') 

632 .order_by('template__name') 

633 ) 

634 

635 def parameters_map(self) -> dict: 

636 """Return a map (dict) of parameter values associated with this Part instance, of the form. 

637 

638 Example: 

639 { 

640 "name_1": "value_1", 

641 "name_2": "value_2", 

642 } 

643 """ 

644 params = {} 

645 

646 for parameter in self.parameters.all().prefetch_related('template'): 

647 params[parameter.template.name] = parameter.data 

648 

649 return params 

650 

651 def check_parameter_delete(self, parameter): 

652 """Run a check to determine if the provided parameter can be deleted. 

653 

654 The default implementation always returns True, but this can be overridden in the implementing class. 

655 """ 

656 return True 

657 

658 def check_parameter_save(self, parameter): 

659 """Run a check to determine if the provided parameter can be saved. 

660 

661 The default implementation always returns True, but this can be overridden in the implementing class. 

662 """ 

663 return True 

664 

665 

666class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin): 

667 """Provides an abstracted class for managing file attachments. 

668 

669 Links the implementing model to the common.models.Attachment table, 

670 and provides the following methods: 

671 

672 - attachments: Return a queryset containing all attachments for this model 

673 """ 

674 

675 def delete(self, *args, **kwargs): 

676 """Handle the deletion of a model instance. 

677 

678 Before deleting the model instance, delete any associated attachments. 

679 """ 

680 for attachment in list(self.attachments.all()): 680 ↛ 681line 680 didn't jump to line 681 because the loop on line 680 never started

681 attachment.delete() 

682 

683 super().delete(*args, **kwargs) 

684 

685 @property 

686 def attachments(self) -> QuerySet: 

687 """Return a queryset containing all attachments for this model.""" 

688 return self.attachments_for_model().filter(model_id=self.pk) 

689 

690 def attachments_for_model(self) -> QuerySet: 

691 """Return all attachments for this model class.""" 

692 from common.models import Attachment 

693 

694 model_type = self.__class__.__name__.lower() 

695 return Attachment.objects.filter(model_type=model_type) 

696 

697 def create_attachment(self, attachment=None, link=None, comment='', **kwargs): 

698 """Create an attachment / link for this model.""" 

699 from common.models import Attachment 

700 

701 kwargs['attachment'] = attachment 

702 kwargs['link'] = link 

703 kwargs['comment'] = comment 

704 kwargs['model_type'] = self.__class__.__name__.lower() 

705 kwargs['model_id'] = self.pk 

706 

707 Attachment.objects.create(**kwargs) 

708 

709 

710class InvenTreeTree(ContentTypeMixin, MPTTModel): 

711 """Provides an abstracted self-referencing tree model, based on the MPTTModel class. 

712 

713 Our implementation provides the following key improvements: 

714 

715 - Allow tracking of separate concepts of "nodes" and "items" 

716 - Better handling of deletion of nodes and items 

717 - Ensure tree is correctly rebuilt after deletion and other operations 

718 - Improved protection against recursive tree structures 

719 """ 

720 

721 # How each node reference its parent object 

722 NODE_PARENT_KEY = 'parent' 

723 

724 # How items (not nodes) are hooked into the tree 

725 # e.g. for StockLocation, this value is 'location' 

726 ITEM_PARENT_KEY = None 

727 

728 class Meta: 

729 """Metaclass defines extra model properties.""" 

730 

731 abstract = True 

732 

733 class MPTTMeta: 

734 """MPTT metaclass options.""" 

735 

736 order_insertion_by = ['name'] 

737 

738 @transaction.atomic 

739 def delete(self, *args, **kwargs): 

740 """Handle the deletion of a tree node. 

741 

742 kwargs: 

743 delete_children: If True, delete all child nodes (otherwise, point to the parent of this node) 

744 delete_items: If True, delete all items associated with this node (otherwise, point to the parent of this node) 

745 

746 Order of operations: 

747 1. Update nodes and items under the current node 

748 2. Delete this node 

749 3. Rebuild the model tree 

750 """ 

751 delete_children = kwargs.pop('delete_children', False) 

752 delete_items = kwargs.pop('delete_items', False) 

753 

754 # Ensure that we have the latest version of the database object 

755 try: 

756 self.refresh_from_db() 

757 except self.__class__.DoesNotExist: 

758 # If the object no longer exists, raise a ValidationError 

759 raise ValidationError( 

760 'Object %s of type %s no longer exists', str(self), str(self.__class__) 

761 ) 

762 

763 tree_id = self.tree_id 

764 parent = getattr(self, self.NODE_PARENT_KEY, None) 

765 

766 # When deleting a top level node with multiple children, 

767 # we need to assign a new tree_id to each child node 

768 # otherwise they will all have the same tree_id (which is not allowed) 

769 lower_trees = [] 

770 

771 if not parent: # No parent, which means this is a top-level node 

772 for child in self.get_children(): 

773 # Store a flattened list of node IDs for each of the lower trees 

774 nodes = list( 

775 child 

776 .get_descendants(include_self=True) 

777 .values_list('pk', flat=True) 

778 .distinct() 

779 ) 

780 lower_trees.append(nodes) 

781 

782 # 1. Update nodes and items under the current node 

783 self.handle_tree_delete( 

784 delete_children=delete_children, delete_items=delete_items 

785 ) 

786 

787 # 2. Delete *this* node 

788 super().delete(*args, **kwargs) 

789 

790 # A set of tree_id values which need to be rebuilt 

791 trees = set() 

792 

793 if tree_id: 

794 # If this node had a tree_id, we need to rebuild that tree 

795 trees.add(tree_id) 

796 

797 # Did we delete a top-level node? 

798 next_tree_id = self.getNextTreeID() 

799 

800 # If there is only one sub-tree, it can retain the same tree_id value 

801 for tree in lower_trees[1:]: 

802 # Bulk update the tree_id for all lower nodes 

803 lower_nodes = self.__class__.objects.filter(pk__in=tree) 

804 lower_nodes.update(tree_id=next_tree_id) 

805 trees.add(next_tree_id) 

806 next_tree_id += 1 

807 

808 # 3. Rebuild the model tree(s) as required 

809 self.__class__.rebuild_trees(trees) 

810 

811 def handle_tree_delete(self, delete_children=False, delete_items=False): 

812 """Delete a single instance of the tree, based on provided kwargs. 

813 

814 Removing a tree "node" from the database must be considered carefully, 

815 based on what the user intends for any items which exist *under* that node. 

816 

817 - "children" are any nodes (of the same type) which exist *under* this node (e.g. PartCategory) 

818 - "items" are any items (of a different type) which exist *under* this node (e.g. Part) 

819 

820 Arguments: 

821 delete_children: If True, delete all child items 

822 delete_items: If True, delete all items associated with this node 

823 

824 There are multiple scenarios we can consider here: 

825 

826 A) delete_children = True and delete_items = True 

827 B) delete_children = True and delete_items = False 

828 C) delete_children = False and delete_items = True 

829 D) delete_children = False and delete_items = False 

830 """ 

831 child_nodes = self.get_descendants(include_self=False) 

832 

833 # Case A: Delete all child items, and all child nodes. 

834 # - Delete all items at any lower level 

835 # - Delete all descendant nodes 

836 if delete_children and delete_items: 

837 self.delete_items(cascade=True) 

838 self.delete_nodes(child_nodes) 

839 

840 # Case B: Delete all child nodes, but move all child items up to the parent 

841 # - Move all items at any lower level to the parent of this item 

842 # - Delete all descendant nodes 

843 elif delete_children and not delete_items: 

844 if items := self.get_items(cascade=True): 

845 parent = getattr(self, self.NODE_PARENT_KEY, None) 

846 items.update(**{self.ITEM_PARENT_KEY: parent}) 

847 self.delete_nodes(child_nodes) 

848 

849 # Case C: Delete all child items, but keep all child nodes 

850 # - Remove all items directly associated with this node 

851 # - Move any direct child nodes up one level 

852 elif not delete_children and delete_items: 

853 self.delete_items(cascade=False) 

854 parent = getattr(self, self.NODE_PARENT_KEY, None) 

855 self.get_children().update(**{self.NODE_PARENT_KEY: parent}) 

856 

857 # Case D: Keep all child items, and keep all child nodes 

858 # - Move all items directly associated with this node up one level 

859 # - Move any direct child nodes up one level 

860 elif not delete_children and not delete_items: 

861 parent = getattr(self, self.NODE_PARENT_KEY, None) 

862 if items := self.get_items(cascade=False): 

863 items.update(**{self.ITEM_PARENT_KEY: parent}) 

864 self.get_children().update(**{self.NODE_PARENT_KEY: parent}) 

865 

866 def delete_nodes(self, nodes): 

867 """Delete a set of nodes from the tree. 

868 

869 1. First, set the "parent" value for selected nodes to None 

870 2. Then, perform bulk deletion of selected nodes 

871 

872 Step 1. is required because we cannot guarantee the order-of-operations in the db backend 

873 

874 Arguments: 

875 nodes: A queryset of nodes to delete 

876 """ 

877 nodes.update(**{self.NODE_PARENT_KEY: None}) 

878 nodes.delete() 

879 

880 def api_instance_filters(self): 

881 """Instance filters for InvenTreeTree models.""" 

882 return {self.NODE_PARENT_KEY: {'exclude_tree': self.pk}} 

883 

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

885 """Custom save method for InvenTreeTree abstract model.""" 

886 db_instance = None 

887 

888 parent = getattr(self, self.NODE_PARENT_KEY, None) 

889 

890 if not self.tree_id: 

891 if parent: 891 ↛ 893line 891 didn't jump to line 893 because the condition on line 891 was never true

892 # If we have a parent, use the parent's tree_id 

893 self.tree_id = parent.tree_id 

894 self.level = parent.level + 1 

895 else: 

896 # Otherwise, we need to generate a new tree_id 

897 self.tree_id = self.getNextTreeID() 

898 

899 if self.pk: 

900 try: 

901 db_instance = self.get_db_instance() 

902 except self.__class__.DoesNotExist: 

903 # If the instance does not exist, we cannot get the db instance 

904 db_instance = None 

905 try: 

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

907 except InvalidMove: 

908 # Provide better error for parent selection 

909 raise ValidationError({self.NODE_PARENT_KEY: _('Invalid choice')}) 

910 

911 trees = set() 

912 

913 parent = getattr(self, self.NODE_PARENT_KEY, None) 

914 

915 if db_instance: 

916 # If the tree_id or parent has changed, we need to rebuild the tree 

917 if getattr(db_instance, self.NODE_PARENT_KEY) != parent: 917 ↛ 918line 917 didn't jump to line 918 because the condition on line 917 was never true

918 trees.add(db_instance.tree_id) 

919 if db_instance.tree_id != self.tree_id: 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true

920 trees.add(self.tree_id) 

921 trees.add(db_instance.tree_id) 

922 elif parent: 922 ↛ 924line 922 didn't jump to line 924 because the condition on line 922 was never true

923 # New instance, so we need to rebuild the tree (if it has a parent) 

924 trees.add(self.tree_id) 

925 

926 # Flag to indicate that a tree rebuild task was triggered by this save 

927 self._tree_rebuild_offloaded = False 

928 

929 if len(trees) > 0: 929 ↛ 933line 929 didn't jump to line 933 because the condition on line 929 was never true

930 # Offload the tree rebuild(s) to the background worker. 

931 # Note that repeated calls are de-duplicated (per tree), 

932 # so a bulk operation results in a single rebuild per affected tree. 

933 ran_sync = self.__class__.offload_tree_rebuild(trees) 

934 self._tree_rebuild_offloaded = True 

935 

936 if ran_sync: 

937 # The tree was rebuilt synchronously, so refresh the instance 

938 try: 

939 self.refresh_from_db() 

940 except TransactionManagementError: 

941 # If we are inside a transaction block, we cannot refresh from db 

942 pass 

943 except Exception as e: 

944 # Any other error is unexpected 

945 InvenTree.sentry.report_exception(e) 

946 InvenTree.exceptions.log_error(f'{self.__class__.__name__}.save') 

947 

948 @classmethod 

949 def offload_tree_rebuild(cls, tree_ids) -> bool: 

950 """Offload a rebuild of the specified trees to the background worker. 

951 

952 - The tree structure (and pathstring values, where applicable) are rebuilt for each tree 

953 - If the background worker is not running, the rebuild is performed synchronously 

954 - Identical pending tasks are skipped, so repeated calls (e.g. during a bulk 

955 operation) result in (at most) a single queued rebuild per affected tree 

956 

957 Returns: 

958 bool: True if any rebuild was performed synchronously (in the calling thread) 

959 """ 

960 from InvenTree.tasks import offload_task 

961 

962 ran_sync = False 

963 

964 for tree_id in tree_ids: 

965 if tree_id: 

966 result = offload_task( 

967 'InvenTree.tasks.rebuild_model_tree', cls._meta.label_lower, tree_id 

968 ) 

969 

970 if result is True: 

971 # offload_task returns True if the task ran synchronously 

972 ran_sync = True 

973 

974 return ran_sync 

975 

976 @classmethod 

977 def rebuild_trees(cls, tree_ids) -> None: 

978 """Rebuild the specified trees, with fallback to a full rebuild. 

979 

980 - Perform a partial rebuild for each provided tree_id 

981 - If any partial rebuild fails, rebuild the entire tree (expensive!!!) 

982 """ 

983 result = True 

984 

985 for tree_id in tree_ids: 

986 if tree_id and not cls.partial_rebuild(tree_id): 

987 result = False 

988 

989 if not result: 

990 # Rebuild the entire tree (expensive!!!) 

991 cls.objects.rebuild() 

992 

993 @classmethod 

994 def partial_rebuild(cls, tree_id: int) -> bool: 

995 """Perform a partial rebuild of the tree structure. 

996 

997 If a failure occurs, log the error and return False. 

998 """ 

999 try: 

1000 cls.objects.partial_rebuild(tree_id) 

1001 return True 

1002 except Exception as e: 

1003 # This is a critical error, explicitly report to sentry 

1004 InvenTree.sentry.report_exception(e) 

1005 

1006 InvenTree.exceptions.log_error(f'{cls.__name__}.partial_rebuild') 

1007 logger.exception( 

1008 'Failed to rebuild tree <%s> for %s: %s', tree_id, cls.__name__, e 

1009 ) 

1010 return False 

1011 

1012 def delete_items(self, cascade: bool = False): 

1013 """Delete any 'items' which exist under this node in the tree. 

1014 

1015 - Note that an 'item' is an instance of a different model class. 

1016 - Not all tree structures will have items associated with them. 

1017 """ 

1018 if items := self.get_items(cascade=cascade): 

1019 items.delete() 

1020 

1021 def get_items(self, cascade: bool = False): 

1022 """Return a queryset of items which exist *under* this node in the tree. 

1023 

1024 - For a StockLocation instance, this would be a queryset of StockItem objects 

1025 - For a PartCategory instance, this would be a queryset of Part objects 

1026 

1027 The default implementation returns None, indicating that no items exist under this node. 

1028 """ 

1029 return None 

1030 

1031 def getUniqueParents(self) -> QuerySet: 

1032 """Return a flat set of all parent items that exist above this node.""" 

1033 return self.get_ancestors() 

1034 

1035 def getUniqueChildren(self, include_self=True) -> QuerySet: 

1036 """Return a flat set of all child items that exist under this node.""" 

1037 return self.get_descendants(include_self=include_self) 

1038 

1039 @property 

1040 def has_children(self) -> bool: 

1041 """True if there are any children under this item.""" 

1042 return self.getUniqueChildren(include_self=False).count() > 0 

1043 

1044 @classmethod 

1045 def getNextTreeID(cls) -> int: 

1046 """Return the next available tree_id for this model class.""" 

1047 instance = cls.objects.order_by('-tree_id').first() 

1048 

1049 if instance: 

1050 return instance.tree_id + 1 

1051 else: 

1052 return 1 

1053 

1054 

1055class PathStringMixin(models.Model): 

1056 """Mixin class for adding a 'pathstring' field to a model class. 

1057 

1058 The pathstring is a string representation of the path to this model instance, 

1059 which can be used for display purposes. 

1060 

1061 The pathstring is automatically generated when the model instance is saved. 

1062 """ 

1063 

1064 # Field to use for constructing a "pathstring" for the tree 

1065 PATH_FIELD = 'name' 

1066 

1067 # Extra fields to include in the get_path result. E.g. icon 

1068 EXTRA_PATH_FIELDS = [] 

1069 

1070 class Meta: 

1071 """Metaclass options for this mixin. 

1072 

1073 Note: abstract must be true, as this is only a mixin, not a separate table 

1074 """ 

1075 

1076 abstract = True 

1077 

1078 name = models.CharField( 

1079 blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Name') 

1080 ) 

1081 

1082 description = models.CharField( 

1083 blank=True, 

1084 max_length=250, 

1085 verbose_name=_('Description'), 

1086 help_text=_('Description (optional)'), 

1087 ) 

1088 

1089 # When a category is deleted, graft the children onto its parent 

1090 parent = TreeForeignKey( 

1091 'self', 

1092 on_delete=models.DO_NOTHING, 

1093 blank=True, 

1094 null=True, 

1095 verbose_name='parent', 

1096 related_name='children', 

1097 ) 

1098 

1099 # The 'pathstring' field is calculated each time the model is saved 

1100 pathstring = models.CharField( 

1101 blank=True, max_length=250, verbose_name=_('Path'), help_text=_('Path') 

1102 ) 

1103 

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

1105 """Update the pathstring field when saving the model instance.""" 

1106 old_pathstring = self.pathstring 

1107 

1108 # Rebuild upper first, to ensure the lower nodes are updated correctly 

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

1110 

1111 # Determine if a tree rebuild task was already triggered by this save 

1112 # (e.g. if the node was re-parented) - if so, the pathstring values 

1113 # for any lower nodes are updated by that task 

1114 rebuild_offloaded = getattr(self, '_tree_rebuild_offloaded', False) 

1115 

1116 # Ensure that the pathstring is correctly constructed 

1117 pathstring = self.construct_pathstring(refresh=True) 

1118 

1119 if pathstring != old_pathstring: 

1120 kwargs.pop('force_insert', None) 

1121 kwargs['force_update'] = True 

1122 

1123 self.pathstring = pathstring 

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

1125 

1126 # Update the pathstring values for any lower nodes, 

1127 # by offloading the update to the background worker 

1128 if not rebuild_offloaded and self.get_descendant_count() > 0: 1128 ↛ 1129line 1128 didn't jump to line 1129 because the condition on line 1128 was never true

1129 self.__class__.offload_tree_rebuild([self.tree_id]) 

1130 

1131 def delete(self, *args, **kwargs): 

1132 """Custom delete method for PathStringMixin. 

1133 

1134 - Before deleting the object, update the pathstring for any child nodes. 

1135 - Then, delete the object. 

1136 """ 

1137 # Ensure that we have the latest version of the database object 

1138 try: 

1139 self.refresh_from_db() 

1140 except self.__class__.DoesNotExist: 

1141 # If the object no longer exists, raise a ValidationError 

1142 raise ValidationError( 

1143 'Object %s of type %s no longer exists', str(self), str(self.__class__) 

1144 ) 

1145 

1146 # Store the node ID values for lower nodes, before we delete this one 

1147 lower_nodes = self.get_lower_nodes() 

1148 

1149 # Delete this node - after which we expect the tree structure will be updated 

1150 super().delete(*args, **kwargs) 

1151 

1152 # Rebuild the pathstring for lower nodes 

1153 self.rebuild_lower_nodes(lower_nodes) 

1154 

1155 def __str__(self): 

1156 """String representation of a category is the full path to that category.""" 

1157 return f'{self.pathstring} - {self.description}' 

1158 

1159 def get_lower_nodes(self) -> list[int]: 

1160 """Return a list of all lower nodes in the tree.""" 

1161 return list( 

1162 self.get_descendants(include_self=False).values_list('pk', flat=True) 

1163 ) 

1164 

1165 def rebuild_lower_nodes(self, lower_nodes: list[int]): 

1166 """Rebuild the pathstring for lower nodes in the tree. 

1167 

1168 - This is used when the pathstring for this node is updated, and we need to update all lower nodes. 

1169 - We use a bulk-update to update the pathstring for all lower nodes in the tree. 

1170 """ 

1171 nodes = self.__class__.objects.filter(pk__in=lower_nodes) 

1172 

1173 nodes_to_update = [] 

1174 

1175 for node in nodes: 

1176 new_path = node.construct_pathstring() 

1177 

1178 if new_path != node.pathstring: 

1179 node.pathstring = new_path 

1180 nodes_to_update.append(node) 

1181 

1182 if len(nodes_to_update) > 0: 

1183 self.__class__.objects.bulk_update(nodes_to_update, ['pathstring']) 

1184 

1185 @classmethod 

1186 def rebuild_tree_pathstring_values(cls, tree_ids) -> None: 

1187 """Rebuild the 'pathstring' values for all nodes in the specified trees. 

1188 

1189 Each tree is processed in a single pass: 

1190 the pathstring for each node is constructed from its parent node, 

1191 and any changed values are written back in a single bulk update. 

1192 """ 

1193 tree_nodes = list(cls.objects.filter(tree_id__in=tree_ids)) 

1194 node_map = {node.pk: node for node in tree_nodes} 

1195 

1196 # Cache of node ID -> list of path elements (from the top level down) 

1197 path_cache: dict[int, list[str]] = {} 

1198 

1199 def path_names(node) -> list[str]: 

1200 """Construct the path (list of names) for a node, via its parent chain.""" 

1201 if node.pk in path_cache: 

1202 return path_cache[node.pk] 

1203 

1204 names = [str(getattr(node, cls.PATH_FIELD, node.pk))] 

1205 

1206 if node.parent_id: 

1207 parent = node_map.get(node.parent_id) 

1208 

1209 if parent is None: 

1210 # Parent node exists outside the selected trees 

1211 parent = cls.objects.get(pk=node.parent_id) 

1212 node_map[node.parent_id] = parent 

1213 

1214 names = [*path_names(parent), *names] 

1215 

1216 path_cache[node.pk] = names 

1217 return names 

1218 

1219 nodes_to_update = [] 

1220 

1221 for node in tree_nodes: 

1222 pathstring = InvenTree.helpers.constructPathString(path_names(node)) 

1223 

1224 if pathstring != node.pathstring: 

1225 node.pathstring = pathstring 

1226 nodes_to_update.append(node) 

1227 

1228 if len(nodes_to_update) > 0: 

1229 cls.objects.bulk_update(nodes_to_update, ['pathstring'], batch_size=250) 

1230 

1231 def construct_pathstring(self, refresh: bool = False) -> str: 

1232 """Construct the pathstring for this tree node. 

1233 

1234 Arguments: 

1235 refresh: If True, force a refresh of the model instance 

1236 """ 

1237 if refresh: 1237 ↛ 1241line 1237 didn't jump to line 1241 because the condition on line 1237 was always true

1238 # Refresh the model instance from the database 

1239 self.refresh_from_db() 

1240 

1241 return InvenTree.helpers.constructPathString([ 

1242 getattr(item, self.PATH_FIELD, item.pk) for item in self.path 

1243 ]) 

1244 

1245 def validate_unique(self, exclude=None): 

1246 """Validate that this tree instance satisfies our uniqueness requirements. 

1247 

1248 Note that a 'unique_together' requirement for ('name', 'parent') is insufficient, 

1249 as it ignores cases where parent=None (i.e. top-level items) 

1250 """ 

1251 super().validate_unique(exclude) 

1252 

1253 results = self.__class__.objects.filter( 

1254 name=self.name, parent=self.parent 

1255 ).exclude(pk=self.pk) 

1256 

1257 if results.exists(): 

1258 raise ValidationError( 

1259 _('Duplicate names cannot exist under the same parent') 

1260 ) 

1261 

1262 @property 

1263 def parentpath(self) -> list: 

1264 """Get the parent path of this category. 

1265 

1266 Returns: 

1267 List of category names from the top level to the parent of this category 

1268 """ 

1269 return list(self.get_ancestors()) 

1270 

1271 @property 

1272 def path(self) -> list: 

1273 """Get the complete part of this category. 

1274 

1275 e.g. ["Top", "Second", "Third", "This"] 

1276 

1277 Returns: 

1278 List of category names from the top level to this category 

1279 """ 

1280 return [*self.parentpath, self] 

1281 

1282 def get_path(self) -> list: 

1283 """Return a list of element in the item tree. 

1284 

1285 Contains the full path to this item, with each entry containing the following data: 

1286 

1287 { 

1288 pk: <pk>, 

1289 name: <name>, 

1290 } 

1291 """ 

1292 return [ 

1293 { 

1294 'pk': item.pk, 

1295 'name': getattr(item, self.PATH_FIELD, item.pk), 

1296 **{k: getattr(item, k, None) for k in self.EXTRA_PATH_FIELDS}, 

1297 } 

1298 for item in self.path 

1299 ] 

1300 

1301 

1302class InvenTreeNotesMixin(models.Model): 

1303 """A mixin class for adding notes functionality to a model class. 

1304 

1305 The following fields are added to any model which implements this mixin: 

1306 

1307 - notes : A text field for storing notes 

1308 """ 

1309 

1310 class Meta: 

1311 """Metaclass options for this mixin. 

1312 

1313 Note: abstract must be true, as this is only a mixin, not a separate table 

1314 """ 

1315 

1316 abstract = True 

1317 

1318 def delete(self, *args, **kwargs): 

1319 """Custom delete method for InvenTreeNotesMixin. 

1320 

1321 - Before deleting the object, check if there are any uploaded images associated with it. 

1322 - If so, delete the notes first 

1323 """ 

1324 from common.models import NotesImage 

1325 

1326 images = NotesImage.objects.filter( 

1327 model_type=self.__class__.__name__.lower(), model_id=self.pk 

1328 ) 

1329 

1330 if images.exists(): 1330 ↛ 1331line 1330 didn't jump to line 1331 because the condition on line 1330 was never true

1331 logger.info( 

1332 'Deleting %s uploaded images associated with %s <%s>', 

1333 images.count(), 

1334 self.__class__.__name__, 

1335 self.pk, 

1336 ) 

1337 

1338 images.delete() 

1339 

1340 super().delete(*args, **kwargs) 

1341 

1342 notes = InvenTree.fields.InvenTreeNotesField( 

1343 verbose_name=_('Notes'), help_text=_('Markdown notes (optional)') 

1344 ) 

1345 

1346 

1347class InvenTreeTagsMixin(models.Model): 

1348 """A mixin class for adding tag functionality to a model class. 

1349 

1350 The following fields are added to any model which implements this mixin: 

1351 

1352 - tags : A text field for storing comma-separated tags 

1353 """ 

1354 

1355 class Meta: 

1356 """Metaclass options for this mixin. 

1357 

1358 Note: abstract must be true, as this is only a mixin, not a separate table 

1359 """ 

1360 

1361 abstract = True 

1362 

1363 tags = TaggableManager(blank=True) 

1364 

1365 

1366class InvenTreeBarcodeMixin(models.Model): 

1367 """A mixin class for adding barcode functionality to a model class. 

1368 

1369 Two types of barcodes are supported: 

1370 

1371 - Internal barcodes (QR codes using a strictly defined format) 

1372 - External barcodes (assign third party barcode data to a model instance) 

1373 

1374 The following fields are added to any model which implements this mixin: 

1375 

1376 - barcode_data : Raw data associated with an assigned barcode 

1377 - barcode_hash : A 'hash' of the assigned barcode data used to improve matching 

1378 

1379 The barcode_model_type_code() classmethod must be implemented in the model class. 

1380 """ 

1381 

1382 class Meta: 

1383 """Metaclass options for this mixin. 

1384 

1385 Note: abstract must be true, as this is only a mixin, not a separate table 

1386 """ 

1387 

1388 abstract = True 

1389 

1390 barcode_data = models.CharField( 

1391 blank=True, 

1392 max_length=500, 

1393 verbose_name=_('Barcode Data'), 

1394 help_text=_('Third party barcode data'), 

1395 ) 

1396 

1397 barcode_hash = models.CharField( 

1398 blank=True, 

1399 max_length=128, 

1400 verbose_name=_('Barcode Hash'), 

1401 help_text=_('Unique hash of barcode data'), 

1402 ) 

1403 

1404 @classmethod 

1405 def barcode_model_type(cls): 

1406 """Return the model 'type' for creating a custom QR code.""" 

1407 # By default, use the name of the class 

1408 return cls.__name__.lower() 

1409 

1410 @classmethod 

1411 def barcode_model_type_code(cls): 

1412 r"""Return a 'short' code for the model type. 

1413 

1414 This is used to generate a efficient QR code for the model type. 

1415 It is expected to match this pattern: [0-9A-Z $%*+-.\/:]{2} 

1416 

1417 Note: Due to the shape constraints (45**2=2025 different allowed codes) 

1418 this needs to be explicitly implemented in the model class to avoid collisions. 

1419 """ 

1420 raise NotImplementedError( 

1421 'barcode_model_type_code() must be implemented in the model class' 

1422 ) 

1423 

1424 def format_barcode(self, **kwargs): 

1425 """Return a string for formatting a QR code for this model instance.""" 

1426 from plugin.base.barcodes.helper import generate_barcode 

1427 

1428 return generate_barcode(self) 

1429 

1430 def format_matched_response(self, user, **kwargs): 

1431 """Format a standard response for a matched barcode.""" 

1432 # Check permission for this object 

1433 from users.permissions import check_user_permission 

1434 

1435 if not check_user_permission(user, self, 'view'): 

1436 raise PermissionDenied( 

1437 _('User does not have permission to view this model') 

1438 ) 

1439 

1440 data = {'pk': self.pk} 

1441 

1442 if hasattr(self, 'get_api_url'): 

1443 api_url = self.get_api_url() 

1444 data['api_url'] = api_url = f'{api_url}{self.pk}/' 

1445 

1446 # Attempt to serialize the object too 

1447 try: 

1448 match = resolve(api_url) 

1449 view_class = match.func.view_class 

1450 serializer_class = view_class.serializer_class 

1451 serializer = serializer_class(self) 

1452 data['instance'] = serializer.data 

1453 except Exception: 

1454 pass 

1455 

1456 if hasattr(self, 'get_absolute_url'): 

1457 data['web_url'] = self.get_absolute_url() 

1458 

1459 return data 

1460 

1461 @property 

1462 def barcode(self) -> str: 

1463 """Format a minimal barcode string (e.g. for label printing).""" 

1464 return self.format_barcode() 

1465 

1466 @classmethod 

1467 def lookup_barcode(cls, barcode_hash: str) -> models.Model: 

1468 """Check if a model instance exists with the specified third-party barcode hash.""" 

1469 return cls.objects.filter(barcode_hash=barcode_hash).first() 

1470 

1471 def assign_barcode( 

1472 self, 

1473 barcode_hash: Optional[str] = None, 

1474 barcode_data: Optional[str] = None, 

1475 raise_error: bool = True, 

1476 save: bool = True, 

1477 ): 

1478 """Assign an external (third-party) barcode to this object.""" 

1479 # Must provide either barcode_hash or barcode_data 

1480 if barcode_hash is None and barcode_data is None: 

1481 raise ValueError("Provide either 'barcode_hash' or 'barcode_data'") 

1482 

1483 # If barcode_hash is not provided, create from supplier barcode_data 

1484 if barcode_hash is None and barcode_data is not None: 

1485 barcode_hash = InvenTree.helpers.hash_barcode(barcode_data) 

1486 

1487 # Check for existing item 

1488 if self.__class__.lookup_barcode(barcode_hash) is not None: 

1489 if raise_error: 

1490 raise ValidationError(_('Existing barcode found')) 

1491 else: 

1492 return False 

1493 

1494 if barcode_data is not None: 

1495 self.barcode_data = barcode_data 

1496 

1497 self.barcode_hash = barcode_hash 

1498 

1499 if save: 

1500 self.save() 

1501 

1502 return True 

1503 

1504 def unassign_barcode(self): 

1505 """Unassign custom barcode from this model.""" 

1506 self.barcode_data = '' 

1507 self.barcode_hash = '' 

1508 

1509 self.save() 

1510 

1511 

1512def notify_staff_users_of_error(instance, label: str, context: dict): 

1513 """Helper function to notify staff users of an error.""" 

1514 import common.models 

1515 import common.notifications 

1516 from plugin.builtin.integration.core_notifications import InvenTreeUINotifications 

1517 

1518 try: 

1519 # Get all staff users 

1520 staff_users = get_user_model().objects.filter(is_active=True, is_staff=True) 

1521 

1522 target_users = [] 

1523 

1524 # Send a notification to each staff user (unless they have disabled error notifications) 

1525 for user in staff_users: 

1526 if common.models.InvenTreeUserSetting.get_setting( 1526 ↛ 1525line 1526 didn't jump to line 1525 because the condition on line 1526 was always true

1527 'NOTIFICATION_ERROR_REPORT', True, user=user 

1528 ): 

1529 target_users.append(user) 

1530 

1531 if len(target_users) > 0: 1531 ↛ exitline 1531 didn't return from function 'notify_staff_users_of_error' because the condition on line 1531 was always true

1532 common.notifications.trigger_notification( 

1533 instance, 

1534 label, 

1535 context=context, 

1536 targets=target_users, 

1537 delivery_methods={InvenTreeUINotifications}, 

1538 ) 

1539 

1540 except Exception as exc: 

1541 # We do not want to throw an exception while reporting an exception! 

1542 logger.error(exc) 

1543 

1544 

1545@receiver(post_save, sender=Task, dispatch_uid='failure_post_save_notification') 

1546def after_failed_task(sender, instance: Task, created: bool, **kwargs): 

1547 """Callback when a new task failure log is generated.""" 

1548 from django.conf import settings 

1549 

1550 from InvenTree.exceptions import log_error 

1551 

1552 max_attempts = int(settings.Q_CLUSTER.get('max_attempts', 5)) 

1553 n = instance.attempt_count 

1554 

1555 # Only notify once the maximum number of attempts has been reached 

1556 if not instance.success and n >= max_attempts: 

1557 # Create a new Error object associated with this failed task 

1558 # This will, in turn, trigger a notification to staff users via the Error post_save signal 

1559 

1560 log_error( 

1561 'task_failure', 

1562 scope='worker', 

1563 error_name='Task Failure', 

1564 error_info=f"Task '{instance.pk}' failed after {n} attempts", 

1565 error_data=str(instance.result) if instance.result else '', 

1566 ) 

1567 

1568 

1569@receiver(post_save, sender=Error, dispatch_uid='error_post_save_notification') 

1570def after_error_logged(sender, instance: Error, created: bool, **kwargs): 

1571 """Callback when a server error is logged. 

1572 

1573 - Send a UI notification to all users with staff status 

1574 """ 

1575 if created: 1575 ↛ exitline 1575 didn't return from function 'after_error_logged' because the condition on line 1575 was always true

1576 try: 

1577 url = InvenTree.helpers_model.construct_absolute_url( 

1578 reverse( 

1579 'admin:error_report_error_change', kwargs={'object_id': instance.pk} 

1580 ) 

1581 ) 

1582 except NoReverseMatch: 

1583 url = '' 

1584 

1585 notify_staff_users_of_error( 

1586 instance, 

1587 'inventree.error_log', 

1588 { 

1589 'error': instance, 

1590 'name': _('Server Error'), 

1591 'message': _('An error has been logged by the server.'), 

1592 'link': url, 

1593 }, 

1594 ) 

1595 

1596 

1597class InvenTreeImageMixin(models.Model): 

1598 """A mixin class for adding image functionality to a model class. 

1599 

1600 The following fields are added to any model which implements this mixin: 

1601 

1602 - image : An image field for storing an image 

1603 """ 

1604 

1605 IMAGE_RENAME: Callable | None = None 

1606 

1607 class Meta: 

1608 """Metaclass options for this mixin. 

1609 

1610 Note: abstract must be true, as this is only a mixin, not a separate table 

1611 """ 

1612 

1613 abstract = True 

1614 

1615 def __init__(self, *args: Any, **kwargs: Any) -> None: 

1616 """Custom init method for InvenTreeImageMixin to ensure IMAGE_RENAME is implemented.""" 

1617 if self.IMAGE_RENAME is None: 1617 ↛ 1618line 1617 didn't jump to line 1618 because the condition on line 1617 was never true

1618 raise NotImplementedError( 

1619 'IMAGE_RENAME must be implemented in the model class' 

1620 ) 

1621 super().__init__(*args, **kwargs) 

1622 

1623 def rename_image(self, filename): 

1624 """Rename the uploaded image file using the IMAGE_RENAME function.""" 

1625 return self.IMAGE_RENAME(filename) 

1626 

1627 image = StdImageField( 

1628 upload_to=rename_image, 

1629 null=True, 

1630 blank=True, 

1631 variations={'thumbnail': (128, 128), 'preview': (256, 256)}, 

1632 delete_orphans=False, 

1633 verbose_name=_('Image'), 

1634 ) 

1635 

1636 def get_image_url(self): 

1637 """Return the URL of the image for this object.""" 

1638 if self.image: 

1639 return InvenTree.helpers.getMediaUrl(self.image) 

1640 return InvenTree.helpers.getBlankImage() 

1641 

1642 def get_thumbnail_url(self) -> str: 

1643 """Return the URL of the image thumbnail for this object.""" 

1644 if self.image: 1644 ↛ 1645line 1644 didn't jump to line 1645 because the condition on line 1644 was never true

1645 return InvenTree.helpers.getMediaUrl(self.image, 'thumbnail') 

1646 return InvenTree.helpers.getBlankThumbnail()