• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inventree / InvenTree / 5890077779

17 Aug 2023 11:04AM UTC coverage: 88.555% (-0.02%) from 88.571%
5890077779

push

github

web-flow
Fix plugin pickeling (#5412) (#5457)

(cherry picked from commit 1fe382e31)

Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>

3 of 3 new or added lines in 1 file covered. (100.0%)

26850 of 30320 relevant lines covered (88.56%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.39
/InvenTree/common/models.py
1
"""Common database model definitions.
2

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

6
import base64
1✔
7
import decimal
1✔
8
import hashlib
1✔
9
import hmac
1✔
10
import json
1✔
11
import logging
1✔
12
import math
1✔
13
import os
1✔
14
import re
1✔
15
import uuid
1✔
16
from datetime import datetime, timedelta
1✔
17
from enum import Enum
1✔
18
from secrets import compare_digest
1✔
19
from typing import Any, Callable, Dict, List, Tuple, TypedDict, Union
1✔
20

21
from django.apps import apps
1✔
22
from django.conf import settings
1✔
23
from django.contrib.auth.models import Group, User
1✔
24
from django.contrib.contenttypes.fields import GenericForeignKey
1✔
25
from django.contrib.contenttypes.models import ContentType
1✔
26
from django.contrib.humanize.templatetags.humanize import naturaltime
1✔
27
from django.contrib.sites.models import Site
1✔
28
from django.core.cache import cache
1✔
29
from django.core.exceptions import AppRegistryNotReady, ValidationError
1✔
30
from django.core.validators import (MaxValueValidator, MinValueValidator,
1✔
31
                                    URLValidator)
×
32
from django.db import models, transaction
1✔
33
from django.db.utils import IntegrityError, OperationalError
1✔
34
from django.urls import reverse
1✔
35
from django.utils.timezone import now
1✔
36
from django.utils.translation import gettext_lazy as _
1✔
37

38
from djmoney.contrib.exchange.exceptions import MissingRate
1✔
39
from djmoney.contrib.exchange.models import convert_money
1✔
40
from djmoney.settings import CURRENCY_CHOICES
1✔
41
from rest_framework.exceptions import PermissionDenied
1✔
42

43
import build.validators
1✔
44
import InvenTree.fields
1✔
45
import InvenTree.helpers
1✔
46
import InvenTree.models
1✔
47
import InvenTree.ready
1✔
48
import InvenTree.tasks
1✔
49
import InvenTree.validators
1✔
50
import order.validators
1✔
51
from plugin import registry
1✔
52

53
logger = logging.getLogger('inventree')
1✔
54

55

56
class MetaMixin(models.Model):
1✔
57
    """A base class for InvenTree models to include shared meta fields.
58

59
    Attributes:
60
    - updated: The last time this object was updated
61
    """
62

63
    class Meta:
1✔
64
        """Meta options for MetaMixin."""
65
        abstract = True
66

67
    updated = models.DateTimeField(
68
        verbose_name=_('Updated'),
69
        help_text=_('Timestamp of last update'),
70
        auto_now=True,
71
        null=True,
72
    )
73

74

75
class EmptyURLValidator(URLValidator):
76
    """Validator for filed with url - that can be empty."""
77

78
    def __call__(self, value):
1✔
79
        """Make sure empty values pass."""
80
        value = str(value).strip()
81

82
        if len(value) == 0:
83
            pass
84

85
        else:
86
            super().__call__(value)
87

88

89
class ProjectCode(InvenTree.models.MetadataMixin, models.Model):
90
    """A ProjectCode is a unique identifier for a project."""
91

92
    @staticmethod
1✔
93
    def get_api_url():
1✔
94
        """Return the API URL for this model."""
95
        return reverse('api-project-code-list')
96

97
    def __str__(self):
98
        """String representation of a ProjectCode."""
99
        return self.code
×
100

101
    code = models.CharField(
1✔
102
        max_length=50,
1✔
103
        unique=True,
1✔
104
        verbose_name=_('Project Code'),
1✔
105
        help_text=_('Unique project code'),
1✔
106
    )
107

108
    description = models.CharField(
1✔
109
        max_length=200,
1✔
110
        blank=True,
1✔
111
        verbose_name=_('Description'),
1✔
112
        help_text=_('Project description'),
1✔
113
    )
114

115

116
class SettingsKeyType(TypedDict, total=False):
1✔
117
    """Type definitions for a SettingsKeyType
118

119
    Attributes:
120
        name: Translatable string name of the setting (required)
121
        description: Translatable string description of the setting (required)
122
        units: Units of the particular setting (optional)
123
        validator: Validation function/list of functions for the setting (optional, default: None, e.g: bool, int, str, MinValueValidator, ...)
124
        default: Default value or function that returns default value (optional)
125
        choices: (Function that returns) Tuple[str: key, str: display value] (optional)
126
        hidden: Hide this setting from settings page (optional)
127
        before_save: Function that gets called after save with *args, **kwargs (optional)
128
        after_save: Function that gets called after save with *args, **kwargs (optional)
129
        protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
130
        model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
131
    """
132

133
    name: str
1✔
134
    description: str
1✔
135
    units: str
1✔
136
    validator: Union[Callable, List[Callable], Tuple[Callable]]
1✔
137
    default: Union[Callable, Any]
1✔
138
    choices: Union[Tuple[str, str], Callable[[], Tuple[str, str]]]
1✔
139
    hidden: bool
1✔
140
    before_save: Callable[..., None]
1✔
141
    after_save: Callable[..., None]
1✔
142
    protected: bool
1✔
143
    model: str
1✔
144

145

146
class BaseInvenTreeSetting(models.Model):
1✔
147
    """An base InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).
148

149
    Attributes:
150
        SETTINGS: definition of all available settings
151
        extra_unique_fields: List of extra fields used to be unique, e.g. for PluginConfig -> plugin
152
    """
153

154
    SETTINGS: Dict[str, SettingsKeyType] = {}
1✔
155

156
    extra_unique_fields: List[str] = []
1✔
157

158
    class Meta:
1✔
159
        """Meta options for BaseInvenTreeSetting -> abstract stops creation of database entry."""
160

161
        abstract = True
162

163
    def save(self, *args, **kwargs):
164
        """Enforce validation and clean before saving."""
165
        self.key = str(self.key).upper()
1✔
166

167
        do_cache = kwargs.pop('cache', True)
1✔
168

169
        self.clean(**kwargs)
1✔
170
        self.validate_unique()
1✔
171

172
        # Execute before_save action
173
        self._call_settings_function('before_save', args, kwargs)
1✔
174

175
        super().save()
1✔
176

177
        # Update this setting in the cache after it was saved so a pk exists
178
        if do_cache:
1✔
179
            self.save_to_cache()
1✔
180

181
        # Execute after_save action
182
        self._call_settings_function('after_save', args, kwargs)
1✔
183

184
    def _call_settings_function(self, reference: str, args, kwargs):
1✔
185
        """Call a function associated with a particular setting.
186

187
        Args:
188
            reference (str): The name of the function to call
189
            args: Positional arguments to pass to the function
190
            kwargs: Keyword arguments to pass to the function
191
        """
192
        # Get action
193
        setting = self.get_setting_definition(self.key, *args, **{**self.get_filters_for_instance(), **kwargs})
1✔
194
        settings_fnc = setting.get(reference, None)
1✔
195

196
        # Execute if callable
197
        if callable(settings_fnc):
1✔
198
            settings_fnc(self)
1✔
199

200
    @property
1✔
201
    def cache_key(self):
1✔
202
        """Generate a unique cache key for this settings object"""
203
        return self.__class__.create_cache_key(self.key, **self.get_filters_for_instance())
204

205
    def save_to_cache(self):
206
        """Save this setting object to cache"""
207

208
        ckey = self.cache_key
1✔
209

210
        # skip saving to cache if no pk is set
211
        if self.pk is None:
1✔
212
            return
×
213

214
        logger.debug(f"Saving setting '{ckey}' to cache")
1✔
215

216
        try:
1✔
217
            cache.set(
1✔
218
                ckey,
1✔
219
                self,
1✔
220
                timeout=3600
1✔
221
            )
222
        except TypeError:
×
223
            # Some characters cause issues with caching; ignore and move on
224
            pass
×
225

226
    @classmethod
1✔
227
    def create_cache_key(cls, setting_key, **kwargs):
1✔
228
        """Create a unique cache key for a particular setting object.
229

230
        The cache key uses the following elements to ensure the key is 'unique':
231
        - The name of the class
232
        - The unique KEY string
233
        - Any key:value kwargs associated with the particular setting type (e.g. user-id)
234
        """
235

236
        key = f"{str(cls.__name__)}:{setting_key}"
1✔
237

238
        for k, v in kwargs.items():
1✔
239
            key += f"_{k}:{v}"
1✔
240

241
        return key.replace(" ", "")
1✔
242

243
    @classmethod
1✔
244
    def get_filters(cls, **kwargs):
1✔
245
        """Enable to filter by other kwargs defined in cls.extra_unique_fields"""
246
        return {key: value for key, value in kwargs.items() if key in cls.extra_unique_fields}
247

248
    def get_filters_for_instance(self):
249
        """Enable to filter by other fields defined in self.extra_unique_fields"""
250
        return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)}
1✔
251

252
    @classmethod
1✔
253
    def allValues(cls, exclude_hidden=False, **kwargs):
1✔
254
        """Return a dict of "all" defined global settings.
255

256
        This performs a single database lookup,
257
        and then any settings which are not *in* the database
258
        are assigned their default values
259
        """
260
        results = cls.objects.all()
1✔
261

262
        if exclude_hidden:
1✔
263
            # Keys which start with an underscore are used for internal functionality
264
            results = results.exclude(key__startswith='_')
1✔
265

266
        # Optionally filter by other keys
267
        results = results.filter(**cls.get_filters(**kwargs))
1✔
268

269
        # Query the database
270
        settings = {}
1✔
271

272
        for setting in results:
1✔
273
            if setting.key:
1✔
274
                settings[setting.key.upper()] = setting.value
1✔
275

276
        # Specify any "default" values which are not in the database
277
        for key in cls.SETTINGS.keys():
1✔
278

279
            if key.upper() not in settings:
1✔
280
                settings[key.upper()] = cls.get_setting_default(key)
1✔
281

282
            if exclude_hidden:
1✔
283
                hidden = cls.SETTINGS[key].get('hidden', False)
1✔
284

285
                if hidden:
1✔
286
                    # Remove hidden items
287
                    del settings[key.upper()]
1✔
288

289
        for key, value in settings.items():
1✔
290
            validator = cls.get_setting_validator(key)
1✔
291

292
            if cls.is_protected(key):
1✔
293
                value = '***'
×
294
            elif cls.validator_is_bool(validator):
1✔
295
                value = InvenTree.helpers.str2bool(value)
1✔
296
            elif cls.validator_is_int(validator):
1✔
297
                try:
1✔
298
                    value = int(value)
1✔
299
                except ValueError:
1✔
300
                    value = cls.get_setting_default(key)
1✔
301

302
            settings[key] = value
1✔
303

304
        return settings
1✔
305

306
    @classmethod
1✔
307
    def get_setting_definition(cls, key, **kwargs):
1✔
308
        """Return the 'definition' of a particular settings value, as a dict object.
309

310
        - The 'settings' dict can be passed as a kwarg
311
        - If not passed, look for cls.SETTINGS
312
        - Returns an empty dict if the key is not found
313
        """
314
        settings = kwargs.get('settings', cls.SETTINGS)
1✔
315

316
        key = str(key).strip().upper()
1✔
317

318
        if settings is not None and key in settings:
1✔
319
            return settings[key]
1✔
320
        else:
×
321
            return {}
1✔
322

323
    @classmethod
1✔
324
    def get_setting_name(cls, key, **kwargs):
1✔
325
        """Return the name of a particular setting.
326

327
        If it does not exist, return an empty string.
328
        """
329
        setting = cls.get_setting_definition(key, **kwargs)
1✔
330
        return setting.get('name', '')
1✔
331

332
    @classmethod
1✔
333
    def get_setting_description(cls, key, **kwargs):
1✔
334
        """Return the description for a particular setting.
335

336
        If it does not exist, return an empty string.
337
        """
338
        setting = cls.get_setting_definition(key, **kwargs)
1✔
339

340
        return setting.get('description', '')
1✔
341

342
    @classmethod
1✔
343
    def get_setting_units(cls, key, **kwargs):
1✔
344
        """Return the units for a particular setting.
345

346
        If it does not exist, return an empty string.
347
        """
348
        setting = cls.get_setting_definition(key, **kwargs)
1✔
349

350
        return setting.get('units', '')
1✔
351

352
    @classmethod
1✔
353
    def get_setting_validator(cls, key, **kwargs):
1✔
354
        """Return the validator for a particular setting.
355

356
        If it does not exist, return None
357
        """
358
        setting = cls.get_setting_definition(key, **kwargs)
1✔
359

360
        return setting.get('validator', None)
1✔
361

362
    @classmethod
1✔
363
    def get_setting_default(cls, key, **kwargs):
1✔
364
        """Return the default value for a particular setting.
365

366
        If it does not exist, return an empty string
367
        """
368
        setting = cls.get_setting_definition(key, **kwargs)
1✔
369

370
        default = setting.get('default', '')
1✔
371

372
        if callable(default):
1✔
373
            return default()
×
374
        else:
×
375
            return default
1✔
376

377
    @classmethod
1✔
378
    def get_setting_choices(cls, key, **kwargs):
1✔
379
        """Return the validator choices available for a particular setting."""
380
        setting = cls.get_setting_definition(key, **kwargs)
381

382
        choices = setting.get('choices', None)
383

384
        if callable(choices):
385
            # Evaluate the function (we expect it will return a list of tuples...)
386
            return choices()
387

388
        return choices
389

390
    @classmethod
391
    def get_setting_object(cls, key, **kwargs):
392
        """Return an InvenTreeSetting object matching the given key.
393

394
        - Key is case-insensitive
395
        - Returns None if no match is made
396

397
        First checks the cache to see if this object has recently been accessed,
398
        and returns the cached version if so.
399
        """
400
        key = str(key).strip().upper()
1✔
401

402
        filters = {
1✔
403
            'key__iexact': key,
1✔
404

405
            # Optionally filter by other keys
406
            **cls.get_filters(**kwargs),
1✔
407
        }
408

409
        # Perform cache lookup by default
410
        do_cache = kwargs.pop('cache', True)
1✔
411

412
        ckey = cls.create_cache_key(key, **kwargs)
1✔
413

414
        if do_cache:
1✔
415
            try:
1✔
416
                # First attempt to find the setting object in the cache
417
                cached_setting = cache.get(ckey)
1✔
418

419
                if cached_setting is not None:
1✔
420
                    return cached_setting
1✔
421

422
            except AppRegistryNotReady:
×
423
                # Cache is not ready yet
424
                do_cache = False
×
425

426
        try:
1✔
427
            settings = cls.objects.all()
1✔
428
            setting = settings.filter(**filters).first()
1✔
429
        except (ValueError, cls.DoesNotExist):
1✔
430
            setting = None
×
431
        except (IntegrityError, OperationalError):
1✔
432
            setting = None
1✔
433

434
        # Setting does not exist! (Try to create it)
435
        if not setting:
1✔
436

437
            # Unless otherwise specified, attempt to create the setting
438
            create = kwargs.pop('create', True)
1✔
439

440
            # Prevent creation of new settings objects when importing data
441
            if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True):
1✔
442
                create = False
×
443

444
            if create:
1✔
445
                # Attempt to create a new settings object
446

447
                default_value = cls.get_setting_default(key, **kwargs)
1✔
448

449
                setting = cls(
1✔
450
                    key=key,
1✔
451
                    value=default_value,
1✔
452
                    **kwargs
1✔
453
                )
454

455
                try:
1✔
456
                    # Wrap this statement in "atomic", so it can be rolled back if it fails
457
                    with transaction.atomic():
1✔
458
                        setting.save(**kwargs)
1✔
459
                except (IntegrityError, OperationalError):
×
460
                    # It might be the case that the database isn't created yet
461
                    pass
×
462
                except ValidationError:
×
463
                    # The setting failed validation - might be due to duplicate keys
464
                    pass
×
465

466
        if setting and do_cache:
1✔
467
            # Cache this setting object
468
            setting.save_to_cache()
1✔
469

470
        return setting
1✔
471

472
    @classmethod
1✔
473
    def get_setting(cls, key, backup_value=None, **kwargs):
1✔
474
        """Get the value of a particular setting.
475

476
        If it does not exist, return the backup value (default = None)
477
        """
478
        # If no backup value is specified, attempt to retrieve a "default" value
479
        if backup_value is None:
1✔
480
            backup_value = cls.get_setting_default(key, **kwargs)
1✔
481

482
        setting = cls.get_setting_object(key, **kwargs)
1✔
483

484
        if setting:
1✔
485
            value = setting.value
1✔
486

487
            # Cast to boolean if necessary
488
            if setting.is_bool():
1✔
489
                value = InvenTree.helpers.str2bool(value)
1✔
490

491
            # Cast to integer if necessary
492
            if setting.is_int():
1✔
493
                try:
1✔
494
                    value = int(value)
1✔
495
                except (ValueError, TypeError):
×
496
                    value = backup_value
×
497

498
        else:
×
499
            value = backup_value
1✔
500

501
        return value
1✔
502

503
    @classmethod
1✔
504
    def set_setting(cls, key, value, change_user, create=True, **kwargs):
1✔
505
        """Set the value of a particular setting. If it does not exist, option to create it.
506

507
        Args:
508
            key: settings key
509
            value: New value
510
            change_user: User object (must be staff member to update a core setting)
511
            create: If True, create a new setting if the specified key does not exist.
512
        """
513
        if change_user is not None and not change_user.is_staff:
1✔
514
            return
×
515

516
        filters = {
1✔
517
            'key__iexact': key,
1✔
518

519
            # Optionally filter by other keys
520
            **cls.get_filters(**kwargs),
1✔
521
        }
522

523
        try:
1✔
524
            setting = cls.objects.get(**filters)
1✔
525
        except cls.DoesNotExist:
1✔
526

527
            if create:
1✔
528
                setting = cls(key=key, **kwargs)
1✔
529
            else:
×
530
                return
×
531

532
        # Enforce standard boolean representation
533
        if setting.is_bool():
1✔
534
            value = InvenTree.helpers.str2bool(value)
1✔
535

536
        setting.value = str(value)
1✔
537
        setting.save()
1✔
538

539
    key = models.CharField(max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive)'))
1✔
540

541
    value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value'))
1✔
542

543
    @property
1✔
544
    def name(self):
1✔
545
        """Return name for setting."""
546
        return self.__class__.get_setting_name(self.key, **self.get_filters_for_instance())
547

548
    @property
549
    def default_value(self):
550
        """Return default_value for setting."""
551
        return self.__class__.get_setting_default(self.key, **self.get_filters_for_instance())
1✔
552

553
    @property
1✔
554
    def description(self):
1✔
555
        """Return description for setting."""
556
        return self.__class__.get_setting_description(self.key, **self.get_filters_for_instance())
557

558
    @property
559
    def units(self):
560
        """Return units for setting."""
561
        return self.__class__.get_setting_units(self.key, **self.get_filters_for_instance())
1✔
562

563
    def clean(self, **kwargs):
1✔
564
        """If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field."""
565
        super().clean()
566

567
        # Encode as native values
568
        if self.is_int():
569
            self.value = self.as_int()
570

571
        elif self.is_bool():
572
            self.value = self.as_bool()
573

574
        validator = self.__class__.get_setting_validator(self.key, **kwargs)
575

576
        if validator is not None:
577
            self.run_validator(validator)
578

579
        options = self.valid_options()
580

581
        if options and self.value not in options:
582
            raise ValidationError(_("Chosen value is not a valid option"))
583

584
    def run_validator(self, validator):
585
        """Run a validator against the 'value' field for this InvenTreeSetting object."""
586
        if validator is None:
1✔
587
            return
×
588

589
        value = self.value
1✔
590

591
        # Boolean validator
592
        if validator is bool:
1✔
593
            # Value must "look like" a boolean value
594
            if InvenTree.helpers.is_bool(value):
1✔
595
                # Coerce into either "True" or "False"
596
                value = InvenTree.helpers.str2bool(value)
1✔
597
            else:
×
598
                raise ValidationError({
599
                    'value': _('Value must be a boolean value')
600
                })
601

602
        # Integer validator
603
        if validator is int:
1✔
604

605
            try:
1✔
606
                # Coerce into an integer value
607
                value = int(value)
1✔
608
            except (ValueError, TypeError):
×
609
                raise ValidationError({
610
                    'value': _('Value must be an integer value'),
611
                })
612

613
        # If a list of validators is supplied, iterate through each one
614
        if type(validator) in [list, tuple]:
1✔
615
            for v in validator:
1✔
616
                self.run_validator(v)
1✔
617

618
        if callable(validator):
1✔
619
            # We can accept function validators with a single argument
620

621
            if self.is_bool():
1✔
622
                value = self.as_bool()
1✔
623

624
            if self.is_int():
1✔
625
                value = self.as_int()
1✔
626

627
            validator(value)
1✔
628

629
    def validate_unique(self, exclude=None):
1✔
630
        """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.
631

632
        Note that sub-classes (UserSetting, PluginSetting) use other filters
633
        to determine if the setting is 'unique' or not
634
        """
635
        super().validate_unique(exclude)
1✔
636

637
        filters = {
1✔
638
            'key__iexact': self.key,
1✔
639

640
            # Optionally filter by other keys
641
            **self.get_filters_for_instance(),
1✔
642
        }
643

644
        try:
1✔
645
            # Check if a duplicate setting already exists
646
            setting = self.__class__.objects.filter(**filters).exclude(id=self.id)
1✔
647

648
            if setting.exists():
1✔
649
                raise ValidationError({'key': _('Key string must be unique')})
×
650

651
        except self.DoesNotExist:
×
652
            pass
×
653

654
    def choices(self):
1✔
655
        """Return the available choices for this setting (or None if no choices are defined)."""
656
        return self.__class__.get_setting_choices(self.key, **self.get_filters_for_instance())
657

658
    def valid_options(self):
659
        """Return a list of valid options for this setting."""
660
        choices = self.choices()
1✔
661

662
        if not choices:
1✔
663
            return None
1✔
664

665
        return [opt[0] for opt in choices]
1✔
666

667
    def is_choice(self):
1✔
668
        """Check if this setting is a "choice" field."""
669
        return self.__class__.get_setting_choices(self.key, **self.get_filters_for_instance()) is not None
670

671
    def as_choice(self):
672
        """Render this setting as the "display" value of a choice field.
673

674
        E.g. if the choices are:
675
        [('A4', 'A4 paper'), ('A3', 'A3 paper')],
676
        and the value is 'A4',
677
        then display 'A4 paper'
678
        """
679
        choices = self.get_setting_choices(self.key, **self.get_filters_for_instance())
1✔
680

681
        if not choices:
1✔
682
            return self.value
1✔
683

684
        for value, display in choices:
1✔
685
            if value == self.value:
1✔
686
                return display
1✔
687

688
        return self.value
×
689

690
    def is_model(self):
1✔
691
        """Check if this setting references a model instance in the database."""
692
        return self.model_name() is not None
693

694
    def model_name(self):
695
        """Return the model name associated with this setting."""
696
        setting = self.get_setting_definition(self.key, **self.get_filters_for_instance())
1✔
697

698
        return setting.get('model', None)
1✔
699

700
    def model_class(self):
1✔
701
        """Return the model class associated with this setting.
702

703
        If (and only if):
704
        - It has a defined 'model' parameter
705
        - The 'model' parameter is of the form app.model
706
        - The 'model' parameter has matches a known app model
707
        """
708
        model_name = self.model_name()
1✔
709

710
        if not model_name:
1✔
711
            return None
1✔
712

713
        try:
1✔
714
            (app, mdl) = model_name.strip().split('.')
1✔
715
        except ValueError:
×
716
            logger.error(f"Invalid 'model' parameter for setting {self.key} : '{model_name}'")
×
717
            return None
×
718

719
        app_models = apps.all_models.get(app, None)
1✔
720

721
        if app_models is None:
1✔
722
            logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no app named '{app}'")
×
723
            return None
×
724

725
        model = app_models.get(mdl, None)
1✔
726

727
        if model is None:
1✔
728
            logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no model named '{mdl}'")
×
729
            return None
×
730

731
        # Looks like we have found a model!
732
        return model
1✔
733

734
    def api_url(self):
1✔
735
        """Return the API url associated with the linked model, if provided, and valid!"""
736
        model_class = self.model_class()
737

738
        if model_class:
739
            # If a valid class has been found, see if it has registered an API URL
740
            try:
741
                return model_class.get_api_url()
742
            except Exception:
743
                pass
744

745
            # Some other model types are hard-coded
746
            hardcoded_models = {
747
                'auth.user': 'api-user-list',
748
                'auth.group': 'api-group-list',
749
            }
750

751
            model_table = f'{model_class._meta.app_label}.{model_class._meta.model_name}'
752

753
            if url := hardcoded_models[model_table]:
754
                return reverse(url)
755

756
        return None
757

758
    def is_bool(self):
759
        """Check if this setting is required to be a boolean value."""
760
        validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance())
1✔
761

762
        return self.__class__.validator_is_bool(validator)
1✔
763

764
    def as_bool(self):
1✔
765
        """Return the value of this setting converted to a boolean value.
766

767
        Warning: Only use on values where is_bool evaluates to true!
768
        """
769
        return InvenTree.helpers.str2bool(self.value)
1✔
770

771
    def setting_type(self):
1✔
772
        """Return the field type identifier for this setting object."""
773
        if self.is_bool():
774
            return 'boolean'
775

776
        elif self.is_int():
777
            return 'integer'
778

779
        elif self.is_model():
780
            return 'related field'
781

782
        else:
783
            return 'string'
784

785
    @classmethod
786
    def validator_is_bool(cls, validator):
787
        """Return if validator is for bool."""
788
        if validator == bool:
1✔
789
            return True
1✔
790

791
        if type(validator) in [list, tuple]:
1✔
792
            for v in validator:
1✔
793
                if v == bool:
1✔
794
                    return True
×
795

796
        return False
1✔
797

798
    def is_int(self,):
1✔
799
        """Check if the setting is required to be an integer value."""
800
        validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance())
801

802
        return self.__class__.validator_is_int(validator)
803

804
    @classmethod
805
    def validator_is_int(cls, validator):
806
        """Return if validator is for int."""
807
        if validator == int:
1✔
808
            return True
1✔
809

810
        if type(validator) in [list, tuple]:
1✔
811
            for v in validator:
1✔
812
                if v == int:
1✔
813
                    return True
1✔
814

815
        return False
1✔
816

817
    def as_int(self):
1✔
818
        """Return the value of this setting converted to a boolean value.
819

820
        If an error occurs, return the default value
821
        """
822
        try:
1✔
823
            value = int(self.value)
1✔
824
        except (ValueError, TypeError):
1✔
825
            value = self.default_value
1✔
826

827
        return value
1✔
828

829
    @classmethod
1✔
830
    def is_protected(cls, key, **kwargs):
1✔
831
        """Check if the setting value is protected."""
832
        setting = cls.get_setting_definition(key, **kwargs)
833

834
        return setting.get('protected', False)
835

836
    @property
837
    def protected(self):
838
        """Returns if setting is protected from rendering."""
839
        return self.__class__.is_protected(self.key, **self.get_filters_for_instance())
1✔
840

841

842
def settings_group_options():
1✔
843
    """Build up group tuple for settings based on your choices."""
844
    return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
845

846

847
def update_instance_url(setting):
848
    """Update the first site objects domain to url."""
849
    site_obj = Site.objects.all().order_by('id').first()
1✔
850
    site_obj.domain = setting.value
1✔
851
    site_obj.save()
1✔
852

853

854
def update_instance_name(setting):
1✔
855
    """Update the first site objects name to instance name."""
856
    site_obj = Site.objects.all().order_by('id').first()
857
    site_obj.name = setting.value
858
    site_obj.save()
859

860

861
def validate_email_domains(setting):
862
    """Validate the email domains setting."""
863
    if not setting.value:
1✔
864
        return
1✔
865

866
    domains = setting.value.split(',')
×
867
    for domain in domains:
×
868
        if not domain:
×
869
            raise ValidationError(_('An empty domain is not allowed.'))
×
870
        if not re.match(r'^@[a-zA-Z0-9\.\-_]+$', domain):
×
871
            raise ValidationError(_(f'Invalid domain name: {domain}'))
×
872

873

874
def update_exchange_rates(setting):
1✔
875
    """Update exchange rates when base currency is changed"""
876

877
    if InvenTree.ready.isImportingData():
878
        return
879

880
    if not InvenTree.ready.canAppAccessDatabase():
881
        return
882

883
    InvenTree.tasks.update_exchange_rates()
884

885

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

889
    The class provides a way of retrieving the value for a particular key,
890
    even if that key does not exist.
891
    """
892

893
    class Meta:
1✔
894
        """Meta options for InvenTreeSetting."""
895

896
        verbose_name = "InvenTree Setting"
897
        verbose_name_plural = "InvenTree Settings"
898

899
    def save(self, *args, **kwargs):
900
        """When saving a global setting, check to see if it requires a server restart.
901

902
        If so, set the "SERVER_RESTART_REQUIRED" setting to True
903
        """
904
        super().save()
1✔
905

906
        if self.requires_restart() and not InvenTree.ready.isImportingData():
1✔
907
            InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
1✔
908

909
    """
×
910
    Dict of all global settings values:
×
911

912
    The key of each item is the name of the value as it appears in the database.
×
913

914
    Each global setting has the following parameters:
×
915

916
    - name: Translatable string name of the setting (required)
×
917
    - description: Translatable string description of the setting (required)
×
918
    - default: Default value (optional)
×
919
    - units: Units of the particular setting (optional)
×
920
    - validator: Validation function for the setting (optional)
×
921

922
    The keys must be upper-case
×
923
    """
×
924

925
    SETTINGS = {
1✔
926

927
        'SERVER_RESTART_REQUIRED': {
1✔
928
            'name': _('Restart required'),
1✔
929
            'description': _('A setting has been changed which requires a server restart'),
1✔
930
            'default': False,
1✔
931
            'validator': bool,
1✔
932
            'hidden': True,
1✔
933
        },
934

935
        'INVENTREE_INSTANCE': {
1✔
936
            'name': _('Server Instance Name'),
1✔
937
            'default': 'InvenTree',
1✔
938
            'description': _('String descriptor for the server instance'),
1✔
939
            'after_save': update_instance_name,
1✔
940
        },
941

942
        'INVENTREE_INSTANCE_TITLE': {
1✔
943
            'name': _('Use instance name'),
1✔
944
            'description': _('Use the instance name in the title-bar'),
1✔
945
            'validator': bool,
1✔
946
            'default': False,
1✔
947
        },
948

949
        'INVENTREE_RESTRICT_ABOUT': {
1✔
950
            'name': _('Restrict showing `about`'),
1✔
951
            'description': _('Show the `about` modal only to superusers'),
1✔
952
            'validator': bool,
1✔
953
            'default': False,
1✔
954
        },
955

956
        'INVENTREE_COMPANY_NAME': {
1✔
957
            'name': _('Company name'),
1✔
958
            'description': _('Internal company name'),
1✔
959
            'default': 'My company name',
1✔
960
        },
961

962
        'INVENTREE_BASE_URL': {
1✔
963
            'name': _('Base URL'),
1✔
964
            'description': _('Base URL for server instance'),
1✔
965
            'validator': EmptyURLValidator(),
1✔
966
            'default': '',
1✔
967
            'after_save': update_instance_url,
1✔
968
        },
969

970
        'INVENTREE_DEFAULT_CURRENCY': {
1✔
971
            'name': _('Default Currency'),
1✔
972
            'description': _('Select base currency for pricing calculations'),
1✔
973
            'default': 'USD',
1✔
974
            'choices': CURRENCY_CHOICES,
1✔
975
            'after_save': update_exchange_rates,
1✔
976
        },
977

978
        'INVENTREE_DOWNLOAD_FROM_URL': {
1✔
979
            'name': _('Download from URL'),
1✔
980
            'description': _('Allow download of remote images and files from external URL'),
1✔
981
            'validator': bool,
1✔
982
            'default': False,
1✔
983
        },
984

985
        'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE': {
1✔
986
            'name': _('Download Size Limit'),
1✔
987
            'description': _('Maximum allowable download size for remote image'),
1✔
988
            'units': 'MB',
1✔
989
            'default': 1,
1✔
990
            'validator': [
1✔
991
                int,
1✔
992
                MinValueValidator(1),
1✔
993
                MaxValueValidator(25),
1✔
994
            ]
995
        },
996

997
        'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT': {
1✔
998
            'name': _('User-agent used to download from URL'),
1✔
999
            'description': _('Allow to override the user-agent used to download images and files from external URL (leave blank for the default)'),
1✔
1000
            'default': '',
1✔
1001
        },
1002

1003
        'INVENTREE_REQUIRE_CONFIRM': {
1✔
1004
            'name': _('Require confirm'),
1✔
1005
            'description': _('Require explicit user confirmation for certain action.'),
1✔
1006
            'validator': bool,
1✔
1007
            'default': True,
1✔
1008
        },
1009

1010
        'INVENTREE_TREE_DEPTH': {
1✔
1011
            'name': _('Tree Depth'),
1✔
1012
            'description': _('Default tree depth for treeview. Deeper levels can be lazy loaded as they are needed.'),
1✔
1013
            'default': 1,
1✔
1014
            'validator': [
1✔
1015
                int,
1✔
1016
                MinValueValidator(0),
1✔
1017
            ]
1018
        },
1019

1020
        'INVENTREE_UPDATE_CHECK_INTERVAL': {
1✔
1021
            'name': _('Update Check Interval'),
1✔
1022
            'description': _('How often to check for updates (set to zero to disable)'),
1✔
1023
            'validator': [
1✔
1024
                int,
1✔
1025
                MinValueValidator(0),
1✔
1026
            ],
1027
            'default': 7,
1✔
1028
            'units': _('days'),
1✔
1029
        },
1030

1031
        'INVENTREE_BACKUP_ENABLE': {
1✔
1032
            'name': _('Automatic Backup'),
1✔
1033
            'description': _('Enable automatic backup of database and media files'),
1✔
1034
            'validator': bool,
1✔
1035
            'default': False,
1✔
1036
        },
1037

1038
        'INVENTREE_BACKUP_DAYS': {
1✔
1039
            'name': _('Auto Backup Interval'),
1✔
1040
            'description': _('Specify number of days between automated backup events'),
1✔
1041
            'validator': [
1✔
1042
                int,
1✔
1043
                MinValueValidator(1),
1✔
1044
            ],
1045
            'default': 1,
1✔
1046
            'units': _('days'),
1✔
1047
        },
1048

1049
        'INVENTREE_DELETE_TASKS_DAYS': {
1✔
1050
            'name': _('Task Deletion Interval'),
1✔
1051
            'description': _('Background task results will be deleted after specified number of days'),
1✔
1052
            'default': 30,
1✔
1053
            'units': _('days'),
1✔
1054
            'validator': [
1✔
1055
                int,
1✔
1056
                MinValueValidator(7),
1✔
1057
            ]
1058
        },
1059

1060
        'INVENTREE_DELETE_ERRORS_DAYS': {
1✔
1061
            'name': _('Error Log Deletion Interval'),
1✔
1062
            'description': _('Error logs will be deleted after specified number of days'),
1✔
1063
            'default': 30,
1✔
1064
            'units': _('days'),
1✔
1065
            'validator': [
1✔
1066
                int,
1✔
1067
                MinValueValidator(7)
1✔
1068
            ]
1069
        },
1070

1071
        'INVENTREE_DELETE_NOTIFICATIONS_DAYS': {
1✔
1072
            'name': _('Notification Deletion Interval'),
1✔
1073
            'description': _('User notifications will be deleted after specified number of days'),
1✔
1074
            'default': 30,
1✔
1075
            'units': _('days'),
1✔
1076
            'validator': [
1✔
1077
                int,
1✔
1078
                MinValueValidator(7),
1✔
1079
            ]
1080
        },
1081

1082
        'BARCODE_ENABLE': {
1✔
1083
            'name': _('Barcode Support'),
1✔
1084
            'description': _('Enable barcode scanner support'),
1✔
1085
            'default': True,
1✔
1086
            'validator': bool,
1✔
1087
        },
1088

1089
        'BARCODE_INPUT_DELAY': {
1✔
1090
            'name': _('Barcode Input Delay'),
1✔
1091
            'description': _('Barcode input processing delay time'),
1✔
1092
            'default': 50,
1✔
1093
            'validator': [
1✔
1094
                int,
1✔
1095
                MinValueValidator(1),
1✔
1096
            ],
1097
            'units': 'ms',
1✔
1098
        },
1099

1100
        'BARCODE_WEBCAM_SUPPORT': {
1✔
1101
            'name': _('Barcode Webcam Support'),
1✔
1102
            'description': _('Allow barcode scanning via webcam in browser'),
1✔
1103
            'default': True,
1✔
1104
            'validator': bool,
1✔
1105
        },
1106

1107
        'PART_ENABLE_REVISION': {
1✔
1108
            'name': _('Part Revisions'),
1✔
1109
            'description': _('Enable revision field for Part'),
1✔
1110
            'validator': bool,
1✔
1111
            'default': True,
1✔
1112
        },
1113

1114
        'PART_IPN_REGEX': {
1✔
1115
            'name': _('IPN Regex'),
1✔
1116
            'description': _('Regular expression pattern for matching Part IPN')
1✔
1117
        },
1118

1119
        'PART_ALLOW_DUPLICATE_IPN': {
1✔
1120
            'name': _('Allow Duplicate IPN'),
1✔
1121
            'description': _('Allow multiple parts to share the same IPN'),
1✔
1122
            'default': True,
1✔
1123
            'validator': bool,
1✔
1124
        },
1125

1126
        'PART_ALLOW_EDIT_IPN': {
1✔
1127
            'name': _('Allow Editing IPN'),
1✔
1128
            'description': _('Allow changing the IPN value while editing a part'),
1✔
1129
            'default': True,
1✔
1130
            'validator': bool,
1✔
1131
        },
1132

1133
        'PART_COPY_BOM': {
1✔
1134
            'name': _('Copy Part BOM Data'),
1✔
1135
            'description': _('Copy BOM data by default when duplicating a part'),
1✔
1136
            'default': True,
1✔
1137
            'validator': bool,
1✔
1138
        },
1139

1140
        'PART_COPY_PARAMETERS': {
1✔
1141
            'name': _('Copy Part Parameter Data'),
1✔
1142
            'description': _('Copy parameter data by default when duplicating a part'),
1✔
1143
            'default': True,
1✔
1144
            'validator': bool,
1✔
1145
        },
1146

1147
        'PART_COPY_TESTS': {
1✔
1148
            'name': _('Copy Part Test Data'),
1✔
1149
            'description': _('Copy test data by default when duplicating a part'),
1✔
1150
            'default': True,
1✔
1151
            'validator': bool
1✔
1152
        },
1153

1154
        'PART_CATEGORY_PARAMETERS': {
1✔
1155
            'name': _('Copy Category Parameter Templates'),
1✔
1156
            'description': _('Copy category parameter templates when creating a part'),
1✔
1157
            'default': True,
1✔
1158
            'validator': bool
1✔
1159
        },
1160

1161
        'PART_TEMPLATE': {
1✔
1162
            'name': _('Template'),
1✔
1163
            'description': _('Parts are templates by default'),
1✔
1164
            'default': False,
1✔
1165
            'validator': bool,
1✔
1166
        },
1167

1168
        'PART_ASSEMBLY': {
1✔
1169
            'name': _('Assembly'),
1✔
1170
            'description': _('Parts can be assembled from other components by default'),
1✔
1171
            'default': False,
1✔
1172
            'validator': bool,
1✔
1173
        },
1174

1175
        'PART_COMPONENT': {
1✔
1176
            'name': _('Component'),
1✔
1177
            'description': _('Parts can be used as sub-components by default'),
1✔
1178
            'default': True,
1✔
1179
            'validator': bool,
1✔
1180
        },
1181

1182
        'PART_PURCHASEABLE': {
1✔
1183
            'name': _('Purchaseable'),
1✔
1184
            'description': _('Parts are purchaseable by default'),
1✔
1185
            'default': True,
1✔
1186
            'validator': bool,
1✔
1187
        },
1188

1189
        'PART_SALABLE': {
1✔
1190
            'name': _('Salable'),
1✔
1191
            'description': _('Parts are salable by default'),
1✔
1192
            'default': False,
1✔
1193
            'validator': bool,
1✔
1194
        },
1195

1196
        'PART_TRACKABLE': {
1✔
1197
            'name': _('Trackable'),
1✔
1198
            'description': _('Parts are trackable by default'),
1✔
1199
            'default': False,
1✔
1200
            'validator': bool,
1✔
1201
        },
1202

1203
        'PART_VIRTUAL': {
1✔
1204
            'name': _('Virtual'),
1✔
1205
            'description': _('Parts are virtual by default'),
1✔
1206
            'default': False,
1✔
1207
            'validator': bool,
1✔
1208
        },
1209

1210
        'PART_SHOW_IMPORT': {
1✔
1211
            'name': _('Show Import in Views'),
1✔
1212
            'description': _('Display the import wizard in some part views'),
1✔
1213
            'default': False,
1✔
1214
            'validator': bool,
1✔
1215
        },
1216

1217
        'PART_SHOW_RELATED': {
1✔
1218
            'name': _('Show related parts'),
1✔
1219
            'description': _('Display related parts for a part'),
1✔
1220
            'default': True,
1✔
1221
            'validator': bool,
1✔
1222
        },
1223

1224
        'PART_CREATE_INITIAL': {
1✔
1225
            'name': _('Initial Stock Data'),
1✔
1226
            'description': _('Allow creation of initial stock when adding a new part'),
1✔
1227
            'default': False,
1✔
1228
            'validator': bool,
1✔
1229
        },
1230

1231
        'PART_CREATE_SUPPLIER': {
1✔
1232
            'name': _('Initial Supplier Data'),
1✔
1233
            'description': _('Allow creation of initial supplier data when adding a new part'),
1✔
1234
            'default': True,
1✔
1235
            'validator': bool,
1✔
1236
        },
1237

1238
        'PART_NAME_FORMAT': {
1✔
1239
            'name': _('Part Name Display Format'),
1✔
1240
            'description': _('Format to display the part name'),
1✔
1241
            'default': "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}{{ ' | ' if part.revision }}"
1✔
1242
                       "{{ part.revision if part.revision }}",
1243
            'validator': InvenTree.validators.validate_part_name_format
1✔
1244
        },
1245

1246
        'PART_CATEGORY_DEFAULT_ICON': {
1✔
1247
            'name': _('Part Category Default Icon'),
1✔
1248
            'description': _('Part category default icon (empty means no icon)'),
1✔
1249
            'default': '',
1✔
1250
        },
1251

1252
        'PRICING_DECIMAL_PLACES_MIN': {
1✔
1253
            'name': _('Minimum Pricing Decimal Places'),
1✔
1254
            'description': _('Minimum number of decimal places to display when rendering pricing data'),
1✔
1255
            'default': 0,
1✔
1256
            'validator': [
1✔
1257
                int,
1✔
1258
                MinValueValidator(0),
1✔
1259
                MaxValueValidator(4),
1✔
1260
            ]
1261
        },
1262

1263
        'PRICING_DECIMAL_PLACES': {
1✔
1264
            'name': _('Maximum Pricing Decimal Places'),
1✔
1265
            'description': _('Maximum number of decimal places to display when rendering pricing data'),
1✔
1266
            'default': 6,
1✔
1267
            'validator': [
1✔
1268
                int,
1✔
1269
                MinValueValidator(2),
1✔
1270
                MaxValueValidator(6)
1✔
1271
            ]
1272
        },
1273

1274
        'PRICING_USE_SUPPLIER_PRICING': {
1✔
1275
            'name': _('Use Supplier Pricing'),
1✔
1276
            'description': _('Include supplier price breaks in overall pricing calculations'),
1✔
1277
            'default': True,
1✔
1278
            'validator': bool,
1✔
1279
        },
1280

1281
        'PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER': {
1✔
1282
            'name': _('Purchase History Override'),
1✔
1283
            'description': _('Historical purchase order pricing overrides supplier price breaks'),
1✔
1284
            'default': False,
1✔
1285
            'validator': bool,
1✔
1286
        },
1287

1288
        'PRICING_USE_STOCK_PRICING': {
1✔
1289
            'name': _('Use Stock Item Pricing'),
1✔
1290
            'description': _('Use pricing from manually entered stock data for pricing calculations'),
1✔
1291
            'default': True,
1✔
1292
            'validator': bool,
1✔
1293
        },
1294

1295
        'PRICING_STOCK_ITEM_AGE_DAYS': {
1✔
1296
            'name': _('Stock Item Pricing Age'),
1✔
1297
            'description': _('Exclude stock items older than this number of days from pricing calculations'),
1✔
1298
            'default': 0,
1✔
1299
            'units': _('days'),
1✔
1300
            'validator': [
1✔
1301
                int,
1✔
1302
                MinValueValidator(0),
1✔
1303
            ]
1304
        },
1305

1306
        'PRICING_USE_VARIANT_PRICING': {
1✔
1307
            'name': _('Use Variant Pricing'),
1✔
1308
            'description': _('Include variant pricing in overall pricing calculations'),
1✔
1309
            'default': True,
1✔
1310
            'validator': bool,
1✔
1311
        },
1312

1313
        'PRICING_ACTIVE_VARIANTS': {
1✔
1314
            'name': _('Active Variants Only'),
1✔
1315
            'description': _('Only use active variant parts for calculating variant pricing'),
1✔
1316
            'default': False,
1✔
1317
            'validator': bool,
1✔
1318
        },
1319

1320
        'PRICING_UPDATE_DAYS': {
1✔
1321
            'name': _('Pricing Rebuild Interval'),
1✔
1322
            'description': _('Number of days before part pricing is automatically updated'),
1✔
1323
            'units': _('days'),
1✔
1324
            'default': 30,
1✔
1325
            'validator': [
1✔
1326
                int,
1✔
1327
                MinValueValidator(10),
1✔
1328
            ]
1329
        },
1330

1331
        'PART_INTERNAL_PRICE': {
1✔
1332
            'name': _('Internal Prices'),
1✔
1333
            'description': _('Enable internal prices for parts'),
1✔
1334
            'default': False,
1✔
1335
            'validator': bool
1✔
1336
        },
1337

1338
        'PART_BOM_USE_INTERNAL_PRICE': {
1✔
1339
            'name': _('Internal Price Override'),
1✔
1340
            'description': _('If available, internal prices override price range calculations'),
1✔
1341
            'default': False,
1✔
1342
            'validator': bool
1✔
1343
        },
1344

1345
        'LABEL_ENABLE': {
1✔
1346
            'name': _('Enable label printing'),
1✔
1347
            'description': _('Enable label printing from the web interface'),
1✔
1348
            'default': True,
1✔
1349
            'validator': bool,
1✔
1350
        },
1351

1352
        'LABEL_DPI': {
1✔
1353
            'name': _('Label Image DPI'),
1✔
1354
            'description': _('DPI resolution when generating image files to supply to label printing plugins'),
1✔
1355
            'default': 300,
1✔
1356
            'validator': [
1✔
1357
                int,
1✔
1358
                MinValueValidator(100),
1✔
1359
            ]
1360
        },
1361

1362
        'REPORT_ENABLE': {
1✔
1363
            'name': _('Enable Reports'),
1✔
1364
            'description': _('Enable generation of reports'),
1✔
1365
            'default': False,
1✔
1366
            'validator': bool,
1✔
1367
        },
1368

1369
        'REPORT_DEBUG_MODE': {
1✔
1370
            'name': _('Debug Mode'),
1✔
1371
            'description': _('Generate reports in debug mode (HTML output)'),
1✔
1372
            'default': False,
1✔
1373
            'validator': bool,
1✔
1374
        },
1375

1376
        'REPORT_DEFAULT_PAGE_SIZE': {
1✔
1377
            'name': _('Page Size'),
1✔
1378
            'description': _('Default page size for PDF reports'),
1✔
1379
            'default': 'A4',
1✔
1380
            'choices': [
1✔
1381
                ('A4', 'A4'),
1382
                ('Legal', 'Legal'),
1383
                ('Letter', 'Letter')
1384
            ],
1385
        },
1386

1387
        'REPORT_ENABLE_TEST_REPORT': {
1✔
1388
            'name': _('Enable Test Reports'),
1✔
1389
            'description': _('Enable generation of test reports'),
1✔
1390
            'default': True,
1✔
1391
            'validator': bool,
1✔
1392
        },
1393

1394
        'REPORT_ATTACH_TEST_REPORT': {
1✔
1395
            'name': _('Attach Test Reports'),
1✔
1396
            'description': _('When printing a Test Report, attach a copy of the Test Report to the associated Stock Item'),
1✔
1397
            'default': False,
1✔
1398
            'validator': bool,
1✔
1399
        },
1400

1401
        'SERIAL_NUMBER_GLOBALLY_UNIQUE': {
1✔
1402
            'name': _('Globally Unique Serials'),
1✔
1403
            'description': _('Serial numbers for stock items must be globally unique'),
1✔
1404
            'default': False,
1✔
1405
            'validator': bool,
1✔
1406
        },
1407

1408
        'SERIAL_NUMBER_AUTOFILL': {
1✔
1409
            'name': _('Autofill Serial Numbers'),
1✔
1410
            'description': _('Autofill serial numbers in forms'),
1✔
1411
            'default': False,
1✔
1412
            'validator': bool,
1✔
1413
        },
1414

1415
        'STOCK_DELETE_DEPLETED_DEFAULT': {
1✔
1416
            'name': _('Delete Depleted Stock'),
1✔
1417
            'description': _('Determines default behaviour when a stock item is depleted'),
1✔
1418
            'default': True,
1✔
1419
            'validator': bool,
1✔
1420
        },
1421

1422
        'STOCK_BATCH_CODE_TEMPLATE': {
1✔
1423
            'name': _('Batch Code Template'),
1✔
1424
            'description': _('Template for generating default batch codes for stock items'),
1✔
1425
            'default': '',
1✔
1426
        },
1427

1428
        'STOCK_ENABLE_EXPIRY': {
1✔
1429
            'name': _('Stock Expiry'),
1✔
1430
            'description': _('Enable stock expiry functionality'),
1✔
1431
            'default': False,
1✔
1432
            'validator': bool,
1✔
1433
        },
1434

1435
        'STOCK_ALLOW_EXPIRED_SALE': {
1✔
1436
            'name': _('Sell Expired Stock'),
1✔
1437
            'description': _('Allow sale of expired stock'),
1✔
1438
            'default': False,
1✔
1439
            'validator': bool,
1✔
1440
        },
1441

1442
        'STOCK_STALE_DAYS': {
1✔
1443
            'name': _('Stock Stale Time'),
1✔
1444
            'description': _('Number of days stock items are considered stale before expiring'),
1✔
1445
            'default': 0,
1✔
1446
            'units': _('days'),
1✔
1447
            'validator': [int],
1✔
1448
        },
1449

1450
        'STOCK_ALLOW_EXPIRED_BUILD': {
1✔
1451
            'name': _('Build Expired Stock'),
1✔
1452
            'description': _('Allow building with expired stock'),
1✔
1453
            'default': False,
1✔
1454
            'validator': bool,
1✔
1455
        },
1456

1457
        'STOCK_OWNERSHIP_CONTROL': {
1✔
1458
            'name': _('Stock Ownership Control'),
1✔
1459
            'description': _('Enable ownership control over stock locations and items'),
1✔
1460
            'default': False,
1✔
1461
            'validator': bool,
1✔
1462
        },
1463

1464
        'STOCK_LOCATION_DEFAULT_ICON': {
1✔
1465
            'name': _('Stock Location Default Icon'),
1✔
1466
            'description': _('Stock location default icon (empty means no icon)'),
1✔
1467
            'default': '',
1✔
1468
        },
1469

1470
        'STOCK_SHOW_INSTALLED_ITEMS': {
1✔
1471
            'name': _('Show Installed Stock Items'),
1✔
1472
            'description': _('Display installed stock items in stock tables'),
1✔
1473
            'default': False,
1✔
1474
            'validator': bool,
1✔
1475
        },
1476

1477
        'BUILDORDER_REFERENCE_PATTERN': {
1✔
1478
            'name': _('Build Order Reference Pattern'),
1✔
1479
            'description': _('Required pattern for generating Build Order reference field'),
1✔
1480
            'default': 'BO-{ref:04d}',
1✔
1481
            'validator': build.validators.validate_build_order_reference_pattern,
1✔
1482
        },
1483

1484
        'RETURNORDER_ENABLED': {
1✔
1485
            'name': _('Enable Return Orders'),
1✔
1486
            'description': _('Enable return order functionality in the user interface'),
1✔
1487
            'validator': bool,
1✔
1488
            'default': False,
1✔
1489
        },
1490

1491
        'RETURNORDER_REFERENCE_PATTERN': {
1✔
1492
            'name': _('Return Order Reference Pattern'),
1✔
1493
            'description': _('Required pattern for generating Return Order reference field'),
1✔
1494
            'default': 'RMA-{ref:04d}',
1✔
1495
            'validator': order.validators.validate_return_order_reference_pattern,
1✔
1496
        },
1497

1498
        'RETURNORDER_EDIT_COMPLETED_ORDERS': {
1✔
1499
            'name': _('Edit Completed Return Orders'),
1✔
1500
            'description': _('Allow editing of return orders after they have been completed'),
1✔
1501
            'default': False,
1✔
1502
            'validator': bool,
1✔
1503
        },
1504

1505
        'SALESORDER_REFERENCE_PATTERN': {
1✔
1506
            'name': _('Sales Order Reference Pattern'),
1✔
1507
            'description': _('Required pattern for generating Sales Order reference field'),
1✔
1508
            'default': 'SO-{ref:04d}',
1✔
1509
            'validator': order.validators.validate_sales_order_reference_pattern,
1✔
1510
        },
1511

1512
        'SALESORDER_DEFAULT_SHIPMENT': {
1✔
1513
            'name': _('Sales Order Default Shipment'),
1✔
1514
            'description': _('Enable creation of default shipment with sales orders'),
1✔
1515
            'default': False,
1✔
1516
            'validator': bool,
1✔
1517
        },
1518

1519
        'SALESORDER_EDIT_COMPLETED_ORDERS': {
1✔
1520
            'name': _('Edit Completed Sales Orders'),
1✔
1521
            'description': _('Allow editing of sales orders after they have been shipped or completed'),
1✔
1522
            'default': False,
1✔
1523
            'validator': bool,
1✔
1524
        },
1525

1526
        'PURCHASEORDER_REFERENCE_PATTERN': {
1✔
1527
            'name': _('Purchase Order Reference Pattern'),
1✔
1528
            'description': _('Required pattern for generating Purchase Order reference field'),
1✔
1529
            'default': 'PO-{ref:04d}',
1✔
1530
            'validator': order.validators.validate_purchase_order_reference_pattern,
1✔
1531
        },
1532

1533
        'PURCHASEORDER_EDIT_COMPLETED_ORDERS': {
1✔
1534
            'name': _('Edit Completed Purchase Orders'),
1✔
1535
            'description': _('Allow editing of purchase orders after they have been shipped or completed'),
1✔
1536
            'default': False,
1✔
1537
            'validator': bool,
1✔
1538
        },
1539

1540
        # login / SSO
1541
        'LOGIN_ENABLE_PWD_FORGOT': {
1✔
1542
            'name': _('Enable password forgot'),
1✔
1543
            'description': _('Enable password forgot function on the login pages'),
1✔
1544
            'default': True,
1✔
1545
            'validator': bool,
1✔
1546
        },
1547

1548
        'LOGIN_ENABLE_REG': {
1✔
1549
            'name': _('Enable registration'),
1✔
1550
            'description': _('Enable self-registration for users on the login pages'),
1✔
1551
            'default': False,
1✔
1552
            'validator': bool,
1✔
1553
        },
1554

1555
        'LOGIN_ENABLE_SSO': {
1✔
1556
            'name': _('Enable SSO'),
1✔
1557
            'description': _('Enable SSO on the login pages'),
1✔
1558
            'default': False,
1✔
1559
            'validator': bool,
1✔
1560
        },
1561

1562
        'LOGIN_ENABLE_SSO_REG': {
1✔
1563
            'name': _('Enable SSO registration'),
1✔
1564
            'description': _('Enable self-registration via SSO for users on the login pages'),
1✔
1565
            'default': False,
1✔
1566
            'validator': bool,
1✔
1567
        },
1568

1569
        'LOGIN_MAIL_REQUIRED': {
1✔
1570
            'name': _('Email required'),
1✔
1571
            'description': _('Require user to supply mail on signup'),
1✔
1572
            'default': False,
1✔
1573
            'validator': bool,
1✔
1574
        },
1575

1576
        'LOGIN_SIGNUP_SSO_AUTO': {
1✔
1577
            'name': _('Auto-fill SSO users'),
1✔
1578
            'description': _('Automatically fill out user-details from SSO account-data'),
1✔
1579
            'default': True,
1✔
1580
            'validator': bool,
1✔
1581
        },
1582

1583
        'LOGIN_SIGNUP_MAIL_TWICE': {
1✔
1584
            'name': _('Mail twice'),
1✔
1585
            'description': _('On signup ask users twice for their mail'),
1✔
1586
            'default': False,
1✔
1587
            'validator': bool,
1✔
1588
        },
1589

1590
        'LOGIN_SIGNUP_PWD_TWICE': {
1✔
1591
            'name': _('Password twice'),
1✔
1592
            'description': _('On signup ask users twice for their password'),
1✔
1593
            'default': True,
1✔
1594
            'validator': bool,
1✔
1595
        },
1596

1597
        'LOGIN_SIGNUP_MAIL_RESTRICTION': {
1✔
1598
            'name': _('Allowed domains'),
1✔
1599
            'description': _('Restrict signup to certain domains (comma-separated, starting with @)'),
1✔
1600
            'default': '',
1✔
1601
            'before_save': validate_email_domains,
1✔
1602
        },
1603

1604
        'SIGNUP_GROUP': {
1✔
1605
            'name': _('Group on signup'),
1✔
1606
            'description': _('Group to which new users are assigned on registration'),
1✔
1607
            'default': '',
1✔
1608
            'choices': settings_group_options
1✔
1609
        },
1610

1611
        'LOGIN_ENFORCE_MFA': {
1✔
1612
            'name': _('Enforce MFA'),
1✔
1613
            'description': _('Users must use multifactor security.'),
1✔
1614
            'default': False,
1✔
1615
            'validator': bool,
1✔
1616
        },
1617

1618
        'PLUGIN_ON_STARTUP': {
1✔
1619
            'name': _('Check plugins on startup'),
1✔
1620
            'description': _('Check that all plugins are installed on startup - enable in container environments'),
1✔
1621
            'default': str(os.getenv('INVENTREE_DOCKER', False)).lower() in ['1', 'true'],
1✔
1622
            'validator': bool,
1✔
1623
            'requires_restart': True,
1✔
1624
        },
1625

1626
        # Settings for plugin mixin features
1627
        'ENABLE_PLUGINS_URL': {
1✔
1628
            'name': _('Enable URL integration'),
1✔
1629
            'description': _('Enable plugins to add URL routes'),
1✔
1630
            'default': False,
1✔
1631
            'validator': bool,
1✔
1632
            'requires_restart': True,
1✔
1633
        },
1634

1635
        'ENABLE_PLUGINS_NAVIGATION': {
1✔
1636
            'name': _('Enable navigation integration'),
1✔
1637
            'description': _('Enable plugins to integrate into navigation'),
1✔
1638
            'default': False,
1✔
1639
            'validator': bool,
1✔
1640
            'requires_restart': True,
1✔
1641
        },
1642

1643
        'ENABLE_PLUGINS_APP': {
1✔
1644
            'name': _('Enable app integration'),
1✔
1645
            'description': _('Enable plugins to add apps'),
1✔
1646
            'default': False,
1✔
1647
            'validator': bool,
1✔
1648
            'requires_restart': True,
1✔
1649
        },
1650

1651
        'ENABLE_PLUGINS_SCHEDULE': {
1✔
1652
            'name': _('Enable schedule integration'),
1✔
1653
            'description': _('Enable plugins to run scheduled tasks'),
1✔
1654
            'default': False,
1✔
1655
            'validator': bool,
1✔
1656
            'requires_restart': True,
1✔
1657
        },
1658

1659
        'ENABLE_PLUGINS_EVENTS': {
1✔
1660
            'name': _('Enable event integration'),
1✔
1661
            'description': _('Enable plugins to respond to internal events'),
1✔
1662
            'default': False,
1✔
1663
            'validator': bool,
1✔
1664
            'requires_restart': True,
1✔
1665
        },
1666

1667
        "PROJECT_CODES_ENABLED": {
1✔
1668
            'name': _('Enable project codes'),
1✔
1669
            'description': _('Enable project codes for tracking projects'),
1✔
1670
            'default': False,
1✔
1671
            'validator': bool,
1✔
1672
        },
1673

1674
        'STOCKTAKE_ENABLE': {
1✔
1675
            'name': _('Stocktake Functionality'),
1✔
1676
            'description': _('Enable stocktake functionality for recording stock levels and calculating stock value'),
1✔
1677
            'validator': bool,
1✔
1678
            'default': False,
1✔
1679
        },
1680

1681
        'STOCKTAKE_AUTO_DAYS': {
1✔
1682
            'name': _('Automatic Stocktake Period'),
1✔
1683
            'description': _('Number of days between automatic stocktake recording (set to zero to disable)'),
1✔
1684
            'validator': [
1✔
1685
                int,
1✔
1686
                MinValueValidator(0),
1✔
1687
            ],
1688
            'default': 0,
1✔
1689
        },
1690

1691
        'STOCKTAKE_DELETE_REPORT_DAYS': {
1✔
1692
            'name': _('Report Deletion Interval'),
1✔
1693
            'description': _('Stocktake reports will be deleted after specified number of days'),
1✔
1694
            'default': 30,
1✔
1695
            'units': _('days'),
1✔
1696
            'validator': [
1✔
1697
                int,
1✔
1698
                MinValueValidator(7),
1✔
1699
            ]
1700
        },
1701

1702
    }
1703

1704
    typ = 'inventree'
1✔
1705

1706
    key = models.CharField(
1✔
1707
        max_length=50,
1✔
1708
        blank=False,
1✔
1709
        unique=True,
1✔
1710
        help_text=_('Settings key (must be unique - case insensitive'),
1✔
1711
    )
1712

1713
    def to_native_value(self):
1✔
1714
        """Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1."""
1715
        return self.__class__.get_setting(self.key)
1716

1717
    def requires_restart(self):
1718
        """Return True if this setting requires a server restart after changing."""
1719
        options = InvenTreeSetting.SETTINGS.get(self.key, None)
1✔
1720

1721
        if options:
1✔
1722
            return options.get('requires_restart', False)
1✔
1723
        else:
1724
            return False
1✔
1725

1726

1727
def label_printer_options():
1✔
1728
    """Build a list of available label printer options."""
1729
    printers = [('', _('No Printer (Export to PDF)'))]
1730
    label_printer_plugins = registry.with_mixin('labels')
1731
    if label_printer_plugins:
1732
        printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins])
1733
    return printers
1734

1735

1736
class InvenTreeUserSetting(BaseInvenTreeSetting):
1737
    """An InvenTreeSetting object with a usercontext."""
1738

1739
    class Meta:
1✔
1740
        """Meta options for InvenTreeUserSetting."""
1741

1742
        verbose_name = "InvenTree User Setting"
1743
        verbose_name_plural = "InvenTree User Settings"
1744
        constraints = [
1745
            models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
1746
        ]
1747

1748
    SETTINGS = {
1749

1750
        'HOMEPAGE_HIDE_INACTIVE': {
1751
            'name': _('Hide inactive parts'),
1752
            'description': _('Hide inactive parts in results displayed on the homepage'),
1753
            'default': True,
1754
            'validator': bool,
1755
        },
1756

1757
        'HOMEPAGE_PART_STARRED': {
1758
            'name': _('Show subscribed parts'),
1759
            'description': _('Show subscribed parts on the homepage'),
1760
            'default': True,
1761
            'validator': bool,
1762
        },
1763

1764
        'HOMEPAGE_CATEGORY_STARRED': {
1765
            'name': _('Show subscribed categories'),
1766
            'description': _('Show subscribed part categories on the homepage'),
1767
            'default': True,
1768
            'validator': bool,
1769
        },
1770

1771
        'HOMEPAGE_PART_LATEST': {
1772
            'name': _('Show latest parts'),
1773
            'description': _('Show latest parts on the homepage'),
1774
            'default': True,
1775
            'validator': bool,
1776
        },
1777

1778
        'HOMEPAGE_BOM_REQUIRES_VALIDATION': {
1779
            'name': _('Show unvalidated BOMs'),
1780
            'description': _('Show BOMs that await validation on the homepage'),
1781
            'default': False,
1782
            'validator': bool,
1783
        },
1784

1785
        'HOMEPAGE_STOCK_RECENT': {
1786
            'name': _('Show recent stock changes'),
1787
            'description': _('Show recently changed stock items on the homepage'),
1788
            'default': True,
1789
            'validator': bool,
1790
        },
1791

1792
        'HOMEPAGE_STOCK_LOW': {
1793
            'name': _('Show low stock'),
1794
            'description': _('Show low stock items on the homepage'),
1795
            'default': True,
1796
            'validator': bool,
1797
        },
1798

1799
        'HOMEPAGE_SHOW_STOCK_DEPLETED': {
1800
            'name': _('Show depleted stock'),
1801
            'description': _('Show depleted stock items on the homepage'),
1802
            'default': False,
1803
            'validator': bool,
1804
        },
1805

1806
        'HOMEPAGE_BUILD_STOCK_NEEDED': {
1807
            'name': _('Show needed stock'),
1808
            'description': _('Show stock items needed for builds on the homepage'),
1809
            'default': False,
1810
            'validator': bool,
1811
        },
1812

1813
        'HOMEPAGE_STOCK_EXPIRED': {
1814
            'name': _('Show expired stock'),
1815
            'description': _('Show expired stock items on the homepage'),
1816
            'default': True,
1817
            'validator': bool,
1818
        },
1819

1820
        'HOMEPAGE_STOCK_STALE': {
1821
            'name': _('Show stale stock'),
1822
            'description': _('Show stale stock items on the homepage'),
1823
            'default': True,
1824
            'validator': bool,
1825
        },
1826

1827
        'HOMEPAGE_BUILD_PENDING': {
1828
            'name': _('Show pending builds'),
1829
            'description': _('Show pending builds on the homepage'),
1830
            'default': True,
1831
            'validator': bool,
1832
        },
1833

1834
        'HOMEPAGE_BUILD_OVERDUE': {
1835
            'name': _('Show overdue builds'),
1836
            'description': _('Show overdue builds on the homepage'),
1837
            'default': True,
1838
            'validator': bool,
1839
        },
1840

1841
        'HOMEPAGE_PO_OUTSTANDING': {
1842
            'name': _('Show outstanding POs'),
1843
            'description': _('Show outstanding POs on the homepage'),
1844
            'default': True,
1845
            'validator': bool,
1846
        },
1847

1848
        'HOMEPAGE_PO_OVERDUE': {
1849
            'name': _('Show overdue POs'),
1850
            'description': _('Show overdue POs on the homepage'),
1851
            'default': True,
1852
            'validator': bool,
1853
        },
1854

1855
        'HOMEPAGE_SO_OUTSTANDING': {
1856
            'name': _('Show outstanding SOs'),
1857
            'description': _('Show outstanding SOs on the homepage'),
1858
            'default': True,
1859
            'validator': bool,
1860
        },
1861

1862
        'HOMEPAGE_SO_OVERDUE': {
1863
            'name': _('Show overdue SOs'),
1864
            'description': _('Show overdue SOs on the homepage'),
1865
            'default': True,
1866
            'validator': bool,
1867
        },
1868

1869
        'HOMEPAGE_SO_SHIPMENTS_PENDING': {
1870
            'name': _('Show pending SO shipments'),
1871
            'description': _('Show pending SO shipments on the homepage'),
1872
            'default': True,
1873
            'validator': bool,
1874
        },
1875

1876
        'HOMEPAGE_NEWS': {
1877
            'name': _('Show News'),
1878
            'description': _('Show news on the homepage'),
1879
            'default': False,
1880
            'validator': bool,
1881
        },
1882

1883
        "LABEL_INLINE": {
1884
            'name': _('Inline label display'),
1885
            'description': _('Display PDF labels in the browser, instead of downloading as a file'),
1886
            'default': True,
1887
            'validator': bool,
1888
        },
1889

1890
        "LABEL_DEFAULT_PRINTER": {
1891
            'name': _('Default label printer'),
1892
            'description': _('Configure which label printer should be selected by default'),
1893
            'default': '',
1894
            'choices': label_printer_options
1895
        },
1896

1897
        "REPORT_INLINE": {
1898
            'name': _('Inline report display'),
1899
            'description': _('Display PDF reports in the browser, instead of downloading as a file'),
1900
            'default': False,
1901
            'validator': bool,
1902
        },
1903

1904
        'SEARCH_PREVIEW_SHOW_PARTS': {
1905
            'name': _('Search Parts'),
1906
            'description': _('Display parts in search preview window'),
1907
            'default': True,
1908
            'validator': bool,
1909
        },
1910

1911
        'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS': {
1912
            'name': _('Search Supplier Parts'),
1913
            'description': _('Display supplier parts in search preview window'),
1914
            'default': True,
1915
            'validator': bool,
1916
        },
1917

1918
        'SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS': {
1919
            'name': _('Search Manufacturer Parts'),
1920
            'description': _('Display manufacturer parts in search preview window'),
1921
            'default': True,
1922
            'validator': bool,
1923
        },
1924

1925
        'SEARCH_HIDE_INACTIVE_PARTS': {
1926
            'name': _("Hide Inactive Parts"),
1927
            'description': _('Excluded inactive parts from search preview window'),
1928
            'default': False,
1929
            'validator': bool,
1930
        },
1931

1932
        'SEARCH_PREVIEW_SHOW_CATEGORIES': {
1933
            'name': _('Search Categories'),
1934
            'description': _('Display part categories in search preview window'),
1935
            'default': False,
1936
            'validator': bool,
1937
        },
1938

1939
        'SEARCH_PREVIEW_SHOW_STOCK': {
1940
            'name': _('Search Stock'),
1941
            'description': _('Display stock items in search preview window'),
1942
            'default': True,
1943
            'validator': bool,
1944
        },
1945

1946
        'SEARCH_PREVIEW_HIDE_UNAVAILABLE_STOCK': {
1947
            'name': _('Hide Unavailable Stock Items'),
1948
            'description': _('Exclude stock items which are not available from the search preview window'),
1949
            'validator': bool,
1950
            'default': False,
1951
        },
1952

1953
        'SEARCH_PREVIEW_SHOW_LOCATIONS': {
1954
            'name': _('Search Locations'),
1955
            'description': _('Display stock locations in search preview window'),
1956
            'default': False,
1957
            'validator': bool,
1958
        },
1959

1960
        'SEARCH_PREVIEW_SHOW_COMPANIES': {
1961
            'name': _('Search Companies'),
1962
            'description': _('Display companies in search preview window'),
1963
            'default': True,
1964
            'validator': bool,
1965
        },
1966

1967
        'SEARCH_PREVIEW_SHOW_BUILD_ORDERS': {
1968
            'name': _('Search Build Orders'),
1969
            'description': _('Display build orders in search preview window'),
1970
            'default': True,
1971
            'validator': bool,
1972
        },
1973

1974
        'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS': {
1975
            'name': _('Search Purchase Orders'),
1976
            'description': _('Display purchase orders in search preview window'),
1977
            'default': True,
1978
            'validator': bool,
1979
        },
1980

1981
        'SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS': {
1982
            'name': _('Exclude Inactive Purchase Orders'),
1983
            'description': _('Exclude inactive purchase orders from search preview window'),
1984
            'default': True,
1985
            'validator': bool,
1986
        },
1987

1988
        'SEARCH_PREVIEW_SHOW_SALES_ORDERS': {
1989
            'name': _('Search Sales Orders'),
1990
            'description': _('Display sales orders in search preview window'),
1991
            'default': True,
1992
            'validator': bool,
1993
        },
1994

1995
        'SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS': {
1996
            'name': _('Exclude Inactive Sales Orders'),
1997
            'description': _('Exclude inactive sales orders from search preview window'),
1998
            'validator': bool,
1999
            'default': True,
2000
        },
2001

2002
        'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': {
2003
            'name': _('Search Return Orders'),
2004
            'description': _('Display return orders in search preview window'),
2005
            'default': True,
2006
            'validator': bool,
2007
        },
2008

2009
        'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': {
2010
            'name': _('Exclude Inactive Return Orders'),
2011
            'description': _('Exclude inactive return orders from search preview window'),
2012
            'validator': bool,
2013
            'default': True,
2014
        },
2015

2016
        'SEARCH_PREVIEW_RESULTS': {
2017
            'name': _('Search Preview Results'),
2018
            'description': _('Number of results to show in each section of the search preview window'),
2019
            'default': 10,
2020
            'validator': [int, MinValueValidator(1)]
2021
        },
2022

2023
        'SEARCH_REGEX': {
2024
            'name': _('Regex Search'),
2025
            'description': _('Enable regular expressions in search queries'),
2026
            'default': False,
2027
            'validator': bool,
2028
        },
2029

2030
        'SEARCH_WHOLE': {
2031
            'name': _('Whole Word Search'),
2032
            'description': _('Search queries return results for whole word matches'),
2033
            'default': False,
2034
            'validator': bool,
2035
        },
2036

2037
        'PART_SHOW_QUANTITY_IN_FORMS': {
2038
            'name': _('Show Quantity in Forms'),
2039
            'description': _('Display available part quantity in some forms'),
2040
            'default': True,
2041
            'validator': bool,
2042
        },
2043

2044
        'FORMS_CLOSE_USING_ESCAPE': {
2045
            'name': _('Escape Key Closes Forms'),
2046
            'description': _('Use the escape key to close modal forms'),
2047
            'default': False,
2048
            'validator': bool,
2049
        },
2050

2051
        'STICKY_HEADER': {
2052
            'name': _('Fixed Navbar'),
2053
            'description': _('The navbar position is fixed to the top of the screen'),
2054
            'default': False,
2055
            'validator': bool,
2056
        },
2057

2058
        'DATE_DISPLAY_FORMAT': {
2059
            'name': _('Date Format'),
2060
            'description': _('Preferred format for displaying dates'),
2061
            'default': 'YYYY-MM-DD',
2062
            'choices': [
2063
                ('YYYY-MM-DD', '2022-02-22'),
2064
                ('YYYY/MM/DD', '2022/22/22'),
2065
                ('DD-MM-YYYY', '22-02-2022'),
2066
                ('DD/MM/YYYY', '22/02/2022'),
2067
                ('MM-DD-YYYY', '02-22-2022'),
2068
                ('MM/DD/YYYY', '02/22/2022'),
2069
                ('MMM DD YYYY', 'Feb 22 2022'),
2070
            ]
2071
        },
2072

2073
        'DISPLAY_SCHEDULE_TAB': {
2074
            'name': _('Part Scheduling'),
2075
            'description': _('Display part scheduling information'),
2076
            'default': True,
2077
            'validator': bool,
2078
        },
2079

2080
        'DISPLAY_STOCKTAKE_TAB': {
2081
            'name': _('Part Stocktake'),
2082
            'description': _('Display part stocktake information (if stocktake functionality is enabled)'),
2083
            'default': True,
2084
            'validator': bool,
2085
        },
2086

2087
        'TABLE_STRING_MAX_LENGTH': {
2088
            'name': _('Table String Length'),
2089
            'description': _('Maximimum length limit for strings displayed in table views'),
2090
            'validator': [
2091
                int,
2092
                MinValueValidator(0),
2093
            ],
2094
            'default': 100,
2095
        },
2096

2097
        'DEFAULT_PART_LABEL_TEMPLATE': {
2098
            'name': _('Default part label template'),
2099
            'description': _('The part label template to be automatically selected'),
2100
            'validator': [
2101
                int,
2102
            ],
2103
            'default': '',
2104
        },
2105

2106
        'DEFAULT_ITEM_LABEL_TEMPLATE': {
2107
            'name': _('Default stock item template'),
2108
            'description': _('The stock item label template to be automatically selected'),
2109
            'validator': [
2110
                int,
2111
            ],
2112
            'default': '',
2113
        },
2114

2115
        'DEFAULT_LOCATION_LABEL_TEMPLATE': {
2116
            'name': _('Default stock location label template'),
2117
            'description': _('The stock location label template to be automatically selected'),
2118
            'validator': [
2119
                int,
2120
            ],
2121
            'default': '',
2122
        },
2123

2124
    }
2125

2126
    typ = 'user'
2127
    extra_unique_fields = ['user']
2128

2129
    key = models.CharField(
2130
        max_length=50,
2131
        blank=False,
2132
        unique=False,
2133
        help_text=_('Settings key (must be unique - case insensitive'),
2134
    )
2135

2136
    user = models.ForeignKey(
2137
        User,
2138
        on_delete=models.CASCADE,
2139
        blank=True, null=True,
2140
        verbose_name=_('User'),
2141
        help_text=_('User'),
2142
    )
2143

2144
    def to_native_value(self):
2145
        """Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1."""
2146
        return self.__class__.get_setting(self.key, user=self.user)
1✔
2147

2148

2149
class PriceBreak(MetaMixin):
1✔
2150
    """Represents a PriceBreak model."""
2151

2152
    class Meta:
2153
        """Define this as abstract -> no DB entry is created."""
2154

2155
        abstract = True
1✔
2156

2157
    quantity = InvenTree.fields.RoundingDecimalField(
1✔
2158
        max_digits=15,
1✔
2159
        decimal_places=5,
1✔
2160
        default=1,
1✔
2161
        validators=[MinValueValidator(1)],
1✔
2162
        verbose_name=_('Quantity'),
1✔
2163
        help_text=_('Price break quantity'),
1✔
2164
    )
2165

2166
    price = InvenTree.fields.InvenTreeModelMoneyField(
1✔
2167
        max_digits=19,
1✔
2168
        decimal_places=6,
1✔
2169
        null=True,
1✔
2170
        verbose_name=_('Price'),
1✔
2171
        help_text=_('Unit price at specified quantity'),
1✔
2172
    )
2173

2174
    def convert_to(self, currency_code):
1✔
2175
        """Convert the unit-price at this price break to the specified currency code.
2176

2177
        Args:
2178
            currency_code: The currency code to convert to (e.g "USD" or "AUD")
2179
        """
2180
        try:
1✔
2181
            converted = convert_money(self.price, currency_code)
1✔
2182
        except MissingRate:
2183
            logger.warning(f"No currency conversion rate available for {self.price_currency} -> {currency_code}")
2184
            return self.price.amount
2185

2186
        return converted.amount
1✔
2187

2188

2189
def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'):
1✔
2190
    """Calculate the price based on quantity price breaks.
2191

2192
    - Don't forget to add in flat-fee cost (base_cost field)
2193
    - If MOQ (minimum order quantity) is required, bump quantity
2194
    - If order multiples are to be observed, then we need to calculate based on that, too
2195
    """
2196
    from common.settings import currency_code_default
1✔
2197

2198
    if hasattr(instance, break_name):
1✔
2199
        price_breaks = getattr(instance, break_name).all()
1✔
2200
    else:
2201
        price_breaks = []
2202

2203
    # No price break information available?
2204
    if len(price_breaks) == 0:
1✔
2205
        return None
1✔
2206

2207
    # Check if quantity is fraction and disable multiples
2208
    multiples = (quantity % 1 == 0)
1✔
2209

2210
    # Order multiples
2211
    if multiples:
1✔
2212
        quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple)
1✔
2213

2214
    pb_found = False
1✔
2215
    pb_quantity = -1
1✔
2216
    pb_cost = 0.0
1✔
2217

2218
    if currency is None:
1✔
2219
        # Default currency selection
2220
        currency = currency_code_default()
1✔
2221

2222
    pb_min = None
1✔
2223
    for pb in price_breaks:
1✔
2224
        # Store smallest price break
2225
        if not pb_min:
1✔
2226
            pb_min = pb
1✔
2227

2228
        # Ignore this pricebreak (quantity is too high)
2229
        if pb.quantity > quantity:
1✔
2230
            continue
1✔
2231

2232
        pb_found = True
1✔
2233

2234
        # If this price-break quantity is the largest so far, use it!
2235
        if pb.quantity > pb_quantity:
1✔
2236
            pb_quantity = pb.quantity
1✔
2237

2238
            # Convert everything to the selected currency
2239
            pb_cost = pb.convert_to(currency)
1✔
2240

2241
    # Use smallest price break
2242
    if not pb_found and pb_min:
1✔
2243
        # Update price break information
2244
        pb_quantity = pb_min.quantity
1✔
2245
        pb_cost = pb_min.convert_to(currency)
1✔
2246
        # Trigger cost calculation using smallest price break
2247
        pb_found = True
1✔
2248

2249
    # Convert quantity to decimal.Decimal format
2250
    quantity = decimal.Decimal(f'{quantity}')
1✔
2251

2252
    if pb_found:
1✔
2253
        cost = pb_cost * quantity
1✔
2254
        return InvenTree.helpers.normalize(cost + instance.base_cost)
1✔
2255
    else:
2256
        return None
2257

2258

2259
class ColorTheme(models.Model):
1✔
2260
    """Color Theme Setting."""
2261
    name = models.CharField(max_length=20,
2262
                            default='',
2263
                            blank=True)
2264

2265
    user = models.CharField(max_length=150,
2266
                            unique=True)
2267

2268
    @classmethod
2269
    def get_color_themes_choices(cls):
2270
        """Get all color themes from static folder."""
2271
        if not settings.STATIC_COLOR_THEMES_DIR.exists():
1✔
2272
            logger.error('Theme directory does not exsist')
1✔
2273
            return []
1✔
2274

2275
        # Get files list from css/color-themes/ folder
2276
        files_list = []
2277

2278
        for file in settings.STATIC_COLOR_THEMES_DIR.iterdir():
2279
            files_list.append([file.stem, file.suffix])
2280

2281
        # Get color themes choices (CSS sheets)
2282
        choices = [(file_name.lower(), _(file_name.replace('-', ' ').title()))
2283
                   for file_name, file_ext in files_list
2284
                   if file_ext == '.css']
2285

2286
        return choices
2287

2288
    @classmethod
1✔
2289
    def is_valid_choice(cls, user_color_theme):
1✔
2290
        """Check if color theme is valid choice."""
2291
        try:
2292
            user_color_theme_name = user_color_theme.name
2293
        except AttributeError:
2294
            return False
2295

2296
        for color_theme in cls.get_color_themes_choices():
2297
            if user_color_theme_name == color_theme[0]:
2298
                return True
2299

2300
        return False
2301

2302

2303
class VerificationMethod(Enum):
2304
    """Class to hold method references."""
2305
    NONE = 0
1✔
2306
    TOKEN = 1
1✔
2307
    HMAC = 2
1✔
2308

2309

2310
class WebhookEndpoint(models.Model):
1✔
2311
    """Defines a Webhook entdpoint.
2312

2313
    Attributes:
2314
        endpoint_id: Path to the webhook,
2315
        name: Name of the webhook,
2316
        active: Is this webhook active?,
2317
        user: User associated with webhook,
2318
        token: Token for sending a webhook,
2319
        secret: Shared secret for HMAC verification,
2320
    """
2321

2322
    # Token
2323
    TOKEN_NAME = "Token"
1✔
2324
    VERIFICATION_METHOD = VerificationMethod.NONE
1✔
2325

2326
    MESSAGE_OK = "Message was received."
1✔
2327
    MESSAGE_TOKEN_ERROR = "Incorrect token in header."
1✔
2328

2329
    endpoint_id = models.CharField(
1✔
2330
        max_length=255,
1✔
2331
        verbose_name=_('Endpoint'),
1✔
2332
        help_text=_('Endpoint at which this webhook is received'),
1✔
2333
        default=uuid.uuid4,
1✔
2334
        editable=False,
1✔
2335
    )
2336

2337
    name = models.CharField(
1✔
2338
        max_length=255,
1✔
2339
        blank=True, null=True,
1✔
2340
        verbose_name=_('Name'),
1✔
2341
        help_text=_('Name for this webhook')
1✔
2342
    )
2343

2344
    active = models.BooleanField(
1✔
2345
        default=True,
1✔
2346
        verbose_name=_('Active'),
1✔
2347
        help_text=_('Is this webhook active')
1✔
2348
    )
2349

2350
    user = models.ForeignKey(
1✔
2351
        User,
1✔
2352
        on_delete=models.SET_NULL,
1✔
2353
        blank=True, null=True,
1✔
2354
        verbose_name=_('User'),
1✔
2355
        help_text=_('User'),
1✔
2356
    )
2357

2358
    token = models.CharField(
1✔
2359
        max_length=255,
1✔
2360
        blank=True, null=True,
1✔
2361
        verbose_name=_('Token'),
1✔
2362
        help_text=_('Token for access'),
1✔
2363
        default=uuid.uuid4,
1✔
2364
    )
2365

2366
    secret = models.CharField(
1✔
2367
        max_length=255,
1✔
2368
        blank=True, null=True,
1✔
2369
        verbose_name=_('Secret'),
1✔
2370
        help_text=_('Shared secret for HMAC'),
1✔
2371
    )
2372

2373
    # To be overridden
2374

2375
    def init(self, request, *args, **kwargs):
1✔
2376
        """Set verification method.
2377

2378
        Args:
2379
            request: Original request object.
2380
        """
2381
        self.verify = self.VERIFICATION_METHOD
1✔
2382

2383
    def process_webhook(self):
1✔
2384
        """Process the webhook incoming.
2385

2386
        This does not deal with the data itself - that happens in process_payload.
2387
        Do not touch or pickle data here - it was not verified to be safe.
2388
        """
2389
        if self.token:
1✔
2390
            self.verify = VerificationMethod.TOKEN
1✔
2391
        if self.secret:
1✔
2392
            self.verify = VerificationMethod.HMAC
1✔
2393
        return True
1✔
2394

2395
    def validate_token(self, payload, headers, request):
1✔
2396
        """Make sure that the provided token (if any) confirms to the setting for this endpoint.
2397

2398
        This can be overridden to create your own token validation method.
2399
        """
2400
        token = headers.get(self.TOKEN_NAME, "")
1✔
2401

2402
        # no token
2403
        if self.verify == VerificationMethod.NONE:
1✔
2404
            # do nothing as no method was chosen
2405
            pass
1✔
2406

2407
        # static token
2408
        elif self.verify == VerificationMethod.TOKEN:
1✔
2409
            if not compare_digest(token, self.token):
1✔
2410
                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
1✔
2411

2412
        # hmac token
2413
        elif self.verify == VerificationMethod.HMAC:
1✔
2414
            digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest()
1✔
2415
            computed_hmac = base64.b64encode(digest)
1✔
2416
            if not hmac.compare_digest(computed_hmac, token.encode('utf-8')):
1✔
2417
                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
1✔
2418

2419
        return True
1✔
2420

2421
    def save_data(self, payload=None, headers=None, request=None):
1✔
2422
        """Safes payload to database.
2423

2424
        Args:
2425
            payload  (optional): Payload that was send along. Defaults to None.
2426
            headers (optional): Headers that were send along. Defaults to None.
2427
            request (optional): Original request object. Defaults to None.
2428
        """
2429
        return WebhookMessage.objects.create(
1✔
2430
            host=request.get_host(),
1✔
2431
            header=json.dumps(dict(headers.items())),
1✔
2432
            body=payload,
1✔
2433
            endpoint=self,
1✔
2434
        )
2435

2436
    def process_payload(self, message, payload=None, headers=None) -> bool:
1✔
2437
        """Process a payload.
2438

2439
        Args:
2440
            message: DB entry for this message mm
2441
            payload (optional): Payload that was send along. Defaults to None.
2442
            headers (optional): Headers that were included. Defaults to None.
2443

2444
        Returns:
2445
            bool: Was the message processed
2446
        """
2447
        return True
1✔
2448

2449
    def get_return(self, payload=None, headers=None, request=None) -> str:
1✔
2450
        """Returns the message that should be returned to the endpoint caller.
2451

2452
        Args:
2453
            payload  (optional): Payload that was send along. Defaults to None.
2454
            headers (optional): Headers that were send along. Defaults to None.
2455
            request (optional): Original request object. Defaults to None.
2456

2457
        Returns:
2458
            str: Message for caller.
2459
        """
2460
        return self.MESSAGE_OK
1✔
2461

2462

2463
class WebhookMessage(models.Model):
1✔
2464
    """Defines a webhook message.
2465

2466
    Attributes:
2467
        message_id: Unique identifier for this message,
2468
        host: Host from which this message was received,
2469
        header: Header of this message,
2470
        body: Body of this message,
2471
        endpoint: Endpoint on which this message was received,
2472
        worked_on: Was the work on this message finished?
2473
    """
2474

2475
    message_id = models.UUIDField(
1✔
2476
        verbose_name=_('Message ID'),
1✔
2477
        help_text=_('Unique identifier for this message'),
1✔
2478
        primary_key=True,
1✔
2479
        default=uuid.uuid4,
1✔
2480
        editable=False,
1✔
2481
    )
2482

2483
    host = models.CharField(
1✔
2484
        max_length=255,
1✔
2485
        verbose_name=_('Host'),
1✔
2486
        help_text=_('Host from which this message was received'),
1✔
2487
        editable=False,
1✔
2488
    )
2489

2490
    header = models.CharField(
1✔
2491
        max_length=255,
1✔
2492
        blank=True, null=True,
1✔
2493
        verbose_name=_('Header'),
1✔
2494
        help_text=_('Header of this message'),
1✔
2495
        editable=False,
1✔
2496
    )
2497

2498
    body = models.JSONField(
1✔
2499
        blank=True, null=True,
1✔
2500
        verbose_name=_('Body'),
1✔
2501
        help_text=_('Body of this message'),
1✔
2502
        editable=False,
1✔
2503
    )
2504

2505
    endpoint = models.ForeignKey(
1✔
2506
        WebhookEndpoint,
1✔
2507
        on_delete=models.SET_NULL,
1✔
2508
        blank=True, null=True,
1✔
2509
        verbose_name=_('Endpoint'),
1✔
2510
        help_text=_('Endpoint on which this message was received'),
1✔
2511
    )
2512

2513
    worked_on = models.BooleanField(
1✔
2514
        default=False,
1✔
2515
        verbose_name=_('Worked on'),
1✔
2516
        help_text=_('Was the work on this message finished?'),
1✔
2517
    )
2518

2519

2520
class NotificationEntry(MetaMixin):
1✔
2521
    """A NotificationEntry records the last time a particular notification was sent out.
2522

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

2525
    Attributes:
2526
    - key: A text entry describing the notification e.g. 'part.notify_low_stock'
2527
    - uid: An (optional) numerical ID for a particular instance
2528
    - date: The last time this notification was sent
2529
    """
2530

2531
    class Meta:
1✔
2532
        """Meta options for NotificationEntry."""
2533

2534
        unique_together = [
2535
            ('key', 'uid'),
2536
        ]
2537

2538
    key = models.CharField(
2539
        max_length=250,
2540
        blank=False,
2541
    )
2542

2543
    uid = models.IntegerField(
2544
    )
2545

2546
    @classmethod
2547
    def check_recent(cls, key: str, uid: int, delta: timedelta):
2548
        """Test if a particular notification has been sent in the specified time period."""
2549
        since = datetime.now().date() - delta
1✔
2550

2551
        entries = cls.objects.filter(
1✔
2552
            key=key,
1✔
2553
            uid=uid,
1✔
2554
            updated__gte=since
1✔
2555
        )
2556

2557
        return entries.exists()
1✔
2558

2559
    @classmethod
1✔
2560
    def notify(cls, key: str, uid: int):
1✔
2561
        """Notify the database that a particular notification has been sent out."""
2562
        entry, created = cls.objects.get_or_create(
2563
            key=key,
2564
            uid=uid
2565
        )
2566

2567
        entry.save()
2568

2569

2570
class NotificationMessage(models.Model):
2571
    """A NotificationMessage is a message sent to a particular user, notifying them of some *important information*
2572

2573
    Notification messages can be generated by a variety of sources.
2574

2575
    Attributes:
2576
        target_object: The 'target' of the notification message
2577
        source_object: The 'source' of the notification message
2578
    """
2579

2580
    # generic link to target
2581
    target_content_type = models.ForeignKey(
1✔
2582
        ContentType,
1✔
2583
        on_delete=models.CASCADE,
1✔
2584
        related_name='notification_target',
1✔
2585
    )
2586

2587
    target_object_id = models.PositiveIntegerField()
1✔
2588

2589
    target_object = GenericForeignKey('target_content_type', 'target_object_id')
1✔
2590

2591
    # generic link to source
2592
    source_content_type = models.ForeignKey(
1✔
2593
        ContentType,
1✔
2594
        on_delete=models.SET_NULL,
1✔
2595
        related_name='notification_source',
1✔
2596
        null=True,
1✔
2597
        blank=True,
1✔
2598
    )
2599

2600
    source_object_id = models.PositiveIntegerField(
1✔
2601
        null=True,
1✔
2602
        blank=True,
1✔
2603
    )
2604

2605
    source_object = GenericForeignKey('source_content_type', 'source_object_id')
1✔
2606

2607
    # user that receives the notification
2608
    user = models.ForeignKey(
1✔
2609
        User,
1✔
2610
        on_delete=models.CASCADE,
1✔
2611
        verbose_name=_('User'),
1✔
2612
        help_text=_('User'),
1✔
2613
        null=True,
1✔
2614
        blank=True,
1✔
2615
    )
2616

2617
    category = models.CharField(
1✔
2618
        max_length=250,
1✔
2619
        blank=False,
1✔
2620
    )
2621

2622
    name = models.CharField(
1✔
2623
        max_length=250,
1✔
2624
        blank=False,
1✔
2625
    )
2626

2627
    message = models.CharField(
1✔
2628
        max_length=250,
1✔
2629
        blank=True,
1✔
2630
        null=True,
1✔
2631
    )
2632

2633
    creation = models.DateTimeField(
1✔
2634
        auto_now_add=True,
1✔
2635
    )
2636

2637
    read = models.BooleanField(
1✔
2638
        default=False,
1✔
2639
    )
2640

2641
    @staticmethod
1✔
2642
    def get_api_url():
1✔
2643
        """Return API endpoint."""
2644
        return reverse('api-notifications-list')
2645

2646
    def age(self):
2647
        """Age of the message in seconds."""
2648
        delta = now() - self.creation
1✔
2649
        return delta.seconds
1✔
2650

2651
    def age_human(self):
1✔
2652
        """Humanized age."""
2653
        return naturaltime(self.creation)
2654

2655

2656
class NewsFeedEntry(models.Model):
2657
    """A NewsFeedEntry represents an entry on the RSS/Atom feed that is generated for InvenTree news.
2658

2659
    Attributes:
2660
    - feed_id: Unique id for the news item
2661
    - title: Title for the news item
2662
    - link: Link to the news item
2663
    - published: Date of publishing of the news item
2664
    - author: Author of news item
2665
    - summary: Summary of the news items content
2666
    - read: Was this iteam already by a superuser?
2667
    """
2668

2669
    feed_id = models.CharField(
1✔
2670
        verbose_name=_('Id'),
1✔
2671
        unique=True,
1✔
2672
        max_length=250,
1✔
2673
    )
2674

2675
    title = models.CharField(
1✔
2676
        verbose_name=_('Title'),
1✔
2677
        max_length=250,
1✔
2678
    )
2679

2680
    link = models.URLField(
1✔
2681
        verbose_name=_('Link'),
1✔
2682
        max_length=250,
1✔
2683
    )
2684

2685
    published = models.DateTimeField(
1✔
2686
        verbose_name=_('Published'),
1✔
2687
        max_length=250,
1✔
2688
    )
2689

2690
    author = models.CharField(
1✔
2691
        verbose_name=_('Author'),
1✔
2692
        max_length=250,
1✔
2693
    )
2694

2695
    summary = models.CharField(
1✔
2696
        verbose_name=_('Summary'),
1✔
2697
        max_length=250,
1✔
2698
    )
2699

2700
    read = models.BooleanField(
1✔
2701
        verbose_name=_('Read'),
1✔
2702
        help_text=_('Was this news item read?'),
1✔
2703
        default=False
1✔
2704
    )
2705

2706

2707
def rename_notes_image(instance, filename):
1✔
2708
    """Function for renaming uploading image file. Will store in the 'notes' directory."""
2709

2710
    fname = os.path.basename(filename)
2711
    return os.path.join('notes', fname)
2712

2713

2714
class NotesImage(models.Model):
2715
    """Model for storing uploading images for the 'notes' fields of various models.
2716

2717
    Simply stores the image file, for use in the 'notes' field (of any models which support markdown)
2718
    """
2719

2720
    image = models.ImageField(
1✔
2721
        upload_to=rename_notes_image,
1✔
2722
        verbose_name=_('Image'),
1✔
2723
        help_text=_('Image file'),
1✔
2724
    )
2725

2726
    user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
1✔
2727

2728
    date = models.DateTimeField(auto_now_add=True)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc