""" Common database model definitions. These models are 'generic' and do not fit a particular business logic object. """ # -*- coding: utf-8 -*- from __future__ import unicode_literals import os import decimal import math import uuid import hmac import json import hashlib import base64 from secrets import compare_digest from datetime import datetime, timedelta from django.db import models, transaction from django.contrib.auth.models import User, Group from django.db.utils import IntegrityError, OperationalError from django.conf import settings from djmoney.settings import CURRENCY_CHOICES from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate from rest_framework.exceptions import PermissionDenied from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator, URLValidator from django.core.exceptions import ValidationError import InvenTree.helpers import InvenTree.fields import InvenTree.validators import logging logger = logging.getLogger('inventree') class EmptyURLValidator(URLValidator): def __call__(self, value): value = str(value).strip() if len(value) == 0: pass else: super().__call__(value) class BaseInvenTreeSetting(models.Model): """ An base InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values). """ SETTINGS = {} class Meta: abstract = True def save(self, *args, **kwargs): """ Enforce validation and clean before saving """ self.key = str(self.key).upper() self.clean(**kwargs) self.validate_unique() super().save() @classmethod def allValues(cls, user=None, exclude_hidden=False): """ Return a dict of "all" defined global settings. This performs a single database lookup, and then any settings which are not *in* the database are assigned their default values """ results = cls.objects.all() # Optionally filter by user if user is not None: results = results.filter(user=user) # Query the database settings = {} for setting in results: if setting.key: settings[setting.key.upper()] = setting.value # Specify any "default" values which are not in the database for key in cls.SETTINGS.keys(): if key.upper() not in settings: settings[key.upper()] = cls.get_setting_default(key) if exclude_hidden: hidden = cls.SETTINGS[key].get('hidden', False) if hidden: # Remove hidden items del settings[key.upper()] for key, value in settings.items(): validator = cls.get_setting_validator(key) if cls.is_protected(key): value = '***' elif cls.validator_is_bool(validator): value = InvenTree.helpers.str2bool(value) elif cls.validator_is_int(validator): try: value = int(value) except ValueError: value = cls.get_setting_default(key) settings[key] = value return settings @classmethod def get_setting_definition(cls, key, **kwargs): """ Return the 'definition' of a particular settings value, as a dict object. - The 'settings' dict can be passed as a kwarg - If not passed, look for cls.SETTINGS - Returns an empty dict if the key is not found """ settings = kwargs.get('settings', cls.SETTINGS) key = str(key).strip().upper() if settings is not None and key in settings: return settings[key] else: return {} @classmethod def get_setting_name(cls, key, **kwargs): """ Return the name of a particular setting. If it does not exist, return an empty string. """ setting = cls.get_setting_definition(key, **kwargs) return setting.get('name', '') @classmethod def get_setting_description(cls, key, **kwargs): """ Return the description for a particular setting. If it does not exist, return an empty string. """ setting = cls.get_setting_definition(key, **kwargs) return setting.get('description', '') @classmethod def get_setting_units(cls, key, **kwargs): """ Return the units for a particular setting. If it does not exist, return an empty string. """ setting = cls.get_setting_definition(key, **kwargs) return setting.get('units', '') @classmethod def get_setting_validator(cls, key, **kwargs): """ Return the validator for a particular setting. If it does not exist, return None """ setting = cls.get_setting_definition(key, **kwargs) return setting.get('validator', None) @classmethod def get_setting_default(cls, key, **kwargs): """ Return the default value for a particular setting. If it does not exist, return an empty string """ setting = cls.get_setting_definition(key, **kwargs) return setting.get('default', '') @classmethod def get_setting_choices(cls, key, **kwargs): """ Return the validator choices available for a particular setting. """ setting = cls.get_setting_definition(key, **kwargs) choices = setting.get('choices', None) if callable(choices): # Evaluate the function (we expect it will return a list of tuples...) return choices() return choices @classmethod def get_filters(cls, key, **kwargs): return {'key__iexact': key} @classmethod def get_setting_object(cls, key, **kwargs): """ Return an InvenTreeSetting object matching the given key. - Key is case-insensitive - Returns None if no match is made """ key = str(key).strip().upper() settings = cls.objects.all() # Filter by user user = kwargs.get('user', None) if user is not None: settings = settings.filter(user=user) try: setting = settings.filter(**cls.get_filters(key, **kwargs)).first() except (ValueError, cls.DoesNotExist): setting = None except (IntegrityError, OperationalError): setting = None plugin = kwargs.pop('plugin', None) if plugin: from plugin import InvenTreePluginBase if issubclass(plugin.__class__, InvenTreePluginBase): plugin = plugin.plugin_config() kwargs['plugin'] = plugin # Setting does not exist! (Try to create it) if not setting: # Unless otherwise specified, attempt to create the setting create = kwargs.get('create', True) if create: # Attempt to create a new settings object setting = cls( key=key, value=cls.get_setting_default(key, **kwargs), **kwargs ) try: # Wrap this statement in "atomic", so it can be rolled back if it fails with transaction.atomic(): setting.save() except (IntegrityError, OperationalError): # It might be the case that the database isn't created yet pass return setting @classmethod def get_setting(cls, key, backup_value=None, **kwargs): """ Get the value of a particular setting. If it does not exist, return the backup value (default = None) """ # If no backup value is specified, atttempt to retrieve a "default" value if backup_value is None: backup_value = cls.get_setting_default(key, **kwargs) setting = cls.get_setting_object(key, **kwargs) if setting: value = setting.value # Cast to boolean if necessary if setting.is_bool(**kwargs): value = InvenTree.helpers.str2bool(value) # Cast to integer if necessary if setting.is_int(**kwargs): try: value = int(value) except (ValueError, TypeError): value = backup_value else: value = backup_value return value @classmethod def set_setting(cls, key, value, change_user, create=True, **kwargs): """ Set the value of a particular setting. If it does not exist, option to create it. Args: key: settings key value: New value change_user: User object (must be staff member to update a core setting) create: If True, create a new setting if the specified key does not exist. """ if change_user is not None and not change_user.is_staff: return try: setting = cls.objects.get(**cls.get_filters(key, **kwargs)) except cls.DoesNotExist: if create: setting = cls(key=key, **kwargs) else: return # Enforce standard boolean representation if setting.is_bool(): value = InvenTree.helpers.str2bool(value) setting.value = str(value) setting.save() key = models.CharField(max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive)')) value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value')) @property def name(self): return self.__class__.get_setting_name(self.key) @property def default_value(self): return self.__class__.get_setting_default(self.key) @property def description(self): return self.__class__.get_setting_description(self.key) @property def units(self): return self.__class__.get_setting_units(self.key) def clean(self, **kwargs): """ If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field. """ super().clean() validator = self.__class__.get_setting_validator(self.key, **kwargs) if validator is not None: self.run_validator(validator) options = self.valid_options() if options and self.value not in options: raise ValidationError(_("Chosen value is not a valid option")) def run_validator(self, validator): """ Run a validator against the 'value' field for this InvenTreeSetting object. """ if validator is None: return value = self.value # Boolean validator if validator is bool: # Value must "look like" a boolean value if InvenTree.helpers.is_bool(value): # Coerce into either "True" or "False" value = InvenTree.helpers.str2bool(value) else: raise ValidationError({ 'value': _('Value must be a boolean value') }) # Integer validator if validator is int: try: # Coerce into an integer value value = int(value) except (ValueError, TypeError): raise ValidationError({ 'value': _('Value must be an integer value'), }) # If a list of validators is supplied, iterate through each one if type(validator) in [list, tuple]: for v in validator: self.run_validator(v) if callable(validator): # We can accept function validators with a single argument validator(self.value) def validate_unique(self, exclude=None, **kwargs): """ 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. """ super().validate_unique(exclude) try: setting = self.__class__.objects.exclude(id=self.id).filter(**self.get_filters(self.key, **kwargs)) if setting.exists(): raise ValidationError({'key': _('Key string must be unique')}) except self.DoesNotExist: pass def choices(self, **kwargs): """ Return the available choices for this setting (or None if no choices are defined) """ return self.__class__.get_setting_choices(self.key, **kwargs) def valid_options(self): """ Return a list of valid options for this setting """ choices = self.choices() if not choices: return None return [opt[0] for opt in choices] def is_choice(self, **kwargs): """ Check if this setting is a "choice" field """ return self.__class__.get_setting_choices(self.key, **kwargs) is not None def as_choice(self, **kwargs): """ Render this setting as the "display" value of a choice field, e.g. if the choices are: [('A4', 'A4 paper'), ('A3', 'A3 paper')], and the value is 'A4', then display 'A4 paper' """ choices = self.get_setting_choices(self.key, **kwargs) if not choices: return self.value for value, display in choices: if value == self.value: return display return self.value def is_bool(self, **kwargs): """ Check if this setting is required to be a boolean value """ validator = self.__class__.get_setting_validator(self.key, **kwargs) return self.__class__.validator_is_bool(validator) def as_bool(self): """ Return the value of this setting converted to a boolean value. Warning: Only use on values where is_bool evaluates to true! """ return InvenTree.helpers.str2bool(self.value) def setting_type(self, **kwargs): """ Return the field type identifier for this setting object """ if self.is_bool(**kwargs): return 'boolean' elif self.is_int(**kwargs): return 'integer' else: return 'string' @classmethod def validator_is_bool(cls, validator): if validator == bool: return True if type(validator) in [list, tuple]: for v in validator: if v == bool: return True return False def is_int(self, **kwargs): """ Check if the setting is required to be an integer value: """ validator = self.__class__.get_setting_validator(self.key, **kwargs) return self.__class__.validator_is_int(validator) @classmethod def validator_is_int(cls, validator): if validator == int: return True if type(validator) in [list, tuple]: for v in validator: if v == int: return True return False def as_int(self): """ Return the value of this setting converted to a boolean value. If an error occurs, return the default value """ try: value = int(self.value) except (ValueError, TypeError): value = self.default_value() return value @classmethod def is_protected(cls, key, **kwargs): """ Check if the setting value is protected """ setting = cls.get_setting_definition(key, **kwargs) return setting.get('protected', False) def settings_group_options(): """ Build up group tuple for settings based on your choices """ return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]] class InvenTreeSetting(BaseInvenTreeSetting): """ An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values). The class provides a way of retrieving the value for a particular key, even if that key does not exist. """ def save(self, *args, **kwargs): """ When saving a global setting, check to see if it requires a server restart. If so, set the "SERVER_RESTART_REQUIRED" setting to True """ super().save() if self.requires_restart(): InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None) """ Dict of all global settings values: The key of each item is the name of the value as it appears in the database. Each global setting has the following parameters: - name: Translatable string name of the setting (required) - description: Translatable string description of the setting (required) - default: Default value (optional) - units: Units of the particular setting (optional) - validator: Validation function for the setting (optional) The keys must be upper-case """ SETTINGS = { 'SERVER_RESTART_REQUIRED': { 'name': _('Restart required'), 'description': _('A setting has been changed which requires a server restart'), 'default': False, 'validator': bool, 'hidden': True, }, 'INVENTREE_INSTANCE': { 'name': _('InvenTree Instance Name'), 'default': 'InvenTree server', 'description': _('String descriptor for the server instance'), }, 'INVENTREE_INSTANCE_TITLE': { 'name': _('Use instance name'), 'description': _('Use the instance name in the title-bar'), 'validator': bool, 'default': False, }, 'INVENTREE_COMPANY_NAME': { 'name': _('Company name'), 'description': _('Internal company name'), 'default': 'My company name', }, 'INVENTREE_BASE_URL': { 'name': _('Base URL'), 'description': _('Base URL for server instance'), 'validator': EmptyURLValidator(), 'default': '', }, 'INVENTREE_DEFAULT_CURRENCY': { 'name': _('Default Currency'), 'description': _('Default currency'), 'default': 'USD', 'choices': CURRENCY_CHOICES, }, 'INVENTREE_DOWNLOAD_FROM_URL': { 'name': _('Download from URL'), 'description': _('Allow download of remote images and files from external URL'), 'validator': bool, 'default': False, }, 'BARCODE_ENABLE': { 'name': _('Barcode Support'), 'description': _('Enable barcode scanner support'), 'default': True, 'validator': bool, }, 'PART_IPN_REGEX': { 'name': _('IPN Regex'), 'description': _('Regular expression pattern for matching Part IPN') }, 'PART_ALLOW_DUPLICATE_IPN': { 'name': _('Allow Duplicate IPN'), 'description': _('Allow multiple parts to share the same IPN'), 'default': True, 'validator': bool, }, 'PART_ALLOW_EDIT_IPN': { 'name': _('Allow Editing IPN'), 'description': _('Allow changing the IPN value while editing a part'), 'default': True, 'validator': bool, }, 'PART_COPY_BOM': { 'name': _('Copy Part BOM Data'), 'description': _('Copy BOM data by default when duplicating a part'), 'default': True, 'validator': bool, }, 'PART_COPY_PARAMETERS': { 'name': _('Copy Part Parameter Data'), 'description': _('Copy parameter data by default when duplicating a part'), 'default': True, 'validator': bool, }, 'PART_COPY_TESTS': { 'name': _('Copy Part Test Data'), 'description': _('Copy test data by default when duplicating a part'), 'default': True, 'validator': bool }, 'PART_CATEGORY_PARAMETERS': { 'name': _('Copy Category Parameter Templates'), 'description': _('Copy category parameter templates when creating a part'), 'default': True, 'validator': bool }, 'PART_TEMPLATE': { 'name': _('Template'), 'description': _('Parts are templates by default'), 'default': False, 'validator': bool, }, 'PART_ASSEMBLY': { 'name': _('Assembly'), 'description': _('Parts can be assembled from other components by default'), 'default': False, 'validator': bool, }, 'PART_COMPONENT': { 'name': _('Component'), 'description': _('Parts can be used as sub-components by default'), 'default': True, 'validator': bool, }, 'PART_PURCHASEABLE': { 'name': _('Purchaseable'), 'description': _('Parts are purchaseable by default'), 'default': True, 'validator': bool, }, 'PART_SALABLE': { 'name': _('Salable'), 'description': _('Parts are salable by default'), 'default': False, 'validator': bool, }, 'PART_TRACKABLE': { 'name': _('Trackable'), 'description': _('Parts are trackable by default'), 'default': False, 'validator': bool, }, 'PART_VIRTUAL': { 'name': _('Virtual'), 'description': _('Parts are virtual by default'), 'default': False, 'validator': bool, }, 'PART_SHOW_IMPORT': { 'name': _('Show Import in Views'), 'description': _('Display the import wizard in some part views'), 'default': False, 'validator': bool, }, 'PART_SHOW_PRICE_IN_FORMS': { 'name': _('Show Price in Forms'), 'description': _('Display part price in some forms'), 'default': True, 'validator': bool, }, # 2021-10-08 # This setting exists as an interim solution for https://github.com/inventree/InvenTree/issues/2042 # The BOM API can be extremely slow when calculating pricing information "on the fly" # A future solution will solve this properly, # but as an interim step we provide a global to enable / disable BOM pricing 'PART_SHOW_PRICE_IN_BOM': { 'name': _('Show Price in BOM'), 'description': _('Include pricing information in BOM tables'), 'default': True, 'validator': bool, }, # 2022-02-03 # This setting exists as an interim solution for extremely slow part page load times when the part has a complex BOM # In an upcoming release, pricing history (and BOM pricing) will be cached, # rather than having to be re-calculated every time the page is loaded! # For now, we will simply hide part pricing by default 'PART_SHOW_PRICE_HISTORY': { 'name': _('Show Price History'), 'description': _('Display historical pricing for Part'), 'default': False, 'validator': bool, }, 'PART_SHOW_RELATED': { 'name': _('Show related parts'), 'description': _('Display related parts for a part'), 'default': True, 'validator': bool, }, 'PART_CREATE_INITIAL': { 'name': _('Create initial stock'), 'description': _('Create initial stock on part creation'), 'default': False, 'validator': bool, }, 'PART_INTERNAL_PRICE': { 'name': _('Internal Prices'), 'description': _('Enable internal prices for parts'), 'default': False, 'validator': bool }, 'PART_BOM_USE_INTERNAL_PRICE': { 'name': _('Internal Price as BOM-Price'), 'description': _('Use the internal price (if set) in BOM-price calculations'), 'default': False, 'validator': bool }, 'PART_NAME_FORMAT': { 'name': _('Part Name Display Format'), 'description': _('Format to display the part name'), 'default': "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}{{ ' | ' if part.revision }}" "{{ part.revision if part.revision }}", 'validator': InvenTree.validators.validate_part_name_format }, 'REPORT_ENABLE': { 'name': _('Enable Reports'), 'description': _('Enable generation of reports'), 'default': False, 'validator': bool, }, 'REPORT_DEBUG_MODE': { 'name': _('Debug Mode'), 'description': _('Generate reports in debug mode (HTML output)'), 'default': False, 'validator': bool, }, 'REPORT_DEFAULT_PAGE_SIZE': { 'name': _('Page Size'), 'description': _('Default page size for PDF reports'), 'default': 'A4', 'choices': [ ('A4', 'A4'), ('Legal', 'Legal'), ('Letter', 'Letter') ], }, 'REPORT_ENABLE_TEST_REPORT': { 'name': _('Test Reports'), 'description': _('Enable generation of test reports'), 'default': True, 'validator': bool, }, 'STOCK_ENABLE_EXPIRY': { 'name': _('Stock Expiry'), 'description': _('Enable stock expiry functionality'), 'default': False, 'validator': bool, }, 'STOCK_ALLOW_EXPIRED_SALE': { 'name': _('Sell Expired Stock'), 'description': _('Allow sale of expired stock'), 'default': False, 'validator': bool, }, 'STOCK_STALE_DAYS': { 'name': _('Stock Stale Time'), 'description': _('Number of days stock items are considered stale before expiring'), 'default': 0, 'units': _('days'), 'validator': [int], }, 'STOCK_ALLOW_EXPIRED_BUILD': { 'name': _('Build Expired Stock'), 'description': _('Allow building with expired stock'), 'default': False, 'validator': bool, }, 'STOCK_OWNERSHIP_CONTROL': { 'name': _('Stock Ownership Control'), 'description': _('Enable ownership control over stock locations and items'), 'default': False, 'validator': bool, }, 'BUILDORDER_REFERENCE_PREFIX': { 'name': _('Build Order Reference Prefix'), 'description': _('Prefix value for build order reference'), 'default': 'BO', }, 'BUILDORDER_REFERENCE_REGEX': { 'name': _('Build Order Reference Regex'), 'description': _('Regular expression pattern for matching build order reference') }, 'SALESORDER_REFERENCE_PREFIX': { 'name': _('Sales Order Reference Prefix'), 'description': _('Prefix value for sales order reference'), 'default': 'SO', }, 'PURCHASEORDER_REFERENCE_PREFIX': { 'name': _('Purchase Order Reference Prefix'), 'description': _('Prefix value for purchase order reference'), 'default': 'PO', }, # login / SSO 'LOGIN_ENABLE_PWD_FORGOT': { 'name': _('Enable password forgot'), 'description': _('Enable password forgot function on the login pages'), 'default': True, 'validator': bool, }, 'LOGIN_ENABLE_REG': { 'name': _('Enable registration'), 'description': _('Enable self-registration for users on the login pages'), 'default': False, 'validator': bool, }, 'LOGIN_ENABLE_SSO': { 'name': _('Enable SSO'), 'description': _('Enable SSO on the login pages'), 'default': False, 'validator': bool, }, 'LOGIN_MAIL_REQUIRED': { 'name': _('Email required'), 'description': _('Require user to supply mail on signup'), 'default': False, 'validator': bool, }, 'LOGIN_SIGNUP_SSO_AUTO': { 'name': _('Auto-fill SSO users'), 'description': _('Automatically fill out user-details from SSO account-data'), 'default': True, 'validator': bool, }, 'LOGIN_SIGNUP_MAIL_TWICE': { 'name': _('Mail twice'), 'description': _('On signup ask users twice for their mail'), 'default': False, 'validator': bool, }, 'LOGIN_SIGNUP_PWD_TWICE': { 'name': _('Password twice'), 'description': _('On signup ask users twice for their password'), 'default': True, 'validator': bool, }, 'SIGNUP_GROUP': { 'name': _('Group on signup'), 'description': _('Group to which new users are assigned on registration'), 'default': '', 'choices': settings_group_options }, 'LOGIN_ENFORCE_MFA': { 'name': _('Enforce MFA'), 'description': _('Users must use multifactor security.'), 'default': False, 'validator': bool, }, # Settings for plugin mixin features 'ENABLE_PLUGINS_URL': { 'name': _('Enable URL integration'), 'description': _('Enable plugins to add URL routes'), 'default': False, 'validator': bool, 'requires_restart': True, }, 'ENABLE_PLUGINS_NAVIGATION': { 'name': _('Enable navigation integration'), 'description': _('Enable plugins to integrate into navigation'), 'default': False, 'validator': bool, 'requires_restart': True, }, 'ENABLE_PLUGINS_APP': { 'name': _('Enable app integration'), 'description': _('Enable plugins to add apps'), 'default': False, 'validator': bool, 'requires_restart': True, }, 'ENABLE_PLUGINS_SCHEDULE': { 'name': _('Enable schedule integration'), 'description': _('Enable plugins to run scheduled tasks'), 'default': False, 'validator': bool, 'requires_restart': True, }, 'ENABLE_PLUGINS_EVENTS': { 'name': _('Enable event integration'), 'description': _('Enable plugins to respond to internal events'), 'default': False, 'validator': bool, 'requires_restart': True, }, } class Meta: verbose_name = "InvenTree Setting" verbose_name_plural = "InvenTree Settings" key = models.CharField( max_length=50, blank=False, unique=True, help_text=_('Settings key (must be unique - case insensitive'), ) def to_native_value(self): """ Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1 """ return self.__class__.get_setting(self.key) def requires_restart(self): """ Return True if this setting requires a server restart after changing """ options = InvenTreeSetting.SETTINGS.get(self.key, None) if options: return options.get('requires_restart', False) else: return False class InvenTreeUserSetting(BaseInvenTreeSetting): """ An InvenTreeSetting object with a usercontext """ SETTINGS = { 'HOMEPAGE_PART_STARRED': { 'name': _('Show subscribed parts'), 'description': _('Show subscribed parts on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_CATEGORY_STARRED': { 'name': _('Show subscribed categories'), 'description': _('Show subscribed part categories on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_PART_LATEST': { 'name': _('Show latest parts'), 'description': _('Show latest parts on the homepage'), 'default': True, 'validator': bool, }, 'PART_RECENT_COUNT': { 'name': _('Recent Part Count'), 'description': _('Number of recent parts to display on index page'), 'default': 10, 'validator': [int, MinValueValidator(1)] }, 'HOMEPAGE_BOM_VALIDATION': { 'name': _('Show unvalidated BOMs'), 'description': _('Show BOMs that await validation on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_STOCK_RECENT': { 'name': _('Show recent stock changes'), 'description': _('Show recently changed stock items on the homepage'), 'default': True, 'validator': bool, }, 'STOCK_RECENT_COUNT': { 'name': _('Recent Stock Count'), 'description': _('Number of recent stock items to display on index page'), 'default': 10, 'validator': [int, MinValueValidator(1)] }, 'HOMEPAGE_STOCK_LOW': { 'name': _('Show low stock'), 'description': _('Show low stock items on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_STOCK_DEPLETED': { 'name': _('Show depleted stock'), 'description': _('Show depleted stock items on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_STOCK_NEEDED': { 'name': _('Show needed stock'), 'description': _('Show stock items needed for builds on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_STOCK_EXPIRED': { 'name': _('Show expired stock'), 'description': _('Show expired stock items on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_STOCK_STALE': { 'name': _('Show stale stock'), 'description': _('Show stale stock items on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_BUILD_PENDING': { 'name': _('Show pending builds'), 'description': _('Show pending builds on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_BUILD_OVERDUE': { 'name': _('Show overdue builds'), 'description': _('Show overdue builds on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_PO_OUTSTANDING': { 'name': _('Show outstanding POs'), 'description': _('Show outstanding POs on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_PO_OVERDUE': { 'name': _('Show overdue POs'), 'description': _('Show overdue POs on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_SO_OUTSTANDING': { 'name': _('Show outstanding SOs'), 'description': _('Show outstanding SOs on the homepage'), 'default': True, 'validator': bool, }, 'HOMEPAGE_SO_OVERDUE': { 'name': _('Show overdue SOs'), 'description': _('Show overdue SOs on the homepage'), 'default': True, 'validator': bool, }, "LABEL_INLINE": { 'name': _('Inline label display'), 'description': _('Display PDF labels in the browser, instead of downloading as a file'), 'default': True, 'validator': bool, }, "REPORT_INLINE": { 'name': _('Inline report display'), 'description': _('Display PDF reports in the browser, instead of downloading as a file'), 'default': False, 'validator': bool, }, 'SEARCH_PREVIEW_RESULTS': { 'name': _('Search Preview Results'), 'description': _('Number of results to show in search preview window'), 'default': 10, 'validator': [int, MinValueValidator(1)] }, 'SEARCH_SHOW_STOCK_LEVELS': { 'name': _('Search Show Stock'), 'description': _('Display stock levels in search preview window'), 'default': True, 'validator': bool, }, 'SEARCH_HIDE_INACTIVE_PARTS': { 'name': _("Hide Inactive Parts"), 'description': _('Hide inactive parts in search preview window'), 'default': False, 'validator': bool, }, 'PART_SHOW_QUANTITY_IN_FORMS': { 'name': _('Show Quantity in Forms'), 'description': _('Display available part quantity in some forms'), 'default': True, 'validator': bool, }, 'FORMS_CLOSE_USING_ESCAPE': { 'name': _('Escape Key Closes Forms'), 'description': _('Use the escape key to close modal forms'), 'default': False, 'validator': bool, }, 'STICKY_HEADER': { 'name': _('Fixed Navbar'), 'description': _('InvenTree navbar position is fixed to the top of the screen'), 'default': False, 'validator': bool, }, 'DATE_DISPLAY_FORMAT': { 'name': _('Date Format'), 'description': _('Preferred format for displaying dates'), 'default': 'YYYY-MM-DD', 'choices': [ ('YYYY-MM-DD', '2022-02-22'), ('YYYY/MM/DD', '2022/22/22'), ('DD-MM-YYYY', '22-02-2022'), ('DD/MM/YYYY', '22/02/2022'), ('MM-DD-YYYY', '02-22-2022'), ('MM/DD/YYYY', '02/22/2022'), ('MMM DD YYYY', 'Feb 22 2022'), ] } } class Meta: verbose_name = "InvenTree User Setting" verbose_name_plural = "InvenTree User Settings" constraints = [ models.UniqueConstraint(fields=['key', 'user'], name='unique key and user') ] key = models.CharField( max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive'), ) user = models.ForeignKey( User, on_delete=models.CASCADE, blank=True, null=True, verbose_name=_('User'), help_text=_('User'), ) @classmethod def get_setting_object(cls, key, user): return super().get_setting_object(key, user=user) def validate_unique(self, exclude=None): return super().validate_unique(exclude=exclude, user=self.user) @classmethod def get_filters(cls, key, **kwargs): return { 'key__iexact': key, 'user__id': kwargs['user'].id } def to_native_value(self): """ Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1 """ return self.__class__.get_setting(self.key, user=self.user) class PriceBreak(models.Model): """ Represents a PriceBreak model """ class Meta: abstract = True quantity = InvenTree.fields.RoundingDecimalField( max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)], verbose_name=_('Quantity'), help_text=_('Price break quantity'), ) price = InvenTree.fields.InvenTreeModelMoneyField( max_digits=19, decimal_places=4, null=True, verbose_name=_('Price'), help_text=_('Unit price at specified quantity'), ) def convert_to(self, currency_code): """ Convert the unit-price at this price break to the specified currency code. Args: currency_code - The currency code to convert to (e.g "USD" or "AUD") """ try: converted = convert_money(self.price, currency_code) except MissingRate: logger.warning(f"No currency conversion rate available for {self.price_currency} -> {currency_code}") return self.price.amount return converted.amount def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'): """ Calculate the price based on quantity price breaks. - Don't forget to add in flat-fee cost (base_cost field) - If MOQ (minimum order quantity) is required, bump quantity - If order multiples are to be observed, then we need to calculate based on that, too """ from common.settings import currency_code_default if hasattr(instance, break_name): price_breaks = getattr(instance, break_name).all() else: price_breaks = [] # No price break information available? if len(price_breaks) == 0: return None # Check if quantity is fraction and disable multiples multiples = (quantity % 1 == 0) # Order multiples if multiples: quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple) pb_found = False pb_quantity = -1 pb_cost = 0.0 if currency is None: # Default currency selection currency = currency_code_default() pb_min = None for pb in price_breaks: # Store smallest price break if not pb_min: pb_min = pb # Ignore this pricebreak (quantity is too high) if pb.quantity > quantity: continue pb_found = True # If this price-break quantity is the largest so far, use it! if pb.quantity > pb_quantity: pb_quantity = pb.quantity # Convert everything to the selected currency pb_cost = pb.convert_to(currency) # Use smallest price break if not pb_found and pb_min: # Update price break information pb_quantity = pb_min.quantity pb_cost = pb_min.convert_to(currency) # Trigger cost calculation using smallest price break pb_found = True # Convert quantity to decimal.Decimal format quantity = decimal.Decimal(f'{quantity}') if pb_found: cost = pb_cost * quantity return InvenTree.helpers.normalize(cost + instance.base_cost) else: return None class ColorTheme(models.Model): """ Color Theme Setting """ name = models.CharField(max_length=20, default='', blank=True) user = models.CharField(max_length=150, unique=True) @classmethod def get_color_themes_choices(cls): """ Get all color themes from static folder """ # Get files list from css/color-themes/ folder files_list = [] for file in os.listdir(settings.STATIC_COLOR_THEMES_DIR): files_list.append(os.path.splitext(file)) # Get color themes choices (CSS sheets) choices = [(file_name.lower(), _(file_name.replace('-', ' ').title())) for file_name, file_ext in files_list if file_ext == '.css'] return choices @classmethod def is_valid_choice(cls, user_color_theme): """ Check if color theme is valid choice """ try: user_color_theme_name = user_color_theme.name except AttributeError: return False for color_theme in cls.get_color_themes_choices(): if user_color_theme_name == color_theme[0]: return True return False class VerificationMethod: NONE = 0 TOKEN = 1 HMAC = 2 class WebhookEndpoint(models.Model): """ Defines a Webhook entdpoint Attributes: endpoint_id: Path to the webhook, name: Name of the webhook, active: Is this webhook active?, user: User associated with webhook, token: Token for sending a webhook, secret: Shared secret for HMAC verification, """ # Token TOKEN_NAME = "Token" VERIFICATION_METHOD = VerificationMethod.NONE MESSAGE_OK = "Message was received." MESSAGE_TOKEN_ERROR = "Incorrect token in header." endpoint_id = models.CharField( max_length=255, verbose_name=_('Endpoint'), help_text=_('Endpoint at which this webhook is received'), default=uuid.uuid4, editable=False, ) name = models.CharField( max_length=255, blank=True, null=True, verbose_name=_('Name'), help_text=_('Name for this webhook') ) active = models.BooleanField( default=True, verbose_name=_('Active'), help_text=_('Is this webhook active') ) user = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('User'), help_text=_('User'), ) token = models.CharField( max_length=255, blank=True, null=True, verbose_name=_('Token'), help_text=_('Token for access'), default=uuid.uuid4, ) secret = models.CharField( max_length=255, blank=True, null=True, verbose_name=_('Secret'), help_text=_('Shared secret for HMAC'), ) # To be overridden def init(self, request, *args, **kwargs): self.verify = self.VERIFICATION_METHOD def process_webhook(self): if self.token: self.verify = VerificationMethod.TOKEN # TODO make a object-setting if self.secret: self.verify = VerificationMethod.HMAC # TODO make a object-setting return True def validate_token(self, payload, headers, request): token = headers.get(self.TOKEN_NAME, "") # no token if self.verify == VerificationMethod.NONE: # do nothing as no method was chosen pass # static token elif self.verify == VerificationMethod.TOKEN: if not compare_digest(token, self.token): raise PermissionDenied(self.MESSAGE_TOKEN_ERROR) # hmac token elif self.verify == VerificationMethod.HMAC: digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest() computed_hmac = base64.b64encode(digest) if not hmac.compare_digest(computed_hmac, token.encode('utf-8')): raise PermissionDenied(self.MESSAGE_TOKEN_ERROR) return True def save_data(self, payload, headers=None, request=None): return WebhookMessage.objects.create( host=request.get_host(), header=json.dumps({key: val for key, val in headers.items()}), body=payload, endpoint=self, ) def process_payload(self, message, payload=None, headers=None): return True def get_return(self, payload, headers=None, request=None): return self.MESSAGE_OK class WebhookMessage(models.Model): """ Defines a webhook message Attributes: message_id: Unique identifier for this message, host: Host from which this message was received, header: Header of this message, body: Body of this message, endpoint: Endpoint on which this message was received, worked_on: Was the work on this message finished? """ message_id = models.UUIDField( verbose_name=_('Message ID'), help_text=_('Unique identifier for this message'), primary_key=True, default=uuid.uuid4, editable=False, ) host = models.CharField( max_length=255, verbose_name=_('Host'), help_text=_('Host from which this message was received'), editable=False, ) header = models.CharField( max_length=255, blank=True, null=True, verbose_name=_('Header'), help_text=_('Header of this message'), editable=False, ) body = models.JSONField( blank=True, null=True, verbose_name=_('Body'), help_text=_('Body of this message'), editable=False, ) endpoint = models.ForeignKey( WebhookEndpoint, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Endpoint'), help_text=_('Endpoint on which this message was received'), ) worked_on = models.BooleanField( default=False, verbose_name=_('Worked on'), help_text=_('Was the work on this message finished?'), ) class NotificationEntry(models.Model): """ A NotificationEntry records the last time a particular notifaction was sent out. It is recorded to ensure that notifications are not sent out "too often" to users. Attributes: - key: A text entry describing the notification e.g. 'part.notify_low_stock' - uid: An (optional) numerical ID for a particular instance - date: The last time this notification was sent """ class Meta: unique_together = [ ('key', 'uid'), ] key = models.CharField( max_length=250, blank=False, ) uid = models.IntegerField( ) updated = models.DateTimeField( auto_now=True, null=False, ) @classmethod def check_recent(cls, key: str, uid: int, delta: timedelta): """ Test if a particular notification has been sent in the specified time period """ since = datetime.now().date() - delta entries = cls.objects.filter( key=key, uid=uid, updated__gte=since ) return entries.exists() @classmethod def notify(cls, key: str, uid: int): """ Notify the database that a particular notification has been sent out """ entry, created = cls.objects.get_or_create( key=key, uid=uid ) entry.save()