Coverage for src/backend/InvenTree/common/models.py: 56%

1345 statements  

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

1"""Common database model definitions. 

2 

3These models are 'generic' and do not fit a particular business logic object. 

4""" 

5 

6import base64 

7import hashlib 

8import hmac 

9import json 

10import math 

11import os 

12import uuid 

13from collections import OrderedDict 

14from datetime import timedelta, timezone 

15from email.utils import make_msgid 

16from enum import Enum 

17from io import BytesIO 

18from pathlib import Path 

19from secrets import compare_digest 

20from typing import Any, Optional 

21 

22from django.apps import apps 

23from django.conf import settings as django_settings 

24from django.contrib.auth.models import User 

25from django.contrib.contenttypes.fields import GenericForeignKey 

26from django.contrib.contenttypes.models import ContentType 

27from django.contrib.humanize.templatetags.humanize import naturaltime 

28from django.core.cache import cache 

29from django.core.exceptions import SuspiciousFileOperation, ValidationError 

30from django.core.files.base import ContentFile 

31from django.core.files.storage import default_storage 

32from django.core.files.utils import validate_file_name 

33from django.core.mail import EmailMultiAlternatives, get_connection 

34from django.core.mail.utils import DNS_NAME 

35from django.core.validators import MinLengthValidator, MinValueValidator 

36from django.db import models, transaction 

37from django.db.models import enums 

38from django.db.models.signals import post_delete, post_save 

39from django.db.utils import IntegrityError, OperationalError, ProgrammingError 

40from django.dispatch import receiver 

41from django.urls import reverse 

42from django.utils.timezone import now 

43from django.utils.translation import gettext_lazy as _ 

44 

45import structlog 

46from anymail.signals import inbound, tracking 

47from django_q.signals import post_spawn 

48from djmoney.contrib.exchange.exceptions import MissingRate 

49from djmoney.contrib.exchange.models import convert_money 

50from opentelemetry import trace 

51from PIL import Image 

52from rest_framework.exceptions import PermissionDenied 

53 

54import common.validators 

55import InvenTree.conversion 

56import InvenTree.exceptions 

57import InvenTree.fields 

58import InvenTree.helpers 

59import InvenTree.models 

60import InvenTree.ready 

61import InvenTree.tasks 

62import InvenTree.validators 

63import users.models 

64from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType 

65from common.settings import get_global_setting, global_setting_overrides 

66from generic.enums import StringEnum 

67from generic.states import ColorEnum 

68from generic.states.custom import state_color_mappings 

69from InvenTree.cache import get_session_cache, set_session_cache 

70from InvenTree.sanitizer import sanitize_svg 

71from InvenTree.tracing import TRACE_PROC, TRACE_PROV 

72from InvenTree.version import inventree_identifier 

73 

74logger = structlog.get_logger('inventree') 

75 

76 

77class RenderMeta(enums.ChoicesType): 

78 """Metaclass for rendering choices.""" 

79 

80 choice_fnc = None 

81 

82 @property 

83 def choices(self): 

84 """Return a list of choices for the enum class.""" 

85 fnc = getattr(self, 'choice_fnc', None) 

86 if fnc: 86 ↛ 88line 86 didn't jump to line 88 because the condition on line 86 was always true

87 return fnc() 

88 return [] 

89 

90 

91class RenderChoices(models.TextChoices, metaclass=RenderMeta): 

92 """Class for creating enumerated string choices for schema rendering.""" # ty:ignore[conflicting-metaclass] 

93 

94 

95class MetaMixin(models.Model): 

96 """A base class for InvenTree models to include shared meta fields. 

97 

98 Attributes: 

99 - updated: The last time this object was updated 

100 """ 

101 

102 class Meta: 

103 """Meta options for MetaMixin.""" 

104 

105 abstract = True 

106 

107 updated = models.DateTimeField( 

108 verbose_name=_('Updated'), 

109 help_text=_('Timestamp of last update'), 

110 auto_now=True, 

111 null=True, 

112 ) 

113 

114 

115class UpdatedUserMixin(models.Model): 

116 """A mixin which stores additional information about the user who created or last modified the object.""" 

117 

118 class Meta: 

119 """Meta options for MetaUserMixin.""" 

120 

121 abstract = True 

122 

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

124 """Extract the user object from kwargs, if provided.""" 

125 if updated_by := kwargs.pop('updated_by', None): 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 self.updated_by = updated_by 

127 

128 self.updated = InvenTree.helpers.current_time() 

129 

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

131 

132 updated = models.DateTimeField( 

133 verbose_name=_('Updated'), 

134 help_text=_('Timestamp of last update'), 

135 default=None, 

136 blank=True, 

137 null=True, 

138 ) 

139 

140 updated_by = models.ForeignKey( 

141 User, 

142 on_delete=models.SET_NULL, 

143 null=True, 

144 blank=True, 

145 related_name='%(class)s_updated', 

146 verbose_name=_('Update By'), 

147 help_text=_('User who last updated this object'), 

148 ) 

149 

150 

151class ProjectCode(InvenTree.models.InvenTreeMetadataModel): 

152 """A ProjectCode is a unique identifier for a project.""" 

153 

154 IMPORT_ID_FIELDS = ['code'] 

155 

156 class Meta: 

157 """Class options for the ProjectCode model.""" 

158 

159 verbose_name = _('Project Code') 

160 

161 @staticmethod 

162 def get_api_url(): 

163 """Return the API URL for this model.""" 

164 return reverse('api-project-code-list') 

165 

166 def __str__(self): 

167 """String representation of a ProjectCode.""" 

168 return self.code 

169 

170 code = models.CharField( 

171 max_length=50, 

172 unique=True, 

173 verbose_name=_('Project Code'), 

174 help_text=_('Unique project code'), 

175 ) 

176 

177 description = models.CharField( 

178 max_length=200, 

179 blank=True, 

180 verbose_name=_('Description'), 

181 help_text=_('Project description'), 

182 ) 

183 

184 responsible = models.ForeignKey( 

185 users.models.Owner, 

186 on_delete=models.SET_NULL, 

187 blank=True, 

188 null=True, 

189 verbose_name=_('Responsible'), 

190 help_text=_('User or group responsible for this project'), 

191 related_name='project_codes', 

192 ) 

193 

194 

195class BaseInvenTreeSetting(models.Model): 

196 """An base InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values). 

197 

198 Attributes: 

199 SETTINGS: definition of all available settings 

200 extra_unique_fields: List of extra fields used to be unique, e.g. for PluginConfig -> plugin 

201 """ 

202 

203 SETTINGS: dict[str, SettingsKeyType] = {} 

204 

205 CHECK_SETTING_KEY = False 

206 

207 extra_unique_fields: list[str] = [] 

208 

209 class Meta: 

210 """Meta options for BaseInvenTreeSetting -> abstract stops creation of database entry.""" 

211 

212 abstract = True 

213 

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

215 """Enforce validation and clean before saving.""" 

216 self.key = str(self.key).upper() 

217 

218 do_cache = kwargs.pop('cache', True) 

219 

220 self.clean() 

221 self.validate_unique() 

222 

223 # Execute before_save action 

224 self._call_settings_function('before_save', args, kwargs) 

225 

226 super().save() 

227 

228 # Update this setting in the cache after it was saved so a pk exists 

229 if do_cache: 229 ↛ 233line 229 didn't jump to line 233 because the condition on line 229 was always true

230 self.save_to_cache() 

231 

232 # Remove the setting from the request cache 

233 set_session_cache(self.cache_key, None) 

234 

235 # Execute after_save action 

236 self._call_settings_function('after_save', args, kwargs) 

237 

238 @classmethod 

239 def build_default_values(cls, **kwargs): 

240 """Ensure that all values defined in SETTINGS are present in the database. 

241 

242 If a particular setting is not present, create it with the default value 

243 """ 

244 try: 

245 existing_keys = cls.objects.filter(**kwargs).values_list('key', flat=True) 

246 settings_keys = cls.SETTINGS.keys() 

247 

248 missing_keys = set(settings_keys) - set(existing_keys) 

249 

250 if len(missing_keys) > 0: 

251 logger.info('Building %s default values for %s', len(missing_keys), cls) 

252 cls.objects.bulk_create( 

253 [ 

254 cls(key=key, value=cls.get_setting_default(key), **kwargs) 

255 for key in missing_keys 

256 if not key.startswith('_') 

257 ], 

258 batch_size=250, 

259 ) 

260 except Exception as exc: 

261 logger.exception( 

262 'Failed to build default values for %s (%s)', cls, type(exc) 

263 ) 

264 

265 def _call_settings_function(self, reference: str, args, kwargs): 

266 """Call a function associated with a particular setting. 

267 

268 Args: 

269 reference (str): The name of the function to call 

270 args: Positional arguments to pass to the function 

271 kwargs: Keyword arguments to pass to the function 

272 """ 

273 # Get action 

274 setting = self.get_setting_definition( 

275 self.key, *args, **{**self.get_filters_for_instance(), **kwargs} 

276 ) 

277 settings_fnc = setting.get(reference, None) 

278 

279 # Execute if callable 

280 if callable(settings_fnc): 

281 settings_fnc(self) 

282 

283 @property 

284 def cache_key(self): 

285 """Generate a unique cache key for this settings object.""" 

286 return self.__class__.create_cache_key( 

287 self.key, **self.get_filters_for_instance() 

288 ) 

289 

290 def save_to_cache(self): 

291 """Save this setting object to cache.""" 

292 key = self.cache_key 

293 

294 # skip saving to cache if no pk is set 

295 if self.pk is None: 

296 return 

297 

298 logger.debug("Saving setting '%s' to cache", key) 

299 

300 try: 

301 cache.set(key, self, timeout=3600) 

302 except Exception: # pragma: no cover 

303 pass 

304 

305 @classmethod 

306 def create_cache_key(cls, setting_key, **kwargs): 

307 """Create a unique cache key for a particular setting object. 

308 

309 The cache key uses the following elements to ensure the key is 'unique': 

310 - The name of the class 

311 - The unique KEY string 

312 - Any key:value kwargs associated with the particular setting type (e.g. user-id) 

313 """ 

314 key = f'{cls.__name__!s}:{setting_key}' 

315 

316 for k, v in kwargs.items(): 

317 key += f'_{k}:{v}' 

318 

319 return key.replace(' ', '') 

320 

321 @classmethod 

322 def get_filters(cls, **kwargs): 

323 """Enable to filter by other kwargs defined in cls.extra_unique_fields.""" 

324 return { 

325 key: value 

326 for key, value in kwargs.items() 

327 if key in cls.extra_unique_fields 

328 } 

329 

330 def get_filters_for_instance(self): 

331 """Enable to filter by other fields defined in self.extra_unique_fields.""" 

332 return { 

333 key: getattr(self, key, None) 

334 for key in self.extra_unique_fields 

335 if hasattr(self, key) 

336 } 

337 

338 @classmethod 

339 def all_settings( 

340 cls, 

341 *, 

342 exclude_hidden=False, 

343 settings_definition: dict[str, SettingsKeyType] | None = None, 

344 **kwargs, 

345 ): 

346 """Return a list of "all" defined settings. 

347 

348 This performs a single database lookup, 

349 and then any settings which are not *in* the database 

350 are assigned their default values 

351 """ 

352 filters = cls.get_filters(**kwargs) 

353 

354 results = cls.objects.all() 

355 

356 if exclude_hidden: 356 ↛ 358line 356 didn't jump to line 358 because the condition on line 356 was never true

357 # Keys which start with an underscore are used for internal functionality 

358 results = results.exclude(key__startswith='_') 

359 

360 # Optionally filter by other keys 

361 results = results.filter(**filters) 

362 

363 settings: dict[str, BaseInvenTreeSetting] = {} 

364 

365 # Query the database 

366 for setting in results: 

367 if setting.key: 367 ↛ 366line 367 didn't jump to line 366 because the condition on line 367 was always true

368 settings[setting.key.upper()] = setting 

369 

370 # Specify any "default" values which are not in the database 

371 settings_definition = settings_definition or cls.SETTINGS 

372 

373 all_settings = OrderedDict() 

374 

375 for key, setting in settings_definition.items(): 

376 # If the setting is already in the database, use that value 

377 if key.upper() in settings: 377 ↛ 380line 377 didn't jump to line 380 because the condition on line 377 was always true

378 all_settings[key] = settings[key.upper()] 

379 else: 

380 all_settings[key.upper()] = cls( 

381 key=key.upper(), 

382 value=cls.get_setting_default(key, **filters), 

383 **filters, 

384 ) 

385 

386 # remove any hidden settings 

387 if exclude_hidden and setting.get('hidden', False): 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true

388 del all_settings[key.upper()] 

389 

390 # format settings values and remove protected 

391 for key, setting in all_settings.items(): 

392 validator = cls.get_setting_validator(key, **filters) 

393 

394 if cls.is_protected(key, **filters) and setting.value != '': 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 setting.value = '***' 

396 elif cls.validator_is_bool(validator): 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 setting.value = InvenTree.helpers.str2bool(setting.value) 

398 elif cls.validator_is_int(validator): 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 try: 

400 setting.value = int(setting.value) 

401 except ValueError: 

402 setting.value = cls.get_setting_default(key, **filters) 

403 

404 return all_settings 

405 

406 @classmethod 

407 def allValues( 

408 cls, 

409 *, 

410 exclude_hidden=False, 

411 settings_definition: dict[str, SettingsKeyType] | None = None, 

412 **kwargs, 

413 ): 

414 """Return a dict of "all" defined global settings. 

415 

416 This performs a single database lookup, 

417 and then any settings which are not *in* the database 

418 are assigned their default values 

419 """ 

420 all_settings = cls.all_settings( 

421 exclude_hidden=exclude_hidden, 

422 settings_definition=settings_definition, 

423 **kwargs, 

424 ) 

425 

426 settings: dict[str, Any] = {} 

427 

428 for key, setting in all_settings.items(): 

429 settings[key] = setting.value 

430 

431 return settings 

432 

433 @classmethod 

434 def check_all_settings( 

435 cls, 

436 *, 

437 exclude_hidden=False, 

438 settings_definition: dict[str, SettingsKeyType] | None = None, 

439 **kwargs, 

440 ): 

441 """Check if all required settings are set by definition. 

442 

443 Returns: 

444 is_valid: Are all required settings defined 

445 missing_settings: List of all settings that are missing (empty if is_valid is 'True') 

446 """ 

447 all_settings = cls.all_settings( 

448 exclude_hidden=exclude_hidden, 

449 settings_definition=settings_definition, 

450 **kwargs, 

451 ) 

452 

453 missing_settings: list[str] = [] 

454 

455 for setting in all_settings.values(): 

456 if setting.required: 

457 value = setting.value or cls.get_setting_default(setting.key, **kwargs) 

458 

459 if value == '': 

460 missing_settings.append(setting.key.upper()) 

461 

462 return len(missing_settings) == 0, missing_settings 

463 

464 @classmethod 

465 def get_setting_definition(cls, key, **kwargs): 

466 """Return the 'definition' of a particular settings value, as a dict object. 

467 

468 - The 'settings' dict can be passed as a kwarg 

469 - If not passed, look for cls.SETTINGS 

470 - Returns an empty dict if the key is not found 

471 """ 

472 settings = kwargs.get('settings', cls.SETTINGS) 

473 

474 key = str(key).strip().upper() 

475 

476 if settings is not None and key in settings: 

477 return settings[key] 

478 return {} 

479 

480 @classmethod 

481 def get_setting_name(cls, key, **kwargs): 

482 """Return the name of a particular setting. 

483 

484 If it does not exist, return an empty string. 

485 """ 

486 setting = cls.get_setting_definition(key, **kwargs) 

487 return setting.get('name', '') 

488 

489 @classmethod 

490 def get_setting_description(cls, key, **kwargs): 

491 """Return the description for a particular setting. 

492 

493 If it does not exist, return an empty string. 

494 """ 

495 setting = cls.get_setting_definition(key, **kwargs) 

496 

497 return setting.get('description', '') 

498 

499 @classmethod 

500 def get_setting_units(cls, key, **kwargs): 

501 """Return the units for a particular setting. 

502 

503 If it does not exist, return an empty string. 

504 """ 

505 setting = cls.get_setting_definition(key, **kwargs) 

506 

507 return setting.get('units', '') 

508 

509 @classmethod 

510 def get_setting_validator(cls, key, **kwargs): 

511 """Return the validator for a particular setting. 

512 

513 If it does not exist, return None 

514 """ 

515 setting = cls.get_setting_definition(key, **kwargs) 

516 

517 return setting.get('validator', None) 

518 

519 @classmethod 

520 def get_setting_default(cls, key, **kwargs): 

521 """Return the default value for a particular setting. 

522 

523 If it does not exist, return an empty string 

524 """ 

525 setting = cls.get_setting_definition(key, **kwargs) 

526 

527 default = setting.get('default', '') 

528 

529 if callable(default): 

530 return default() 

531 return default 

532 

533 @classmethod 

534 def get_setting_choices(cls, key, **kwargs): 

535 """Return the validator choices available for a particular setting.""" 

536 setting = cls.get_setting_definition(key, **kwargs) 

537 

538 choices = setting.get('choices', None) 

539 

540 if callable(choices): 

541 # Evaluate the function (we expect it will return a list of tuples...) 

542 try: 

543 # Attempt to pass the kwargs to the function, if it doesn't expect them, ignore and call without 

544 return choices(**kwargs) 

545 except TypeError: 

546 return choices() 

547 

548 return choices 

549 

550 @classmethod 

551 def get_setting_object(cls, key, **kwargs): 

552 """Return an InvenTreeSetting object matching the given key. 

553 

554 - Key is case-insensitive 

555 - Returns None if no match is made 

556 

557 As settings are accessed frequently, this function will attempt to access the cache first: 

558 

559 1. Check the ephemeral request cache 

560 2. Check the global cache 

561 3. Query the database 

562 """ 

563 key = str(key).strip().upper() 

564 

565 # Unless otherwise specified, attempt to create the setting 

566 create = kwargs.pop('create', True) 

567 

568 # Specify if global cache lookup should be performed 

569 # If not specified, determine based on whether global cache is enabled 

570 access_global_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED) 

571 

572 # Prevent saving to the database during certain operations 

573 if ( 573 ↛ 579line 573 didn't jump to line 579 because the condition on line 573 was never true

574 InvenTree.ready.isImportingData() 

575 or InvenTree.ready.isRunningMigrations() 

576 or InvenTree.ready.isRebuildingData() 

577 or InvenTree.ready.isRunningBackup() 

578 ): # pragma: no cover 

579 create = False 

580 access_global_cache = False 

581 

582 cache_key = cls.create_cache_key(key, **kwargs) 

583 

584 # Fist, attempt to pull the setting from the request cache 

585 if setting := get_session_cache(cache_key): 

586 return setting 

587 

588 if access_global_cache: 

589 try: 

590 # First attempt to find the setting object in the cache 

591 cached_setting = cache.get(cache_key) 

592 

593 if cached_setting is not None: 

594 # Store the cached setting into the session cache 

595 

596 set_session_cache(cache_key, cached_setting) 

597 return cached_setting 

598 

599 except Exception: 

600 # Cache is not ready yet 

601 access_global_cache = False 

602 

603 # At this point, we need to query the database 

604 

605 filters = { 

606 'key__iexact': key, 

607 # Optionally filter by other keys 

608 **cls.get_filters(**kwargs), 

609 } 

610 

611 try: 

612 settings = cls.objects.all() 

613 setting = settings.filter(**filters).first() 

614 except (ValueError, cls.DoesNotExist): 

615 setting = None 

616 except (IntegrityError, OperationalError, ProgrammingError): 

617 setting = None 

618 

619 # Setting does not exist! (Try to create it) 

620 if not setting and create: 

621 # Attempt to create a new settings object 

622 default_value = cls.get_setting_default(key, **kwargs) 

623 

624 extra_fields = {} 

625 

626 # Provide extra default fields 

627 for field in cls.extra_unique_fields: 

628 if field in kwargs: 628 ↛ 627line 628 didn't jump to line 627 because the condition on line 628 was always true

629 extra_fields[field] = kwargs[field] 

630 

631 setting = cls(key=key, value=default_value, **extra_fields) 

632 

633 try: 

634 # Wrap this statement in "atomic", so it can be rolled back if it fails 

635 with transaction.atomic(): 

636 setting.save(**kwargs) 

637 except (IntegrityError, OperationalError, ProgrammingError): 

638 # It might be the case that the database isn't created yet 

639 pass 

640 except ValidationError: 

641 # The setting failed validation - might be due to duplicate keys 

642 pass 

643 

644 if setting: 

645 # Cache this setting object to the request cache 

646 set_session_cache(cache_key, setting) 

647 

648 if access_global_cache: 

649 # Cache this setting object to the global cache 

650 setting.save_to_cache() 

651 

652 return setting 

653 

654 @classmethod 

655 def get_setting(cls, key, backup_value=None, **kwargs): 

656 """Get the value of a particular setting. 

657 

658 If it does not exist, return the backup value (default = None) 

659 """ 

660 if ( 660 ↛ 665line 660 didn't jump to line 665 because the condition on line 660 was never true

661 cls.CHECK_SETTING_KEY 

662 and key not in cls.SETTINGS 

663 and not key.startswith('_') 

664 ): 

665 logger.warning( 

666 "get_setting: Setting key '%s' is not defined for class %s", key, cls 

667 ) 

668 

669 # If no backup value is specified, attempt to retrieve a "default" value 

670 if backup_value is None: 

671 backup_value = cls.get_setting_default(key, **kwargs) 

672 

673 setting = cls.get_setting_object(key, **kwargs) 

674 

675 if setting: 

676 value = setting.value 

677 

678 # Cast to boolean if necessary 

679 if setting.is_bool(): 

680 value = InvenTree.helpers.str2bool(value) 

681 

682 # Cast to integer if necessary 

683 if setting.is_int(): 

684 try: 

685 value = int(value) 

686 except (ValueError, TypeError): 

687 value = backup_value 

688 

689 else: 

690 value = backup_value 

691 

692 return value 

693 

694 @classmethod 

695 def set_setting(cls, key, value, change_user=None, create=True, **kwargs): 

696 """Set the value of a particular setting. If it does not exist, option to create it. 

697 

698 Args: 

699 key: settings key 

700 value: New value 

701 change_user: User object (must be staff member to update a core setting) 

702 create: If True, create a new setting if the specified key does not exist. 

703 """ 

704 if ( 704 ↛ 709line 704 didn't jump to line 709 because the condition on line 704 was never true

705 cls.CHECK_SETTING_KEY 

706 and key not in cls.SETTINGS 

707 and not key.startswith('_') 

708 ): 

709 logger.warning( 

710 "set_setting: Setting key '%s' is not defined for class %s", key, cls 

711 ) 

712 

713 if change_user is not None and not change_user.is_staff: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true

714 return 

715 

716 # Do not write to the database under certain conditions 

717 if ( 717 ↛ 723line 717 didn't jump to line 723 because the condition on line 717 was never true

718 InvenTree.ready.isImportingData() 

719 or InvenTree.ready.isRunningMigrations() 

720 or InvenTree.ready.isRebuildingData() 

721 or InvenTree.ready.isRunningBackup() 

722 ): # pragma: no cover 

723 return 

724 

725 attempts = int(kwargs.pop('attempts', 3)) 

726 

727 filters = { 

728 'key__iexact': key, 

729 # Optionally filter by other keys 

730 **cls.get_filters(**kwargs), 

731 } 

732 

733 try: 

734 setting = cls.objects.filter(**filters).first() 

735 

736 if not setting: 

737 if create: 737 ↛ 740line 737 didn't jump to line 740 because the condition on line 737 was always true

738 setting = cls(key=key, **kwargs) 

739 else: 

740 return 

741 

742 except (OperationalError, ProgrammingError): 

743 if not key.startswith('_'): 743 ↛ 744line 743 didn't jump to line 744 because the condition on line 743 was never true

744 logger.warning("Database is locked, cannot set setting '%s'", key) 

745 # Likely the DB is locked - not much we can do here 

746 return 

747 except Exception as exc: # pragma: no cover 

748 logger.exception( 

749 "Error setting setting '%s' for %s: %s", key, cls, type(exc) 

750 ) 

751 return 

752 

753 # Enforce standard boolean representation 

754 if setting.is_bool(): 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 value = InvenTree.helpers.str2bool(value) 

756 

757 try: 

758 setting.value = str(value) 

759 setting.save() 

760 except ValidationError as exc: 

761 # We need to know about validation errors 

762 raise exc 

763 except IntegrityError: 

764 # Likely a race condition has caused a duplicate entry to be created 

765 if attempts > 0: 

766 # Try again 

767 logger.info( 

768 "Duplicate setting key '%s' for %s - trying again", key, cls 

769 ) 

770 cls.set_setting( 

771 key, 

772 value, 

773 change_user, 

774 create=create, 

775 attempts=attempts - 1, 

776 **kwargs, 

777 ) 

778 

779 except (OperationalError, ProgrammingError): 

780 logger.warning("Database is locked, cannot set setting '%s'", key) 

781 # Likely the DB is locked - not much we can do here 

782 except Exception as exc: # pragma: no cover 

783 # Some other error 

784 logger.exception( 

785 "Error setting setting '%s' for %s: %s", key, cls, type(exc) 

786 ) 

787 

788 key = models.CharField( 

789 max_length=50, blank=False, unique=False, help_text=_('Settings key') 

790 ) 

791 

792 value = models.CharField( 

793 max_length=2000, blank=True, unique=False, help_text=_('Settings value') 

794 ) 

795 

796 @property 

797 def name(self): 

798 """Return name for setting.""" 

799 return self.__class__.get_setting_name( 

800 self.key, **self.get_filters_for_instance() 

801 ) 

802 

803 @property 

804 def default_value(self): 

805 """Return default_value for setting.""" 

806 return self.__class__.get_setting_default( 

807 self.key, **self.get_filters_for_instance() 

808 ) 

809 

810 @property 

811 def description(self): 

812 """Return description for setting.""" 

813 return self.__class__.get_setting_description( 

814 self.key, **self.get_filters_for_instance() 

815 ) 

816 

817 @property 

818 def units(self): 

819 """Return units for setting.""" 

820 return self.__class__.get_setting_units( 

821 self.key, **self.get_filters_for_instance() 

822 ) 

823 

824 def clean(self): 

825 """If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field.""" 

826 super().clean() 

827 

828 # Encode as native values 

829 if self.is_int(): 

830 self.value = self.as_int(raise_error=True) 

831 

832 elif self.is_bool(): 

833 self.value = self.as_bool() 

834 

835 elif self.is_float(): 835 ↛ 836line 835 didn't jump to line 836 because the condition on line 835 was never true

836 self.value = self.as_float(raise_error=True) 

837 

838 validator = self.__class__.get_setting_validator( 

839 self.key, **self.get_filters_for_instance() 

840 ) 

841 

842 if validator is not None: 

843 self.run_validator(validator) 

844 

845 options = self.valid_options() 

846 

847 if options and self.value not in options: 847 ↛ 848line 847 didn't jump to line 848 because the condition on line 847 was never true

848 raise ValidationError(_('Chosen value is not a valid option')) 

849 

850 def run_validator(self, validator): 

851 """Run a validator against the 'value' field for this InvenTreeSetting object.""" 

852 if validator is None: 852 ↛ 853line 852 didn't jump to line 853 because the condition on line 852 was never true

853 return 

854 

855 value = self.value 

856 

857 # Boolean validator 

858 if validator is bool: 

859 # Value must "look like" a boolean value 

860 if InvenTree.helpers.is_bool(value): 860 ↛ 864line 860 didn't jump to line 864 because the condition on line 860 was always true

861 # Coerce into either "True" or "False" 

862 value = InvenTree.helpers.str2bool(value) 

863 else: 

864 raise ValidationError({'value': _('Value must be a boolean value')}) 

865 

866 # Integer validator 

867 if validator is int: 

868 try: 

869 # Coerce into an integer value 

870 value = int(value) 

871 except (ValueError, TypeError): 

872 raise ValidationError({'value': _('Value must be an integer value')}) 

873 

874 # Floating point validator 

875 if validator is float: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true

876 try: 

877 # Coerce into a floating point value 

878 value = float(value) 

879 except (ValueError, TypeError): 

880 raise ValidationError({'value': _('Value must be a valid number')}) 

881 

882 # If a list of validators is supplied, iterate through each one 

883 if type(validator) in [list, tuple]: 

884 for v in validator: 

885 self.run_validator(v) 

886 

887 if callable(validator): 

888 # We can accept function validators with a single argument 

889 

890 if self.is_bool(): 

891 value = self.as_bool() 

892 

893 elif self.is_int(): 893 ↛ 896line 893 didn't jump to line 896 because the condition on line 893 was always true

894 value = self.as_int() 

895 

896 elif self.is_float(): 

897 value = self.as_float() 

898 

899 try: 

900 validator(value) 

901 except ValidationError as e: 

902 raise e 

903 except Exception: 

904 raise ValidationError({ 

905 'value': _('Value does not pass validation checks') 

906 }) 

907 

908 def validate_unique(self, exclude=None): 

909 """Ensure that the key:value pair is unique. In addition to the base validators, this ensures that the 'key' is unique, using a case-insensitive comparison. 

910 

911 Note that sub-classes (UserSetting, PluginSetting) use other filters 

912 to determine if the setting is 'unique' or not 

913 """ 

914 super().validate_unique(exclude) 

915 

916 filters = { 

917 'key__iexact': self.key, 

918 # Optionally filter by other keys 

919 **self.get_filters_for_instance(), 

920 } 

921 

922 try: 

923 # Check if a duplicate setting already exists 

924 setting = self.__class__.objects.filter(**filters).exclude(id=self.id) 

925 

926 if setting.exists(): 926 ↛ 927line 926 didn't jump to line 927 because the condition on line 926 was never true

927 raise ValidationError({'key': _('Key string must be unique')}) 

928 

929 except self.DoesNotExist: 

930 pass 

931 

932 def choices(self): 

933 """Return the available choices for this setting (or None if no choices are defined).""" 

934 return self.__class__.get_setting_choices( 

935 self.key, **self.get_filters_for_instance() 

936 ) 

937 

938 def valid_options(self): 

939 """Return a list of valid options for this setting.""" 

940 choices = self.choices() 

941 

942 if not choices: 

943 return None 

944 

945 return [opt[0] for opt in choices] 

946 

947 def is_choice(self): 

948 """Check if this setting is a "choice" field.""" 

949 return ( 

950 self.__class__.get_setting_choices( 

951 self.key, **self.get_filters_for_instance() 

952 ) 

953 is not None 

954 ) 

955 

956 def as_choice(self): 

957 """Render this setting as the "display" value of a choice field. 

958 

959 E.g. if the choices are: 

960 [('A4', 'A4 paper'), ('A3', 'A3 paper')], 

961 and the value is 'A4', 

962 then display 'A4 paper' 

963 """ 

964 choices = self.get_setting_choices(self.key, **self.get_filters_for_instance()) 

965 

966 if not choices: 

967 return self.value 

968 

969 for value, display in choices: 

970 if value == self.value: 

971 return display 

972 

973 return self.value 

974 

975 def is_model(self): 

976 """Check if this setting references a model instance in the database.""" 

977 return self.model_name() is not None 

978 

979 def model_name(self) -> str: 

980 """Return the model name associated with this setting.""" 

981 setting = self.get_setting_definition( 

982 self.key, **self.get_filters_for_instance() 

983 ) 

984 

985 return setting.get('model', None) 

986 

987 def confirm(self) -> bool: 

988 """Return if this setting requires confirmation on change.""" 

989 setting = self.get_setting_definition( 

990 self.key, **self.get_filters_for_instance() 

991 ) 

992 

993 return setting.get('confirm', False) 

994 

995 def confirm_text(self) -> str: 

996 """Return the confirmation text for this setting, if provided.""" 

997 setting = self.get_setting_definition( 

998 self.key, **self.get_filters_for_instance() 

999 ) 

1000 

1001 return setting.get('confirm_text', '') 

1002 

1003 def model_filters(self) -> Optional[dict]: 

1004 """Return the model filters associated with this setting.""" 

1005 setting = self.get_setting_definition( 

1006 self.key, **self.get_filters_for_instance() 

1007 ) 

1008 

1009 filters = setting.get('model_filters', None) 

1010 

1011 if filters is not None and type(filters) is not dict: 1011 ↛ 1012line 1011 didn't jump to line 1012 because the condition on line 1011 was never true

1012 filters = None 

1013 

1014 return filters 

1015 

1016 def model_class(self): 

1017 """Return the model class associated with this setting. 

1018 

1019 If (and only if): 

1020 - It has a defined 'model' parameter 

1021 - The 'model' parameter is of the form app.model 

1022 - The 'model' parameter has matches a known app model 

1023 """ 

1024 model_name = self.model_name() 

1025 

1026 if not model_name: 1026 ↛ 1030line 1026 didn't jump to line 1030 because the condition on line 1026 was always true

1027 return None 

1028 

1029 # Enforce lower-case model name 

1030 model_name = str(model_name).strip().lower() 

1031 

1032 try: 

1033 (app, mdl) = model_name.strip().split('.') 

1034 except ValueError: 

1035 logger.exception( 

1036 "Invalid 'model' parameter for setting '%s': '%s'", self.key, model_name 

1037 ) 

1038 return None 

1039 

1040 app_models = apps.all_models.get(app, None) 

1041 

1042 if app_models is None: 

1043 logger.error( 

1044 "Error retrieving model class '%s' for setting '%s' - no app named '%s'", 

1045 model_name, 

1046 self.key, 

1047 app, 

1048 ) 

1049 return None 

1050 

1051 model = app_models.get(mdl, None) 

1052 

1053 if model is None: 

1054 logger.error( 

1055 "Error retrieving model class '%s' for setting '%s' - no model named '%s'", 

1056 model_name, 

1057 self.key, 

1058 mdl, 

1059 ) 

1060 return None 

1061 

1062 # Looks like we have found a model! 

1063 return model 

1064 

1065 def api_url(self): 

1066 """Return the API url associated with the linked model, if provided, and valid!""" 

1067 model_class = self.model_class() 

1068 

1069 if model_class: 1069 ↛ 1071line 1069 didn't jump to line 1071 because the condition on line 1069 was never true

1070 # If a valid class has been found, see if it has registered an API URL 

1071 try: 

1072 return model_class.get_api_url() 

1073 except Exception: 

1074 pass 

1075 

1076 # Some other model types are hard-coded 

1077 hardcoded_models = { 

1078 'auth.user': 'api-user-list', 

1079 'auth.group': 'api-group-list', 

1080 } 

1081 

1082 model_table = ( 

1083 f'{model_class._meta.app_label}.{model_class._meta.model_name}' 

1084 ) 

1085 

1086 if url := hardcoded_models[model_table]: 

1087 return reverse(url) 

1088 

1089 return None 

1090 

1091 def is_bool(self): 

1092 """Check if this setting is required to be a boolean value.""" 

1093 validator = self.__class__.get_setting_validator( 

1094 self.key, **self.get_filters_for_instance() 

1095 ) 

1096 

1097 return self.__class__.validator_is_bool(validator) 

1098 

1099 def as_bool(self) -> bool: 

1100 """Return the value of this setting converted to a boolean value. 

1101 

1102 Warning: Only use on values where is_bool evaluates to true! 

1103 """ 

1104 return InvenTree.helpers.str2bool(self.value) 

1105 

1106 def setting_type(self): 

1107 """Return the field type identifier for this setting object.""" 

1108 if self.is_bool(): 

1109 return 'boolean' 

1110 

1111 elif self.is_int(): 

1112 return 'integer' 

1113 

1114 elif self.is_model(): 1114 ↛ 1115line 1114 didn't jump to line 1115 because the condition on line 1114 was never true

1115 return 'related field' 

1116 return 'string' 

1117 

1118 @classmethod 

1119 def validator_is_bool(cls, validator): 

1120 """Return if validator is for bool.""" 

1121 if validator == bool: 

1122 return True 

1123 

1124 if type(validator) in [list, tuple]: 

1125 for v in validator: 

1126 if v == bool: 1126 ↛ 1127line 1126 didn't jump to line 1127 because the condition on line 1126 was never true

1127 return True 

1128 

1129 return False 

1130 

1131 def is_float(self): 

1132 """Check if the setting is required to be a float value.""" 

1133 validator = self.__class__.get_setting_validator( 

1134 self.key, **self.get_filters_for_instance() 

1135 ) 

1136 

1137 return self.__class__.validator_is_float(validator) 

1138 

1139 @classmethod 

1140 def validator_is_float(cls, validator): 

1141 """Return if validator is for float.""" 

1142 if validator == float: 1142 ↛ 1143line 1142 didn't jump to line 1143 because the condition on line 1142 was never true

1143 return True 

1144 

1145 if type(validator) in [list, tuple]: 1145 ↛ 1146line 1145 didn't jump to line 1146 because the condition on line 1145 was never true

1146 for v in validator: 

1147 if v == float: 

1148 return True 

1149 

1150 return False 

1151 

1152 def as_float(self, raise_error: bool = False) -> float: 

1153 """Return the value of this setting converted to a float value. 

1154 

1155 If an error occurs, return the default value 

1156 """ 

1157 try: 

1158 value = float(self.value) 

1159 except (ValueError, TypeError): 

1160 if raise_error: 

1161 raise ValidationError('Provided value is not a valid float') 

1162 value = self.default_value 

1163 

1164 return value 

1165 

1166 def is_int(self): 

1167 """Check if the setting is required to be an integer value.""" 

1168 validator = self.__class__.get_setting_validator( 

1169 self.key, **self.get_filters_for_instance() 

1170 ) 

1171 

1172 return self.__class__.validator_is_int(validator) 

1173 

1174 @classmethod 

1175 def validator_is_int(cls, validator): 

1176 """Return if validator is for int.""" 

1177 if validator == int: 

1178 return True 

1179 

1180 if type(validator) in [list, tuple]: 

1181 for v in validator: 1181 ↛ 1185line 1181 didn't jump to line 1185 because the loop on line 1181 didn't complete

1182 if v == int: 1182 ↛ 1181line 1182 didn't jump to line 1181 because the condition on line 1182 was always true

1183 return True 

1184 

1185 return False 

1186 

1187 def as_int(self, raise_error: bool = False) -> int: 

1188 """Return the value of this setting converted to a boolean value. 

1189 

1190 If an error occurs, return the default value 

1191 """ 

1192 try: 

1193 value = int(self.value) 

1194 except (ValueError, TypeError): 

1195 if raise_error: 

1196 raise ValidationError('Provided value is not a valid integer') 

1197 value = self.default_value 

1198 

1199 return value 

1200 

1201 @classmethod 

1202 def is_protected(cls, key, **kwargs): 

1203 """Check if the setting value is protected.""" 

1204 setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs)) 

1205 

1206 return setting.get('protected', False) 

1207 

1208 @property 

1209 def protected(self): 

1210 """Returns if setting is protected from rendering.""" 

1211 return self.__class__.is_protected(self.key, **self.get_filters_for_instance()) 

1212 

1213 @classmethod 

1214 def is_required(cls, key, **kwargs): 

1215 """Check if this setting value is required.""" 

1216 setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs)) 

1217 

1218 return setting.get('required', False) 

1219 

1220 @property 

1221 def required(self): 

1222 """Returns if setting is required.""" 

1223 return self.__class__.is_required(self.key, **self.get_filters_for_instance()) 

1224 

1225 

1226class InvenTreeSetting(BaseInvenTreeSetting): 

1227 """An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values). 

1228 

1229 The class provides a way of retrieving the value for a particular key, 

1230 even if that key does not exist. 

1231 """ 

1232 

1233 from common.setting.system import SYSTEM_SETTINGS 

1234 

1235 SETTINGS: dict[str, InvenTreeSettingsKeyType] = SYSTEM_SETTINGS 

1236 

1237 CHECK_SETTING_KEY = True 

1238 

1239 class Meta: 

1240 """Meta options for InvenTreeSetting.""" 

1241 

1242 verbose_name = 'InvenTree Setting' 

1243 verbose_name_plural = 'InvenTree Settings' 

1244 

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

1246 """When saving a global setting, check to see if it requires a server restart. 

1247 

1248 If so, set the "SERVER_RESTART_REQUIRED" setting to True 

1249 """ 

1250 overrides = global_setting_overrides() 

1251 

1252 # If an override is specified for this setting, use that value 

1253 if self.key in overrides: 1253 ↛ 1254line 1253 didn't jump to line 1254 because the condition on line 1253 was never true

1254 self.value = overrides[self.key] 

1255 

1256 super().save() 

1257 

1258 if self.requires_restart() and not InvenTree.ready.isImportingData(): 1258 ↛ 1259line 1258 didn't jump to line 1259 because the condition on line 1258 was never true

1259 InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None) 

1260 

1261 @classmethod 

1262 def get_setting_default(cls, key, **kwargs): 

1263 """Return the default value a particular setting.""" 

1264 overrides = global_setting_overrides() 

1265 

1266 if key in overrides: 

1267 # If an override is specified for this setting, use that value 

1268 return overrides[key] 

1269 

1270 return super().get_setting_default(key, **kwargs) 

1271 

1272 @classmethod 

1273 def get_setting(cls, key, backup_value=None, **kwargs): 

1274 """Get the value of a particular setting. 

1275 

1276 If it does not exist, return the backup value (default = None) 

1277 """ 

1278 overrides = global_setting_overrides() 

1279 

1280 if key in overrides: 1280 ↛ 1282line 1280 didn't jump to line 1282 because the condition on line 1280 was never true

1281 # If an override is specified for this setting, use that value 

1282 return overrides[key] 

1283 

1284 return super().get_setting(key, backup_value=backup_value, **kwargs) 

1285 

1286 """ 

1287 Dict of all global settings values: 

1288 

1289 The key of each item is the name of the value as it appears in the database. 

1290 

1291 Each global setting has the following parameters: 

1292 

1293 - name: Translatable string name of the setting (required) 

1294 - description: Translatable string description of the setting (required) 

1295 - default: Default value (optional) 

1296 - units: Units of the particular setting (optional) 

1297 - validator: Validation function for the setting (optional) 

1298 

1299 The keys must be upper-case 

1300 """ 

1301 

1302 typ = 'inventree' 

1303 

1304 key = models.CharField( 

1305 max_length=50, blank=False, unique=True, help_text=_('Settings key') 

1306 ) 

1307 

1308 def to_native_value(self): 

1309 """Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1.""" 

1310 return self.__class__.get_setting(self.key) 

1311 

1312 def requires_restart(self): 

1313 """Return True if this setting requires a server restart after changing.""" 

1314 options = InvenTreeSetting.SETTINGS.get(self.key, None) 

1315 

1316 if options: 

1317 return options.get('requires_restart', False) 

1318 return False 

1319 

1320 

1321class InvenTreeUserSetting(BaseInvenTreeSetting): 

1322 """An InvenTreeSetting object with a user context.""" 

1323 

1324 import common.setting.user 

1325 

1326 SETTINGS = common.setting.user.USER_SETTINGS 

1327 

1328 CHECK_SETTING_KEY = True 

1329 

1330 class Meta: 

1331 """Meta options for InvenTreeUserSetting.""" 

1332 

1333 verbose_name = 'InvenTree User Setting' 

1334 verbose_name_plural = 'InvenTree User Settings' 

1335 constraints = [ 

1336 models.UniqueConstraint(fields=['key', 'user'], name='unique key and user') 

1337 ] 

1338 

1339 typ = 'user' 

1340 extra_unique_fields = ['user'] 

1341 

1342 key = models.CharField( 

1343 max_length=50, blank=False, unique=False, help_text=_('Settings key') 

1344 ) 

1345 

1346 user = models.ForeignKey( 

1347 User, 

1348 on_delete=models.CASCADE, 

1349 blank=True, 

1350 null=True, 

1351 verbose_name=_('User'), 

1352 help_text=_('User'), 

1353 ) 

1354 

1355 def to_native_value(self): 

1356 """Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1.""" 

1357 return self.__class__.get_setting(self.key, user=self.user) 

1358 

1359 

1360class PriceBreak(MetaMixin): 

1361 """Represents a PriceBreak model.""" 

1362 

1363 class Meta: 

1364 """Define this as abstract -> no DB entry is created.""" 

1365 

1366 abstract = True 

1367 

1368 quantity = InvenTree.fields.RoundingDecimalField( 

1369 max_digits=15, 

1370 decimal_places=5, 

1371 default=1, 

1372 validators=[MinValueValidator(1)], 

1373 verbose_name=_('Quantity'), 

1374 help_text=_('Price break quantity'), 

1375 ) 

1376 

1377 price = InvenTree.fields.InvenTreeModelMoneyField( 

1378 max_digits=19, 

1379 decimal_places=6, 

1380 null=True, 

1381 verbose_name=_('Price'), 

1382 help_text=_('Unit price at specified quantity'), 

1383 ) 

1384 

1385 def convert_to(self, currency_code): 

1386 """Convert the unit-price at this price break to the specified currency code. 

1387 

1388 Args: 

1389 currency_code: The currency code to convert to (e.g "USD" or "AUD") 

1390 """ 

1391 try: 

1392 converted = convert_money(self.price, currency_code) 

1393 except MissingRate: 

1394 logger.warning( 

1395 'No currency conversion rate available for %s -> %s', 

1396 self.price_currency, 

1397 currency_code, 

1398 ) 

1399 return self.price.amount 

1400 

1401 return converted.amount 

1402 

1403 

1404class VerificationMethod(Enum): 

1405 """Class to hold method references.""" 

1406 

1407 NONE = 0 

1408 TOKEN = 1 

1409 HMAC = 2 

1410 

1411 

1412class WebhookEndpoint(models.Model): 

1413 """Defines a Webhook endpoint. 

1414 

1415 Attributes: 

1416 endpoint_id: Path to the webhook, 

1417 name: Name of the webhook, 

1418 active: Is this webhook active?, 

1419 user: User associated with webhook, 

1420 token: Token for sending a webhook, 

1421 secret: Shared secret for HMAC verification, 

1422 """ 

1423 

1424 # Token 

1425 TOKEN_NAME = 'Token' 

1426 VERIFICATION_METHOD = VerificationMethod.NONE 

1427 

1428 MESSAGE_OK = 'Message was received.' 

1429 MESSAGE_TOKEN_ERROR = 'Incorrect token in header.' 

1430 

1431 endpoint_id = models.CharField( 

1432 max_length=255, 

1433 verbose_name=_('Endpoint'), 

1434 help_text=_('Endpoint at which this webhook is received'), 

1435 default=uuid.uuid4, 

1436 editable=False, 

1437 ) 

1438 

1439 name = models.CharField( 

1440 max_length=255, 

1441 blank=True, 

1442 null=True, 

1443 verbose_name=_('Name'), 

1444 help_text=_('Name for this webhook'), 

1445 ) 

1446 

1447 active = models.BooleanField( 

1448 default=True, verbose_name=_('Active'), help_text=_('Is this webhook active') 

1449 ) 

1450 

1451 user = models.ForeignKey( 

1452 User, 

1453 on_delete=models.SET_NULL, 

1454 blank=True, 

1455 null=True, 

1456 verbose_name=_('User'), 

1457 help_text=_('User'), 

1458 ) 

1459 

1460 token = models.CharField( 

1461 max_length=255, 

1462 blank=True, 

1463 null=True, 

1464 verbose_name=_('Token'), 

1465 help_text=_('Token for access'), 

1466 default=uuid.uuid4, 

1467 ) 

1468 

1469 secret = models.CharField( 

1470 max_length=255, 

1471 blank=True, 

1472 null=True, 

1473 verbose_name=_('Secret'), 

1474 help_text=_('Shared secret for HMAC'), 

1475 ) 

1476 

1477 # To be overridden 

1478 

1479 def init(self, request, *args, **kwargs): 

1480 """Set verification method. 

1481 

1482 Args: 

1483 request: Original request object. 

1484 """ 

1485 self.verify = self.VERIFICATION_METHOD 

1486 

1487 def process_webhook(self): 

1488 """Process the webhook incoming. 

1489 

1490 This does not deal with the data itself - that happens in process_payload. 

1491 Do not touch or pickle data here - it was not verified to be safe. 

1492 """ 

1493 if self.token: 

1494 self.verify = VerificationMethod.TOKEN 

1495 if self.secret: 

1496 self.verify = VerificationMethod.HMAC 

1497 return True 

1498 

1499 def validate_token(self, payload, headers, request): 

1500 """Make sure that the provided token (if any) confirms to the setting for this endpoint. 

1501 

1502 This can be overridden to create your own token validation method. 

1503 """ 

1504 token = headers.get(self.TOKEN_NAME, '') 

1505 

1506 # no token 

1507 if self.verify == VerificationMethod.NONE: 

1508 # do nothing as no method was chosen 

1509 pass 

1510 

1511 # static token 

1512 elif self.verify == VerificationMethod.TOKEN: 

1513 if not compare_digest(token, self.token): 

1514 raise PermissionDenied(self.MESSAGE_TOKEN_ERROR) 

1515 

1516 # hmac token 

1517 elif self.verify == VerificationMethod.HMAC: 

1518 digest = hmac.new( 

1519 self.secret.encode('utf-8'), request.body, hashlib.sha256 

1520 ).digest() 

1521 computed_hmac = base64.b64encode(digest) 

1522 if not hmac.compare_digest(computed_hmac, token.encode('utf-8')): 

1523 raise PermissionDenied(self.MESSAGE_TOKEN_ERROR) 

1524 

1525 return True 

1526 

1527 def save_data(self, payload=None, headers=None, request=None): 

1528 """Safes payload to database. 

1529 

1530 Args: 

1531 payload (optional): Payload that was send along. Defaults to None. 

1532 headers (optional): Headers that were send along. Defaults to None. 

1533 request (optional): Original request object. Defaults to None. 

1534 """ 

1535 return WebhookMessage.objects.create( 

1536 host=request.get_host() if request else '', 

1537 header=json.dumps(dict(headers.items())) if headers else None, 

1538 body=payload, 

1539 endpoint=self, 

1540 ) 

1541 

1542 def process_payload(self, message, payload=None, headers=None) -> bool: 

1543 """Process a payload. 

1544 

1545 Args: 

1546 message: DB entry for this message mm 

1547 payload (optional): Payload that was send along. Defaults to None. 

1548 headers (optional): Headers that were included. Defaults to None. 

1549 

1550 Returns: 

1551 bool: Was the message processed 

1552 """ 

1553 return True 

1554 

1555 def get_return(self, payload=None, headers=None, request=None) -> str: 

1556 """Returns the message that should be returned to the endpoint caller. 

1557 

1558 Args: 

1559 payload (optional): Payload that was send along. Defaults to None. 

1560 headers (optional): Headers that were send along. Defaults to None. 

1561 request (optional): Original request object. Defaults to None. 

1562 

1563 Returns: 

1564 str: Message for caller. 

1565 """ 

1566 return self.MESSAGE_OK 

1567 

1568 

1569class WebhookMessage(models.Model): 

1570 """Defines a webhook message. 

1571 

1572 Attributes: 

1573 message_id: Unique identifier for this message, 

1574 host: Host from which this message was received, 

1575 header: Header of this message, 

1576 body: Body of this message, 

1577 endpoint: Endpoint on which this message was received, 

1578 worked_on: Was the work on this message finished? 

1579 """ 

1580 

1581 message_id = models.UUIDField( 

1582 verbose_name=_('Message ID'), 

1583 help_text=_('Unique identifier for this message'), 

1584 primary_key=True, 

1585 default=uuid.uuid4, 

1586 editable=False, 

1587 ) 

1588 

1589 host = models.CharField( 

1590 max_length=255, 

1591 verbose_name=_('Host'), 

1592 help_text=_('Host from which this message was received'), 

1593 editable=False, 

1594 ) 

1595 

1596 header = models.CharField( 

1597 max_length=255, 

1598 blank=True, 

1599 null=True, 

1600 verbose_name=_('Header'), 

1601 help_text=_('Header of this message'), 

1602 editable=False, 

1603 ) 

1604 

1605 body = models.JSONField( 

1606 blank=True, 

1607 null=True, 

1608 verbose_name=_('Body'), 

1609 help_text=_('Body of this message'), 

1610 editable=False, 

1611 ) 

1612 

1613 endpoint = models.ForeignKey( 

1614 WebhookEndpoint, 

1615 on_delete=models.SET_NULL, 

1616 blank=True, 

1617 null=True, 

1618 verbose_name=_('Endpoint'), 

1619 help_text=_('Endpoint on which this message was received'), 

1620 ) 

1621 

1622 worked_on = models.BooleanField( 

1623 default=False, 

1624 verbose_name=_('Worked on'), 

1625 help_text=_('Was the work on this message finished?'), 

1626 ) 

1627 

1628 

1629class NotificationEntry(MetaMixin): 

1630 """A NotificationEntry records the last time a particular notification was sent out. 

1631 

1632 It is recorded to ensure that notifications are not sent out "too often" to users. 

1633 

1634 Attributes: 

1635 - key: A text entry describing the notification e.g. 'part.notify_low_stock' 

1636 - uid: An (optional) numerical ID for a particular instance 

1637 - date: The last time this notification was sent 

1638 """ 

1639 

1640 class Meta: 

1641 """Meta options for NotificationEntry.""" 

1642 

1643 unique_together = [('key', 'uid')] 

1644 

1645 key = models.CharField(max_length=250, blank=False) 

1646 

1647 uid = models.IntegerField() 

1648 

1649 @classmethod 

1650 def check_recent(cls, key: str, uid: int, delta: timedelta): 

1651 """Test if a particular notification has been sent in the specified time period.""" 

1652 since = InvenTree.helpers.current_date() - delta 

1653 

1654 entries = cls.objects.filter(key=key, uid=uid, updated__gte=since) 

1655 

1656 return entries.exists() 

1657 

1658 @classmethod 

1659 def notify(cls, key: str, uid: int): 

1660 """Notify the database that a particular notification has been sent out.""" 

1661 entry, _ = cls.objects.get_or_create(key=key, uid=uid) 

1662 

1663 entry.save() 

1664 

1665 

1666class NotificationMessage(models.Model): 

1667 """A NotificationMessage is a message sent to a particular user, notifying them of some important information. 

1668 

1669 Notification messages can be generated by a variety of sources. 

1670 

1671 Attributes: 

1672 target_object: The 'target' of the notification message 

1673 source_object: The 'source' of the notification message 

1674 """ 

1675 

1676 # generic link to target 

1677 target_content_type = models.ForeignKey( 

1678 ContentType, on_delete=models.CASCADE, related_name='notification_target' 

1679 ) 

1680 

1681 target_object_id = models.CharField(max_length=255) 

1682 

1683 target_object = GenericForeignKey('target_content_type', 'target_object_id') 

1684 

1685 # generic link to source 

1686 source_content_type = models.ForeignKey( 

1687 ContentType, 

1688 on_delete=models.SET_NULL, 

1689 related_name='notification_source', 

1690 null=True, 

1691 blank=True, 

1692 ) 

1693 

1694 source_object_id = models.CharField(max_length=255, null=True, blank=True) 

1695 

1696 source_object = GenericForeignKey('source_content_type', 'source_object_id') 

1697 

1698 # user that receives the notification 

1699 user = models.ForeignKey( 

1700 User, 

1701 on_delete=models.CASCADE, 

1702 verbose_name=_('User'), 

1703 help_text=_('User'), 

1704 null=True, 

1705 blank=True, 

1706 ) 

1707 

1708 category = models.CharField(max_length=250, blank=False) 

1709 

1710 name = models.CharField(max_length=250, blank=False) 

1711 

1712 message = models.CharField(max_length=250, blank=True, null=True) 

1713 

1714 creation = models.DateTimeField(auto_now_add=True) 

1715 

1716 read = models.BooleanField(default=False) 

1717 

1718 @staticmethod 

1719 def get_api_url(): 

1720 """Return API endpoint.""" 

1721 return reverse('api-notifications-list') 

1722 

1723 def age(self) -> int: 

1724 """Age of the message in seconds.""" 

1725 # Add timezone information if TZ is enabled (in production mode mostly) 

1726 delta = now() - ( 

1727 self.creation.replace(tzinfo=timezone.utc) 

1728 if django_settings.USE_TZ 

1729 else self.creation 

1730 ) 

1731 return delta.seconds 

1732 

1733 def age_human(self) -> str: 

1734 """Humanized age.""" 

1735 return naturaltime(self.creation) 

1736 

1737 

1738class NewsFeedEntry(models.Model): 

1739 """A NewsFeedEntry represents an entry on the RSS/Atom feed that is generated for InvenTree news. 

1740 

1741 Attributes: 

1742 - feed_id: Unique id for the news item 

1743 - title: Title for the news item 

1744 - link: Link to the news item 

1745 - published: Date of publishing of the news item 

1746 - author: Author of news item 

1747 - summary: Summary of the news items content 

1748 - read: Was this item already by a superuser? 

1749 """ 

1750 

1751 feed_id = models.CharField(verbose_name=_('Id'), unique=True, max_length=250) 

1752 

1753 title = models.CharField(verbose_name=_('Title'), max_length=250) 

1754 

1755 link = models.URLField(verbose_name=_('Link'), max_length=250) 

1756 

1757 published = models.DateTimeField(verbose_name=_('Published'), max_length=250) 

1758 

1759 author = models.CharField(verbose_name=_('Author'), max_length=250) 

1760 

1761 summary = models.CharField(verbose_name=_('Summary'), max_length=250) 

1762 

1763 read = models.BooleanField( 

1764 verbose_name=_('Read'), help_text=_('Was this news item read?'), default=False 

1765 ) 

1766 

1767 

1768def rename_notes_image(instance, filename): 

1769 """Function for renaming uploading image file. Will store in the 'notes' directory.""" 

1770 fname = os.path.basename(filename) 

1771 return os.path.join('notes', fname) 

1772 

1773 

1774class NotesImage(models.Model): 

1775 """Model for storing uploading images for the 'notes' fields of various models. 

1776 

1777 Simply stores the image file, for use in the 'notes' field (of any models which support markdown). 

1778 """ 

1779 

1780 image = models.ImageField( 

1781 upload_to=rename_notes_image, verbose_name=_('Image'), help_text=_('Image file') 

1782 ) 

1783 

1784 user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) 

1785 

1786 date = models.DateTimeField(auto_now_add=True) 

1787 

1788 model_type = models.CharField( 

1789 max_length=100, 

1790 blank=True, 

1791 null=True, 

1792 validators=[common.validators.validate_notes_model_type], 

1793 help_text=_('Target model type for this image'), 

1794 ) 

1795 

1796 model_id = models.IntegerField( 

1797 help_text=_('Target model ID for this image'), 

1798 blank=True, 

1799 null=True, 

1800 default=None, 

1801 ) 

1802 

1803 

1804class CustomUnit(models.Model): 

1805 """Model for storing custom physical unit definitions. 

1806 

1807 Model Attributes: 

1808 name: Name of the unit 

1809 definition: Definition of the unit 

1810 symbol: Symbol for the unit (e.g. 'm' for 'metre') (optional) 

1811 

1812 Refer to the pint documentation for further information on unit definitions. 

1813 https://pint.readthedocs.io/en/stable/advanced/defining.html 

1814 """ 

1815 

1816 class Meta: 

1817 """Class meta options.""" 

1818 

1819 verbose_name = _('Custom Unit') 

1820 

1821 def fmt_string(self): 

1822 """Construct a unit definition string e.g. 'dog_year = 52 * day = dy'.""" 

1823 fmt = f'{self.name} = {self.definition}' 

1824 

1825 if self.symbol: 

1826 fmt += f' = {self.symbol}' 

1827 

1828 return fmt 

1829 

1830 def validate_unique(self, exclude=None) -> None: 

1831 """Ensure that the custom unit is unique.""" 

1832 super().validate_unique(exclude) 

1833 

1834 if self.symbol and ( 1834 ↛ 1837line 1834 didn't jump to line 1837 because the condition on line 1834 was never true

1835 CustomUnit.objects.filter(symbol=self.symbol).exclude(pk=self.pk).exists() 

1836 ): 

1837 raise ValidationError({'symbol': _('Unit symbol must be unique')}) 

1838 

1839 def clean(self): 

1840 """Validate that the provided custom unit is indeed valid.""" 

1841 super().clean() 

1842 

1843 from InvenTree.conversion import get_unit_registry 

1844 

1845 registry = get_unit_registry() 

1846 

1847 # Check that the 'name' field is valid 

1848 self.name = self.name.strip() 

1849 

1850 # Cannot be zero length 

1851 if not self.name.isidentifier(): 

1852 raise ValidationError({'name': _('Unit name must be a valid identifier')}) 

1853 

1854 self.definition = self.definition.strip() 

1855 

1856 # Check that the 'definition' is valid, by itself 

1857 try: 

1858 registry.Quantity(self.definition) 

1859 except Exception as exc: 

1860 raise ValidationError({'definition': str(exc)}) 

1861 

1862 # Finally, test that the entire custom unit definition is valid 

1863 try: 

1864 registry.define(self.fmt_string()) 

1865 except Exception as exc: 

1866 raise ValidationError(str(exc)) 

1867 

1868 name = models.CharField( 

1869 max_length=50, 

1870 verbose_name=_('Name'), 

1871 help_text=_('Unit name'), 

1872 unique=True, 

1873 blank=False, 

1874 ) 

1875 

1876 symbol = models.CharField( 

1877 max_length=10, 

1878 verbose_name=_('Symbol'), 

1879 help_text=_('Optional unit symbol'), 

1880 blank=True, 

1881 ) 

1882 

1883 definition = models.CharField( 

1884 max_length=50, 

1885 verbose_name=_('Definition'), 

1886 help_text=_('Unit definition'), 

1887 blank=False, 

1888 ) 

1889 

1890 

1891@receiver(post_save, sender=CustomUnit, dispatch_uid='custom_unit_saved') 

1892@receiver(post_delete, sender=CustomUnit, dispatch_uid='custom_unit_deleted') 

1893def after_custom_unit_updated(sender, instance, **kwargs): 

1894 """Callback when a custom unit is updated or deleted.""" 

1895 # Force reload of the unit registry 

1896 from InvenTree.conversion import reload_unit_registry 

1897 

1898 reload_unit_registry() 

1899 

1900 

1901def rename_attachment(instance, filename: str): 

1902 """Callback function to rename an uploaded attachment file. 

1903 

1904 Args: 

1905 instance (Attachment): The Attachment instance for which the file is being renamed. 

1906 filename (str): The original filename of the uploaded file. 

1907 

1908 Returns: 

1909 str: The new filename for the uploaded file, e.g. 'attachments/<model_type>/<model_id>/<filename>'. 

1910 """ 

1911 # Remove any illegal characters from the filename 

1912 illegal_chars = '\'"\\`~#|!@#$%^&*()[]{}<>?;:+=,' 

1913 

1914 for c in illegal_chars: 

1915 filename = filename.replace(c, '') 

1916 

1917 filename = os.path.basename(filename) 

1918 

1919 # Generate a new filename for the attachment 

1920 return os.path.join( 

1921 'attachments', str(instance.model_type), str(instance.model_id), filename 

1922 ) 

1923 

1924 

1925class Attachment( 

1926 InvenTree.models.MetadataMixin, 

1927 InvenTree.models.InvenTreeTagsMixin, 

1928 InvenTree.models.InvenTreeModel, 

1929): 

1930 """Class which represents an uploaded file attachment. 

1931 

1932 An attachment can be either an uploaded file, or an external URL. 

1933 

1934 Attributes: 

1935 model_type: The type of model to which this attachment is linked 

1936 model_id: The ID of the model to which this attachment is linked 

1937 attachment: The uploaded file 

1938 url: An external URL 

1939 thumbnail: A generated thumbnail for the uploaded file (if applicable) 

1940 is_image: True if this attachment is a valid image file 

1941 comment: A comment or description for the attachment 

1942 user: The user who uploaded the attachment 

1943 upload_date: The date the attachment was uploaded 

1944 file_size: The size of the uploaded file 

1945 metadata: Arbitrary metadata for the attachment (inherit from MetadataMixin) 

1946 tags: Tags for the attachment 

1947 """ 

1948 

1949 THUMBNAIL_SIZE = 256 

1950 

1951 class Meta: 

1952 """Metaclass options.""" 

1953 

1954 verbose_name = _('Attachment') 

1955 

1956 class ModelChoices(RenderChoices): 

1957 """Model choices for attachments.""" 

1958 

1959 choice_fnc = common.validators.attachment_model_options 

1960 

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

1962 """Custom delete method for the Attachment model. 

1963 

1964 - Ensure that the attached file is deleted from storage when the database entry is removed 

1965 """ 

1966 attachment = self.attachment 

1967 thumbnail = self.thumbnail 

1968 

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

1970 

1971 # Delete the associated files from storage (if they exist)W 

1972 if attachment and default_storage.exists(attachment.name): 

1973 try: 

1974 # Remove the attached file from storage 

1975 default_storage.delete(attachment.name) 

1976 except Exception: # pragma: no cover 

1977 pass 

1978 

1979 if thumbnail and default_storage.exists(thumbnail.name): 

1980 try: 

1981 # Remove the thumbnail file from storage 

1982 default_storage.delete(thumbnail.name) 

1983 except Exception: # pragma: no cover 

1984 pass 

1985 

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

1987 """Custom 'save' method for the Attachment model. 

1988 

1989 - Record the file size of the uploaded attachment (if applicable) 

1990 - Ensure that the 'content_type' and 'object_id' fields are set 

1991 - Run extra validations 

1992 """ 

1993 import common.tasks 

1994 

1995 rebuild = kwargs.pop('rebuild', True) 

1996 

1997 # Either 'attachment' or 'link' must be specified! 

1998 if not self.attachment and not self.link: 1998 ↛ 2004line 1998 didn't jump to line 2004 because the condition on line 1998 was always true

1999 raise ValidationError({ 

2000 'attachment': _('Missing file'), 

2001 'link': _('Missing external link'), 

2002 }) 

2003 

2004 if self.attachment: 

2005 if self.attachment.name.lower().endswith('.svg'): 

2006 self.attachment.file.file = self.clean_svg(self.attachment) 

2007 else: 

2008 self.file_size = 0 

2009 

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

2011 

2012 # Update file size 

2013 if self.file_size == 0 and self.attachment: 

2014 # Get file size 

2015 if default_storage.exists(self.attachment.name): 

2016 try: 

2017 self.file_size = default_storage.size(self.attachment.name) 

2018 except Exception: 

2019 pass 

2020 

2021 if self.file_size != 0: 

2022 super().save() 

2023 

2024 # Offload a background task to update the thumbnail for this attachment 

2025 if rebuild: 

2026 InvenTree.tasks.offload_task( 

2027 common.tasks.rebuild_attachment, self.pk, group='attachments' 

2028 ) 

2029 

2030 def clean_svg(self, field): 

2031 """Sanitize SVG file before saving.""" 

2032 cleaned = sanitize_svg(field.file.read()) 

2033 return BytesIO(bytes(cleaned, 'utf8')) 

2034 

2035 def __str__(self): 

2036 """Human name for attachment.""" 

2037 if self.attachment is not None: 

2038 return os.path.basename(self.attachment.name) 

2039 return str(self.link) 

2040 

2041 def validate_rename(self, filename: str): 

2042 """Validate that the provided filename is valid, for renaming an attachment.""" 

2043 filename = filename.strip() 

2044 

2045 if not self.attachment: 

2046 raise ValidationError(_('No file attached to rename')) 

2047 

2048 if not filename: 

2049 raise ValidationError(_('Filename cannot be empty')) 

2050 

2051 try: 

2052 validate_file_name(filename, allow_relative_path=False) 

2053 except SuspiciousFileOperation: 

2054 raise ValidationError(_('Invalid filename')) 

2055 

2056 current_ext = os.path.splitext(self.attachment.name)[1] 

2057 new_ext = os.path.splitext(filename)[1] 

2058 

2059 if current_ext.lower() != new_ext.lower(): 

2060 raise ValidationError(_('Cannot change file extension')) 

2061 

2062 def rename(self, filename: str): 

2063 """Rename the attached file.""" 

2064 self.validate_rename(filename) 

2065 

2066 old_path = Path(self.attachment.name) 

2067 new_path = old_path.parent / filename 

2068 

2069 if old_path == new_path: # pragma: no cover 

2070 # No change in filename 

2071 return 

2072 

2073 if not new_path.is_relative_to(old_path.parent): # pragma: no cover 

2074 raise ValidationError(_('Invalid filename')) 

2075 

2076 new_path = new_path.as_posix() 

2077 

2078 if default_storage.exists(new_path): 

2079 raise ValidationError(_('A file with this name already exists')) 

2080 

2081 # Create a new file with the new name, and delete the old file 

2082 new_path = default_storage.save(new_path, self.attachment.file) 

2083 

2084 # Ensure that the new file exists 

2085 if not default_storage.exists(new_path): # pragma: no cover 

2086 raise ValidationError(_('Failed to save renamed file')) 

2087 

2088 # Update the database file path 

2089 self.attachment.name = new_path 

2090 self.save() 

2091 

2092 # Remove the old path 

2093 default_storage.delete(old_path) 

2094 

2095 model_type = models.CharField( 

2096 max_length=100, 

2097 validators=[common.validators.validate_attachment_model_type], 

2098 verbose_name=_('Model type'), 

2099 help_text=_('Target model type for image'), 

2100 ) 

2101 

2102 model_id = models.PositiveIntegerField() 

2103 

2104 attachment = models.FileField( 

2105 upload_to=rename_attachment, 

2106 verbose_name=_('Attachment'), 

2107 validators=[common.validators.validate_attachment_file], 

2108 help_text=_('Select file to attach'), 

2109 blank=True, 

2110 null=True, 

2111 ) 

2112 

2113 thumbnail = models.ImageField( 

2114 verbose_name=_('Thumbnail'), 

2115 help_text=_('Thumbnail image for this attachment'), 

2116 blank=True, 

2117 null=True, 

2118 ) 

2119 

2120 link = InvenTree.fields.InvenTreeURLField( 

2121 blank=True, 

2122 null=True, 

2123 verbose_name=_('Link'), 

2124 help_text=_('Link to external URL'), 

2125 max_length=2000, 

2126 ) 

2127 

2128 comment = models.CharField( 

2129 blank=True, 

2130 max_length=250, 

2131 verbose_name=_('Comment'), 

2132 help_text=_('Attachment comment'), 

2133 ) 

2134 

2135 upload_user = models.ForeignKey( 

2136 User, 

2137 on_delete=models.SET_NULL, 

2138 blank=True, 

2139 null=True, 

2140 verbose_name=_('User'), 

2141 help_text=_('User'), 

2142 ) 

2143 

2144 upload_date = models.DateField( 

2145 auto_now_add=True, 

2146 null=True, 

2147 blank=True, 

2148 verbose_name=_('Upload date'), 

2149 help_text=_('Date the file was uploaded'), 

2150 ) 

2151 

2152 is_image = models.BooleanField( 

2153 default=False, 

2154 verbose_name=_('Is image'), 

2155 help_text=_('True if this attachment is a valid image file'), 

2156 ) 

2157 

2158 file_size = models.PositiveIntegerField( 

2159 default=0, verbose_name=_('File size'), help_text=_('File size in bytes') 

2160 ) 

2161 

2162 @property 

2163 def basename(self): 

2164 """Base name/path for attachment.""" 

2165 if self.attachment: 

2166 return os.path.basename(self.attachment.name) 

2167 return None 

2168 

2169 def fully_qualified_url(self): 

2170 """Return a 'fully qualified' URL for this attachment. 

2171 

2172 - If the attachment is a link to an external resource, return the link 

2173 - If the attachment is an uploaded file, return the fully qualified media URL 

2174 """ 

2175 if self.link: 

2176 return self.link 

2177 

2178 if self.attachment: 

2179 import InvenTree.helpers_model 

2180 

2181 media_url = InvenTree.helpers.getMediaUrl(self.attachment) 

2182 return InvenTree.helpers_model.construct_absolute_url(media_url) 

2183 

2184 return '' 

2185 

2186 def check_permission(self, permission, user): 

2187 """Check if the user has the required permission for this attachment.""" 

2188 from InvenTree.models import InvenTreeAttachmentMixin 

2189 

2190 model_class = common.validators.attachment_model_class_from_label( 

2191 self.model_type 

2192 ) 

2193 

2194 if not issubclass(model_class, InvenTreeAttachmentMixin): 

2195 raise ValidationError(_('Invalid model type specified for attachment')) 

2196 

2197 return model_class.check_related_permission(permission, user) 

2198 

2199 def check_is_image(self) -> bool: 

2200 """Check if the attached file is an image. 

2201 

2202 We consider it a valid image if: 

2203 

2204 - The file exists in storage 

2205 - The file can be opened and verified by the PIL library 

2206 

2207 """ 

2208 if not self.attachment: 

2209 return False 

2210 

2211 if not self.attachment.name: 

2212 return False 

2213 

2214 try: 

2215 if not default_storage.exists(self.attachment.name): 

2216 return False 

2217 except Exception: 

2218 return False 

2219 

2220 img_data = default_storage.open(self.attachment.name).read() 

2221 

2222 try: 

2223 Image.open(BytesIO(img_data)).verify() 

2224 return True 

2225 except Exception: 

2226 return False 

2227 

2228 def generate_thumbnail(self): 

2229 """Generate a thumbnail for the attached image.""" 

2230 # Remove any existing thumbnail 

2231 if self.thumbnail: 

2232 self.thumbnail.delete(save=False) 

2233 

2234 if not self.attachment: 

2235 return 

2236 

2237 if not self.attachment.name or not default_storage.exists(self.attachment.name): 

2238 return 

2239 

2240 # TODO: Offload to plugins, for creating custom thumbnails for different file types 

2241 # TODO: If a plugin provides a thumbnail, return early 

2242 

2243 # Default action is to generate a thumbnail for image files 

2244 try: 

2245 img_data = default_storage.open(self.attachment.name).read() 

2246 except Exception: 

2247 # No file found, or file cannot be read - cannot generate thumbnail 

2248 return 

2249 

2250 try: 

2251 img = Image.open(BytesIO(img_data)) 

2252 img.thumbnail((self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE)) 

2253 thumb_io = BytesIO() 

2254 img.save(thumb_io, format='PNG') 

2255 thumb_io.seek(0) 

2256 

2257 thumb_name = f'thumb_{os.path.basename(self.attachment.name)}' 

2258 self.thumbnail.save(thumb_name, ContentFile(thumb_io.read()), save=False) 

2259 except Exception: 

2260 pass 

2261 

2262 

2263class InvenTreeCustomUserStateModel(models.Model): 

2264 """Custom model to extends any registered state with extra custom, user defined states. 

2265 

2266 Fields: 

2267 reference_status: Status set that is extended with this custom state 

2268 logical_key: State logical key that is equal to this custom state in business logic 

2269 key: Numerical value that will be saved in the models database 

2270 name: Name of the state (must be uppercase and a valid variable identifier) 

2271 label: Label that will be displayed in the frontend (human readable) 

2272 color: Color that will be displayed in the frontend 

2273 

2274 """ 

2275 

2276 class Meta: 

2277 """Metaclass options for this mixin.""" 

2278 

2279 verbose_name = _('Custom State') 

2280 verbose_name_plural = _('Custom States') 

2281 unique_together = [('reference_status', 'key'), ('reference_status', 'name')] 

2282 

2283 reference_status = models.CharField( 

2284 max_length=250, 

2285 verbose_name=_('Reference Status Set'), 

2286 help_text=_('Status set that is extended with this custom state'), 

2287 ) 

2288 

2289 logical_key = models.IntegerField( 

2290 verbose_name=_('Logical Key'), 

2291 help_text=_( 

2292 'State logical key that is equal to this custom state in business logic' 

2293 ), 

2294 ) 

2295 

2296 key = models.IntegerField( 

2297 verbose_name=_('Value'), 

2298 help_text=_('Numerical value that will be saved in the models database'), 

2299 ) 

2300 

2301 name = models.CharField( 

2302 max_length=250, 

2303 verbose_name=_('Name'), 

2304 help_text=_('Name of the state'), 

2305 validators=[ 

2306 common.validators.validate_uppercase, 

2307 common.validators.validate_variable_string, 

2308 ], 

2309 ) 

2310 

2311 label = models.CharField( 

2312 max_length=250, 

2313 verbose_name=_('Label'), 

2314 help_text=_('Label that will be displayed in the frontend'), 

2315 ) 

2316 

2317 color = models.CharField( 

2318 max_length=10, 

2319 choices=state_color_mappings(), 

2320 default=ColorEnum.secondary.value, 

2321 verbose_name=_('Color'), 

2322 help_text=_('Color that will be displayed in the frontend'), 

2323 ) 

2324 

2325 model = models.ForeignKey( 

2326 ContentType, 

2327 on_delete=models.SET_NULL, 

2328 null=True, 

2329 blank=True, 

2330 verbose_name=_('Model'), 

2331 help_text=_('Model this state is associated with'), 

2332 ) 

2333 

2334 def __str__(self) -> str: 

2335 """Return string representation of the custom state.""" 

2336 return f'{self.model.name} ({self.reference_status}): {self.name} | {self.key} ({self.logical_key})' 

2337 

2338 def save(self, *args, **kwargs) -> None: 

2339 """Ensure that the custom state is valid before saving.""" 

2340 self.clean() 

2341 return super().save(*args, **kwargs) 

2342 

2343 def clean(self) -> None: 

2344 """Validate custom state data.""" 

2345 if self.model is None: 

2346 raise ValidationError({'model': _('Model must be selected')}) 

2347 

2348 if self.key is None: 

2349 raise ValidationError({'key': _('Key must be selected')}) 

2350 

2351 if self.logical_key is None: 

2352 raise ValidationError({'logical_key': _('Logical key must be selected')}) 

2353 

2354 # Ensure that the key is not the same as the logical key 

2355 if self.key == self.logical_key: 

2356 raise ValidationError({'key': _('Key must be different from logical key')}) 

2357 

2358 # Check against the reference status class 

2359 status_class = self.get_status_class() 

2360 

2361 if not status_class: 

2362 raise ValidationError({ 

2363 'reference_status': _('Valid reference status class must be provided') 

2364 }) 

2365 

2366 if self.key in status_class.values(): 

2367 raise ValidationError({ 

2368 'key': _( 

2369 'Key must be different from the logical keys of the reference status' 

2370 ) 

2371 }) 

2372 

2373 if self.logical_key not in status_class.values(): 

2374 raise ValidationError({ 

2375 'logical_key': _( 

2376 'Logical key must be in the logical keys of the reference status' 

2377 ) 

2378 }) 

2379 

2380 if self.name in status_class.names(): 

2381 raise ValidationError({ 

2382 'name': _( 

2383 'Name must be different from the names of the reference status' 

2384 ) 

2385 }) 

2386 

2387 return super().clean() 

2388 

2389 def get_status_class(self): 

2390 """Return the appropriate status class for this custom state.""" 

2391 from generic.states import StatusCode 

2392 from InvenTree.helpers import inheritors 

2393 

2394 if not self.reference_status: 

2395 return None 

2396 

2397 # Return the first class that matches the reference status 

2398 for cls in inheritors(StatusCode): 

2399 if cls.__name__ == self.reference_status: 

2400 return cls 

2401 

2402 

2403class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): 

2404 """Class which represents a list of selectable items for parameters. 

2405 

2406 A lists selection options can be either manually defined, or sourced from a plugin. 

2407 

2408 Attributes: 

2409 name: The name of the selection list 

2410 description: A description of the selection list 

2411 locked: Is this selection list locked (i.e. cannot be modified)? 

2412 active: Is this selection list active? 

2413 source_plugin: The plugin which provides the selection list 

2414 source_string: The string representation of the selection list 

2415 default: The default value for the selection list 

2416 created: The date/time that the selection list was created 

2417 last_updated: The date/time that the selection list was last updated 

2418 """ 

2419 

2420 class Meta: 

2421 """Meta options for SelectionList.""" 

2422 

2423 verbose_name = _('Selection List') 

2424 verbose_name_plural = _('Selection Lists') 

2425 

2426 name = models.CharField( 

2427 max_length=100, 

2428 verbose_name=_('Name'), 

2429 help_text=_('Name of the selection list'), 

2430 unique=True, 

2431 ) 

2432 

2433 description = models.CharField( 

2434 max_length=250, 

2435 verbose_name=_('Description'), 

2436 help_text=_('Description of the selection list'), 

2437 blank=True, 

2438 ) 

2439 

2440 locked = models.BooleanField( 

2441 default=False, 

2442 verbose_name=_('Locked'), 

2443 help_text=_('Is this selection list locked?'), 

2444 ) 

2445 

2446 active = models.BooleanField( 

2447 default=True, 

2448 verbose_name=_('Active'), 

2449 help_text=_('Can this selection list be used?'), 

2450 ) 

2451 

2452 source_plugin = models.ForeignKey( 

2453 'plugin.PluginConfig', 

2454 on_delete=models.SET_NULL, 

2455 blank=True, 

2456 null=True, 

2457 verbose_name=_('Source Plugin'), 

2458 help_text=_('Plugin which provides the selection list'), 

2459 ) 

2460 

2461 source_string = models.CharField( 

2462 max_length=1000, 

2463 verbose_name=_('Source String'), 

2464 help_text=_('Optional string identifying the source used for this list'), 

2465 blank=True, 

2466 ) 

2467 

2468 default = models.ForeignKey( 

2469 'SelectionListEntry', 

2470 on_delete=models.SET_NULL, 

2471 blank=True, 

2472 null=True, 

2473 verbose_name=_('Default Entry'), 

2474 help_text=_('Default entry for this selection list'), 

2475 ) 

2476 

2477 created = models.DateTimeField( 

2478 auto_now_add=True, 

2479 verbose_name=_('Created'), 

2480 help_text=_('Date and time that the selection list was created'), 

2481 ) 

2482 

2483 last_updated = models.DateTimeField( 

2484 auto_now=True, 

2485 verbose_name=_('Last Updated'), 

2486 help_text=_('Date and time that the selection list was last updated'), 

2487 ) 

2488 

2489 def __str__(self): 

2490 """Return string representation of the selection list.""" 

2491 if not self.active: 

2492 return f'{self.name} (Inactive)' 

2493 return self.name 

2494 

2495 @staticmethod 

2496 def get_api_url(): 

2497 """Return the API URL associated with the SelectionList model.""" 

2498 return reverse('api-selectionlist-list') 

2499 

2500 def get_choices(self, active: Optional[bool] = True): 

2501 """Return the choices for the selection list. 

2502 

2503 Arguments: 

2504 active: If specified, filter choices by active status 

2505 

2506 Returns: 

2507 List of choice values for this selection list 

2508 """ 

2509 choices = self.entries.all() 

2510 

2511 if active is not None: 

2512 choices = choices.filter(active=active) 

2513 

2514 return [c.value for c in choices] 

2515 

2516 def has_choice(self, value: str, active: Optional[bool] = None): 

2517 """Check if the selection list has a particular choice. 

2518 

2519 Arguments: 

2520 value: The value to check for 

2521 active: If specified, filter choices by active status 

2522 

2523 Returns: 

2524 True if the choice exists in the selection list, False otherwise 

2525 """ 

2526 choices = self.entries.all() 

2527 

2528 if active is not None: 

2529 choices = choices.filter(active=active) 

2530 

2531 return choices.filter(value=value).exists() 

2532 

2533 

2534class SelectionListEntry(models.Model): 

2535 """Class which represents a single entry in a SelectionList. 

2536 

2537 Attributes: 

2538 list: The SelectionList to which this entry belongs 

2539 value: The value of the selection list entry 

2540 label: The label for the selection list entry 

2541 description: A description of the selection list entry 

2542 active: Is this selection list entry active? 

2543 """ 

2544 

2545 class Meta: 

2546 """Meta options for SelectionListEntry.""" 

2547 

2548 verbose_name = _('Selection List Entry') 

2549 verbose_name_plural = _('Selection List Entries') 

2550 unique_together = [['list', 'value']] 

2551 

2552 list = models.ForeignKey( 

2553 SelectionList, 

2554 on_delete=models.CASCADE, 

2555 null=True, 

2556 blank=True, 

2557 related_name='entries', 

2558 verbose_name=_('Selection List'), 

2559 help_text=_('Selection list to which this entry belongs'), 

2560 ) 

2561 

2562 value = models.CharField( 

2563 max_length=255, 

2564 verbose_name=_('Value'), 

2565 help_text=_('Value of the selection list entry'), 

2566 ) 

2567 

2568 label = models.CharField( 

2569 max_length=255, 

2570 verbose_name=_('Label'), 

2571 help_text=_('Label for the selection list entry'), 

2572 ) 

2573 

2574 description = models.CharField( 

2575 max_length=250, 

2576 verbose_name=_('Description'), 

2577 help_text=_('Description of the selection list entry'), 

2578 blank=True, 

2579 ) 

2580 

2581 active = models.BooleanField( 

2582 default=True, 

2583 verbose_name=_('Active'), 

2584 help_text=_('Is this selection list entry active?'), 

2585 ) 

2586 

2587 def __str__(self): 

2588 """Return string representation of the selection list entry.""" 

2589 if not self.active: 

2590 return f'{self.label} (Inactive)' 

2591 return self.label 

2592 

2593 

2594class ParameterTemplate( 

2595 InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel 

2596): 

2597 """A ParameterTemplate provides a template for defining parameter values against various models. 

2598 

2599 This allow for assigning arbitrary data fields against existing models, 

2600 extending their functionality beyond the built-in fields. 

2601 

2602 Attributes: 

2603 name: The name (key) of the template 

2604 description: A description of the template 

2605 model_type: The type of model to which this template applies (e.g. 'part') 

2606 units: The units associated with the template (if applicable) 

2607 checkbox: Is this template a checkbox (boolean) type? 

2608 choices: Comma-separated list of choices (if applicable) 

2609 selectionlist: Optional link to a SelectionList for this template 

2610 enabled: Is this template enabled? 

2611 """ 

2612 

2613 IMPORT_ID_FIELDS = ['name'] 

2614 

2615 class Meta: 

2616 """Metaclass options for the ParameterTemplate model.""" 

2617 

2618 verbose_name = _('Parameter Template') 

2619 verbose_name_plural = _('Parameter Templates') 

2620 

2621 # Note: Data was migrated from the existing 'part_partparametertemplate' table 

2622 # Ref: https://github.com/inventree/InvenTree/pull/10699 

2623 # To avoid data loss, we retain the existing table name 

2624 db_table = 'part_partparametertemplate' 

2625 

2626 class ModelChoices(RenderChoices): 

2627 """Model choices for parameters.""" 

2628 

2629 choice_fnc = common.validators.parameter_template_model_options 

2630 

2631 @staticmethod 

2632 def get_api_url() -> str: 

2633 """Return the API URL associated with the ParameterTemplate model.""" 

2634 return reverse('api-parameter-template-list') 

2635 

2636 def __str__(self): 

2637 """Return a string representation of a ParameterTemplate instance.""" 

2638 s = str(self.name) 

2639 if self.units: 

2640 s += f' ({self.units})' 

2641 return s 

2642 

2643 def clean(self): 

2644 """Custom cleaning step for this model. 

2645 

2646 Checks: 

2647 - A 'checkbox' field cannot have 'choices' set 

2648 - A 'checkbox' field cannot have 'units' set 

2649 """ 

2650 super().clean() 

2651 

2652 # Check that checkbox parameters do not have units or choices 

2653 if self.checkbox: 2653 ↛ 2654line 2653 didn't jump to line 2654 because the condition on line 2653 was never true

2654 if self.units: 

2655 raise ValidationError({ 

2656 'units': _('Checkbox parameters cannot have units') 

2657 }) 

2658 

2659 if self.choices: 

2660 raise ValidationError({ 

2661 'choices': _('Checkbox parameters cannot have choices') 

2662 }) 

2663 

2664 # Check that 'choices' are in fact valid 

2665 if self.choices is None: 2665 ↛ 2666line 2665 didn't jump to line 2666 because the condition on line 2665 was never true

2666 self.choices = '' 

2667 else: 

2668 self.choices = str(self.choices).strip() 

2669 

2670 if self.choices: 2670 ↛ 2671line 2670 didn't jump to line 2671 because the condition on line 2670 was never true

2671 choice_set = set() 

2672 

2673 for choice in self.choices.split(','): 

2674 choice = choice.strip() 

2675 

2676 # Ignore empty choices 

2677 if not choice: 

2678 continue 

2679 

2680 if choice in choice_set: 

2681 raise ValidationError({'choices': _('Choices must be unique')}) 

2682 

2683 choice_set.add(choice) 

2684 

2685 def validate_unique(self, exclude=None): 

2686 """Ensure that ParameterTemplates cannot be created with the same name. 

2687 

2688 This test should be case-insensitive (which the unique caveat does not cover). 

2689 """ 

2690 super().validate_unique(exclude) 

2691 

2692 try: 

2693 others = ParameterTemplate.objects.filter(name__iexact=self.name).exclude( 

2694 pk=self.pk 

2695 ) 

2696 

2697 if others.exists(): 2697 ↛ 2698line 2697 didn't jump to line 2698 because the condition on line 2697 was never true

2698 msg = _('Parameter template name must be unique') 

2699 raise ValidationError({'name': msg}) 

2700 except ParameterTemplate.DoesNotExist: 

2701 pass 

2702 

2703 def get_choices(self): 

2704 """Return a list of choices for this parameter template.""" 

2705 if self.selectionlist: 

2706 return self.selectionlist.get_choices() 

2707 

2708 if not self.choices: 

2709 return [] 

2710 

2711 return [x.strip() for x in self.choices.split(',') if x.strip()] 

2712 

2713 # TODO: Reintroduce validator for model_type 

2714 model_type = models.ForeignKey( 

2715 ContentType, 

2716 on_delete=models.SET_NULL, 

2717 blank=True, 

2718 null=True, 

2719 verbose_name=_('Model type'), 

2720 help_text=_('Target model type for this parameter template'), 

2721 ) 

2722 

2723 name = models.CharField( 

2724 max_length=100, 

2725 verbose_name=_('Name'), 

2726 help_text=_('Parameter Name'), 

2727 unique=True, 

2728 ) 

2729 

2730 units = models.CharField( 

2731 max_length=25, 

2732 verbose_name=_('Units'), 

2733 help_text=_('Physical units for this parameter'), 

2734 blank=True, 

2735 validators=[InvenTree.validators.validate_physical_units], 

2736 ) 

2737 

2738 description = models.CharField( 

2739 max_length=250, 

2740 verbose_name=_('Description'), 

2741 help_text=_('Parameter description'), 

2742 blank=True, 

2743 ) 

2744 

2745 checkbox = models.BooleanField( 

2746 default=False, 

2747 verbose_name=_('Checkbox'), 

2748 help_text=_('Is this parameter a checkbox?'), 

2749 ) 

2750 

2751 choices = models.CharField( 

2752 max_length=5000, 

2753 verbose_name=_('Choices'), 

2754 help_text=_('Valid choices for this parameter (comma-separated)'), 

2755 blank=True, 

2756 ) 

2757 

2758 selectionlist = models.ForeignKey( 

2759 SelectionList, 

2760 blank=True, 

2761 null=True, 

2762 on_delete=models.SET_NULL, 

2763 related_name='templates', 

2764 verbose_name=_('Selection List'), 

2765 help_text=_('Selection list for this parameter'), 

2766 ) 

2767 

2768 enabled = models.BooleanField( 

2769 default=True, 

2770 verbose_name=_('Enabled'), 

2771 help_text=_('Is this parameter template enabled?'), 

2772 ) 

2773 

2774 

2775@receiver( 

2776 post_save, sender=ParameterTemplate, dispatch_uid='post_save_parameter_template' 

2777) 

2778def post_save_parameter_template(sender, instance, created, **kwargs): 

2779 """Callback function when a ParameterTemplate is created or saved.""" 

2780 import common.tasks 

2781 

2782 if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): 2782 ↛ exitline 2782 didn't return from function 'post_save_parameter_template' because the condition on line 2782 was always true

2783 if not created: 

2784 # Schedule a background task to rebuild the parameters against this template 

2785 InvenTree.tasks.offload_task( 

2786 common.tasks.rebuild_parameters, 

2787 instance.pk, 

2788 force_async=True, 

2789 group='parameters', 

2790 ) 

2791 

2792 

2793class Parameter( 

2794 UpdatedUserMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel 

2795): 

2796 """Class which represents a parameter value assigned to a particular model instance. 

2797 

2798 Attributes: 

2799 model_type: The type of model to which this parameter is linked 

2800 model_id: The ID of the model to which this parameter is linked 

2801 template: The ParameterTemplate which defines this parameter 

2802 data: The value of the parameter [string] 

2803 data_numeric: Numeric value of the parameter (if applicable) [float] 

2804 note: Optional note associated with this parameter [string] 

2805 updated: Date/time that this parameter was last updated 

2806 updated_by: User who last updated this parameter 

2807 """ 

2808 

2809 class Meta: 

2810 """Meta options for Parameter model.""" 

2811 

2812 verbose_name = _('Parameter') 

2813 verbose_name_plural = _('Parameters') 

2814 unique_together = [['model_type', 'model_id', 'template']] 

2815 indexes = [models.Index(fields=['model_type', 'model_id'])] 

2816 

2817 # Note: Data was migrated from the existing 'part_partparameter' table 

2818 # Ref: https://github.com/inventree/InvenTree/pull/10699 

2819 # To avoid data loss, we retain the existing table name 

2820 db_table = 'part_partparameter' 

2821 

2822 class ModelChoices(RenderChoices): 

2823 """Model choices for parameters.""" 

2824 

2825 choice_fnc = common.validators.parameter_model_options 

2826 

2827 @staticmethod 

2828 def get_api_url() -> str: 

2829 """Return the API URL associated with the Parameter model.""" 

2830 return reverse('api-parameter-list') 

2831 

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

2833 """Custom save method for Parameter model. 

2834 

2835 - Update the numeric data field (if applicable) 

2836 """ 

2837 self.calculate_numeric_value() 

2838 

2839 # Convert 'boolean' values to 'True' / 'False' 

2840 if self.template.checkbox: 

2841 self.data = InvenTree.helpers.str2bool(self.data) 

2842 self.data_numeric = 1 if self.data else 0 

2843 

2844 self.check_save() 

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

2846 

2847 def delete(self): 

2848 """Perform custom delete checks before deleting a Parameter instance.""" 

2849 self.check_delete() 

2850 super().delete() 

2851 

2852 def clean(self): 

2853 """Validate the Parameter before saving to the database.""" 

2854 super().clean() 

2855 

2856 # Validate the parameter data against the template choices 

2857 if choices := self.template.get_choices(): 

2858 if self.data not in choices: 

2859 raise ValidationError({'data': _('Invalid choice for parameter value')}) 

2860 

2861 self.calculate_numeric_value() 

2862 

2863 # TODO: Check that the model_type for this parameter matches the template 

2864 

2865 # Validate the parameter data against the template units 

2866 if ( 

2867 get_global_setting( 

2868 'PARAMETER_ENFORCE_UNITS', True, cache=False, create=False 

2869 ) 

2870 and self.template.units 

2871 ): 

2872 try: 

2873 InvenTree.conversion.convert_physical_value( 

2874 self.data, self.template.units 

2875 ) 

2876 except ValidationError as e: 

2877 raise ValidationError({'data': e.message}) 

2878 

2879 if InvenTree.ready.isReadOnlyCommand(): 

2880 # Skip plugin validation checks during read-only management commands 

2881 return 

2882 

2883 # Finally, run custom validation checks (via plugins) 

2884 from plugin import PluginMixinEnum, registry 

2885 

2886 for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): 

2887 # Note: The validate_parameter function may raise a ValidationError 

2888 try: 

2889 if hasattr(plugin, 'validate_parameter'): 

2890 result = plugin.validate_parameter(self, self.data) 

2891 if result: 

2892 break 

2893 except ValidationError as exc: 

2894 # Re-throw the ValidationError against the 'data' field 

2895 raise ValidationError({'data': exc.message}) 

2896 except Exception: 

2897 InvenTree.exceptions.log_error('validate_parameter', plugin=plugin.slug) 

2898 

2899 def calculate_numeric_value(self): 

2900 """Calculate a numeric value for the parameter data. 

2901 

2902 - If a 'units' field is provided, then the data will be converted to the base SI unit. 

2903 - Otherwise, we'll try to do a simple float cast 

2904 """ 

2905 if self.template.units: 

2906 try: 

2907 self.data_numeric = InvenTree.conversion.convert_physical_value( 

2908 self.data, self.template.units 

2909 ) 

2910 except (ValidationError, ValueError): 

2911 self.data_numeric = None 

2912 

2913 # No units provided, so try to cast to a float 

2914 else: 

2915 try: 

2916 self.data_numeric = float(self.data) 

2917 except ValueError: 

2918 self.data_numeric = None 

2919 

2920 if self.data_numeric is not None and type(self.data_numeric) is float: 

2921 # Prevent out of range numbers, etc 

2922 # Ref: https://github.com/inventree/InvenTree/issues/7593 

2923 if math.isnan(self.data_numeric) or math.isinf(self.data_numeric): 

2924 self.data_numeric = None 

2925 

2926 def check_permission(self, permission, user): 

2927 """Check if the user has the required permission for this parameter.""" 

2928 from InvenTree.models import InvenTreeParameterMixin 

2929 

2930 model_class = self.model_type.model_class() 

2931 

2932 if not issubclass(model_class, InvenTreeParameterMixin): 

2933 raise ValidationError(_('Invalid model type specified for parameter')) 

2934 

2935 return model_class.check_related_permission(permission, user) 

2936 

2937 def check_save(self): 

2938 """Check if this parameter can be saved. 

2939 

2940 The linked content_object can implement custom checks by overriding 

2941 the 'check_parameter_edit' method. 

2942 """ 

2943 from InvenTree.models import InvenTreeParameterMixin 

2944 

2945 try: 

2946 instance = self.content_object 

2947 except InvenTree.models.InvenTreeModel.DoesNotExist: 

2948 return 

2949 

2950 if instance and isinstance(instance, InvenTreeParameterMixin): 

2951 instance.check_parameter_save(self) 

2952 

2953 def check_delete(self): 

2954 """Check if this parameter can be deleted.""" 

2955 from InvenTree.models import InvenTreeParameterMixin 

2956 

2957 try: 

2958 instance = self.content_object 

2959 except InvenTree.models.InvenTreeModel.DoesNotExist: 

2960 return 

2961 

2962 if instance and isinstance(instance, InvenTreeParameterMixin): 

2963 instance.check_parameter_delete(self) 

2964 

2965 # TODO: Reintroduce validator for model_type 

2966 model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 

2967 

2968 model_id = models.PositiveIntegerField( 

2969 verbose_name=_('Model ID'), 

2970 help_text=_('ID of the target model for this parameter'), 

2971 ) 

2972 

2973 content_object = GenericForeignKey('model_type', 'model_id') 

2974 

2975 template = models.ForeignKey( 

2976 ParameterTemplate, 

2977 on_delete=models.CASCADE, 

2978 related_name='parameters', 

2979 verbose_name=_('Template'), 

2980 help_text=_('Parameter template'), 

2981 ) 

2982 

2983 data = models.CharField( 

2984 max_length=500, 

2985 verbose_name=_('Data'), 

2986 help_text=_('Parameter Value'), 

2987 validators=[MinLengthValidator(1)], 

2988 ) 

2989 

2990 data_numeric = models.FloatField(default=None, null=True, blank=True) 

2991 

2992 note = models.CharField( 

2993 max_length=500, 

2994 blank=True, 

2995 verbose_name=_('Note'), 

2996 help_text=_('Optional note field'), 

2997 ) 

2998 

2999 @property 

3000 def units(self): 

3001 """Return the units associated with the template.""" 

3002 return self.template.units 

3003 

3004 @property 

3005 def name(self): 

3006 """Return the name of the template.""" 

3007 return self.template.name 

3008 

3009 @property 

3010 def description(self): 

3011 """Return the description of the template.""" 

3012 return self.template.description 

3013 

3014 

3015class BarcodeScanResult(InvenTree.models.InvenTreeModel): 

3016 """Model for storing barcode scans results.""" 

3017 

3018 BARCODE_SCAN_MAX_LEN = 250 

3019 

3020 class Meta: 

3021 """Model meta options.""" 

3022 

3023 verbose_name = _('Barcode Scan') 

3024 

3025 data = models.CharField( 

3026 max_length=BARCODE_SCAN_MAX_LEN, 

3027 verbose_name=_('Data'), 

3028 help_text=_('Barcode data'), 

3029 blank=False, 

3030 null=False, 

3031 ) 

3032 

3033 user = models.ForeignKey( 

3034 User, 

3035 on_delete=models.SET_NULL, 

3036 blank=True, 

3037 null=True, 

3038 verbose_name=_('User'), 

3039 help_text=_('User who scanned the barcode'), 

3040 ) 

3041 

3042 timestamp = models.DateTimeField( 

3043 auto_now_add=True, 

3044 verbose_name=_('Timestamp'), 

3045 help_text=_('Date and time of the barcode scan'), 

3046 ) 

3047 

3048 endpoint = models.CharField( 

3049 max_length=250, 

3050 verbose_name=_('Path'), 

3051 help_text=_('URL endpoint which processed the barcode'), 

3052 blank=True, 

3053 null=True, 

3054 ) 

3055 

3056 context = models.JSONField( 

3057 max_length=1000, 

3058 verbose_name=_('Context'), 

3059 help_text=_('Context data for the barcode scan'), 

3060 blank=True, 

3061 null=True, 

3062 ) 

3063 

3064 response = models.JSONField( 

3065 max_length=1000, 

3066 verbose_name=_('Response'), 

3067 help_text=_('Response data from the barcode scan'), 

3068 blank=True, 

3069 null=True, 

3070 ) 

3071 

3072 result = models.BooleanField( 

3073 verbose_name=_('Result'), 

3074 help_text=_('Was the barcode scan successful?'), 

3075 default=False, 

3076 ) 

3077 

3078 

3079class DataOutput(models.Model): 

3080 """Model for storing generated data output from various processes. 

3081 

3082 This model is intended for storing data files which are generated by various processes, 

3083 and need to be retained for future use (e.g. download by the user). 

3084 

3085 Attributes: 

3086 created: Date and time that the data output was created 

3087 user: User who created the data output (if applicable) 

3088 total: Total number of items / records in the data output 

3089 progress: Current progress of the data output generation process 

3090 complete: Has the data output generation process completed? 

3091 output_type: The type of data output generated (e.g. 'label', 'report', etc) 

3092 template_name: Name of the template used to generate the data output (if applicable) 

3093 plugin: Key for the plugin which generated the data output (if applicable) 

3094 output: File field for storing the generated file 

3095 errors: JSON field for storing any errors generated during the data output generation process 

3096 """ 

3097 

3098 class DataOutputTypes(StringEnum): 

3099 """Enum for data output types.""" 

3100 

3101 LABEL = 'label' 

3102 REPORT = 'report' 

3103 EXPORT = 'export' 

3104 

3105 created = models.DateField(auto_now_add=True, editable=False) 

3106 

3107 user = models.ForeignKey( 

3108 User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+' 

3109 ) 

3110 

3111 total = models.PositiveIntegerField(default=1) 

3112 

3113 progress = models.PositiveIntegerField(default=0) 

3114 

3115 complete = models.BooleanField(default=False) 

3116 

3117 output_type = models.CharField(max_length=100, blank=True, null=True) 

3118 

3119 template_name = models.CharField(max_length=100, blank=True, null=True) 

3120 

3121 plugin = models.CharField(max_length=100, blank=True, null=True) 

3122 

3123 output = models.FileField(upload_to='data_output', blank=True, null=True) 

3124 

3125 errors = models.JSONField(blank=True, null=True) 

3126 

3127 def mark_complete(self, progress: int = 100, output: Optional[ContentFile] = None): 

3128 """Mark the data output generation process as complete. 

3129 

3130 Arguments: 

3131 progress (int, optional): Progress percentage of the data output generation. Defaults to 100. 

3132 output (ContentFile, optional): The generated output file. Defaults to None. 

3133 """ 

3134 self.complete = True 

3135 self.progress = progress 

3136 self.output = output 

3137 self.save() 

3138 

3139 def mark_failure( 

3140 self, error: Optional[str] = None, error_dict: Optional[dict] = None 

3141 ): 

3142 """Log an error message to the errors field. 

3143 

3144 Arguments: 

3145 error (str, optional): Error message to log. Defaults to None. 

3146 error_dict (dict): Dictionary containing error messages. Defaults to None. 

3147 """ 

3148 self.complete = False 

3149 self.output = None 

3150 

3151 if error_dict is not None: 

3152 self.errors = error_dict 

3153 elif error is not None: 

3154 self.errors = {'error': str(error)} 

3155 else: 

3156 self.errors = {'error': str(_('An error occurred'))} 

3157 

3158 self.save() 

3159 

3160 

3161# region Email 

3162class Priority(models.IntegerChoices): 

3163 """Enumeration for defining email priority levels.""" 

3164 

3165 NONE = 0 

3166 VERY_HIGH = 1 

3167 HIGH = 2 

3168 NORMAL = 3 

3169 LOW = 4 

3170 VERY_LOW = 5 

3171 

3172 

3173HEADER_PRIORITY = 'X-Priority' 

3174HEADER_MSG_ID = 'Message-ID' 

3175 

3176del_error_msg = _( 

3177 'INVE-E8: Email log deletion is protected. Set INVENTREE_PROTECT_EMAIL_LOG to False to allow deletion.' 

3178) 

3179 

3180 

3181class NoDeleteQuerySet(models.query.QuerySet): 

3182 """Custom QuerySet to prevent deletion of EmailLog entries.""" 

3183 

3184 def delete(self): 

3185 """Override delete method to prevent deletion of EmailLog entries.""" 

3186 if get_global_setting('INVENTREE_PROTECT_EMAIL_LOG'): 

3187 raise ValidationError(del_error_msg) 

3188 super().delete() 

3189 

3190 

3191class NoDeleteManager(models.Manager): 

3192 """Custom Manager to use NoDeleteQuerySet.""" 

3193 

3194 def get_queryset(self): 

3195 """Return a NoDeleteQuerySet.""" 

3196 return NoDeleteQuerySet(self.model, using=self._db) 

3197 

3198 

3199class EmailMessage(models.Model): 

3200 """Model for storing email messages sent or received by the system. 

3201 

3202 Attributes: 

3203 global_id: Unique identifier for the email message 

3204 message_id_key: Identifier for the email message - might be supplied by external system 

3205 thread_id_key: Identifier of thread - might be supplied by external system 

3206 subject: Subject of the email message 

3207 body: Body of the email message 

3208 to: Recipient of the email message 

3209 sender: Sender of the email message 

3210 status: Status of the email message (e.g. 'sent', 'failed', etc) 

3211 timestamp: Date and time that the email message left the system or was received by the system 

3212 headers: Headers of the email message 

3213 full_message: Full email message content 

3214 direction: Direction of the email message (e.g. 'inbound', 'outbound') 

3215 error_code: Error code (if applicable) 

3216 error_message: Error message (if applicable) 

3217 error_timestamp: Date and time of the error (if applicable) 

3218 delivery_options: Delivery options for the email message 

3219 """ 

3220 

3221 class Meta: 

3222 """Meta options for EmailMessage.""" 

3223 

3224 verbose_name = _('Email Message') 

3225 verbose_name_plural = _('Email Messages') 

3226 

3227 class EmailStatus(models.TextChoices): 

3228 """Machine setting config type enum.""" 

3229 

3230 ANNOUNCED = ( 

3231 'A', 

3232 _('Announced'), 

3233 ) # Intend to send mail was announced (saved in system, pushed to queue) 

3234 SENT = 'S', _('Sent') # Mail was sent to the email server 

3235 FAILED = 'F', _('Failed') # There was en error sending the email 

3236 DELIVERED = ( 

3237 'D', 

3238 _('Delivered'), 

3239 ) # Mail was delivered to the recipient - this means we got some kind of feedback from the email server or user 

3240 READ = ( 

3241 'R', 

3242 _('Read'), 

3243 ) # Mail was read by the recipient - this means we got some kind of feedback from the user 

3244 CONFIRMED = ( 

3245 'C', 

3246 _('Confirmed'), 

3247 ) # Mail delivery was confirmed by the recipient explicitly 

3248 

3249 class EmailDirection(models.TextChoices): 

3250 """Email direction enum.""" 

3251 

3252 INBOUND = 'I', _('Inbound') 

3253 OUTBOUND = 'O', _('Outbound') 

3254 

3255 class DeliveryOptions(models.TextChoices): 

3256 """Email delivery options enum.""" 

3257 

3258 NO_REPLY = 'no_reply', _('No Reply') 

3259 TRACK_DELIVERY = 'track_delivery', _('Track Delivery') 

3260 TRACK_READ = 'track_read', _('Track Read') 

3261 TRACK_CLICK = 'track_click', _('Track Click') 

3262 

3263 global_id = models.UUIDField( 

3264 verbose_name=_('Global ID'), 

3265 help_text=_('Unique identifier for this message'), 

3266 primary_key=True, 

3267 default=uuid.uuid4, 

3268 editable=False, 

3269 unique=True, 

3270 ) 

3271 message_id_key = models.CharField( 

3272 max_length=250, 

3273 blank=True, 

3274 null=True, 

3275 verbose_name=_('Message ID'), 

3276 help_text=_( 

3277 'Identifier for this message (might be supplied by external system)' 

3278 ), 

3279 ) 

3280 thread_id_key = models.CharField( 

3281 max_length=250, 

3282 blank=True, 

3283 null=True, 

3284 verbose_name=_('Thread ID'), 

3285 help_text=_( 

3286 'Identifier for this message thread (might be supplied by external system)' 

3287 ), 

3288 ) 

3289 thread = models.ForeignKey( 

3290 'EmailThread', 

3291 on_delete=models.SET_NULL, 

3292 blank=True, 

3293 null=True, 

3294 related_name='messages', 

3295 verbose_name=_('Thread'), 

3296 help_text=_('Linked thread for this message'), 

3297 ) 

3298 subject = models.CharField(max_length=250, blank=False, null=False) 

3299 body = models.TextField(blank=False, null=False) 

3300 to = models.EmailField(blank=False, null=False) 

3301 sender = models.EmailField(blank=False, null=False) 

3302 status = models.CharField( 

3303 max_length=50, blank=True, null=True, choices=EmailStatus.choices 

3304 ) 

3305 timestamp = models.DateTimeField(auto_now_add=True, editable=False) 

3306 headers = models.JSONField(blank=True, null=True) 

3307 # Additional info 

3308 full_message = models.TextField(blank=True, null=True) 

3309 direction = models.CharField( 

3310 max_length=50, blank=True, null=True, choices=EmailDirection.choices 

3311 ) 

3312 priority = models.IntegerField(verbose_name=_('Priority'), choices=Priority) 

3313 delivery_options = models.JSONField( 

3314 blank=True, 

3315 null=True, 

3316 # choices=DeliveryOptions.choices 

3317 ) 

3318 # Optional tracking of delivery 

3319 error_code = models.CharField(max_length=50, blank=True, null=True) 

3320 error_message = models.TextField(blank=True, null=True) 

3321 error_timestamp = models.DateTimeField(blank=True, null=True) 

3322 

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

3324 """Ensure threads exist before saving the email message.""" 

3325 ret = super().save(*args, **kwargs) 

3326 

3327 # Ensure thread is linked 

3328 if not self.thread: 

3329 thread, created = EmailThread.objects.get_or_create( 

3330 key=self.thread_id_key, started_internal=True 

3331 ) 

3332 self.thread = thread 

3333 if created and not self.thread_id_key: 

3334 self.thread_id_key = thread.global_id 

3335 self.save() 

3336 

3337 return ret 

3338 

3339 objects = NoDeleteManager() 

3340 

3341 def delete(self, *kwargs): 

3342 """Delete entry - if not protected.""" 

3343 if get_global_setting('INVENTREE_PROTECT_EMAIL_LOG'): 

3344 raise ValidationError(del_error_msg) 

3345 return super().delete(*kwargs) 

3346 

3347 

3348class EmailThread(InvenTree.models.InvenTreeMetadataModel): 

3349 """Model for storing email threads.""" 

3350 

3351 class Meta: 

3352 """Meta options for EmailThread.""" 

3353 

3354 verbose_name = _('Email Thread') 

3355 verbose_name_plural = _('Email Threads') 

3356 unique_together = [['key', 'global_id']] 

3357 ordering = ['-updated'] 

3358 

3359 @staticmethod 

3360 def get_api_url(): 

3361 """Return the API URL associated with the EmailThread model.""" 

3362 return reverse('api-email-list') 

3363 

3364 key = models.CharField( 

3365 max_length=250, 

3366 verbose_name=_('Key'), 

3367 null=True, 

3368 blank=True, 

3369 help_text=_('Unique key for this thread (used to identify the thread)'), 

3370 ) 

3371 global_id = models.UUIDField( 

3372 verbose_name=_('Global ID'), 

3373 help_text=_('Unique identifier for this thread'), 

3374 primary_key=True, 

3375 default=uuid.uuid4, 

3376 editable=False, 

3377 ) 

3378 started_internal = models.BooleanField( 

3379 default=False, 

3380 verbose_name=_('Started Internal'), 

3381 help_text=_('Was this thread started internally?'), 

3382 ) 

3383 created = models.DateTimeField( 

3384 auto_now_add=True, 

3385 verbose_name=_('Created'), 

3386 help_text=_('Date and time that the thread was created'), 

3387 ) 

3388 updated = models.DateTimeField( 

3389 auto_now=True, 

3390 verbose_name=_('Updated'), 

3391 help_text=_('Date and time that the thread was last updated'), 

3392 ) 

3393 

3394 

3395def issue_mail( 

3396 subject: str, 

3397 body: str, 

3398 from_email: str, 

3399 recipients: str | list, 

3400 fail_silently: bool = False, 

3401 html_message=None, 

3402 prio: Priority = Priority.NORMAL, 

3403 headers: Optional[dict] = None, 

3404): 

3405 """Send an email with the specified subject and body, to the specified recipients list. 

3406 

3407 Mostly used by tasks. 

3408 """ 

3409 connection = get_connection(fail_silently=fail_silently) 

3410 

3411 message = EmailMultiAlternatives( 

3412 subject, body, from_email, recipients, connection=connection 

3413 ) 

3414 if html_message: 

3415 message.attach_alternative(html_message, 'text/html') 

3416 

3417 # Add any extra headers 

3418 if headers is not None: 

3419 for key, value in headers.items(): 

3420 message.extra_headers[key] = value 

3421 

3422 # Stabilize the message ID before creating the object 

3423 if HEADER_MSG_ID not in message.extra_headers: 

3424 message.extra_headers[HEADER_MSG_ID] = make_msgid(domain=DNS_NAME) 

3425 

3426 # TODO add `References` field for the thread ID 

3427 

3428 # Add headers for flags 

3429 message.extra_headers[HEADER_PRIORITY] = str(prio) 

3430 

3431 # And now send 

3432 return message.send() 

3433 

3434 

3435def log_email_messages(email_messages) -> list[EmailMessage]: 

3436 """Log email messages to the database. 

3437 

3438 Args: 

3439 email_messages (list): List of email messages to log. 

3440 """ 

3441 instance_id = inventree_identifier(True) 

3442 

3443 msg_ids = [] 

3444 for msg in email_messages: 

3445 try: 

3446 new_obj = EmailMessage.objects.create( 

3447 message_id_key=msg.extra_headers.get(HEADER_MSG_ID), 

3448 subject=msg.subject, 

3449 body=msg.body, 

3450 to=msg.to, 

3451 sender=msg.from_email, 

3452 status=EmailMessage.EmailStatus.ANNOUNCED, 

3453 direction=EmailMessage.EmailDirection.OUTBOUND, 

3454 priority=msg.extra_headers.get(HEADER_PRIORITY, '3'), 

3455 headers=msg.extra_headers, 

3456 full_message=msg, 

3457 ) 

3458 msg_ids.append(new_obj) 

3459 

3460 # Add InvenTree specific headers to the message to help with identification if we see mails again 

3461 msg.extra_headers['X-InvenTree-MsgId-1'] = str(new_obj.global_id) 

3462 msg.extra_headers['X-InvenTree-ThreadId-1'] = str(new_obj.thread.global_id) 

3463 msg.extra_headers['X-InvenTree-Instance-1'] = str(instance_id) 

3464 except Exception as exc: # pragma: no cover 

3465 logger.error(f' INVE-W10: Failed to log email message: {exc}') 

3466 return msg_ids 

3467 

3468 

3469@receiver(inbound) 

3470def handle_inbound(sender, event, esp_name, **kwargs): 

3471 """Handle inbound email messages from anymail.""" 

3472 message = event.message 

3473 

3474 r_to = message.envelope_recipient or [a.addr_spec for a in message.to] 

3475 r_sender = message.envelope_sender or message.from_email.addr_spec 

3476 

3477 msg = EmailMessage.objects.create( 

3478 message_id_key=event.message[HEADER_MSG_ID], 

3479 subject=message.subject, 

3480 body=message.text, 

3481 to=r_to, 

3482 sender=r_sender, 

3483 status=EmailMessage.EmailStatus.READ, 

3484 direction=EmailMessage.EmailDirection.INBOUND, 

3485 priority=Priority.NONE, 

3486 timestamp=message.date, 

3487 headers=message._headers, 

3488 full_message=message.html, 

3489 ) 

3490 

3491 # Schedule a task to process the email message 

3492 from plugin.base.mail.mail import process_mail_in 

3493 

3494 InvenTree.tasks.offload_task(process_mail_in, mail_id=msg.pk, group='mail') 

3495 

3496 

3497@receiver(tracking) 

3498def handle_event(sender, event, esp_name, **kwargs): 

3499 """Handle tracking events from anymail.""" 

3500 try: 

3501 email = EmailMessage.objects.get(message_id_key=event.message_id) 

3502 

3503 if event.event_type == 'delivered': 

3504 email.status = EmailMessage.EmailStatus.DELIVERED 

3505 elif event.event_type == 'opened': 

3506 email.status = EmailMessage.EmailStatus.READ 

3507 elif event.event_type == 'clicked': 

3508 email.status = EmailMessage.EmailStatus.CONFIRMED 

3509 elif event.event_type == 'sent': 

3510 email.status = EmailMessage.EmailStatus.SENT 

3511 elif event.event_type == 'unknown': 

3512 email.error_message = event.esp_event 

3513 else: 

3514 if event.event_type in ('queued', 'deferred'): 

3515 # We ignore these 

3516 return True 

3517 else: 

3518 email.status = EmailMessage.EmailStatus.FAILED 

3519 email.error_code = event.event_type 

3520 email.error_message = event.esp_event 

3521 email.error_timestamp = event.timestamp 

3522 email.save() 

3523 return True 

3524 except EmailMessage.DoesNotExist: 

3525 return False 

3526 except Exception as exc: # pragma: no cover 

3527 logger.error(f' INVE-W10: Failed to handle tracking event: {exc}') 

3528 return False 

3529 

3530 

3531# endregion Email 

3532 

3533# region tracing for django q 

3534if TRACE_PROC: # pragma: no cover 3534 ↛ 3536line 3534 didn't jump to line 3536 because the condition on line 3534 was never true

3535 

3536 @receiver(post_spawn) 

3537 def spawn_callback(sender, proc_name, **kwargs): 

3538 """Callback to patch in tracing support.""" 

3539 TRACE_PROV.add_span_processor(TRACE_PROC) 

3540 trace.set_tracer_provider(TRACE_PROV) 

3541 trace.get_tracer(__name__) 

3542 

3543# endregion