mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	
							
								
								
									
										7
									
								
								.github/workflows/postgresql.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/postgresql.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -27,6 +27,7 @@ jobs: | ||||
|       INVENTREE_DEBUG: info | ||||
|       INVENTREE_MEDIA_ROOT: ./media | ||||
|       INVENTREE_STATIC_ROOT: ./static | ||||
|       INVENTREE_CACHE_HOST: localhost | ||||
|  | ||||
|     services: | ||||
|       postgres: | ||||
| @@ -37,6 +38,11 @@ jobs: | ||||
|         ports: | ||||
|           - 5432:5432 | ||||
|  | ||||
|       redis: | ||||
|         image: redis | ||||
|         ports: | ||||
|           - 6379:6379 | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout Code | ||||
|         uses: actions/checkout@v2 | ||||
| @@ -49,6 +55,7 @@ jobs: | ||||
|           sudo apt-get install libpq-dev | ||||
|           pip3 install invoke | ||||
|           pip3 install psycopg2 | ||||
|           pip3 install django-redis>=5.0.0 | ||||
|           invoke install | ||||
|       - name: Run Tests | ||||
|         run: invoke test | ||||
|   | ||||
| @@ -118,7 +118,9 @@ class InvenTreeMetadata(SimpleMetadata): | ||||
|             # Iterate through simple fields | ||||
|             for name, field in model_fields.fields.items(): | ||||
|  | ||||
|                 if field.has_default() and name in serializer_info.keys(): | ||||
|                 if name in serializer_info.keys(): | ||||
|  | ||||
|                     if field.has_default(): | ||||
|  | ||||
|                         default = field.default | ||||
|  | ||||
| @@ -133,6 +135,15 @@ class InvenTreeMetadata(SimpleMetadata): | ||||
|                     elif name in model_default_values: | ||||
|                         serializer_info[name]['default'] = model_default_values[name] | ||||
|  | ||||
|                     # Attributes to copy from the model to the field (if they don't exist) | ||||
|                     attributes = ['help_text'] | ||||
|  | ||||
|                     for attr in attributes: | ||||
|                         if attr not in serializer_info[name]: | ||||
|  | ||||
|                             if hasattr(field, attr): | ||||
|                                 serializer_info[name][attr] = getattr(field, attr) | ||||
|  | ||||
|             # Iterate through relations | ||||
|             for name, relation in model_fields.relations.items(): | ||||
|  | ||||
|   | ||||
| @@ -296,3 +296,17 @@ class InvenTreeImageSerializerField(serializers.ImageField): | ||||
|             return None | ||||
|  | ||||
|         return os.path.join(str(settings.MEDIA_URL), str(value)) | ||||
|  | ||||
|  | ||||
| class InvenTreeDecimalField(serializers.FloatField): | ||||
|     """ | ||||
|     Custom serializer for decimal fields. Solves the following issues: | ||||
|  | ||||
|     - The normal DRF DecimalField renders values with trailing zeros | ||||
|     - Using a FloatField can result in rounding issues: https://code.djangoproject.com/ticket/30290 | ||||
|     """ | ||||
|  | ||||
|     def to_internal_value(self, data): | ||||
|  | ||||
|         # Convert the value to a string, and then a decimal | ||||
|         return Decimal(str(data)) | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import logging | ||||
|  | ||||
| import os | ||||
| import random | ||||
| import socket | ||||
| import string | ||||
| import shutil | ||||
| import sys | ||||
| @@ -91,6 +92,12 @@ DEBUG = _is_true(get_setting( | ||||
|     CONFIG.get('debug', True) | ||||
| )) | ||||
|  | ||||
| # Determine if we are running in "demo mode" | ||||
| DEMO_MODE = _is_true(get_setting( | ||||
|     'INVENTREE_DEMO', | ||||
|     CONFIG.get('demo', False) | ||||
| )) | ||||
|  | ||||
| DOCKER = _is_true(get_setting( | ||||
|     'INVENTREE_DOCKER', | ||||
|     False | ||||
| @@ -233,7 +240,10 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes') | ||||
| MEDIA_URL = '/media/' | ||||
|  | ||||
| if DEBUG: | ||||
|     logger.info("InvenTree running in DEBUG mode") | ||||
|     logger.info("InvenTree running with DEBUG enabled") | ||||
|  | ||||
| if DEMO_MODE: | ||||
|     logger.warning("InvenTree running in DEMO mode") | ||||
|  | ||||
| logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") | ||||
| logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") | ||||
| @@ -370,30 +380,6 @@ REST_FRAMEWORK = { | ||||
|  | ||||
| WSGI_APPLICATION = 'InvenTree.wsgi.application' | ||||
|  | ||||
| background_workers = os.environ.get('INVENTREE_BACKGROUND_WORKERS', None) | ||||
|  | ||||
| if background_workers is not None: | ||||
|     try: | ||||
|         background_workers = int(background_workers) | ||||
|     except ValueError: | ||||
|         background_workers = None | ||||
|  | ||||
| if background_workers is None: | ||||
|     # Sensible default? | ||||
|     background_workers = 4 | ||||
|  | ||||
| # django-q configuration | ||||
| Q_CLUSTER = { | ||||
|     'name': 'InvenTree', | ||||
|     'workers': background_workers, | ||||
|     'timeout': 90, | ||||
|     'retry': 120, | ||||
|     'queue_limit': 50, | ||||
|     'bulk': 10, | ||||
|     'orm': 'default', | ||||
|     'sync': False, | ||||
| } | ||||
|  | ||||
| """ | ||||
| Configure the database backend based on the user-specified values. | ||||
|  | ||||
| @@ -571,11 +557,83 @@ DATABASES = { | ||||
| } | ||||
|  | ||||
|  | ||||
| CACHES = { | ||||
|     'default': { | ||||
|         'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', | ||||
| _cache_config = CONFIG.get("cache", {}) | ||||
| _cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST")) | ||||
| _cache_port = _cache_config.get( | ||||
|     "port", os.getenv("INVENTREE_CACHE_PORT", "6379") | ||||
| ) | ||||
|  | ||||
| if _cache_host: | ||||
|     # We are going to rely upon a possibly non-localhost for our cache, | ||||
|     # so don't wait too long for the cache as nothing in the cache should be | ||||
|     # irreplacable.  Django Q Cluster will just try again later. | ||||
|     _cache_options = { | ||||
|         "CLIENT_CLASS": "django_redis.client.DefaultClient", | ||||
|         "SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")), | ||||
|         "SOCKET_TIMEOUT": int(os.getenv("CACHE_SOCKET_TIMEOUT", "2")), | ||||
|         "CONNECTION_POOL_KWARGS": { | ||||
|             "socket_keepalive": _is_true( | ||||
|                 os.getenv("CACHE_TCP_KEEPALIVE", "1") | ||||
|             ), | ||||
|             "socket_keepalive_options": { | ||||
|                 socket.TCP_KEEPCNT: int( | ||||
|                     os.getenv("CACHE_KEEPALIVES_COUNT", "5") | ||||
|                 ), | ||||
|                 socket.TCP_KEEPIDLE: int( | ||||
|                     os.getenv("CACHE_KEEPALIVES_IDLE", "1") | ||||
|                 ), | ||||
|                 socket.TCP_KEEPINTVL: int( | ||||
|                     os.getenv("CACHE_KEEPALIVES_INTERVAL", "1") | ||||
|                 ), | ||||
|                 socket.TCP_USER_TIMEOUT: int( | ||||
|                     os.getenv("CACHE_TCP_USER_TIMEOUT", "1000") | ||||
|                 ), | ||||
|             }, | ||||
|         }, | ||||
|     } | ||||
|     CACHES = { | ||||
|         # Connection configuration for Django Q Cluster | ||||
|         "worker": { | ||||
|             "BACKEND": "django_redis.cache.RedisCache", | ||||
|             "LOCATION": f"redis://{_cache_host}:{_cache_port}/0", | ||||
|             "OPTIONS": _cache_options, | ||||
|         }, | ||||
|         "default": { | ||||
|             "BACKEND": "django_redis.cache.RedisCache", | ||||
|             "LOCATION": f"redis://{_cache_host}:{_cache_port}/1", | ||||
|             "OPTIONS": _cache_options, | ||||
|         }, | ||||
|     } | ||||
| else: | ||||
|     CACHES = { | ||||
|         "default": { | ||||
|             "BACKEND": "django.core.cache.backends.locmem.LocMemCache", | ||||
|         }, | ||||
|     } | ||||
|  | ||||
| try: | ||||
|     # 4 background workers seems like a sensible default | ||||
|     background_workers = int(os.environ.get('INVENTREE_BACKGROUND_WORKERS', 4)) | ||||
| except ValueError: | ||||
|     background_workers = 4 | ||||
|  | ||||
| # django-q configuration | ||||
| Q_CLUSTER = { | ||||
|     'name': 'InvenTree', | ||||
|     'workers': background_workers, | ||||
|     'timeout': 90, | ||||
|     'retry': 120, | ||||
|     'queue_limit': 50, | ||||
|     'bulk': 10, | ||||
|     'orm': 'default', | ||||
|     'sync': False, | ||||
| } | ||||
|  | ||||
| if _cache_host: | ||||
|     # If using external redis cache, make the cache the broker for Django Q | ||||
|     # as well | ||||
|     Q_CLUSTER["django_redis"] = "worker" | ||||
|  | ||||
|  | ||||
| # Password validation | ||||
| # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators | ||||
| @@ -615,6 +673,7 @@ LANGUAGES = [ | ||||
|     ('el', _('Greek')), | ||||
|     ('en', _('English')), | ||||
|     ('es', _('Spanish')), | ||||
|     ('es-mx', _('Spanish (Mexican')), | ||||
|     ('fr', _('French')), | ||||
|     ('he', _('Hebrew')), | ||||
|     ('it', _('Italian')), | ||||
| @@ -623,6 +682,7 @@ LANGUAGES = [ | ||||
|     ('nl', _('Dutch')), | ||||
|     ('no', _('Norwegian')), | ||||
|     ('pl', _('Polish')), | ||||
|     ('pt', _('Portugese')), | ||||
|     ('ru', _('Russian')), | ||||
|     ('sv', _('Swedish')), | ||||
|     ('th', _('Thai')), | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 461 KiB | 
							
								
								
									
										
											BIN
										
									
								
								InvenTree/InvenTree/static/img/paper_splash_large.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								InvenTree/InvenTree/static/img/paper_splash_large.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.2 MiB | 
| @@ -208,7 +208,7 @@ function inventreeDocReady() { | ||||
|     }); | ||||
|  | ||||
|     // Callback for "admin view" button | ||||
|     $('#admin-button').click(function() { | ||||
|     $('#admin-button, .admin-button').click(function() { | ||||
|         var url = $(this).attr('url'); | ||||
|  | ||||
|         location.href = url; | ||||
|   | ||||
| @@ -1,13 +0,0 @@ | ||||
| from rest_framework.views import exception_handler | ||||
|  | ||||
|  | ||||
| def api_exception_handler(exc, context): | ||||
|     response = exception_handler(exc, context) | ||||
|  | ||||
|     # Now add the HTTP status code to the response. | ||||
|     if response is not None: | ||||
|  | ||||
|         data = {'error': response.data} | ||||
|         response.data = data | ||||
|  | ||||
|     return response | ||||
| @@ -12,11 +12,16 @@ import common.models | ||||
| INVENTREE_SW_VERSION = "0.6.0 dev" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 17 | ||||
| INVENTREE_API_VERSION = 18 | ||||
|  | ||||
| """ | ||||
| Increment this API version number whenever there is a significant change to the API that any clients need to know about | ||||
|  | ||||
| v18 -> 2021-11-15 | ||||
|     - Adds the ability to filter BomItem API by "uses" field | ||||
|     - This returns a list of all BomItems which "use" the specified part | ||||
|     - Includes inherited BomItem objects | ||||
|  | ||||
| v17 -> 2021-11-09 | ||||
|     - Adds API endpoints for GLOBAL and USER settings objects | ||||
|     - Ref: https://github.com/inventree/InvenTree/pull/2275 | ||||
|   | ||||
| @@ -18,8 +18,9 @@ from rest_framework.serializers import ValidationError | ||||
| from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer | ||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief | ||||
|  | ||||
| from InvenTree.status_codes import StockStatus | ||||
| import InvenTree.helpers | ||||
| from InvenTree.serializers import InvenTreeDecimalField | ||||
| from InvenTree.status_codes import StockStatus | ||||
|  | ||||
| from stock.models import StockItem, StockLocation | ||||
| from stock.serializers import StockItemSerializerBrief, LocationSerializer | ||||
| @@ -41,7 +42,7 @@ class BuildSerializer(InvenTreeModelSerializer): | ||||
|  | ||||
|     part_detail = PartBriefSerializer(source='part', many=False, read_only=True) | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     overdue = serializers.BooleanField(required=False, read_only=True) | ||||
|  | ||||
| @@ -473,7 +474,7 @@ class BuildItemSerializer(InvenTreeModelSerializer): | ||||
|     stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) | ||||
|     location_detail = LocationSerializer(source='stock_item.location', read_only=True) | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|  | ||||
|   | ||||
| @@ -34,6 +34,7 @@ src="{% static 'img/blank_image.png' %}" | ||||
| {% include "admin_button.html" with url=url %} | ||||
| {% endif %} | ||||
| <!-- Printing options --> | ||||
| {% if report_enabled %} | ||||
| <div class='btn-group'> | ||||
|     <button id='print-options' title='{% trans "Print actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'> | ||||
|         <span class='fas fa-print'></span> <span class='caret'></span> | ||||
| @@ -42,6 +43,7 @@ src="{% static 'img/blank_image.png' %}" | ||||
|         <li><a class='dropdown-item' href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print build order report" %}</a></li> | ||||
|     </ul> | ||||
| </div> | ||||
| {% endif %} | ||||
| <!-- Build actions --> | ||||
| {% if roles.build.change %} | ||||
| <div class='btn-group'> | ||||
| @@ -224,9 +226,11 @@ src="{% static 'img/blank_image.png' %}" | ||||
|         {% endif %} | ||||
|     }); | ||||
|  | ||||
|     {% if report_enabled %} | ||||
|     $('#print-build-report').click(function() { | ||||
|         printBuildReports([{{ build.pk }}]); | ||||
|     }); | ||||
|     {% endif %} | ||||
|  | ||||
|     $("#build-delete").on('click', function() { | ||||
|         launchModalForm( | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
|         <div class='button-toolbar container-fluid' style='float: right;'> | ||||
|             <div class='btn-group' role='group'> | ||||
|                  | ||||
|                 {% if report_enabled %} | ||||
|                 <div class='btn-group' role='group'> | ||||
|                     <!-- Print actions --> | ||||
|                     <button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'> | ||||
| @@ -38,6 +39,7 @@ | ||||
|                         </a></li> | ||||
|                     </ul> | ||||
|                 </div> | ||||
|                 {% endif %} | ||||
|                 <!-- Buttons to switch between list and calendar views --> | ||||
|                 <button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'> | ||||
|                     <span class='fas fa-calendar-alt'></span> | ||||
| @@ -181,6 +183,7 @@ loadBuildTable($("#build-table"), { | ||||
|     url: "{% url 'api-build-list' %}", | ||||
| }); | ||||
|  | ||||
| {% if report_enabled %} | ||||
| $('#multi-build-print').click(function() { | ||||
|     var rows = $("#build-table").bootstrapTable('getSelections'); | ||||
|  | ||||
| @@ -192,5 +195,6 @@ $('#multi-build-print').click(function() { | ||||
|  | ||||
|     printBuildReports(build_ids); | ||||
| }); | ||||
| {% endif %} | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -12,11 +12,31 @@ class SettingsAdmin(ImportExportModelAdmin): | ||||
|  | ||||
|     list_display = ('key', 'value') | ||||
|  | ||||
|     def get_readonly_fields(self, request, obj=None): | ||||
|         """ | ||||
|         Prevent the 'key' field being edited once the setting is created | ||||
|         """ | ||||
|  | ||||
|         if obj: | ||||
|             return ['key'] | ||||
|         else: | ||||
|             return [] | ||||
|  | ||||
|  | ||||
| class UserSettingsAdmin(ImportExportModelAdmin): | ||||
|  | ||||
|     list_display = ('key', 'value', 'user', ) | ||||
|  | ||||
|     def get_readonly_fields(self, request, obj=None): | ||||
|         """ | ||||
|         Prevent the 'key' field being edited once the setting is created | ||||
|         """ | ||||
|  | ||||
|         if obj: | ||||
|             return ['key'] | ||||
|         else: | ||||
|             return [] | ||||
|  | ||||
|  | ||||
| class NotificationEntryAdmin(admin.ModelAdmin): | ||||
|  | ||||
|   | ||||
| @@ -1,10 +1,30 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
|  | ||||
| import logging | ||||
|  | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| logger = logging.getLogger('inventree') | ||||
|  | ||||
|  | ||||
| class CommonConfig(AppConfig): | ||||
|     name = 'common' | ||||
|  | ||||
|     def ready(self): | ||||
|          | ||||
|         self.clear_restart_flag() | ||||
|  | ||||
|     def clear_restart_flag(self): | ||||
|         """ | ||||
|         Clear the SERVER_RESTART_REQUIRED setting | ||||
|         """ | ||||
|  | ||||
|         try: | ||||
|             import common.models | ||||
|              | ||||
|             if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED'): | ||||
|                 logger.info("Clearing SERVER_RESTART_REQUIRED flag") | ||||
|                 common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None) | ||||
|         except: | ||||
|             pass | ||||
|   | ||||
| @@ -63,13 +63,15 @@ class BaseInvenTreeSetting(models.Model): | ||||
|         Enforce validation and clean before saving | ||||
|         """ | ||||
|  | ||||
|         self.key = str(self.key).upper() | ||||
|  | ||||
|         self.clean() | ||||
|         self.validate_unique() | ||||
|  | ||||
|         super().save() | ||||
|  | ||||
|     @classmethod | ||||
|     def allValues(cls, user=None): | ||||
|     def allValues(cls, user=None, exclude_hidden=False): | ||||
|         """ | ||||
|         Return a dict of "all" defined global settings. | ||||
|  | ||||
| @@ -94,9 +96,15 @@ class BaseInvenTreeSetting(models.Model): | ||||
|         for key in cls.GLOBAL_SETTINGS.keys(): | ||||
|  | ||||
|             if key.upper() not in settings: | ||||
|  | ||||
|                 settings[key.upper()] = cls.get_setting_default(key) | ||||
|  | ||||
|             if exclude_hidden: | ||||
|                 hidden = cls.GLOBAL_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) | ||||
|  | ||||
| @@ -545,6 +553,17 @@ class InvenTreeSetting(BaseInvenTreeSetting): | ||||
|     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_REQUIRES_RESTART', True, None) | ||||
|  | ||||
|     """ | ||||
|     Dict of all global settings values: | ||||
|  | ||||
| @@ -563,6 +582,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): | ||||
|  | ||||
|     GLOBAL_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', | ||||
| @@ -768,6 +795,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): | ||||
|             '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)'), | ||||
| @@ -935,6 +969,18 @@ class InvenTreeSetting(BaseInvenTreeSetting): | ||||
|  | ||||
|         return self.__class__.get_setting(self.key) | ||||
|  | ||||
|     def requires_restart(self): | ||||
|         """ | ||||
|         Return True if this setting requires a server restart after changing | ||||
|         """ | ||||
|  | ||||
|         options = InvenTreeSetting.GLOBAL_SETTINGS.get(self.key, None) | ||||
|  | ||||
|         if options: | ||||
|             return options.get('requires_restart', False) | ||||
|         else: | ||||
|             return False | ||||
|  | ||||
|  | ||||
| class InvenTreeUserSetting(BaseInvenTreeSetting): | ||||
|     """ | ||||
| @@ -1268,9 +1314,6 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break | ||||
|  | ||||
| class ColorTheme(models.Model): | ||||
|     """ Color Theme Setting """ | ||||
|  | ||||
|     default_color_theme = ('', _('Default')) | ||||
|  | ||||
|     name = models.CharField(max_length=20, | ||||
|                             default='', | ||||
|                             blank=True) | ||||
| @@ -1290,10 +1333,7 @@ class ColorTheme(models.Model): | ||||
|         # 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' and file_name.lower() != 'default'] | ||||
|  | ||||
|         # Add default option as empty option | ||||
|         choices.insert(0, cls.default_color_theme) | ||||
|                    if file_ext == '.css'] | ||||
|  | ||||
|         return choices | ||||
|  | ||||
|   | ||||
| @@ -8,9 +8,10 @@ from rest_framework import serializers | ||||
|  | ||||
| from sql_util.utils import SubqueryCount | ||||
|  | ||||
| from InvenTree.serializers import InvenTreeDecimalField | ||||
| from InvenTree.serializers import InvenTreeImageSerializerField | ||||
| from InvenTree.serializers import InvenTreeModelSerializer | ||||
| from InvenTree.serializers import InvenTreeMoneySerializer | ||||
| from InvenTree.serializers import InvenTreeImageSerializerField | ||||
|  | ||||
| from part.serializers import PartBriefSerializer | ||||
|  | ||||
| @@ -255,7 +256,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer): | ||||
| class SupplierPriceBreakSerializer(InvenTreeModelSerializer): | ||||
|     """ Serializer for SupplierPriceBreak object """ | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     price = InvenTreeMoneySerializer( | ||||
|         allow_null=True, | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								InvenTree/locale/es_MX/LC_MESSAGES/django.mo
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								InvenTree/locale/es_MX/LC_MESSAGES/django.mo
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										8451
									
								
								InvenTree/locale/es_MX/LC_MESSAGES/django.po
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8451
									
								
								InvenTree/locale/es_MX/LC_MESSAGES/django.po
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								InvenTree/locale/pt/LC_MESSAGES/django.mo
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								InvenTree/locale/pt/LC_MESSAGES/django.mo
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										8423
									
								
								InvenTree/locale/pt/LC_MESSAGES/django.po
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8423
									
								
								InvenTree/locale/pt/LC_MESSAGES/django.po
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -17,8 +17,9 @@ from rest_framework.serializers import ValidationError | ||||
|  | ||||
| from sql_util.utils import SubqueryCount | ||||
|  | ||||
| from InvenTree.serializers import InvenTreeModelSerializer | ||||
| from InvenTree.serializers import InvenTreeAttachmentSerializer | ||||
| from InvenTree.serializers import InvenTreeModelSerializer | ||||
| from InvenTree.serializers import InvenTreeDecimalField | ||||
| from InvenTree.serializers import InvenTreeMoneySerializer | ||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField | ||||
|  | ||||
| @@ -551,7 +552,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): | ||||
|     part_detail = PartBriefSerializer(source='part', many=False, read_only=True) | ||||
|     allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|      | ||||
|     allocated = serializers.FloatField(source='allocated_quantity', read_only=True) | ||||
|     fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) | ||||
|   | ||||
| @@ -29,7 +29,9 @@ | ||||
|         <span class='fas fa-print'></span> <span class='caret'></span> | ||||
|     </button> | ||||
|     <ul class='dropdown-menu' role='menu'> | ||||
|         {% if report_enabled %} | ||||
|         <li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print purchase order report" %}</a></li> | ||||
|         {% endif %} | ||||
|         <li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li> | ||||
|     </ul> | ||||
| </div> | ||||
| @@ -169,9 +171,11 @@ $("#place-order").click(function() { | ||||
| }); | ||||
| {% endif %} | ||||
|  | ||||
| {% if report_enabled %} | ||||
| $('#print-order-report').click(function() { | ||||
|     printPurchaseOrderReports([{{ order.pk }}]); | ||||
| }); | ||||
| {% endif %} | ||||
|  | ||||
| $("#edit-order").click(function() { | ||||
|  | ||||
|   | ||||
| @@ -26,10 +26,11 @@ | ||||
|     <div id='table-buttons'> | ||||
|         <div class='button-toolbar container-fluid' style='float: right;'> | ||||
|             <div class='btn-group' role='group'> | ||||
|                  | ||||
|                 {% if report_enabled %} | ||||
|                 <button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'> | ||||
|                     <span class='fas fa-print'></span> | ||||
|                 </button> | ||||
|                 {% endif %} | ||||
|                 <button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'> | ||||
|                     <span class='fas fa-calendar-alt'></span> | ||||
|                 </button> | ||||
| @@ -169,6 +170,7 @@ $("#view-list").click(function() { | ||||
|     $("#view-calendar").show(); | ||||
| }); | ||||
|  | ||||
| {% if report_enabled %} | ||||
| $("#order-print").click(function() { | ||||
|     var rows = $("#purchase-order-table").bootstrapTable('getSelections'); | ||||
|  | ||||
| @@ -180,6 +182,7 @@ $("#order-print").click(function() { | ||||
|  | ||||
|     printPurchaseOrderReports(orders); | ||||
| }) | ||||
| {% endif %} | ||||
|  | ||||
| $("#po-create").click(function() { | ||||
|     createPurchaseOrder(); | ||||
|   | ||||
| @@ -39,7 +39,9 @@ src="{% static 'img/blank_image.png' %}" | ||||
|         <span class='fas fa-print'></span> <span class='caret'></span> | ||||
|     </button> | ||||
|     <ul class='dropdown-menu' role='menu'> | ||||
|         {% if report_enabled %} | ||||
|         <li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print sales order report" %}</a></li> | ||||
|         {% endif %} | ||||
|         <li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li> | ||||
|         <!-- | ||||
|         <li><a class='dropdown-item' href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li> | ||||
| @@ -206,9 +208,11 @@ $("#ship-order").click(function() { | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| {% if report_enabled %} | ||||
| $('#print-order-report').click(function() { | ||||
|     printSalesOrderReports([{{ order.pk }}]); | ||||
| }); | ||||
| {% endif %} | ||||
|  | ||||
| $('#export-order').click(function() { | ||||
|     exportOrder('{% url "so-export" order.id %}'); | ||||
|   | ||||
| @@ -29,10 +29,11 @@ | ||||
|     <div id='table-buttons'> | ||||
|         <div class='button-toolbar container-fluid' style='float: right;'> | ||||
|             <div class='btn-group'> | ||||
|                  | ||||
|                 {% if report_enabled %} | ||||
|                 <button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'> | ||||
|                     <span class='fas fa-print'></span> | ||||
|                 </button> | ||||
|                 {% endif %} | ||||
|                 <button class='btn btn-outline-secondary' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'> | ||||
|                     <span class='fas fa-calendar-alt'></span> | ||||
|                 </button> | ||||
| @@ -173,6 +174,7 @@ loadSalesOrderTable("#sales-order-table", { | ||||
|     url: "{% url 'api-so-list' %}", | ||||
| }); | ||||
|  | ||||
| {% if report_enabled %} | ||||
| $("#order-print").click(function() { | ||||
|     var rows = $("#sales-order-table").bootstrapTable('getSelections'); | ||||
|  | ||||
| @@ -184,6 +186,7 @@ $("#order-print").click(function() { | ||||
|  | ||||
|     printSalesOrderReports(orders); | ||||
| }) | ||||
| {% endif %} | ||||
|  | ||||
| $("#so-create").click(function() { | ||||
|     createSalesOrder(); | ||||
|   | ||||
| @@ -832,18 +832,6 @@ class PartList(generics.ListCreateAPIView): | ||||
|  | ||||
|         queryset = super().filter_queryset(queryset) | ||||
|  | ||||
|         # Filter by "uses" query - Limit to parts which use the provided part | ||||
|         uses = params.get('uses', None) | ||||
|  | ||||
|         if uses: | ||||
|             try: | ||||
|                 uses = Part.objects.get(pk=uses) | ||||
|  | ||||
|                 queryset = queryset.filter(uses.get_used_in_filter()) | ||||
|  | ||||
|             except (ValueError, Part.DoesNotExist): | ||||
|                 pass | ||||
|  | ||||
|         # Exclude specific part ID values? | ||||
|         exclude_id = [] | ||||
|  | ||||
| @@ -1040,13 +1028,19 @@ class PartParameterTemplateList(generics.ListCreateAPIView): | ||||
|     serializer_class = part_serializers.PartParameterTemplateSerializer | ||||
|  | ||||
|     filter_backends = [ | ||||
|         DjangoFilterBackend, | ||||
|         filters.OrderingFilter, | ||||
|         filters.SearchFilter, | ||||
|     ] | ||||
|  | ||||
|     filter_fields = [ | ||||
|         'name', | ||||
|     ] | ||||
|  | ||||
|     search_fields = [ | ||||
|         'name', | ||||
|     ] | ||||
|  | ||||
|  | ||||
| class PartParameterList(generics.ListCreateAPIView): | ||||
|     """ API endpoint for accessing a list of PartParameter objects | ||||
| @@ -1211,6 +1205,54 @@ class BomList(generics.ListCreateAPIView): | ||||
|             except (ValueError, Part.DoesNotExist): | ||||
|                 pass | ||||
|  | ||||
|         """ | ||||
|         Filter by 'uses'? | ||||
|  | ||||
|         Here we pass a part ID and return BOM items for any assemblies which "use" (or "require") that part. | ||||
|  | ||||
|         There are multiple ways that an assembly can "use" a sub-part: | ||||
|  | ||||
|         A) Directly specifying the sub_part in a BomItem field | ||||
|         B) Specifing a "template" part with inherited=True | ||||
|         C) Allowing variant parts to be substituted | ||||
|         D) Allowing direct substitute parts to be specified | ||||
|  | ||||
|         - BOM items which are "inherited" by parts which are variants of the master BomItem | ||||
|         """ | ||||
|         uses = params.get('uses', None) | ||||
|  | ||||
|         if uses is not None: | ||||
|  | ||||
|             try: | ||||
|                 # Extract the part we are interested in | ||||
|                 uses_part = Part.objects.get(pk=uses) | ||||
|  | ||||
|                 # Construct the database query in multiple parts | ||||
|  | ||||
|                 # A) Direct specification of sub_part | ||||
|                 q_A = Q(sub_part=uses_part) | ||||
|  | ||||
|                 # B) BomItem is inherited and points to a "parent" of this part | ||||
|                 parents = uses_part.get_ancestors(include_self=False) | ||||
|  | ||||
|                 q_B = Q( | ||||
|                     inherited=True, | ||||
|                     sub_part__in=parents | ||||
|                 ) | ||||
|  | ||||
|                 # C) Substitution of variant parts | ||||
|                 # TODO | ||||
|  | ||||
|                 # D) Specification of individual substitutes | ||||
|                 # TODO | ||||
|  | ||||
|                 q = q_A | q_B | ||||
|  | ||||
|                 queryset = queryset.filter(q) | ||||
|  | ||||
|             except (ValueError, Part.DoesNotExist): | ||||
|                 pass | ||||
|  | ||||
|         if self.include_pricing(): | ||||
|             queryset = self.annotate_pricing(queryset) | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ from collections import OrderedDict | ||||
|  | ||||
| from django.utils.translation import gettext as _ | ||||
|  | ||||
| from InvenTree.helpers import DownloadFile, GetExportFormats | ||||
| from InvenTree.helpers import DownloadFile, GetExportFormats, normalize | ||||
|  | ||||
| from .admin import BomItemResource | ||||
| from .models import BomItem | ||||
| @@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa | ||||
|  | ||||
|     uids = [] | ||||
|  | ||||
|     def add_items(items, level): | ||||
|     def add_items(items, level, cascade): | ||||
|         # Add items at a given layer | ||||
|         for item in items: | ||||
|  | ||||
| @@ -71,21 +71,13 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa | ||||
|  | ||||
|             bom_items.append(item) | ||||
|  | ||||
|             if item.sub_part.assembly: | ||||
|             if cascade and item.sub_part.assembly: | ||||
|                 if max_levels is None or level < max_levels: | ||||
|                     add_items(item.sub_part.bom_items.all().order_by('id'), level + 1) | ||||
|  | ||||
|     if cascade: | ||||
|         # Cascading (multi-level) BOM | ||||
|     top_level_items = part.get_bom_items().order_by('id') | ||||
|  | ||||
|         # Start with the top level | ||||
|         items_to_process = part.bom_items.all().order_by('id') | ||||
|  | ||||
|         add_items(items_to_process, 1) | ||||
|  | ||||
|     else: | ||||
|         # No cascading needed - just the top-level items | ||||
|         bom_items = [item for item in part.bom_items.all().order_by('id')] | ||||
|     add_items(top_level_items, 1, cascade) | ||||
|  | ||||
|     dataset = BomItemResource().export(queryset=bom_items, cascade=cascade) | ||||
|  | ||||
| @@ -148,8 +140,9 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa | ||||
|                     stock_data.append('') | ||||
|             except AttributeError: | ||||
|                 stock_data.append('') | ||||
|  | ||||
|             # Get part current stock | ||||
|             stock_data.append(str(bom_item.sub_part.available_stock)) | ||||
|             stock_data.append(str(normalize(bom_item.sub_part.available_stock))) | ||||
|  | ||||
|             for s_idx, header in enumerate(stock_headers): | ||||
|                 try: | ||||
|   | ||||
| @@ -1,13 +1,29 @@ | ||||
| # Generated by Django 2.2 on 2019-05-20 12:04 | ||||
|  | ||||
| import InvenTree.validators | ||||
| from django.conf import settings | ||||
| import django.core.validators | ||||
| import os | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| import django.core.validators | ||||
|  | ||||
| import InvenTree.validators | ||||
| import part.models | ||||
|  | ||||
|  | ||||
| def attach_file(instance, filename): | ||||
|     """ | ||||
|     Generate a filename for the uploaded attachment. | ||||
|  | ||||
|     2021-11-17 - This was moved here from part.models.py, | ||||
|     as the function itself is no longer used, | ||||
|     but is still required for migration | ||||
|     """ | ||||
|  | ||||
|     # Construct a path to store a file attachment | ||||
|     return os.path.join('part_files', str(instance.part.id), filename) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
| @@ -61,7 +77,7 @@ class Migration(migrations.Migration): | ||||
|             name='PartAttachment', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('attachment', models.FileField(help_text='Select file to attach', upload_to=part.models.attach_file)), | ||||
|                 ('attachment', models.FileField(help_text='Select file to attach', upload_to=attach_file)), | ||||
|                 ('comment', models.CharField(help_text='File comment', max_length=100)), | ||||
|             ], | ||||
|         ), | ||||
|   | ||||
| @@ -1392,6 +1392,27 @@ class Part(MPTTModel): | ||||
|  | ||||
|         return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited)) | ||||
|  | ||||
|     def get_installed_part_options(self, include_inherited=True, include_variants=True): | ||||
|         """ | ||||
|         Return a set of all Parts which can be "installed" into this part, based on the BOM. | ||||
|  | ||||
|         arguments: | ||||
|             include_inherited - If set, include BomItem entries defined for parent parts | ||||
|             include_variants - If set, include variant parts for BomItems which allow variants | ||||
|         """ | ||||
|  | ||||
|         parts = set() | ||||
|  | ||||
|         for bom_item in self.get_bom_items(include_inherited=include_inherited): | ||||
|  | ||||
|             if include_variants and bom_item.allow_variants: | ||||
|                 for part in bom_item.sub_part.get_descendants(include_self=True): | ||||
|                     parts.add(part) | ||||
|             else: | ||||
|                 parts.add(bom_item.sub_part) | ||||
|  | ||||
|         return parts | ||||
|  | ||||
|     def get_used_in_filter(self, include_inherited=True): | ||||
|         """ | ||||
|         Return a query filter for all parts that this part is used in. | ||||
| @@ -2114,20 +2135,6 @@ def after_save_part(sender, instance: Part, created, **kwargs): | ||||
|         InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) | ||||
|  | ||||
|  | ||||
| def attach_file(instance, filename): | ||||
|     """ Function for storing a file for a PartAttachment | ||||
|  | ||||
|     Args: | ||||
|         instance: Instance of a PartAttachment object | ||||
|         filename: name of uploaded file | ||||
|  | ||||
|     Returns: | ||||
|         path to store file, format: 'part_file_<pk>_filename' | ||||
|     """ | ||||
|     # Construct a path to store a file attachment | ||||
|     return os.path.join('part_files', str(instance.part.id), filename) | ||||
|  | ||||
|  | ||||
| class PartAttachment(InvenTreeAttachment): | ||||
|     """ | ||||
|     Model for storing file attachments against a Part object | ||||
|   | ||||
| @@ -15,6 +15,7 @@ from sql_util.utils import SubqueryCount, SubquerySum | ||||
| from djmoney.contrib.django_rest_framework import MoneyField | ||||
|  | ||||
| from InvenTree.serializers import (InvenTreeAttachmentSerializerField, | ||||
|                                    InvenTreeDecimalField, | ||||
|                                    InvenTreeImageSerializerField, | ||||
|                                    InvenTreeModelSerializer, | ||||
|                                    InvenTreeAttachmentSerializer, | ||||
| @@ -120,7 +121,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer): | ||||
|     Serializer for sale prices for Part model. | ||||
|     """ | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     price = InvenTreeMoneySerializer( | ||||
|         allow_null=True | ||||
| @@ -144,7 +145,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer): | ||||
|     Serializer for internal prices for Part model. | ||||
|     """ | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     price = InvenTreeMoneySerializer( | ||||
|         allow_null=True | ||||
| @@ -428,7 +429,7 @@ class BomItemSerializer(InvenTreeModelSerializer): | ||||
|  | ||||
|     price_range = serializers.CharField(read_only=True) | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True)) | ||||
|  | ||||
|   | ||||
| @@ -164,7 +164,9 @@ | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li> | ||||
|                     {% endif %} | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li> | ||||
|                     {% if report_enabled %} | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li> | ||||
|                     {% endif %} | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li> | ||||
|                 </ul> | ||||
|             </div> | ||||
|   | ||||
| @@ -373,7 +373,9 @@ | ||||
|                     </button> | ||||
|                     <ul class='dropdown-menu' role='menu'> | ||||
|                         <li><a class='dropdown-item' href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li> | ||||
|                         {% if report_enabled %} | ||||
|                         <li><a class='dropdown-item' href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li> | ||||
|                         {% endif %} | ||||
|                     </ul> | ||||
|                 </div> | ||||
|                 <!-- Actions menu --> | ||||
| @@ -386,9 +388,7 @@ | ||||
|                         {% if part.variant_of %} | ||||
|                         <li><a class='dropdown-item' href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li> | ||||
|                         {% endif %} | ||||
|                         {% if not part.is_bom_valid %} | ||||
|                         <li><a class='dropdown-item' href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li> | ||||
|                         {% endif %} | ||||
|                     </ul> | ||||
|                 </div> | ||||
|          | ||||
| @@ -647,14 +647,10 @@ | ||||
|  | ||||
|     // Load the "used in" tab | ||||
|     onPanelLoad("used-in", function() { | ||||
|         loadPartTable('#used-table', | ||||
|             '{% url "api-part-list" %}', | ||||
|             { | ||||
|                 params: { | ||||
|                     uses: {{ part.pk }}, | ||||
|                 }, | ||||
|                 filterTarget: '#filter-list-usedin', | ||||
|             } | ||||
|  | ||||
|         loadUsedInTable( | ||||
|             '#used-table', | ||||
|             {{ part.pk }}, | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
| @@ -766,9 +762,11 @@ | ||||
|             ); | ||||
|         }); | ||||
|  | ||||
|         {% if report_enabled %} | ||||
|         $("#print-bom-report").click(function() { | ||||
|             printBomReports([{{ part.pk }}]); | ||||
|         }); | ||||
|         {% endif %} | ||||
|     }); | ||||
|  | ||||
|     // Load the "related parts" tab | ||||
|   | ||||
| @@ -90,6 +90,13 @@ def inventree_in_debug_mode(*args, **kwargs): | ||||
|     return djangosettings.DEBUG | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def inventree_demo_mode(*args, **kwargs): | ||||
|     """ Return True if the server is running in DEMO mode """ | ||||
|  | ||||
|     return djangosettings.DEMO_MODE | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def inventree_docker_mode(*args, **kwargs): | ||||
|     """ Return True if the server is running as a Docker image """ | ||||
| @@ -251,6 +258,15 @@ def global_settings(*args, **kwargs): | ||||
|     return InvenTreeSetting.allValues() | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def visible_global_settings(*args, **kwargs): | ||||
|     """ | ||||
|     Return any global settings which are not marked as 'hidden' | ||||
|     """ | ||||
|  | ||||
|     return InvenTreeSetting.allValues(exclude_hidden=True) | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def progress_bar(val, max, *args, **kwargs): | ||||
|     """ | ||||
| @@ -292,6 +308,19 @@ def progress_bar(val, max, *args, **kwargs): | ||||
|  | ||||
| @register.simple_tag() | ||||
| def get_color_theme_css(username): | ||||
|     user_theme_name = get_user_color_theme(username) | ||||
|     # Build path to CSS sheet | ||||
|     inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css') | ||||
|  | ||||
|     # Build static URL | ||||
|     inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet) | ||||
|  | ||||
|     return inventree_css_static_url | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def get_user_color_theme(username): | ||||
|     """ Get current user color theme """ | ||||
|     try: | ||||
|         user_theme = ColorTheme.objects.filter(user=username).get() | ||||
|         user_theme_name = user_theme.name | ||||
| @@ -300,13 +329,7 @@ def get_color_theme_css(username): | ||||
|     except ColorTheme.DoesNotExist: | ||||
|         user_theme_name = 'default' | ||||
|  | ||||
|     # Build path to CSS sheet | ||||
|     inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css') | ||||
|  | ||||
|     # Build static URL | ||||
|     inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet) | ||||
|  | ||||
|     return inventree_css_static_url | ||||
|     return user_theme_name | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
|   | ||||
| @@ -1123,6 +1123,59 @@ class BomItemTest(InvenTreeAPITestCase): | ||||
|         response = self.get(url, expected_code=200) | ||||
|         self.assertEqual(len(response.data), 5) | ||||
|  | ||||
|     def test_bom_item_uses(self): | ||||
|         """ | ||||
|         Tests for the 'uses' field | ||||
|         """ | ||||
|  | ||||
|         url = reverse('api-bom-list') | ||||
|  | ||||
|         # Test that the direct 'sub_part' association works | ||||
|  | ||||
|         assemblies = [] | ||||
|  | ||||
|         for i in range(5): | ||||
|             assy = Part.objects.create( | ||||
|                 name=f"Assy_{i}", | ||||
|                 description="An assembly made of other parts", | ||||
|                 active=True, | ||||
|                 assembly=True | ||||
|             ) | ||||
|  | ||||
|             assemblies.append(assy) | ||||
|  | ||||
|         components = [] | ||||
|  | ||||
|         # Create some sub-components | ||||
|         for i in range(5): | ||||
|  | ||||
|             cmp = Part.objects.create( | ||||
|                 name=f"Component_{i}", | ||||
|                 description="A sub component", | ||||
|                 active=True, | ||||
|                 component=True | ||||
|             ) | ||||
|  | ||||
|             for j in range(i): | ||||
|                 # Create a BOM item | ||||
|                 BomItem.objects.create( | ||||
|                     quantity=10, | ||||
|                     part=assemblies[j], | ||||
|                     sub_part=cmp, | ||||
|                 ) | ||||
|  | ||||
|             components.append(cmp) | ||||
|  | ||||
|             response = self.get( | ||||
|                 url, | ||||
|                 { | ||||
|                     'uses': cmp.pk, | ||||
|                 }, | ||||
|                 expected_code=200, | ||||
|             ) | ||||
|  | ||||
|             self.assertEqual(len(response.data), i) | ||||
|  | ||||
|  | ||||
| class PartParameterTest(InvenTreeAPITestCase): | ||||
|     """ | ||||
|   | ||||
| @@ -117,6 +117,8 @@ class StockItemResource(ModelResource): | ||||
|         exclude = [ | ||||
|             # Exclude MPTT internal model fields | ||||
|             'lft', 'rght', 'tree_id', 'level', | ||||
|             # Exclude internal fields | ||||
|             'serial_int', | ||||
|         ] | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -69,6 +69,13 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|  | ||||
|         ctx = super().get_serializer_context() | ||||
|         ctx['user'] = getattr(self.request, 'user', None) | ||||
|  | ||||
|         return ctx | ||||
|  | ||||
|     def get_serializer(self, *args, **kwargs): | ||||
|  | ||||
|         kwargs['part_detail'] = True | ||||
| @@ -79,16 +86,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): | ||||
|  | ||||
|         return self.serializer_class(*args, **kwargs) | ||||
|  | ||||
|     def update(self, request, *args, **kwargs): | ||||
|         """ | ||||
|         Record the user who updated the item | ||||
|         """ | ||||
|  | ||||
|         # TODO: Record the user! | ||||
|         # user = request.user | ||||
|  | ||||
|         return super().update(request, *args, **kwargs) | ||||
|  | ||||
|     def perform_destroy(self, instance): | ||||
|         """ | ||||
|         Instead of "deleting" the StockItem | ||||
| @@ -392,6 +389,13 @@ class StockList(generics.ListCreateAPIView): | ||||
|     queryset = StockItem.objects.all() | ||||
|     filterset_class = StockFilter | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|  | ||||
|         ctx = super().get_serializer_context() | ||||
|         ctx['user'] = getattr(self.request, 'user', None) | ||||
|  | ||||
|         return ctx | ||||
|  | ||||
|     def create(self, request, *args, **kwargs): | ||||
|         """ | ||||
|         Create a new StockItem object via the API. | ||||
| @@ -876,6 +880,7 @@ class StockList(generics.ListCreateAPIView): | ||||
|  | ||||
|     ordering_field_aliases = { | ||||
|         'SKU': 'supplier_part__SKU', | ||||
|         'stock': ['quantity', 'serial_int', 'serial'], | ||||
|     } | ||||
|  | ||||
|     ordering_fields = [ | ||||
| @@ -887,6 +892,7 @@ class StockList(generics.ListCreateAPIView): | ||||
|         'stocktake_date', | ||||
|         'expiry_date', | ||||
|         'quantity', | ||||
|         'stock', | ||||
|         'status', | ||||
|         'SKU', | ||||
|     ] | ||||
|   | ||||
							
								
								
									
										18
									
								
								InvenTree/stock/migrations/0068_stockitem_serial_int.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								InvenTree/stock/migrations/0068_stockitem_serial_int.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.2.5 on 2021-11-09 23:30 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('stock', '0067_alter_stockitem_part'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='stockitem', | ||||
|             name='serial_int', | ||||
|             field=models.IntegerField(default=0), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										54
									
								
								InvenTree/stock/migrations/0069_auto_20211109_2347.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								InvenTree/stock/migrations/0069_auto_20211109_2347.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| # Generated by Django 3.2.5 on 2021-11-09 23:47 | ||||
|  | ||||
| import re | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def update_serials(apps, schema_editor): | ||||
|     """ | ||||
|     Rebuild the integer serial number field for existing StockItem objects | ||||
|     """ | ||||
|  | ||||
|     StockItem = apps.get_model('stock', 'stockitem') | ||||
|  | ||||
|     for item in StockItem.objects.all(): | ||||
|  | ||||
|         if item.serial is None: | ||||
|             # Skip items without existing serial numbers | ||||
|             continue | ||||
|  | ||||
|         serial = 0 | ||||
|  | ||||
|         result = re.match(r"^(\d+)", str(item.serial)) | ||||
|  | ||||
|         if result and len(result.groups()) == 1: | ||||
|             try: | ||||
|                 serial = int(result.groups()[0]) | ||||
|             except: | ||||
|                 serial = 0 | ||||
|  | ||||
|  | ||||
|         item.serial_int = serial | ||||
|         item.save() | ||||
|  | ||||
|  | ||||
| def nupdate_serials(apps, schema_editor): | ||||
|     """ | ||||
|     Provided only for reverse migration compatibility | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('stock', '0068_stockitem_serial_int'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_serials, | ||||
|             reverse_code=nupdate_serials, | ||||
|         ) | ||||
|     ] | ||||
| @@ -7,6 +7,7 @@ Stock database model definitions | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| import os | ||||
| import re | ||||
|  | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from django.core.exceptions import ValidationError, FieldError | ||||
| @@ -223,6 +224,32 @@ class StockItem(MPTTModel): | ||||
|         self.scheduled_for_deletion = True | ||||
|         self.save() | ||||
|  | ||||
|     def update_serial_number(self): | ||||
|         """ | ||||
|         Update the 'serial_int' field, to be an integer representation of the serial number. | ||||
|         This is used for efficient numerical sorting | ||||
|         """ | ||||
|  | ||||
|         serial = getattr(self, 'serial', '') | ||||
|  | ||||
|         # Default value if we cannot convert to an integer | ||||
|         serial_int = 0 | ||||
|  | ||||
|         if serial is not None: | ||||
|  | ||||
|             serial = str(serial) | ||||
|  | ||||
|             # Look at the start of the string - can it be "integerized"? | ||||
|             result = re.match(r'^(\d+)', serial) | ||||
|  | ||||
|             if result and len(result.groups()) == 1: | ||||
|                 try: | ||||
|                     serial_int = int(result.groups()[0]) | ||||
|                 except: | ||||
|                     serial_int = 0 | ||||
|  | ||||
|         self.serial_int = serial_int | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         """ | ||||
|         Save this StockItem to the database. Performs a number of checks: | ||||
| @@ -234,17 +261,19 @@ class StockItem(MPTTModel): | ||||
|         self.validate_unique() | ||||
|         self.clean() | ||||
|  | ||||
|         self.update_serial_number() | ||||
|  | ||||
|         user = kwargs.pop('user', None) | ||||
|  | ||||
|         if user is None: | ||||
|             user = getattr(self, '_user', None) | ||||
|  | ||||
|         # If 'add_note = False' specified, then no tracking note will be added for item creation | ||||
|         add_note = kwargs.pop('add_note', True) | ||||
|  | ||||
|         notes = kwargs.pop('notes', '') | ||||
|          | ||||
|         if not self.pk: | ||||
|             # StockItem has not yet been saved | ||||
|             add_note = add_note and True | ||||
|         else: | ||||
|         if self.pk: | ||||
|             # StockItem has already been saved | ||||
|  | ||||
|             # Check if "interesting" fields have been changed | ||||
| @@ -272,11 +301,10 @@ class StockItem(MPTTModel): | ||||
|             except (ValueError, StockItem.DoesNotExist): | ||||
|                 pass | ||||
|  | ||||
|             add_note = False | ||||
|  | ||||
|         super(StockItem, self).save(*args, **kwargs) | ||||
|  | ||||
|         if add_note: | ||||
|         # If user information is provided, and no existing note exists, create one! | ||||
|         if user and self.tracking_info.count() == 0: | ||||
|  | ||||
|             tracking_info = { | ||||
|                 'status': self.status, | ||||
| @@ -504,6 +532,8 @@ class StockItem(MPTTModel): | ||||
|         help_text=_('Serial number for this item') | ||||
|     ) | ||||
|  | ||||
|     serial_int = models.IntegerField(default=0) | ||||
|  | ||||
|     link = InvenTreeURLField( | ||||
|         verbose_name=_('External Link'), | ||||
|         max_length=125, blank=True, | ||||
|   | ||||
| @@ -32,6 +32,7 @@ from company.serializers import SupplierPartSerializer | ||||
|  | ||||
| import InvenTree.helpers | ||||
| import InvenTree.serializers | ||||
| from InvenTree.serializers import InvenTreeDecimalField | ||||
|  | ||||
| from part.serializers import PartBriefSerializer | ||||
|  | ||||
| @@ -55,7 +56,8 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): | ||||
|  | ||||
|     location_name = serializers.CharField(source='location', read_only=True) | ||||
|     part_name = serializers.CharField(source='part.full_name', read_only=True) | ||||
|     quantity = serializers.FloatField() | ||||
|      | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = StockItem | ||||
| @@ -79,6 +81,15 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): | ||||
|     - Includes serialization for the item location | ||||
|     """ | ||||
|  | ||||
|     def update(self, instance, validated_data): | ||||
|         """ | ||||
|         Custom update method to pass the user information through to the instance | ||||
|         """ | ||||
|  | ||||
|         instance._user = self.context['user'] | ||||
|  | ||||
|         return super().update(instance, validated_data) | ||||
|  | ||||
|     @staticmethod | ||||
|     def annotate_queryset(queryset): | ||||
|         """ | ||||
| @@ -136,7 +147,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): | ||||
|  | ||||
|     tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False) | ||||
|  | ||||
|     # quantity = serializers.FloatField() | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     allocated = serializers.FloatField(source='allocation_count', required=False) | ||||
|  | ||||
|   | ||||
| @@ -560,9 +560,8 @@ class StockItemInstall(AjaxUpdateView): | ||||
|  | ||||
|             # Filter for parts to install in this item | ||||
|             if self.install_item: | ||||
|                 # Get parts used in this part's BOM | ||||
|                 bom_items = self.part.get_bom_items() | ||||
|                 allowed_parts = [item.sub_part for item in bom_items] | ||||
|                 # Get all parts which can be installed into this part | ||||
|                 allowed_parts = self.part.get_installed_part_options() | ||||
|                 # Filter | ||||
|                 items = items.filter(part__in=allowed_parts) | ||||
|  | ||||
|   | ||||
| @@ -12,6 +12,7 @@ | ||||
|  | ||||
| <table class='table table-striped table-condensed'> | ||||
|     <tbody> | ||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %} | ||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %} | ||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %} | ||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %} | ||||
|   | ||||
| @@ -4,6 +4,9 @@ | ||||
| {% load static %} | ||||
| {% load inventree_extras %} | ||||
|  | ||||
| {% block breadcrumb_list %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block page_title %} | ||||
| {% inventree_title %} | {% trans "Settings" %} | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -12,12 +12,15 @@ | ||||
| {% endblock %} | ||||
|  | ||||
| {% block actions %} | ||||
| {% inventree_demo_mode as demo %} | ||||
| {% if not demo %} | ||||
| <div class='btn btn-primary' type='button' id='edit-user' title='{% trans "Edit User Information" %}'> | ||||
|     <span class='fas fa-user-cog'></span> {% trans "Edit" %} | ||||
| </div> | ||||
| <div class='btn btn-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'> | ||||
|     <span class='fas fa-key'></span> {% trans "Set Password" %} | ||||
| </div> | ||||
| {% endif %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
| @@ -235,7 +238,6 @@ | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class="row"> | ||||
|     <div class='panel-heading'> | ||||
|   | ||||
| @@ -21,4 +21,33 @@ | ||||
|     </table> | ||||
| </div> | ||||
|  | ||||
| <div class='panel-heading'> | ||||
|     <h4>{% trans "Theme Settings" %}</h4> | ||||
| </div> | ||||
|  | ||||
| <div class='row'> | ||||
|  | ||||
|     <div class='col-sm-6'> | ||||
|         <form action='{% url "settings-appearance" %}' method='post'> | ||||
|             {% csrf_token %} | ||||
|             <input name='next' type='hidden' value='{% url "settings" %}'> | ||||
|             <label for='theme' class=' requiredField'> | ||||
|                 {% trans "Select theme" %} | ||||
|             </label> | ||||
|             <div class='form-group input-group mb-3'> | ||||
|                 <select id='theme' name='theme' class='select form-control'> | ||||
|                     {% get_available_themes as themes %} | ||||
|                     {% get_user_color_theme request.user.username as user_theme %} | ||||
|                     {% for theme in themes %} | ||||
|                     <option value='{{ theme.key }}'{% if theme.key == user_theme %} selected{% endif%}>{{ theme.name }}</option> | ||||
|                     {% endfor %} | ||||
|                 </select> | ||||
|                 <div class='input-group-append'> | ||||
|                     <input type="submit" value="{% trans 'Set Theme' %}" class="btn btn-primary"> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,5 +1,6 @@ | ||||
| {% extends "account/base.html" %} | ||||
|  | ||||
| {% load inventree_extras %} | ||||
| {% load i18n account socialaccount crispy_forms_tags inventree_extras %} | ||||
|  | ||||
| {% block head_title %}{% trans "Sign In" %}{% endblock %} | ||||
| @@ -10,6 +11,7 @@ | ||||
| {% settings_value 'LOGIN_ENABLE_PWD_FORGOT' as enable_pwd_forgot %} | ||||
| {% settings_value 'LOGIN_ENABLE_SSO' as enable_sso %} | ||||
| {% mail_configured as mail_conf %} | ||||
| {% inventree_demo_mode as demo %} | ||||
|  | ||||
| <h1>{% trans "Sign In" %}</h1> | ||||
|  | ||||
| @@ -36,9 +38,16 @@ for a account and sign in below:{% endblocktrans %}</p> | ||||
|   <div class="btn-group float-right" role="group"> | ||||
|     <button class="btn btn-success" type="submit">{% trans "Sign In" %}</button> | ||||
|   </div> | ||||
|   {% if mail_conf and enable_pwd_forgot %} | ||||
|   {% if mail_conf and enable_pwd_forgot and not demo %} | ||||
|   <a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a> | ||||
|   {% endif %} | ||||
|   {% if demo %} | ||||
|   <p> | ||||
|     <h6> | ||||
|       {% trans "InvenTree demo instance" %} - <a href='https://inventree.readthedocs.io/en/latest/demo/'>{% trans "Click here for login details" %}</a> | ||||
|     </h6> | ||||
|   </p> | ||||
|   {% endif %} | ||||
| </form> | ||||
|  | ||||
| {% if enable_sso %} | ||||
|   | ||||
| @@ -4,6 +4,8 @@ | ||||
|  | ||||
| {% settings_value 'BARCODE_ENABLE' as barcodes %} | ||||
| {% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %} | ||||
| {% settings_value "REPORT_ENABLE" as report_enabled %} | ||||
| {% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %} | ||||
|  | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| @@ -88,6 +90,16 @@ | ||||
|             {% block alerts %} | ||||
|             <div class='notification-area' id='alerts'> | ||||
|                 <!-- Div for displayed alerts --> | ||||
|                 {% if server_restart_required %} | ||||
|                 <div id='alert-restart-server' class='alert alert-danger' role='alert'> | ||||
|                     <span class='fas fa-server'></span> | ||||
|                     <b>{% trans "Server Restart Required" %}</b> | ||||
|                     <small> | ||||
|                         <br> | ||||
|                         {% trans "A configuration option has been changed which requires a server restart" %}. {% trans "Contact your system administrator for further information" %} | ||||
|                     </small> | ||||
|                 </div> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|             {% endblock %} | ||||
|  | ||||
|   | ||||
| @@ -13,7 +13,7 @@ const user_settings = { | ||||
|     {% endfor %} | ||||
| }; | ||||
|  | ||||
| {% global_settings as GLOBAL_SETTINGS %} | ||||
| {% visible_global_settings as GLOBAL_SETTINGS %} | ||||
| const global_settings = { | ||||
|     {% for key, value in GLOBAL_SETTINGS.items %} | ||||
|     {{ key }}: {% primitive_to_javascript value %}, | ||||
|   | ||||
| @@ -217,8 +217,10 @@ function showApiError(xhr, url) { | ||||
|         break; | ||||
|     } | ||||
|  | ||||
|     if (url) { | ||||
|         message += '<hr>'; | ||||
|         message += `URL: ${url}`; | ||||
|     } | ||||
|  | ||||
|     showMessage(title, { | ||||
|         style: 'danger', | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
| /* exported | ||||
|     newPartFromBomWizard, | ||||
|     loadBomTable, | ||||
|     loadUsedInTable, | ||||
|     removeRowFromBomWizard, | ||||
|     removeColFromBomWizard, | ||||
| */ | ||||
| @@ -311,7 +312,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) { | ||||
| } | ||||
|  | ||||
|  | ||||
| function loadBomTable(table, options) { | ||||
| function loadBomTable(table, options={}) { | ||||
|     /* Load a BOM table with some configurable options. | ||||
|      *  | ||||
|      * Following options are available: | ||||
| @@ -395,7 +396,7 @@ function loadBomTable(table, options) { | ||||
|  | ||||
|                 var sub_part = row.sub_part_detail; | ||||
|  | ||||
|                 html += makePartIcons(row.sub_part_detail); | ||||
|                 html += makePartIcons(sub_part); | ||||
|  | ||||
|                 if (row.substitutes && row.substitutes.length > 0) { | ||||
|                     html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}'); | ||||
| @@ -672,8 +673,9 @@ function loadBomTable(table, options) { | ||||
|  | ||||
|                     table.treegrid('collapseAll'); | ||||
|                 }, | ||||
|                 error: function() { | ||||
|                 error: function(xhr) { | ||||
|                     console.log('Error requesting BOM for part=' + part_pk); | ||||
|                     showApiError(xhr); | ||||
|                 } | ||||
|             } | ||||
|         ); | ||||
| @@ -835,3 +837,166 @@ function loadBomTable(table, options) { | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Load a table which shows the assemblies which "require" a certain part. | ||||
|  * | ||||
|  * Arguments: | ||||
|  * - table: The ID string of the table element e.g. '#used-in-table' | ||||
|  * - part_id: The ID (PK) of the part we are interested in | ||||
|  *  | ||||
|  * Options: | ||||
|  * -  | ||||
|  *  | ||||
|  * The following "options" are available. | ||||
|  */ | ||||
| function loadUsedInTable(table, part_id, options={}) { | ||||
|  | ||||
|     var params = options.params || {}; | ||||
|  | ||||
|     params.uses = part_id; | ||||
|     params.part_detail = true; | ||||
|     params.sub_part_detail = true, | ||||
|     params.show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM; | ||||
|  | ||||
|     var filters = {}; | ||||
|  | ||||
|     if (!options.disableFilters) { | ||||
|         filters = loadTableFilters('usedin'); | ||||
|     } | ||||
|  | ||||
|     for (var key in params) { | ||||
|         filters[key] = params[key]; | ||||
|     } | ||||
|  | ||||
|     setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin'); | ||||
|  | ||||
|     function loadVariantData(row) { | ||||
|         // Load variants information for inherited BOM rows | ||||
|  | ||||
|         inventreeGet( | ||||
|             '{% url "api-part-list" %}', | ||||
|             { | ||||
|                 assembly: true, | ||||
|                 ancestor: row.part, | ||||
|             }, | ||||
|             { | ||||
|                 success: function(variantData) { | ||||
|                     // Iterate through each variant item | ||||
|                     for (var jj = 0; jj < variantData.length; jj++) { | ||||
|                         variantData[jj].parent = row.pk; | ||||
|                          | ||||
|                         var variant = variantData[jj]; | ||||
|  | ||||
|                         // Add this variant to the table, augmented | ||||
|                         $(table).bootstrapTable('append', [{ | ||||
|                             // Point the parent to the "master" assembly row  | ||||
|                             parent: row.pk, | ||||
|                             part: variant.pk,                        | ||||
|                             part_detail: variant, | ||||
|                             sub_part: row.sub_part, | ||||
|                             sub_part_detail: row.sub_part_detail, | ||||
|                             quantity: row.quantity, | ||||
|                         }]); | ||||
|                     } | ||||
|                 }, | ||||
|                 error: function(xhr) { | ||||
|                     showApiError(xhr); | ||||
|                 } | ||||
|             } | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     $(table).inventreeTable({ | ||||
|         url: options.url || '{% url "api-bom-list" %}', | ||||
|         name: options.table_name || 'usedin', | ||||
|         sortable: true, | ||||
|         search: true, | ||||
|         showColumns: true, | ||||
|         queryParams: filters, | ||||
|         original: params, | ||||
|         rootParentId: 'top-level-item', | ||||
|         idField: 'pk', | ||||
|         uniqueId: 'pk', | ||||
|         parentIdField: 'parent', | ||||
|         treeShowField: 'part', | ||||
|         onLoadSuccess: function(tableData) { | ||||
|             // Once the initial data are loaded, check if there are any "inherited" BOM lines | ||||
|             for (var ii = 0; ii < tableData.length; ii++) { | ||||
|                 var row = tableData[ii]; | ||||
|  | ||||
|                 // This is a "top level" item in the table | ||||
|                 row.parent = 'top-level-item'; | ||||
|  | ||||
|                 // Ignore this row as it is not "inherited" by variant parts | ||||
|                 if (!row.inherited) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 loadVariantData(row); | ||||
|             } | ||||
|         }, | ||||
|         onPostBody: function() { | ||||
|             $(table).treegrid({ | ||||
|                 treeColumn: 0, | ||||
|             }); | ||||
|         }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'pk', | ||||
|                 title: 'ID', | ||||
|                 visible: false, | ||||
|                 switchable: false, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'part', | ||||
|                 title: '{% trans "Assembly" %}', | ||||
|                 switchable: false, | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     var url = `/part/${value}/?display=bom`; | ||||
|                     var html = ''; | ||||
|  | ||||
|                     var part = row.part_detail; | ||||
|  | ||||
|                     html += imageHoverIcon(part.thumbnail); | ||||
|                     html += renderLink(part.full_name, url); | ||||
|                     html += makePartIcons(part); | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'sub_part', | ||||
|                 title: '{% trans "Required Part" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     var url = `/part/${value}/`; | ||||
|                     var html = ''; | ||||
|  | ||||
|                     var sub_part = row.sub_part_detail; | ||||
|  | ||||
|                     html += imageHoverIcon(sub_part.thumbnail); | ||||
|                     html += renderLink(sub_part.full_name, url); | ||||
|                     html += makePartIcons(sub_part); | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'quantity', | ||||
|                 title: '{% trans "Required Quantity" %}', | ||||
|                 formatter: function(value, row) { | ||||
|                     var html = value; | ||||
|  | ||||
|                     if (row.parent && row.parent != 'top-level-item') { | ||||
|                         html += ` <em>({% trans "Inherited from parent BOM" %})</em>`; | ||||
|                     } | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             } | ||||
|         ] | ||||
|     }); | ||||
| } | ||||
|   | ||||
| @@ -281,23 +281,24 @@ function setupFilterList(tableKey, table, target) { | ||||
|     // One blank slate, please | ||||
|     element.empty(); | ||||
|  | ||||
|     element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`); | ||||
|     var buttons = ''; | ||||
|  | ||||
|     // Callback for reloading the table | ||||
|     element.find(`#reload-${tableKey}`).click(function() { | ||||
|         $(table).bootstrapTable('refresh'); | ||||
|     }); | ||||
|     buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`; | ||||
|  | ||||
|     // If there are no filters defined for this table, exit now | ||||
|     if (jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`); | ||||
|     // If there are filters defined for this table, add more buttons | ||||
|     if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) { | ||||
|         buttons += `<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`; | ||||
|  | ||||
|         if (Object.keys(filters).length > 0) { | ||||
|         element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`); | ||||
|             buttons += `<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     element.html(` | ||||
|     <div class='btn-group' role='group'> | ||||
|         ${buttons} | ||||
|     </div> | ||||
|     `); | ||||
|  | ||||
|     for (var key in filters) { | ||||
|         var value = getFilterOptionValue(tableKey, key, filters[key]); | ||||
| @@ -307,6 +308,11 @@ function setupFilterList(tableKey, table, target) { | ||||
|         element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`); | ||||
|     } | ||||
|  | ||||
|     // Callback for reloading the table | ||||
|     element.find(`#reload-${tableKey}`).click(function() { | ||||
|         $(table).bootstrapTable('refresh'); | ||||
|     }); | ||||
|  | ||||
|     // Add a callback for adding a new filter | ||||
|     element.find(`#${add}`).click(function clicked() { | ||||
|  | ||||
| @@ -316,10 +322,12 @@ function setupFilterList(tableKey, table, target) { | ||||
|  | ||||
|             var html = ''; | ||||
|  | ||||
|             html += `<div class='input-group'>`; | ||||
|             html += generateAvailableFilterList(tableKey); | ||||
|             html += generateFilterInput(tableKey); | ||||
|  | ||||
|             html += `<button title='{% trans "Create filter" %}' class='btn btn-outline-secondary filter-button' id='${make}'><span class='fas fa-plus'></span></button>`; | ||||
|             html += `</div>`; | ||||
|  | ||||
|             element.append(html); | ||||
|  | ||||
|   | ||||
| @@ -924,8 +924,8 @@ function handleFormSuccess(response, options) { | ||||
|     var cache = (options.follow && response.url) || options.redirect || options.reload; | ||||
|  | ||||
|     // Display any messages | ||||
|     if (response && response.success) { | ||||
|         showAlertOrCache(response.success, cache, {style: 'success'}); | ||||
|     if (response && (response.success || options.successMessage)) { | ||||
|         showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'}); | ||||
|     } | ||||
|      | ||||
|     if (response && response.info) { | ||||
|   | ||||
| @@ -331,6 +331,7 @@ function editPart(pk) { | ||||
|         groups: groups, | ||||
|         title: '{% trans "Edit Part" %}', | ||||
|         reload: true, | ||||
|         successMessage: '{% trans "Part edited" %}', | ||||
|     }); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1128,7 +1128,9 @@ function loadStockTable(table, options) { | ||||
|  | ||||
|     col = { | ||||
|         field: 'quantity', | ||||
|         sortName: 'stock', | ||||
|         title: '{% trans "Stock" %}', | ||||
|         sortable: true, | ||||
|         formatter: function(value, row) { | ||||
|  | ||||
|             var val = parseFloat(value); | ||||
|   | ||||
| @@ -77,10 +77,22 @@ function getAvailableTableFilters(tableKey) { | ||||
|     // Filters for the "used in" table | ||||
|     if (tableKey == 'usedin') { | ||||
|         return { | ||||
|             'inherited': { | ||||
|                 type: 'bool', | ||||
|                 title: '{% trans "Inherited" %}', | ||||
|             }, | ||||
|             'optional': { | ||||
|                 type: 'bool', | ||||
|                 title: '{% trans "Optional" %}', | ||||
|             }, | ||||
|             'part_active': { | ||||
|                 type: 'bool', | ||||
|                 title: '{% trans "Active" %}', | ||||
|             }, | ||||
|             'part_trackable': { | ||||
|                 type: 'bool', | ||||
|                 title: '{% trans "Trackable" %}', | ||||
|             }, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
|  | ||||
| {% settings_value 'BARCODE_ENABLE' as barcodes %} | ||||
| {% settings_value 'STICKY_HEADER' user=request.user as sticky %} | ||||
| {% inventree_demo_mode as demo %} | ||||
|  | ||||
| <nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light"> | ||||
|   <div class="container-fluid"> | ||||
| @@ -58,6 +59,9 @@ | ||||
|         {% endif %} | ||||
|       </ul> | ||||
|     </div> | ||||
|     {% if demo %} | ||||
|     {% include "navbar_demo.html" %} | ||||
|     {% endif %} | ||||
|     {% include "search_form.html" %} | ||||
|     <ul class='navbar-nav flex-row'> | ||||
|       {% if barcodes %} | ||||
| @@ -78,7 +82,7 @@ | ||||
|         </a> | ||||
|         <ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'> | ||||
|           {% if user.is_authenticated %} | ||||
|           {% if user.is_staff %} | ||||
|           {% if user.is_staff and not demo %} | ||||
|           <li><a class='dropdown-item' href="/admin/"><span class="fas fa-user"></span> {% trans "Admin" %}</a></li> | ||||
|           {% endif %} | ||||
|           <li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li> | ||||
|   | ||||
							
								
								
									
										12
									
								
								InvenTree/templates/navbar_demo.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								InvenTree/templates/navbar_demo.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| {% load i18n %} | ||||
| {% include "spacer.html" %} | ||||
| <div class='flex'> | ||||
|   <h6> | ||||
|     {% trans "InvenTree demo mode" %} | ||||
|     <a href='https://inventree.readthedocs.io/en/latest/demo/'> | ||||
|       <span class='fas fa-info-circle'></span> | ||||
|     </a> | ||||
|   </h6> | ||||
| </div> | ||||
| {% include "spacer.html" %} | ||||
| {% include "spacer.html" %} | ||||
| @@ -2,8 +2,10 @@ | ||||
|  | ||||
| <form class="d-flex" action="{% url 'search' %}" method='post'> | ||||
|     {% csrf_token %} | ||||
|     <div class='input-group'> | ||||
|         <input type="text" name='search' class="form-control" aria-label='{% trans "Search" %}' id="search-bar" placeholder="{% trans 'Search' %}"{% if query_text %} value="{{ query }}"{% endif %}> | ||||
|         <button type="submit" id='search-submit' class="btn btn-secondary" title='{% trans "Search" %}'> | ||||
|             <span class='fas fa-search'></span> | ||||
|         </button> | ||||
|     </div> | ||||
| </form> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| {% load i18n %} | ||||
| <a href="#" id='select-{{ label }}' title='{% trans text %}' class="list-group-item sidebar-list-group-item border-end-0 d-inline-block text-truncate sidebar-selector" data-bs-parent="#sidebar"> | ||||
|     <i class="bi bi-bootstrap"></i> | ||||
|     <span class='sidebar-item-icon fas {{ icon }}'></span> | ||||
|     <span class='sidebar-item-icon fas {{ icon|default:"fa-circle" }}'></span> | ||||
|     <span class='sidebar-item-text' style='display: none;'>{% trans text %}</span> | ||||
|     {% if badge %} | ||||
|     <span id='sidebar-badge-{{ label }}' class='sidebar-item-badge badge rounded-pill badge-right bg-dark'> | ||||
|   | ||||
| @@ -27,6 +27,9 @@ function {{ label }}StatusDisplay(key, options={}) { | ||||
|         label = {{ label }}Codes[key].label; | ||||
|     } | ||||
|  | ||||
|     // Fallback option for label | ||||
|     label = label || 'bg-dark'; | ||||
|  | ||||
|     if (value == null || value.length == 0) { | ||||
|         value = key; | ||||
|         label = ''; | ||||
|   | ||||
| @@ -9,7 +9,6 @@ | ||||
|  | ||||
|  | ||||
|  | ||||
| [](https://gitpod.io/#https://github.com/InvenTree/InvenTree) | ||||
|  | ||||
| InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a JSON API for interaction with external interfaces and applications. | ||||
|  | ||||
| @@ -17,6 +16,10 @@ InvenTree is designed to be lightweight and easy to use for SME or hobbyist appl | ||||
|  | ||||
| However, powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information. | ||||
|  | ||||
| # Demo | ||||
|  | ||||
| A demo instance of InvenTree is provided to allow users to explore the functionality of the software. [Read more here](https://inventree.readthedocs.io/en/latest/demo/) | ||||
|  | ||||
| # Docker | ||||
|  | ||||
| [](https://hub.docker.com/r/inventree/inventree) | ||||
|   | ||||
| @@ -28,6 +28,7 @@ print("=================================") | ||||
| print("Checking static javascript files:") | ||||
| print("=================================") | ||||
|  | ||||
|  | ||||
| def check_invalid_tag(data): | ||||
|  | ||||
|     pattern = r"{%(\w+)" | ||||
| @@ -45,6 +46,7 @@ def check_invalid_tag(data): | ||||
|  | ||||
|     return err_count | ||||
|  | ||||
|  | ||||
| def check_prohibited_tags(data): | ||||
|  | ||||
|     allowed_tags = [ | ||||
| @@ -78,7 +80,7 @@ def check_prohibited_tags(data): | ||||
|                 has_trans = True | ||||
|  | ||||
|     if not has_trans: | ||||
|         print(f" > file is missing 'trans' tags") | ||||
|         print(" > file is missing 'trans' tags") | ||||
|         err_count += 1 | ||||
|  | ||||
|     return err_count | ||||
|   | ||||
| @@ -24,7 +24,7 @@ for line in str(out.decode()).split('\n'): | ||||
| if len(locales) > 0: | ||||
|     print("There are {n} unstaged locale files:".format(n=len(locales))) | ||||
|  | ||||
|     for l in locales: | ||||
|         print(" - {l}".format(l=l)) | ||||
|     for lang in locales: | ||||
|         print(" - {l}".format(l=lang)) | ||||
|  | ||||
| sys.exit(len(locales)) | ||||
| @@ -9,7 +9,6 @@ import sys | ||||
| import re | ||||
| import os | ||||
| import argparse | ||||
| import requests | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|  | ||||
| @@ -65,7 +64,7 @@ if __name__ == '__main__': | ||||
|         e.g. "0.5 dev" | ||||
|         """ | ||||
|  | ||||
|         print(f"Checking development branch") | ||||
|         print("Checking development branch") | ||||
|  | ||||
|         pattern = "^\d+(\.\d+)+ dev$" | ||||
|  | ||||
| @@ -81,7 +80,7 @@ if __name__ == '__main__': | ||||
|         e.g. "0.5.1" | ||||
|         """ | ||||
|  | ||||
|         print(f"Checking release branch") | ||||
|         print("Checking release branch") | ||||
|  | ||||
|         pattern = "^\d+(\.\d+)+$" | ||||
|  | ||||
|   | ||||
| @@ -11,3 +11,6 @@ psycopg2>=2.9.1 | ||||
| mysqlclient>=2.0.3 | ||||
| pgcli>=3.1.0 | ||||
| mariadb>=1.0.7 | ||||
|  | ||||
| # Cache | ||||
| django-redis>=5.0.0 | ||||
|   | ||||
| @@ -29,6 +29,7 @@ djangorestframework==3.12.4     # DRF framework | ||||
| flake8==3.8.3                   # PEP checking | ||||
| gunicorn>=20.1.0                # Gunicorn web server | ||||
| inventree                       # Install the latest version of the InvenTree API python library | ||||
| markdown==3.3.4                 # Force particular version of markdown | ||||
| pep8-naming==0.11.1             # PEP naming convention extension | ||||
| pillow==8.3.2                   # Image manipulation | ||||
| py-moneyed==0.8.0               # Specific version requirement for py-moneyed | ||||
|   | ||||
							
								
								
									
										4
									
								
								tasks.py
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								tasks.py
									
									
									
									
									
								
							| @@ -1,6 +1,5 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
|  | ||||
| from shutil import copyfile | ||||
| import os | ||||
| import json | ||||
| import sys | ||||
| @@ -134,6 +133,7 @@ def rebuild_models(c): | ||||
|  | ||||
|     manage(c, "rebuild_models", pty=True) | ||||
|  | ||||
|  | ||||
| @task | ||||
| def rebuild_thumbnails(c): | ||||
|     """ | ||||
| @@ -142,6 +142,7 @@ def rebuild_thumbnails(c): | ||||
|  | ||||
|     manage(c, "rebuild_thumbnails", pty=True) | ||||
|  | ||||
|  | ||||
| @task | ||||
| def clean_settings(c): | ||||
|     """ | ||||
| @@ -150,6 +151,7 @@ def clean_settings(c): | ||||
|  | ||||
|     manage(c, "clean_settings") | ||||
|  | ||||
|  | ||||
| @task(post=[rebuild_models, rebuild_thumbnails]) | ||||
| def migrate(c): | ||||
|     """ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user