mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge branch 'master' into scheduling
This commit is contained in:
		| @@ -407,14 +407,16 @@ def DownloadFile(data, filename, content_type='application/text', inline=False): | ||||
|  | ||||
|  | ||||
| def extract_serial_numbers(serials, expected_quantity, next_number: int): | ||||
|     """ Attempt to extract serial numbers from an input string. | ||||
|     - Serial numbers must be integer values | ||||
|     - Serial numbers must be positive | ||||
|     - Serial numbers can be split by whitespace / newline / commma chars | ||||
|     - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 | ||||
|     - Serial numbers can be defined as ~ for getting the next available serial number | ||||
|     - Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start> | ||||
|     - Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start> | ||||
|     """ | ||||
|     Attempt to extract serial numbers from an input string: | ||||
|  | ||||
|     Requirements: | ||||
|         - Serial numbers can be either strings, or integers | ||||
|         - Serial numbers can be split by whitespace / newline / commma chars | ||||
|         - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 | ||||
|         - Serial numbers can be defined as ~ for getting the next available serial number | ||||
|         - Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start> | ||||
|         - Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start> | ||||
|  | ||||
|     Args: | ||||
|         serials: input string with patterns | ||||
| @@ -428,17 +430,18 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): | ||||
|     if '~' in serials: | ||||
|         serials = serials.replace('~', str(next_number)) | ||||
|  | ||||
|     # Split input string by whitespace or comma (,) characters | ||||
|     groups = re.split("[\s,]+", serials) | ||||
|  | ||||
|     numbers = [] | ||||
|     errors = [] | ||||
|  | ||||
|     # helpers | ||||
|     def number_add(n): | ||||
|         if n in numbers: | ||||
|             errors.append(_('Duplicate serial: {n}').format(n=n)) | ||||
|     # Helper function to check for duplicated numbers | ||||
|     def add_sn(sn): | ||||
|         if sn in numbers: | ||||
|             errors.append(_('Duplicate serial: {sn}').format(sn=sn)) | ||||
|         else: | ||||
|             numbers.append(n) | ||||
|             numbers.append(sn) | ||||
|  | ||||
|     try: | ||||
|         expected_quantity = int(expected_quantity) | ||||
| @@ -466,7 +469,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): | ||||
|  | ||||
|                     if a < b: | ||||
|                         for n in range(a, b + 1): | ||||
|                             number_add(n) | ||||
|                             add_sn(n) | ||||
|                     else: | ||||
|                         errors.append(_("Invalid group: {g}").format(g=group)) | ||||
|  | ||||
| @@ -495,21 +498,20 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): | ||||
|                     end = start + expected_quantity | ||||
|  | ||||
|                 for n in range(start, end): | ||||
|                     number_add(n) | ||||
|                     add_sn(n) | ||||
|             # no case | ||||
|             else: | ||||
|                 errors.append(_("Invalid group: {g}").format(g=group)) | ||||
|  | ||||
|         # Group should be a number | ||||
|         # At this point, we assume that the "group" is just a single serial value | ||||
|         elif group: | ||||
|             # try conversion | ||||
|             try: | ||||
|                 number = int(group) | ||||
|             except: | ||||
|                 # seem like it is not a number | ||||
|                 raise ValidationError(_(f"Invalid group {group}")) | ||||
|  | ||||
|             number_add(number) | ||||
|             try: | ||||
|                 # First attempt to add as an integer value | ||||
|                 add_sn(int(group)) | ||||
|             except (ValueError): | ||||
|                 # As a backup, add as a string value | ||||
|                 add_sn(group) | ||||
|  | ||||
|         # No valid input group detected | ||||
|         else: | ||||
|   | ||||
| @@ -309,6 +309,14 @@ if DEBUG and CONFIG.get('debug_toolbar', False):  # pragma: no cover | ||||
|     INSTALLED_APPS.append('debug_toolbar') | ||||
|     MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') | ||||
|  | ||||
| # InvenTree URL configuration | ||||
|  | ||||
| # Base URL for admin pages (default="admin") | ||||
| INVENTREE_ADMIN_URL = get_setting( | ||||
|     'INVENTREE_ADMIN_URL', | ||||
|     CONFIG.get('admin_url', 'admin'), | ||||
| ) | ||||
|  | ||||
| ROOT_URLCONF = 'InvenTree.urls' | ||||
|  | ||||
| TEMPLATES = [ | ||||
|   | ||||
| @@ -37,7 +37,6 @@ function showAlertOrCache(message, cache, options={}) { | ||||
|     if (cache) { | ||||
|         addCachedAlert(message, options); | ||||
|     } else { | ||||
|  | ||||
|         showMessage(message, options); | ||||
|     } | ||||
| } | ||||
| @@ -82,6 +81,8 @@ function showMessage(message, options={}) { | ||||
|  | ||||
|     var timeout = options.timeout || 5000; | ||||
|  | ||||
|     var target = options.target || $('#alerts'); | ||||
|  | ||||
|     var details = ''; | ||||
|  | ||||
|     if (options.details) { | ||||
| @@ -111,7 +112,7 @@ function showMessage(message, options={}) { | ||||
|     </div> | ||||
|     `; | ||||
|  | ||||
|     $('#alerts').append(html); | ||||
|     target.append(html); | ||||
|  | ||||
|     // Remove the alert automatically after a specified period of time | ||||
|     $(`#alert-${id}`).delay(timeout).slideUp(200, function() { | ||||
|   | ||||
| @@ -14,6 +14,8 @@ from django_q.monitor import Stat | ||||
|  | ||||
| from django.conf import settings | ||||
|  | ||||
| import InvenTree.ready | ||||
|  | ||||
|  | ||||
| logger = logging.getLogger("inventree") | ||||
|  | ||||
| @@ -56,6 +58,12 @@ def is_email_configured(): | ||||
|  | ||||
|     configured = True | ||||
|  | ||||
|     if InvenTree.ready.isInTestMode(): | ||||
|         return False | ||||
|  | ||||
|     if InvenTree.ready.isImportingData(): | ||||
|         return False | ||||
|  | ||||
|     if not settings.EMAIL_HOST: | ||||
|         configured = False | ||||
|  | ||||
| @@ -89,6 +97,14 @@ def check_system_health(**kwargs): | ||||
|  | ||||
|     result = True | ||||
|  | ||||
|     if InvenTree.ready.isInTestMode(): | ||||
|         # Do not perform further checks if we are running unit tests | ||||
|         return False | ||||
|  | ||||
|     if InvenTree.ready.isImportingData(): | ||||
|         # Do not perform further checks if we are importing data | ||||
|         return False | ||||
|  | ||||
|     if not is_worker_running(**kwargs):  # pragma: no cover | ||||
|         result = False | ||||
|         logger.warning(_("Background worker check failed")) | ||||
|   | ||||
| @@ -4,7 +4,6 @@ Top-level URL lookup for InvenTree application. | ||||
| Passes URL lookup downstream to each app as required. | ||||
| """ | ||||
|  | ||||
|  | ||||
| from django.conf.urls import url, include | ||||
| from django.urls import path | ||||
| from django.contrib import admin | ||||
| @@ -169,9 +168,9 @@ frontendpatterns = [ | ||||
|     url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), | ||||
|  | ||||
|     # admin sites | ||||
|     url(r'^admin/error_log/', include('error_report.urls')), | ||||
|     url(r'^admin/shell/', include('django_admin_shell.urls')), | ||||
|     url(r'^admin/', admin.site.urls, name='inventree-admin'), | ||||
|     url(f'^{settings.INVENTREE_ADMIN_URL}/error_log/', include('error_report.urls')), | ||||
|     url(f'^{settings.INVENTREE_ADMIN_URL}/shell/', include('django_admin_shell.urls')), | ||||
|     url(f'^{settings.INVENTREE_ADMIN_URL}/', admin.site.urls, name='inventree-admin'), | ||||
|  | ||||
|     # DB user sessions | ||||
|     url(r'^accounts/sessions/other/delete/$', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ), | ||||
|   | ||||
| @@ -12,11 +12,14 @@ import common.models | ||||
| INVENTREE_SW_VERSION = "0.7.0 dev" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 26 | ||||
| INVENTREE_API_VERSION = 27 | ||||
|  | ||||
| """ | ||||
| Increment this API version number whenever there is a significant change to the API that any clients need to know about | ||||
|  | ||||
| v27 -> 2022-02-28 | ||||
|     - Adds target_date field to individual line items for purchase orders and sales orders | ||||
|  | ||||
| v26 -> 2022-02-17 | ||||
|     - Adds API endpoint for uploading a BOM file and extracting data | ||||
|  | ||||
|   | ||||
| @@ -151,7 +151,7 @@ src="{% static 'img/blank_image.png' %}" | ||||
|             {% trans "Target Date" %} | ||||
|         </td> | ||||
|         <td> | ||||
|             {{ build.target_date }} | ||||
|             {% render_date build.target_date %} | ||||
|             {% if build.is_overdue %} | ||||
|             <span title='{% blocktrans with target=build.target_date %}This build was due on {{target}}{% endblocktrans %}' class='badge badge-right rounded-pill bg-danger'>{% trans "Overdue" %}</span> | ||||
|             {% endif %} | ||||
|   | ||||
| @@ -18,7 +18,7 @@ | ||||
|     <div class='panel-content'> | ||||
|         <div class='row'> | ||||
|             <div class='col-sm-6'> | ||||
|                 <table class='table table-striped'> | ||||
|                 <table class='table table-striped table-condensed'> | ||||
|                     <col width='25'> | ||||
|                 <tr> | ||||
|                     <td><span class='fas fa-info'></span></td> | ||||
| @@ -120,19 +120,19 @@ | ||||
|                 </table> | ||||
|             </div> | ||||
|             <div class='col-sm-6'> | ||||
|                 <table class='table table-striped'> | ||||
|                 <table class='table table-striped table-condensed'> | ||||
|                     <col width='25'> | ||||
|                     <tr> | ||||
|                         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                         <td>{% trans "Created" %}</td> | ||||
|                         <td>{{ build.creation_date }}</td> | ||||
|                         <td>{% render_date build.creation_date %}</td> | ||||
|                     </tr> | ||||
|                     <tr> | ||||
|                         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                         <td>{% trans "Target Date" %}</td> | ||||
|                         {% if build.target_date %} | ||||
|                         <td> | ||||
|                             {{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %} | ||||
|                             {% render_date build.target_date %}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %} | ||||
|                         </td> | ||||
|                         {% else %} | ||||
|                         <td><em>{% trans "No target date set" %}</em></td> | ||||
| @@ -142,7 +142,7 @@ | ||||
|                         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                         <td>{% trans "Completed" %}</td> | ||||
|                         {% if build.completion_date %} | ||||
|                         <td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge badge-right rounded-pill bg-dark'>{{ build.completed_by }}</span>{% endif %}</td> | ||||
|                         <td>{% render_date build.completion_date %}{% if build.completed_by %}<span class='badge badge-right rounded-pill bg-dark'>{{ build.completed_by }}</span>{% endif %}</td> | ||||
|                         {% else %} | ||||
|                         <td><em>{% trans "Build not complete" %}</em></td> | ||||
|                         {% endif %} | ||||
|   | ||||
| @@ -443,12 +443,12 @@ class BaseInvenTreeSetting(models.Model): | ||||
|         except self.DoesNotExist: | ||||
|             pass | ||||
|  | ||||
|     def choices(self): | ||||
|     def choices(self, **kwargs): | ||||
|         """ | ||||
|         Return the available choices for this setting (or None if no choices are defined) | ||||
|         """ | ||||
|  | ||||
|         return self.__class__.get_setting_choices(self.key) | ||||
|         return self.__class__.get_setting_choices(self.key, **kwargs) | ||||
|  | ||||
|     def valid_options(self): | ||||
|         """ | ||||
| @@ -462,6 +462,33 @@ class BaseInvenTreeSetting(models.Model): | ||||
|  | ||||
|         return [opt[0] for opt in choices] | ||||
|  | ||||
|     def is_choice(self, **kwargs): | ||||
|         """ | ||||
|         Check if this setting is a "choice" field | ||||
|         """ | ||||
|  | ||||
|         return self.__class__.get_setting_choices(self.key, **kwargs) is not None | ||||
|  | ||||
|     def as_choice(self, **kwargs): | ||||
|         """ | ||||
|         Render this setting as the "display" value of a choice field, | ||||
|         e.g. if the choices are: | ||||
|         [('A4', 'A4 paper'), ('A3', 'A3 paper')], | ||||
|         and the value is 'A4', | ||||
|         then display 'A4 paper' | ||||
|         """ | ||||
|  | ||||
|         choices = self.get_setting_choices(self.key, **kwargs) | ||||
|  | ||||
|         if not choices: | ||||
|             return self.value | ||||
|  | ||||
|         for value, display in choices: | ||||
|             if value == self.value: | ||||
|                 return display | ||||
|  | ||||
|         return self.value | ||||
|  | ||||
|     def is_bool(self, **kwargs): | ||||
|         """ | ||||
|         Check if this setting is required to be a boolean value | ||||
| @@ -1212,6 +1239,21 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): | ||||
|             'default': False, | ||||
|             'validator': bool, | ||||
|         }, | ||||
|  | ||||
|         'DATE_DISPLAY_FORMAT': { | ||||
|             'name': _('Date Format'), | ||||
|             'description': _('Preferred format for displaying dates'), | ||||
|             'default': 'YYYY-MM-DD', | ||||
|             'choices': [ | ||||
|                 ('YYYY-MM-DD', '2022-02-22'), | ||||
|                 ('YYYY/MM/DD', '2022/22/22'), | ||||
|                 ('DD-MM-YYYY', '22-02-2022'), | ||||
|                 ('DD/MM/YYYY', '22/02/2022'), | ||||
|                 ('MM-DD-YYYY', '02-22-2022'), | ||||
|                 ('MM/DD/YYYY', '02/22/2022'), | ||||
|                 ('MMM DD YYYY', 'Feb 22 2022'), | ||||
|             ] | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class Meta: | ||||
|   | ||||
| @@ -274,7 +274,7 @@ class POLineItemFilter(rest_filters.FilterSet): | ||||
|         model = models.PurchaseOrderLineItem | ||||
|         fields = [ | ||||
|             'order', | ||||
|             'part' | ||||
|             'part', | ||||
|         ] | ||||
|  | ||||
|     pending = rest_filters.BooleanFilter(label='pending', method='filter_pending') | ||||
| @@ -391,6 +391,7 @@ class POLineItemList(generics.ListCreateAPIView): | ||||
|         'reference', | ||||
|         'SKU', | ||||
|         'total_price', | ||||
|         'target_date', | ||||
|     ] | ||||
|  | ||||
|     search_fields = [ | ||||
| @@ -401,11 +402,6 @@ class POLineItemList(generics.ListCreateAPIView): | ||||
|         'reference', | ||||
|     ] | ||||
|  | ||||
|     filter_fields = [ | ||||
|         'order', | ||||
|         'part' | ||||
|     ] | ||||
|  | ||||
|  | ||||
| class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): | ||||
|     """ | ||||
| @@ -703,6 +699,7 @@ class SOLineItemList(generics.ListCreateAPIView): | ||||
|         'part__name', | ||||
|         'quantity', | ||||
|         'reference', | ||||
|         'target_date', | ||||
|     ] | ||||
|  | ||||
|     search_fields = [ | ||||
|   | ||||
							
								
								
									
										23
									
								
								InvenTree/order/migrations/0062_auto_20220228_0321.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								InvenTree/order/migrations/0062_auto_20220228_0321.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 3.2.10 on 2022-02-28 03:21 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='purchaseorderlineitem', | ||||
|             name='target_date', | ||||
|             field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='salesorderlineitem', | ||||
|             name='target_date', | ||||
|             field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,17 @@ | ||||
| # Generated by Django 3.2.10 on 2022-02-28 04:27 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('order', '0062_auto_20220228_0321'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterUniqueTogether( | ||||
|             name='purchaseorderlineitem', | ||||
|             unique_together=set(), | ||||
|         ), | ||||
|     ] | ||||
| @@ -398,12 +398,22 @@ class PurchaseOrder(Order): | ||||
|         return self.lines.count() > 0 and self.pending_line_items().count() == 0 | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): | ||||
|         """ Receive a line item (or partial line item) against this PO | ||||
|     def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs): | ||||
|         """ | ||||
|         Receive a line item (or partial line item) against this PO | ||||
|         """ | ||||
|  | ||||
|         # Extract optional batch code for the new stock item | ||||
|         batch_code = kwargs.get('batch_code', '') | ||||
|  | ||||
|         # Extract optional list of serial numbers | ||||
|         serials = kwargs.get('serials', None) | ||||
|  | ||||
|         # Extract optional notes field | ||||
|         notes = kwargs.get('notes', '') | ||||
|         barcode = kwargs.get('barcode', '') | ||||
|  | ||||
|         # Extract optional barcode field | ||||
|         barcode = kwargs.get('barcode', None) | ||||
|  | ||||
|         # Prevent null values for barcode | ||||
|         if barcode is None: | ||||
| @@ -427,33 +437,45 @@ class PurchaseOrder(Order): | ||||
|  | ||||
|         # Create a new stock item | ||||
|         if line.part and quantity > 0: | ||||
|             stock = stock_models.StockItem( | ||||
|                 part=line.part.part, | ||||
|                 supplier_part=line.part, | ||||
|                 location=location, | ||||
|                 quantity=quantity, | ||||
|                 purchase_order=self, | ||||
|                 status=status, | ||||
|                 purchase_price=line.purchase_price, | ||||
|                 uid=barcode | ||||
|             ) | ||||
|  | ||||
|             stock.save(add_note=False) | ||||
|             # Determine if we should individually serialize the items, or not | ||||
|             if type(serials) is list and len(serials) > 0: | ||||
|                 serialize = True | ||||
|             else: | ||||
|                 serialize = False | ||||
|                 serials = [None] | ||||
|  | ||||
|             tracking_info = { | ||||
|                 'status': status, | ||||
|                 'purchaseorder': self.pk, | ||||
|             } | ||||
|             for sn in serials: | ||||
|  | ||||
|             stock.add_tracking_entry( | ||||
|                 StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, | ||||
|                 user, | ||||
|                 notes=notes, | ||||
|                 deltas=tracking_info, | ||||
|                 location=location, | ||||
|                 purchaseorder=self, | ||||
|                 quantity=quantity | ||||
|             ) | ||||
|                 stock = stock_models.StockItem( | ||||
|                     part=line.part.part, | ||||
|                     supplier_part=line.part, | ||||
|                     location=location, | ||||
|                     quantity=1 if serialize else quantity, | ||||
|                     purchase_order=self, | ||||
|                     status=status, | ||||
|                     batch=batch_code, | ||||
|                     serial=sn, | ||||
|                     purchase_price=line.purchase_price, | ||||
|                     uid=barcode | ||||
|                 ) | ||||
|  | ||||
|                 stock.save(add_note=False) | ||||
|  | ||||
|                 tracking_info = { | ||||
|                     'status': status, | ||||
|                     'purchaseorder': self.pk, | ||||
|                 } | ||||
|  | ||||
|                 stock.add_tracking_entry( | ||||
|                     StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, | ||||
|                     user, | ||||
|                     notes=notes, | ||||
|                     deltas=tracking_info, | ||||
|                     location=location, | ||||
|                     purchaseorder=self, | ||||
|                     quantity=quantity | ||||
|                 ) | ||||
|  | ||||
|         # Update the number of parts received against the particular line item | ||||
|         line.received += quantity | ||||
| @@ -794,9 +816,18 @@ class OrderLineItem(models.Model): | ||||
|  | ||||
|     Attributes: | ||||
|         quantity: Number of items | ||||
|         reference: Reference text (e.g. customer reference) for this line item | ||||
|         note: Annotation for the item | ||||
|         target_date: An (optional) date for expected shipment of this line item. | ||||
|     """ | ||||
|  | ||||
|     """ | ||||
|     Query filter for determining if an individual line item is "overdue": | ||||
|     - Amount received is less than the required quantity | ||||
|     - Target date is not None | ||||
|     - Target date is in the past | ||||
|     """ | ||||
|     OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date()) | ||||
|  | ||||
|     class Meta: | ||||
|         abstract = True | ||||
| @@ -813,6 +844,12 @@ class OrderLineItem(models.Model): | ||||
|  | ||||
|     notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes')) | ||||
|  | ||||
|     target_date = models.DateField( | ||||
|         blank=True, null=True, | ||||
|         verbose_name=_('Target Date'), | ||||
|         help_text=_('Target shipping date for this line item'), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class PurchaseOrderLineItem(OrderLineItem): | ||||
|     """ Model for a purchase order line item. | ||||
| @@ -824,7 +861,6 @@ class PurchaseOrderLineItem(OrderLineItem): | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ( | ||||
|             ('order', 'part', 'quantity', 'purchase_price') | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
|   | ||||
| @@ -5,12 +5,14 @@ JSON serializers for the Order API | ||||
| # -*- coding: utf-8 -*- | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from decimal import Decimal | ||||
|  | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
|  | ||||
| from django.core.exceptions import ValidationError as DjangoValidationError | ||||
| from django.db import models, transaction | ||||
| from django.db.models import Case, When, Value | ||||
| from django.db.models import BooleanField, ExpressionWrapper, F | ||||
| from django.db.models import BooleanField, ExpressionWrapper, F, Q | ||||
|  | ||||
| from rest_framework import serializers | ||||
| from rest_framework.serializers import ValidationError | ||||
| @@ -26,7 +28,7 @@ from InvenTree.serializers import InvenTreeModelSerializer | ||||
| from InvenTree.serializers import InvenTreeDecimalField | ||||
| from InvenTree.serializers import InvenTreeMoneySerializer | ||||
| from InvenTree.serializers import ReferenceIndexingSerializerMixin | ||||
| from InvenTree.status_codes import StockStatus | ||||
| from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, SalesOrderStatus | ||||
|  | ||||
| import order.models | ||||
|  | ||||
| @@ -126,6 +128,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): | ||||
|         Add some extra annotations to this queryset: | ||||
|  | ||||
|         - Total price = purchase_price * quantity | ||||
|         - "Overdue" status (boolean field) | ||||
|         """ | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
| @@ -135,6 +138,15 @@ class POLineItemSerializer(InvenTreeModelSerializer): | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             overdue=Case( | ||||
|                 When( | ||||
|                     Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) | ||||
|                 ), | ||||
|                 default=Value(False, output_field=BooleanField()), | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
| @@ -155,6 +167,8 @@ class POLineItemSerializer(InvenTreeModelSerializer): | ||||
|     quantity = serializers.FloatField(default=1) | ||||
|     received = serializers.FloatField(default=0) | ||||
|  | ||||
|     overdue = serializers.BooleanField(required=False, read_only=True) | ||||
|  | ||||
|     total_price = serializers.FloatField(read_only=True) | ||||
|  | ||||
|     part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) | ||||
| @@ -185,6 +199,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): | ||||
|             'notes', | ||||
|             'order', | ||||
|             'order_detail', | ||||
|             'overdue', | ||||
|             'part', | ||||
|             'part_detail', | ||||
|             'supplier_part_detail', | ||||
| @@ -194,6 +209,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): | ||||
|             'purchase_price_string', | ||||
|             'destination', | ||||
|             'destination_detail', | ||||
|             'target_date', | ||||
|             'total_price', | ||||
|         ] | ||||
|  | ||||
| @@ -203,6 +219,17 @@ class POLineItemReceiveSerializer(serializers.Serializer): | ||||
|     A serializer for receiving a single purchase order line item against a purchase order | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
|         fields = [ | ||||
|             'barcode', | ||||
|             'line_item', | ||||
|             'location', | ||||
|             'quantity', | ||||
|             'status', | ||||
|             'batch_code' | ||||
|             'serial_numbers', | ||||
|         ] | ||||
|  | ||||
|     line_item = serializers.PrimaryKeyRelatedField( | ||||
|         queryset=order.models.PurchaseOrderLineItem.objects.all(), | ||||
|         many=False, | ||||
| @@ -241,6 +268,22 @@ class POLineItemReceiveSerializer(serializers.Serializer): | ||||
|  | ||||
|         return quantity | ||||
|  | ||||
|     batch_code = serializers.CharField( | ||||
|         label=_('Batch Code'), | ||||
|         help_text=_('Enter batch code for incoming stock items'), | ||||
|         required=False, | ||||
|         default='', | ||||
|         allow_blank=True, | ||||
|     ) | ||||
|  | ||||
|     serial_numbers = serializers.CharField( | ||||
|         label=_('Serial Numbers'), | ||||
|         help_text=_('Enter serial numbers for incoming stock items'), | ||||
|         required=False, | ||||
|         default='', | ||||
|         allow_blank=True, | ||||
|     ) | ||||
|  | ||||
|     status = serializers.ChoiceField( | ||||
|         choices=list(StockStatus.items()), | ||||
|         default=StockStatus.OK, | ||||
| @@ -270,14 +313,35 @@ class POLineItemReceiveSerializer(serializers.Serializer): | ||||
|  | ||||
|         return barcode | ||||
|  | ||||
|     class Meta: | ||||
|         fields = [ | ||||
|             'barcode', | ||||
|             'line_item', | ||||
|             'location', | ||||
|             'quantity', | ||||
|             'status', | ||||
|         ] | ||||
|     def validate(self, data): | ||||
|  | ||||
|         data = super().validate(data) | ||||
|  | ||||
|         line_item = data['line_item'] | ||||
|         quantity = data['quantity'] | ||||
|         serial_numbers = data.get('serial_numbers', '').strip() | ||||
|  | ||||
|         base_part = line_item.part.part | ||||
|  | ||||
|         # Does the quantity need to be "integer" (for trackable parts?) | ||||
|         if base_part.trackable: | ||||
|  | ||||
|             if Decimal(quantity) != int(quantity): | ||||
|                 raise ValidationError({ | ||||
|                     'quantity': _('An integer quantity must be provided for trackable parts'), | ||||
|                 }) | ||||
|  | ||||
|         # If serial numbers are provided | ||||
|         if serial_numbers: | ||||
|             try: | ||||
|                 # Pass the serial numbers through to the parent serializer once validated | ||||
|                 data['serials'] = extract_serial_numbers(serial_numbers, quantity, base_part.getLatestSerialNumberInt()) | ||||
|             except DjangoValidationError as e: | ||||
|                 raise ValidationError({ | ||||
|                     'serial_numbers': e.messages, | ||||
|                 }) | ||||
|  | ||||
|         return data | ||||
|  | ||||
|  | ||||
| class POReceiveSerializer(serializers.Serializer): | ||||
| @@ -366,6 +430,8 @@ class POReceiveSerializer(serializers.Serializer): | ||||
|                         request.user, | ||||
|                         status=item['status'], | ||||
|                         barcode=item.get('barcode', ''), | ||||
|                         batch_code=item.get('batch_code', ''), | ||||
|                         serials=item.get('serials', None), | ||||
|                     ) | ||||
|                 except (ValidationError, DjangoValidationError) as exc: | ||||
|                     # Catch model errors and re-throw as DRF errors | ||||
| @@ -549,6 +615,23 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): | ||||
| class SOLineItemSerializer(InvenTreeModelSerializer): | ||||
|     """ Serializer for a SalesOrderLineItem object """ | ||||
|  | ||||
|     @staticmethod | ||||
|     def annotate_queryset(queryset): | ||||
|         """ | ||||
|         Add some extra annotations to this queryset: | ||||
|  | ||||
|         - "Overdue" status (boolean field) | ||||
|         """ | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             overdue=Case( | ||||
|                 When( | ||||
|                     Q(order__status__in=SalesOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), | ||||
|                 ), | ||||
|                 default=Value(False, output_field=BooleanField()), | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|  | ||||
|         part_detail = kwargs.pop('part_detail', False) | ||||
| @@ -570,6 +653,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer): | ||||
|     part_detail = PartBriefSerializer(source='part', many=False, read_only=True) | ||||
|     allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) | ||||
|  | ||||
|     overdue = serializers.BooleanField(required=False, read_only=True) | ||||
|  | ||||
|     quantity = InvenTreeDecimalField() | ||||
|  | ||||
|     allocated = serializers.FloatField(source='allocated_quantity', read_only=True) | ||||
| @@ -599,12 +684,14 @@ class SOLineItemSerializer(InvenTreeModelSerializer): | ||||
|             'notes', | ||||
|             'order', | ||||
|             'order_detail', | ||||
|             'overdue', | ||||
|             'part', | ||||
|             'part_detail', | ||||
|             'sale_price', | ||||
|             'sale_price_currency', | ||||
|             'sale_price_string', | ||||
|             'shipped', | ||||
|             'target_date', | ||||
|         ] | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -141,27 +141,27 @@ src="{% static 'img/blank_image.png' %}" | ||||
|     <tr> | ||||
|         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|         <td>{% trans "Created" %}</td> | ||||
|         <td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td> | ||||
|         <td>{% render_date order.creation_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td> | ||||
|     </tr> | ||||
|     {% if order.issue_date %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|         <td>{% trans "Issued" %}</td> | ||||
|         <td>{{ order.issue_date }}</td> | ||||
|         <td>{% render_date order.issue_date %}</td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     {% if order.target_date %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|         <td>{% trans "Target Date" %}</td> | ||||
|         <td>{{ order.target_date }}</td> | ||||
|         <td>{% render_date order.target_date %}</td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     {% if order.status == PurchaseOrderStatus.COMPLETE %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|         <td>{% trans "Received" %}</td> | ||||
|         <td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td> | ||||
|         <td>{% render_date order.complete_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     {% if order.responsible %} | ||||
|   | ||||
| @@ -174,6 +174,7 @@ $('#new-po-line').click(function() { | ||||
|                 value: '{{ order.supplier.currency }}', | ||||
|                 {% endif %} | ||||
|             }, | ||||
|             target_date: {}, | ||||
|             destination: {}, | ||||
|             notes: {}, | ||||
|         }, | ||||
| @@ -210,7 +211,7 @@ $('#new-po-line').click(function() { | ||||
| loadPurchaseOrderLineItemTable('#po-line-table', { | ||||
|     order: {{ order.pk }}, | ||||
|     supplier: {{ order.supplier.pk }}, | ||||
|     {% if order.status == PurchaseOrderStatus.PENDING %} | ||||
|     {% if roles.purchase_order.change %} | ||||
|     allow_edit: true, | ||||
|     {% else %} | ||||
|     allow_edit: false, | ||||
|   | ||||
| @@ -155,13 +155,13 @@ src="{% static 'img/blank_image.png' %}" | ||||
|     <tr> | ||||
|         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|         <td>{% trans "Created" %}</td> | ||||
|         <td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td> | ||||
|         <td>{% render_date order.creation_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td> | ||||
|     </tr> | ||||
|     {% if order.target_date %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|         <td>{% trans "Target Date" %}</td> | ||||
|         <td>{{ order.target_date }}</td> | ||||
|         <td>{% render_date order.target_date %}</td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     {% if order.shipment_date %} | ||||
| @@ -169,7 +169,7 @@ src="{% static 'img/blank_image.png' %}" | ||||
|         <td><span class='fas fa-truck'></span></td> | ||||
|         <td>{% trans "Completed" %}</td> | ||||
|         <td> | ||||
|             {{ order.shipment_date }} | ||||
|             {% render_date order.shipment_date %} | ||||
|             {% if order.shipped_by %} | ||||
|             <span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span> | ||||
|             {% endif %} | ||||
|   | ||||
| @@ -238,6 +238,7 @@ | ||||
|                 reference: {}, | ||||
|                 sale_price: {}, | ||||
|                 sale_price_currency: {}, | ||||
|                 target_date: {}, | ||||
|                 notes: {}, | ||||
|             }, | ||||
|             method: 'POST', | ||||
|   | ||||
| @@ -529,6 +529,108 @@ class PurchaseOrderReceiveTest(OrderTest): | ||||
|         self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists()) | ||||
|         self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists()) | ||||
|  | ||||
|     def test_batch_code(self): | ||||
|         """ | ||||
|         Test that we can supply a 'batch code' when receiving items | ||||
|         """ | ||||
|  | ||||
|         line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) | ||||
|         line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) | ||||
|  | ||||
|         self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0) | ||||
|         self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0) | ||||
|  | ||||
|         data = { | ||||
|             'items': [ | ||||
|                 { | ||||
|                     'line_item': 1, | ||||
|                     'quantity': 10, | ||||
|                     'batch_code': 'abc-123', | ||||
|                 }, | ||||
|                 { | ||||
|                     'line_item': 2, | ||||
|                     'quantity': 10, | ||||
|                     'batch_code': 'xyz-789', | ||||
|                 } | ||||
|             ], | ||||
|             'location': 1, | ||||
|         } | ||||
|  | ||||
|         n = StockItem.objects.count() | ||||
|  | ||||
|         self.post( | ||||
|             self.url, | ||||
|             data, | ||||
|             expected_code=201, | ||||
|         ) | ||||
|  | ||||
|         # Check that two new stock items have been created! | ||||
|         self.assertEqual(n + 2, StockItem.objects.count()) | ||||
|  | ||||
|         item_1 = StockItem.objects.filter(supplier_part=line_1.part).first() | ||||
|         item_2 = StockItem.objects.filter(supplier_part=line_2.part).first() | ||||
|  | ||||
|         self.assertEqual(item_1.batch, 'abc-123') | ||||
|         self.assertEqual(item_2.batch, 'xyz-789') | ||||
|  | ||||
|     def test_serial_numbers(self): | ||||
|         """ | ||||
|         Test that we can supply a 'serial number' when receiving items | ||||
|         """ | ||||
|  | ||||
|         line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) | ||||
|         line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) | ||||
|  | ||||
|         self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0) | ||||
|         self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0) | ||||
|  | ||||
|         data = { | ||||
|             'items': [ | ||||
|                 { | ||||
|                     'line_item': 1, | ||||
|                     'quantity': 10, | ||||
|                     'batch_code': 'abc-123', | ||||
|                     'serial_numbers': '100+', | ||||
|                 }, | ||||
|                 { | ||||
|                     'line_item': 2, | ||||
|                     'quantity': 10, | ||||
|                     'batch_code': 'xyz-789', | ||||
|                 } | ||||
|             ], | ||||
|             'location': 1, | ||||
|         } | ||||
|  | ||||
|         n = StockItem.objects.count() | ||||
|  | ||||
|         self.post( | ||||
|             self.url, | ||||
|             data, | ||||
|             expected_code=201, | ||||
|         ) | ||||
|  | ||||
|         # Check that the expected number of stock items has been created | ||||
|         self.assertEqual(n + 11, StockItem.objects.count()) | ||||
|  | ||||
|         # 10 serialized stock items created for the first line item | ||||
|         self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 10) | ||||
|  | ||||
|         # Check that the correct serial numbers have been allocated | ||||
|         for i in range(100, 110): | ||||
|             item = StockItem.objects.get(serial_int=i) | ||||
|             self.assertEqual(item.serial, str(i)) | ||||
|             self.assertEqual(item.quantity, 1) | ||||
|             self.assertEqual(item.batch, 'abc-123') | ||||
|  | ||||
|         # A single stock item (quantity 10) created for the second line item | ||||
|         items = StockItem.objects.filter(supplier_part=line_2.part) | ||||
|         self.assertEqual(items.count(), 1) | ||||
|  | ||||
|         item = items.first() | ||||
|  | ||||
|         self.assertEqual(item.quantity, 10) | ||||
|         self.assertEqual(item.batch, 'xyz-789') | ||||
|  | ||||
|  | ||||
| class SalesOrderTest(OrderTest): | ||||
|     """ | ||||
|   | ||||
| @@ -313,6 +313,10 @@ | ||||
|             fields: fields, | ||||
|             groups: partGroups(), | ||||
|             title: '{% trans "Create Part" %}', | ||||
|             reloadFormAfterSuccess: true, | ||||
|             persist: true, | ||||
|             persistMessage: '{% trans "Create another part after this one" %}', | ||||
|             successMessage: '{% trans "Part created successfully" %}', | ||||
|             onSuccess: function(data) { | ||||
|                 // Follow the new part | ||||
|                 location.href = `/part/${data.pk}/`; | ||||
|   | ||||
| @@ -969,7 +969,7 @@ | ||||
|     {% if price_history %} | ||||
|         var purchasepricedata = { | ||||
|                 labels: [ | ||||
|                     {% for line in price_history %}'{{ line.date }}',{% endfor %} | ||||
|                     {% for line in price_history %}'{% render_date line.date %}',{% endfor %} | ||||
|                 ], | ||||
|                 datasets: [{ | ||||
|                     label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}', | ||||
| @@ -1082,7 +1082,7 @@ | ||||
|     {% if sale_history %} | ||||
|             var salepricedata = { | ||||
|                     labels: [ | ||||
|                         {% for line in sale_history %}'{{ line.date }}',{% endfor %} | ||||
|                         {% for line in sale_history %}'{% render_date line.date %}',{% endfor %} | ||||
|                     ], | ||||
|                     datasets: [{ | ||||
|                         label: '{% blocktrans %}Unit Price - {{currency}}{% endblocktrans %}', | ||||
|   | ||||
| @@ -312,7 +312,7 @@ | ||||
|                     <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                     <td>{% trans "Creation Date" %}</td> | ||||
|                     <td> | ||||
|                         {{ part.creation_date }} | ||||
|                         {% render_date part.creation_date %} | ||||
|                         {% if part.creation_user %} | ||||
|                         <span class='badge badge-right rounded-pill bg-dark'>{{ part.creation_user }}</span> | ||||
|                         {% endif %} | ||||
|   | ||||
| @@ -5,6 +5,7 @@ This module provides template tags for extra functionality, | ||||
| over and above the built-in Django tags. | ||||
| """ | ||||
|  | ||||
| from datetime import date | ||||
| import os | ||||
| import sys | ||||
|  | ||||
| @@ -43,6 +44,52 @@ def define(value, *args, **kwargs): | ||||
|     return value | ||||
|  | ||||
|  | ||||
| @register.simple_tag(takes_context=True) | ||||
| def render_date(context, date_object): | ||||
|     """ | ||||
|     Renders a date according to the preference of the provided user | ||||
|  | ||||
|     Note that the user preference is stored using the formatting adopted by moment.js, | ||||
|     which differs from the python formatting! | ||||
|     """ | ||||
|  | ||||
|     if date_object is None: | ||||
|         return None | ||||
|  | ||||
|     if type(date_object) == str: | ||||
|         # If a string is passed, first convert it to a datetime | ||||
|         date_object = date.fromisoformat(date_object) | ||||
|  | ||||
|     # We may have already pre-cached the date format by calling this already! | ||||
|     user_date_format = context.get('user_date_format', None) | ||||
|  | ||||
|     if user_date_format is None: | ||||
|  | ||||
|         user = context.get('user', None) | ||||
|  | ||||
|         if user: | ||||
|             # User is specified - look for their date display preference | ||||
|             user_date_format = InvenTreeUserSetting.get_setting('DATE_DISPLAY_FORMAT', user=user) | ||||
|         else: | ||||
|             user_date_format = 'YYYY-MM-DD' | ||||
|  | ||||
|         # Convert the format string to Pythonic equivalent | ||||
|         replacements = [ | ||||
|             ('YYYY', '%Y'), | ||||
|             ('MMM', '%b'), | ||||
|             ('MM', '%m'), | ||||
|             ('DD', '%d'), | ||||
|         ] | ||||
|  | ||||
|         for o, n in replacements: | ||||
|             user_date_format = user_date_format.replace(o, n) | ||||
|  | ||||
|         # Update the context cache | ||||
|         context['user_date_format'] = user_date_format | ||||
|  | ||||
|     return date_object.strftime(user_date_format) | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def decimal(x, *args, **kwargs): | ||||
|     """ Simplified rendering of a decimal number """ | ||||
|   | ||||
| @@ -5,11 +5,13 @@ import logging | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.conf import settings | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
|  | ||||
| from maintenance_mode.core import set_maintenance_mode | ||||
|  | ||||
| from InvenTree.ready import isImportingData | ||||
| from plugin import registry | ||||
| from plugin.helpers import check_git_version, log_error | ||||
|  | ||||
|  | ||||
| logger = logging.getLogger('inventree') | ||||
| @@ -34,3 +36,8 @@ class PluginAppConfig(AppConfig): | ||||
|                     # drop out of maintenance | ||||
|                     # makes sure we did not have an error in reloading and maintenance is still active | ||||
|                     set_maintenance_mode(False) | ||||
|  | ||||
|             # check git version | ||||
|             registry.git_is_modern = check_git_version() | ||||
|             if not registry.git_is_modern:  # pragma: no cover  # simulating old git seems not worth it for coverage | ||||
|                 log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load') | ||||
|   | ||||
| @@ -94,21 +94,46 @@ def get_git_log(path): | ||||
|     """ | ||||
|     Get dict with info of the last commit to file named in path | ||||
|     """ | ||||
|     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:] | ||||
|     command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path] | ||||
|     from plugin import registry | ||||
|  | ||||
|     output = None | ||||
|     try: | ||||
|         output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1] | ||||
|         if output: | ||||
|             output = output.split('\n') | ||||
|     except subprocess.CalledProcessError:  # pragma: no cover | ||||
|         pass | ||||
|     if registry.git_is_modern: | ||||
|         path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:] | ||||
|         command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path] | ||||
|         try: | ||||
|             output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1] | ||||
|             if output: | ||||
|                 output = output.split('\n') | ||||
|         except subprocess.CalledProcessError:  # pragma: no cover | ||||
|             pass | ||||
|  | ||||
|     if not output: | ||||
|         output = 7 * ['']  # pragma: no cover | ||||
|  | ||||
|     return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]} | ||||
|  | ||||
|  | ||||
| def check_git_version(): | ||||
|     """returns if the current git version supports modern features""" | ||||
|  | ||||
|     # get version string | ||||
|     try: | ||||
|         output = str(subprocess.check_output(['git', '--version'], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8') | ||||
|     except subprocess.CalledProcessError:  # pragma: no cover | ||||
|         return False | ||||
|  | ||||
|     # process version string | ||||
|     try: | ||||
|         version = output[12:-1].split(".") | ||||
|         if len(version) > 1 and version[0] == '2': | ||||
|             if len(version) > 2 and int(version[1]) >= 22: | ||||
|                 return True | ||||
|     except ValueError:  # pragma: no cover | ||||
|         pass | ||||
|  | ||||
|     return False | ||||
|  | ||||
|  | ||||
| class GitStatus: | ||||
|     """ | ||||
|     Class for resolving git gpg singing state | ||||
|   | ||||
| @@ -52,6 +52,7 @@ class PluginsRegistry: | ||||
|         # flags | ||||
|         self.is_loading = False | ||||
|         self.apps_loading = True        # Marks if apps were reloaded yet | ||||
|         self.git_is_modern = True       # Is a modern version of git available | ||||
|  | ||||
|         # integration specific | ||||
|         self.installed_apps = []         # Holds all added plugin_paths | ||||
|   | ||||
| @@ -120,13 +120,13 @@ content: "v{{report_revision}} - {{ date.isoformat }}"; | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <th>{% trans "Issued" %}</th> | ||||
|                 <td>{{ build.creation_date }}</td> | ||||
|                 <td>{% render_date build.creation_date %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <th>{% trans "Target Date" %}</th> | ||||
|                 <td> | ||||
|                     {% if build.target_date %} | ||||
|                     {{ build.target_date }} | ||||
|                     {% render_date build.target_date %} | ||||
|                     {% else %} | ||||
|                     <em>Not specified</em> | ||||
|                     {% endif %} | ||||
|   | ||||
| @@ -63,6 +63,43 @@ class StockLocation(InvenTreeTree): | ||||
|                               help_text=_('Select Owner'), | ||||
|                               related_name='stock_locations') | ||||
|  | ||||
|     def get_location_owner(self): | ||||
|         """ | ||||
|         Get the closest "owner" for this location. | ||||
|  | ||||
|         Start at this location, and traverse "up" the location tree until we find an owner | ||||
|         """ | ||||
|  | ||||
|         for loc in self.get_ancestors(include_self=True, ascending=True): | ||||
|             if loc.owner is not None: | ||||
|                 return loc.owner | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def check_ownership(self, user): | ||||
|         """ | ||||
|         Check if the user "owns" (is one of the owners of) the location. | ||||
|         """ | ||||
|  | ||||
|         # Superuser accounts automatically "own" everything | ||||
|         if user.is_superuser: | ||||
|             return True | ||||
|  | ||||
|         ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') | ||||
|  | ||||
|         if not ownership_enabled: | ||||
|             # Location ownership function is not enabled, so return True | ||||
|             return True | ||||
|  | ||||
|         owner = self.get_location_owner() | ||||
|  | ||||
|         if owner is None: | ||||
|             # No owner set, for this location or any location above | ||||
|             # So, no ownership checks to perform! | ||||
|             return True | ||||
|  | ||||
|         return user in owner.get_related_owners(include_group=True) | ||||
|  | ||||
|     def get_absolute_url(self): | ||||
|         return reverse('stock-location-detail', kwargs={'pk': self.id}) | ||||
|  | ||||
| @@ -614,6 +651,48 @@ class StockItem(MPTTModel): | ||||
|                               help_text=_('Select Owner'), | ||||
|                               related_name='stock_items') | ||||
|  | ||||
|     def get_item_owner(self): | ||||
|         """ | ||||
|         Return the closest "owner" for this StockItem. | ||||
|  | ||||
|         - If the item has an owner set, return that | ||||
|         - If the item is "in stock", check the StockLocation | ||||
|         - Otherwise, return None | ||||
|         """ | ||||
|  | ||||
|         if self.owner is not None: | ||||
|             return self.owner | ||||
|  | ||||
|         if self.in_stock and self.location is not None: | ||||
|             loc_owner = self.location.get_location_owner() | ||||
|  | ||||
|             if loc_owner: | ||||
|                 return loc_owner | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def check_ownership(self, user): | ||||
|         """ | ||||
|         Check if the user "owns" (or is one of the owners of) the item | ||||
|         """ | ||||
|  | ||||
|         # Superuser accounts automatically "own" everything | ||||
|         if user.is_superuser: | ||||
|             return True | ||||
|  | ||||
|         ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') | ||||
|  | ||||
|         if not ownership_enabled: | ||||
|             # Location ownership function is not enabled, so return True | ||||
|             return True | ||||
|  | ||||
|         owner = self.get_item_owner() | ||||
|  | ||||
|         if owner is None: | ||||
|             return True | ||||
|  | ||||
|         return user in owner.get_related_owners(include_group=True) | ||||
|  | ||||
|     def is_stale(self): | ||||
|         """ | ||||
|         Returns True if this Stock item is "stale". | ||||
|   | ||||
| @@ -18,18 +18,11 @@ | ||||
|             <h4>{% trans "Stock Tracking Information" %}</h4> | ||||
|             {% include "spacer.html" %} | ||||
|             <div class='btn-group' role='group'> | ||||
|                 {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} | ||||
|                 {% if owner_control.value == "True" %} | ||||
|                     {% authorized_owners item.owner as owners %} | ||||
|                 {% endif %} | ||||
|                 <!-- Check permissions and owner --> | ||||
|                 {% if owner_control.value == "False" or owner_control.value == "True" and user in owners %} | ||||
|                 {% if roles.stock.change and not item.is_building %} | ||||
|                 {% if user_owns_item and roles.stock.change and not item.is_building %} | ||||
|                 <button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'> | ||||
|                     <span class='fas fa-plus-circle'></span> {% trans "New Entry" %} | ||||
|                 </button> | ||||
|                 {% endif %} | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|   | ||||
| @@ -59,14 +59,7 @@ | ||||
|     </ul> | ||||
| </div> | ||||
| <!-- Stock adjustment menu --> | ||||
| <!-- Check permissions and owner --> | ||||
|  | ||||
| {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} | ||||
| {% if owner_control.value == "True" %} | ||||
|     {% authorized_owners item.owner as owners %} | ||||
| {% endif %} | ||||
|  | ||||
| {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} | ||||
| {% if user_owns_item %} | ||||
|     {% if roles.stock.change and not item.is_building %} | ||||
|     <div class='btn-group'> | ||||
|         <button id='stock-actions' title='{% trans "Stock adjustment actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button> | ||||
| @@ -194,7 +187,7 @@ | ||||
|         <td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td> | ||||
|         <td>{% trans "Expiry Date" %}</td> | ||||
|         <td> | ||||
|             {{ item.expiry_date }} | ||||
|             {% render_date item.expiry_date %} | ||||
|             {% if item.is_expired %} | ||||
|             <span title='{% blocktrans %}This StockItem expired on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-danger badge-right'>{% trans "Expired" %}</span> | ||||
|             {% elif item.is_stale %} | ||||
| @@ -212,31 +205,15 @@ | ||||
|         <td><span class='fas fa-calendar-alt'></span></td> | ||||
|         <td>{% trans "Last Stocktake" %}</td> | ||||
|         {% if item.stocktake_date %} | ||||
|         <td>{{ item.stocktake_date }} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td> | ||||
|         <td>{% render_date item.stocktake_date %} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td> | ||||
|         {% else %} | ||||
|         <td><em>{% trans "No stocktake performed" %}</em></td> | ||||
|         {% endif %} | ||||
|     </tr> | ||||
| </table> | ||||
|  | ||||
| {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} | ||||
| {% if owner_control.value == "True" %} | ||||
|     {% authorized_owners item.owner as owners %} | ||||
| {% endif %} | ||||
|  | ||||
| <div class='info-messages'> | ||||
|  | ||||
|     {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} | ||||
|     {% if owner_control.value == "True" %} | ||||
|         {% authorized_owners item.owner as owners %} | ||||
|  | ||||
|         {% if not user in owners and not user.is_superuser %} | ||||
|         <div class='alert alert-block alert-info'> | ||||
|             {% trans "You are not in the list of owners of this item. This stock item cannot be edited." %}<br> | ||||
|         </div> | ||||
|         {% endif %} | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if item.is_building %} | ||||
|     <div class='alert alert-block alert-info'> | ||||
|         {% trans "This stock item is in production and cannot be edited." %}<br> | ||||
| @@ -419,11 +396,18 @@ | ||||
|         </td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     {% if item.owner %} | ||||
|     {% if ownership_enabled and item_owner %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-users'></span></td> | ||||
|         <td>{% trans "Owner" %}</td> | ||||
|         <td>{{ item.owner }}</td> | ||||
|         <td> | ||||
|             {{ item_owner }} | ||||
|             {% if not user_owns_item %} | ||||
|             <span class='badge rounded-pill bg-warning badge-right' title='{% trans "You are not in the list of owners of this item. This stock item cannot be edited." %}'> | ||||
|                 {% trans "Read only" %} | ||||
|             </span> | ||||
|             {% endif %} | ||||
|         </td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
| </table> | ||||
|   | ||||
| @@ -20,6 +20,7 @@ | ||||
| {% endblock %} | ||||
|  | ||||
| {% block actions %} | ||||
|  | ||||
| <!-- Admin view --> | ||||
| {% if location and user.is_staff and roles.stock_location.change %} | ||||
| {% url 'admin:stock_stocklocation_change' location.pk as url %} | ||||
| @@ -38,7 +39,7 @@ | ||||
|     </ul> | ||||
| </div> | ||||
| <!-- Check permissions and owner --> | ||||
| {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} | ||||
| {% if user_owns_location %} | ||||
| {% if roles.stock.change %} | ||||
| <div class='btn-group' role='group'> | ||||
|     <button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'> | ||||
| @@ -74,13 +75,11 @@ | ||||
| {% endif %} | ||||
| {% endif %} | ||||
| {% endif %} | ||||
| {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser or not location %} | ||||
| {% if roles.stock_location.add %} | ||||
| {% if user_owns_location and roles.stock_location.add %} | ||||
| <button class='btn btn-success' id='location-create' type='button' title='{% trans "Create new stock location" %}'> | ||||
|     <span class='fas fa-plus-circle'></span> {% trans "New Location" %} | ||||
| </button> | ||||
| {% endif %} | ||||
| {% endif %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block details_left %} | ||||
| @@ -106,23 +105,23 @@ | ||||
|         <td><em>{% trans "Top level stock location" %}</em></td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
|     {% if ownership_enabled and location_owner %} | ||||
|     <tr> | ||||
|         <td><span class='fas fa-users'></span></td> | ||||
|         <td>{% trans "Location Owner" %}</td> | ||||
|         <td> | ||||
|             {{ location_owner }} | ||||
|             {% if not user_owns_location %} | ||||
|             <span class='badge rounded-pill bg-warning badge-right' title='{% trans "You are not in the list of owners of this location. This stock location cannot be edited." %}'> | ||||
|                 {% trans "Read only" %} | ||||
|             </span> | ||||
|             {% endif %} | ||||
|         </td> | ||||
|     </tr> | ||||
|     {% endif %} | ||||
| </table> | ||||
| {% endblock details_left %} | ||||
|  | ||||
| {% block details_below %} | ||||
| {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} | ||||
| {% if owner_control.value == "True" %} | ||||
|     {% authorized_owners location.owner as owners %} | ||||
|  | ||||
|     {% if location and not user in owners and not user.is_superuser %} | ||||
|     <div class='alert alert-block alert-info'> | ||||
|         {% trans "You are not in the list of owners of this location. This stock location cannot be edited." %}<br> | ||||
|     </div> | ||||
|     {% endif %} | ||||
| {% endif %} | ||||
|  | ||||
| {% endblock details_below %} | ||||
|  | ||||
| {% block details_right %} | ||||
| {% if location %} | ||||
| <table class='table table-striped table-condensed'> | ||||
|   | ||||
| @@ -63,6 +63,11 @@ class StockIndex(InvenTreeRoleMixin, ListView): | ||||
|         context['loc_count'] = StockLocation.objects.count() | ||||
|         context['stock_count'] = StockItem.objects.count() | ||||
|  | ||||
|         # No 'ownership' checks are necessary for the top-level StockLocation view | ||||
|         context['user_owns_location'] = True | ||||
|         context['location_owner'] = None | ||||
|         context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| @@ -76,6 +81,16 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView): | ||||
|     queryset = StockLocation.objects.all() | ||||
|     model = StockLocation | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|  | ||||
|         context = super().get_context_data(**kwargs) | ||||
|  | ||||
|         context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') | ||||
|         context['location_owner'] = context['location'].get_location_owner() | ||||
|         context['user_owns_location'] = context['location'].check_ownership(self.request.user) | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class StockItemDetail(InvenTreeRoleMixin, DetailView): | ||||
|     """ | ||||
| @@ -126,6 +141,10 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView): | ||||
|                 # We only support integer serial number progression | ||||
|                 pass | ||||
|  | ||||
|         data['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') | ||||
|         data['item_owner'] = self.object.get_item_owner() | ||||
|         data['user_owns_item'] = self.object.check_ownership(self.request.user) | ||||
|  | ||||
|         return data | ||||
|  | ||||
|     def get(self, request, *args, **kwargs): | ||||
|   | ||||
| @@ -81,7 +81,7 @@ | ||||
|                 {% endif %} | ||||
|             </td> | ||||
|             <td>{{ plugin.author }}</td> | ||||
|             <td>{{ plugin.pub_date }}</td> | ||||
|             <td>{% render_date plugin.pub_date %}</td> | ||||
|             <td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td> | ||||
|         </tr> | ||||
|         {% endfor %} | ||||
|   | ||||
| @@ -36,7 +36,7 @@ | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                 <td>{% trans "Date" %}</td> | ||||
|                 <td>{{ plugin.pub_date }}{% include "clip.html" %}</td> | ||||
|                 <td>{% render_date plugin.pub_date %}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-hashtag'></span></td> | ||||
| @@ -101,7 +101,7 @@ | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                 <td>{% trans "Commit Date" %}</td><td>{{ plugin.package.date }}{% include "clip.html" %}</td> | ||||
|                 <td>{% trans "Commit Date" %}</td><td>{% render_date plugin.package.date %}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-code-branch'></span></td> | ||||
|   | ||||
| @@ -28,7 +28,11 @@ | ||||
|         <div id='setting-{{ setting.pk }}'> | ||||
|             <span id='setting-value-{{ setting.key.upper }}' fieldname='{{ setting.key.upper }}'> | ||||
|                 {% if setting.value %} | ||||
|                 {% if setting.is_choice %} | ||||
|                 <strong>{{ setting.as_choice }}</strong> | ||||
|                 {% else %} | ||||
|                 <strong>{{ setting.value }}</strong> | ||||
|                 {% endif %} | ||||
|                 {% else %} | ||||
|                 <em style='color: #855;'>{% trans "No value set" %}</em> | ||||
|                 {% endif %} | ||||
|   | ||||
| @@ -15,6 +15,7 @@ | ||||
|     <table class='table table-striped table-condensed'> | ||||
|         <tbody> | ||||
|             {% include "InvenTree/settings/setting.html" with key="STICKY_HEADER" icon="fa-bars" user_setting=True %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="DATE_DISPLAY_FORMAT" icon="fa-calendar-alt" user_setting=True %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %} | ||||
|         </tbody> | ||||
|   | ||||
| @@ -44,7 +44,7 @@ | ||||
|                             {% if commit_date %} | ||||
|                             <tr> | ||||
|                                 <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                                 <td>{% trans "Commit Date" %}</td><td>{{ commit_date }}{% include "clip.html" %}</td> | ||||
|                                 <td>{% trans "Commit Date" %}</td><td>{% render_date commit_date %}{% include "clip.html" %}</td> | ||||
|                             </tr> | ||||
|                             {% endif %} | ||||
|                             {% endif %} | ||||
|   | ||||
| @@ -7,6 +7,7 @@ | ||||
|     clearEvents, | ||||
|     endDate, | ||||
|     startDate, | ||||
|     renderDate, | ||||
| */ | ||||
|  | ||||
| /** | ||||
| @@ -32,3 +33,33 @@ function clearEvents(calendar) { | ||||
|         event.remove(); | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Render the provided date in the user-specified format. | ||||
|  * | ||||
|  * The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22 | ||||
|  * The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed. | ||||
|  */ | ||||
|  | ||||
| function renderDate(date, options={}) { | ||||
|  | ||||
|     if (!date) { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     var fmt = user_settings.DATE_DISPLAY_FORMAT || 'YYYY-MM-DD'; | ||||
|  | ||||
|     if (options.showTime) { | ||||
|         fmt += ' HH:mm'; | ||||
|     } | ||||
|  | ||||
|     var m = moment(date); | ||||
|  | ||||
|     if (m.isValid()) { | ||||
|         return m.format(fmt); | ||||
|     } else { | ||||
|         // Invalid input string, simply return provided value | ||||
|         return date; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -40,12 +40,15 @@ function editSetting(pk, options={}) { | ||||
|         url = `/api/settings/user/${pk}/`; | ||||
|     } | ||||
|  | ||||
|     var reload_required = false; | ||||
|  | ||||
|     // First, read the settings object from the server | ||||
|     inventreeGet(url, {}, { | ||||
|         success: function(response) { | ||||
|      | ||||
|             if (response.choices && response.choices.length > 0) { | ||||
|                 response.type = 'choice'; | ||||
|                 reload_required = true; | ||||
|             } | ||||
|  | ||||
|             // Construct the field  | ||||
| @@ -89,7 +92,9 @@ function editSetting(pk, options={}) { | ||||
|  | ||||
|                     var setting = response.key; | ||||
|  | ||||
|                     if (response.type == 'boolean') { | ||||
|                     if (reload_required) { | ||||
|                         location.reload(); | ||||
|                     } else if (response.type == 'boolean') { | ||||
|                         var enabled = response.value.toString().toLowerCase() == 'true'; | ||||
|                         $(`#setting-value-${setting}`).prop('checked', enabled); | ||||
|                     } else { | ||||
|   | ||||
| @@ -165,6 +165,9 @@ function loadAttachmentTable(url, options) { | ||||
|             { | ||||
|                 field: 'upload_date', | ||||
|                 title: '{% trans "Upload Date" %}', | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'actions', | ||||
|   | ||||
| @@ -1961,6 +1961,9 @@ function loadBuildTable(table, options) { | ||||
|                 field: 'creation_date', | ||||
|                 title: '{% trans "Created" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'issued_by', | ||||
| @@ -1990,11 +1993,17 @@ function loadBuildTable(table, options) { | ||||
|                 field: 'target_date', | ||||
|                 title: '{% trans "Target Date" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'completion_date', | ||||
|                 title: '{% trans "Completion Date" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|         ], | ||||
|     }); | ||||
|   | ||||
| @@ -542,6 +542,11 @@ function constructFormBody(fields, options) { | ||||
|         insertConfirmButton(options); | ||||
|     } | ||||
|  | ||||
|     // Insert "persist" button (if required) | ||||
|     if (options.persist) { | ||||
|         insertPersistButton(options); | ||||
|     } | ||||
|  | ||||
|     // Display the modal | ||||
|     $(modal).modal('show'); | ||||
|  | ||||
| @@ -616,6 +621,22 @@ function insertConfirmButton(options) { | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Add a checkbox to select if the modal will stay open after success */ | ||||
| function insertPersistButton(options) { | ||||
|  | ||||
|     var message = options.persistMessage || '{% trans "Keep this form open" %}'; | ||||
|  | ||||
|     var html = ` | ||||
|     <div class="form-check form-switch"> | ||||
|         <input class="form-check-input" type="checkbox" id="modal-persist"> | ||||
|         <label class="form-check-label" for="modal-persist">${message}</label> | ||||
|     </div> | ||||
|     `; | ||||
|  | ||||
|     $(options.modal).find('#modal-footer-buttons').append(html); | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Extract all specified form values as a single object | ||||
|  */ | ||||
| @@ -934,19 +955,40 @@ function getFormFieldValue(name, field={}, options={}) { | ||||
|  */ | ||||
| function handleFormSuccess(response, options) { | ||||
|  | ||||
|     // Close the modal | ||||
|     if (!options.preventClose) { | ||||
|         // Note: The modal will be deleted automatically after closing | ||||
|         $(options.modal).modal('hide'); | ||||
|     } | ||||
|  | ||||
|     // Display any required messages | ||||
|     // Should we show alerts immediately or cache them? | ||||
|     var cache = (options.follow && response.url) || options.redirect || options.reload; | ||||
|  | ||||
|     // Should the form "persist"? | ||||
|     var persist = false; | ||||
|  | ||||
|     if (options.persist && options.modal) { | ||||
|         // Determine if this form should "persist", or be dismissed? | ||||
|         var chk = $(options.modal).find('#modal-persist'); | ||||
|  | ||||
|         persist = chk.exists() && chk.prop('checked'); | ||||
|     } | ||||
|  | ||||
|     if (persist) { | ||||
|         cache = false; | ||||
|     } | ||||
|  | ||||
|     var msg_target = null; | ||||
|  | ||||
|     if (persist) { | ||||
|         // If the modal is persistant, the target for any messages should be the modal! | ||||
|         msg_target = $(options.modal).find('#pre-form-content'); | ||||
|     } | ||||
|  | ||||
|     // Display any messages | ||||
|     if (response && (response.success || options.successMessage)) { | ||||
|         showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'}); | ||||
|         showAlertOrCache( | ||||
|             response.success || options.successMessage, | ||||
|             cache, | ||||
|             { | ||||
|                 style: 'success', | ||||
|                 target: msg_target, | ||||
|             }); | ||||
|     } | ||||
|      | ||||
|     if (response && response.info) { | ||||
| @@ -961,20 +1003,41 @@ function handleFormSuccess(response, options) { | ||||
|         showAlertOrCache(response.danger, cache, {style: 'danger'}); | ||||
|     } | ||||
|  | ||||
|     if (options.onSuccess) { | ||||
|         // Callback function | ||||
|         options.onSuccess(response, options); | ||||
|     } | ||||
|     if (persist) { | ||||
|         // Instead of closing the form and going somewhere else, | ||||
|         // reload (empty) the form so the user can input more data | ||||
|          | ||||
|         // Reset the status of the "submit" button | ||||
|         if (options.modal) { | ||||
|             $(options.modal).find('#modal-form-submit').prop('disabled', false); | ||||
|         } | ||||
|  | ||||
|     if (options.follow && response.url) { | ||||
|         // Follow the returned URL | ||||
|         window.location.href = response.url; | ||||
|     } else if (options.reload) { | ||||
|         // Reload the current page | ||||
|         location.reload(); | ||||
|     } else if (options.redirect) { | ||||
|         // Redirect to a specified URL | ||||
|         window.location.href = options.redirect; | ||||
|         // Remove any error flags from the form | ||||
|         clearFormErrors(options); | ||||
|  | ||||
|     } else { | ||||
|  | ||||
|         // Close the modal | ||||
|         if (!options.preventClose) { | ||||
|             // Note: The modal will be deleted automatically after closing | ||||
|             $(options.modal).modal('hide'); | ||||
|         } | ||||
|  | ||||
|         if (options.onSuccess) { | ||||
|             // Callback function | ||||
|             options.onSuccess(response, options); | ||||
|         } | ||||
|  | ||||
|         if (options.follow && response.url) { | ||||
|             // Follow the returned URL | ||||
|             window.location.href = response.url; | ||||
|         } else if (options.reload) { | ||||
|             // Reload the current page | ||||
|             location.reload(); | ||||
|         } else if (options.redirect) { | ||||
|             // Redirect to a specified URL | ||||
|             window.location.href = options.redirect; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -988,6 +1051,8 @@ function clearFormErrors(options={}) { | ||||
|     if (options && options.modal) { | ||||
|         // Remove the individual error messages | ||||
|         $(options.modal).find('.form-error-message').remove(); | ||||
|      | ||||
|         $(options.modal).find('.modal-content').removeClass('modal-error'); | ||||
|  | ||||
|         // Remove the "has error" class | ||||
|         $(options.modal).find('.form-field-error').removeClass('form-field-error'); | ||||
| @@ -1884,7 +1949,7 @@ function getFieldName(name, options={}) { | ||||
|  * - Field description (help text) | ||||
|  * - Field errors | ||||
|  */ | ||||
| function constructField(name, parameters, options) { | ||||
| function constructField(name, parameters, options={}) { | ||||
|  | ||||
|     var html = ''; | ||||
|  | ||||
| @@ -1976,7 +2041,7 @@ function constructField(name, parameters, options) { | ||||
|     html += `<div class='controls'>`; | ||||
|  | ||||
|     // Does this input deserve "extra" decorators? | ||||
|     var extra = parameters.prefix != null; | ||||
|     var extra = (parameters.icon != null) || (parameters.prefix != null) || (parameters.prefixRaw != null); | ||||
|      | ||||
|     // Some fields can have 'clear' inputs associated with them | ||||
|     if (!parameters.required && !parameters.read_only) { | ||||
| @@ -1998,9 +2063,13 @@ function constructField(name, parameters, options) { | ||||
|      | ||||
|     if (extra) { | ||||
|         html += `<div class='input-group'>`; | ||||
|      | ||||
|   | ||||
|         if (parameters.prefix) { | ||||
|             html += `<span class='input-group-text'>${parameters.prefix}</span>`; | ||||
|         } else if (parameters.prefixRaw) { | ||||
|             html += parameters.prefixRaw; | ||||
|         } else if (parameters.icon) { | ||||
|             html += `<span class='input-group-text'><span class='fas ${parameters.icon}'></span></span>`; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -2147,6 +2216,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) { | ||||
|  | ||||
|     opts.push(`type='${type}'`); | ||||
|  | ||||
|     if (parameters.title || parameters.help_text) { | ||||
|         opts.push(`title='${parameters.title || parameters.help_text}'`); | ||||
|     } | ||||
|  | ||||
|     // Read only? | ||||
|     if (parameters.read_only) { | ||||
|         opts.push(`readonly=''`); | ||||
| @@ -2192,11 +2265,6 @@ function constructInputOptions(name, classes, type, parameters, options={}) { | ||||
|         opts.push(`required=''`); | ||||
|     } | ||||
|  | ||||
|     // Custom mouseover title? | ||||
|     if (parameters.title != null) { | ||||
|         opts.push(`title='${parameters.title}'`); | ||||
|     } | ||||
|  | ||||
|     // Placeholder? | ||||
|     if (parameters.placeholder != null) { | ||||
|         opts.push(`placeholder='${parameters.placeholder}'`); | ||||
|   | ||||
| @@ -116,6 +116,10 @@ function makeIconButton(icon, cls, pk, title, options={}) { | ||||
|         extraProps += `disabled='true' `; | ||||
|     } | ||||
|  | ||||
|     if (options.collapseTarget) { | ||||
|         extraProps += `data-bs-toggle='collapse' href='#${options.collapseTarget}'`; | ||||
|     } | ||||
|  | ||||
|     html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`; | ||||
|     html += `<span class='fas ${icon}'></span>`; | ||||
|     html += `</button>`; | ||||
|   | ||||
| @@ -476,6 +476,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { | ||||
|             quantity = 0; | ||||
|         } | ||||
|  | ||||
|         // Prepend toggles to the quantity input | ||||
|         var toggle_batch = ` | ||||
|             <span class='input-group-text' title='{% trans "Add batch code" %}' data-bs-toggle='collapse' href='#div-batch-${pk}'> | ||||
|                 <span class='fas fa-layer-group'></span> | ||||
|             </span> | ||||
|         `;  | ||||
|  | ||||
|         var toggle_serials = ` | ||||
|             <span class='input-group-text' title='{% trans "Add serial numbers" %}' data-bs-toggle='collapse' href='#div-serials-${pk}'> | ||||
|                 <span class='fas fa-hashtag'></span> | ||||
|             </span> | ||||
|         `; | ||||
|          | ||||
|         // Quantity to Receive | ||||
|         var quantity_input = constructField( | ||||
|             `items_quantity_${pk}`, | ||||
| @@ -491,6 +504,36 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { | ||||
|             } | ||||
|         ); | ||||
|  | ||||
|         // Add in options for "batch code" and "serial numbers" | ||||
|         var batch_input = constructField( | ||||
|             `items_batch_code_${pk}`, | ||||
|             { | ||||
|                 type: 'string', | ||||
|                 required: false, | ||||
|                 label: '{% trans "Batch Code" %}', | ||||
|                 help_text: '{% trans "Enter batch code for incoming stock items" %}', | ||||
|                 prefixRaw: toggle_batch, | ||||
|             } | ||||
|         ); | ||||
|  | ||||
|         var sn_input = constructField( | ||||
|             `items_serial_numbers_${pk}`, | ||||
|             { | ||||
|                 type: 'string', | ||||
|                 required: false, | ||||
|                 label: '{% trans "Serial Numbers" %}', | ||||
|                 help_text: '{% trans "Enter serial numbers for incoming stock items" %}', | ||||
|                 prefixRaw: toggle_serials, | ||||
|             } | ||||
|         ); | ||||
|  | ||||
|         // Hidden inputs below the "quantity" field | ||||
|         var quantity_input_group = `${quantity_input}<div class='collapse' id='div-batch-${pk}'>${batch_input}</div>`; | ||||
|  | ||||
|         if (line_item.part_detail.trackable) { | ||||
|             quantity_input_group += `<div class='collapse' id='div-serials-${pk}'>${sn_input}</div>`; | ||||
|         } | ||||
|  | ||||
|         // Construct list of StockItem status codes | ||||
|         var choices = []; | ||||
|  | ||||
| @@ -528,16 +571,38 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { | ||||
|         ); | ||||
|  | ||||
|         // Button to remove the row | ||||
|         var delete_button = `<div class='btn-group float-right' role='group'>`; | ||||
|         var buttons = `<div class='btn-group float-right' role='group'>`; | ||||
|  | ||||
|         delete_button += makeIconButton( | ||||
|         buttons += makeIconButton( | ||||
|             'fa-layer-group', | ||||
|             'button-row-add-batch', | ||||
|             pk, | ||||
|             '{% trans "Add batch code" %}', | ||||
|             { | ||||
|                 collapseTarget: `div-batch-${pk}` | ||||
|             } | ||||
|         ); | ||||
|  | ||||
|         if (line_item.part_detail.trackable) { | ||||
|             buttons += makeIconButton( | ||||
|                 'fa-hashtag', | ||||
|                 'button-row-add-serials', | ||||
|                 pk, | ||||
|                 '{% trans "Add serial numbers" %}', | ||||
|                 { | ||||
|                     collapseTarget: `div-serials-${pk}`, | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         buttons += makeIconButton( | ||||
|             'fa-times icon-red', | ||||
|             'button-row-remove', | ||||
|             pk, | ||||
|             '{% trans "Remove row" %}', | ||||
|         ); | ||||
|  | ||||
|         delete_button += '</div>'; | ||||
|         buttons += '</div>'; | ||||
|  | ||||
|         var html = ` | ||||
|         <tr id='receive_row_${pk}' class='stock-receive-row'> | ||||
| @@ -554,7 +619,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { | ||||
|                 ${line_item.received} | ||||
|             </td> | ||||
|             <td id='quantity_${pk}'> | ||||
|                 ${quantity_input} | ||||
|                 ${quantity_input_group} | ||||
|             </td> | ||||
|             <td id='status_${pk}'> | ||||
|                 ${status_input} | ||||
| @@ -563,7 +628,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { | ||||
|                 ${destination_input} | ||||
|             </td> | ||||
|             <td id='actions_${pk}'> | ||||
|                 ${delete_button} | ||||
|                 ${buttons} | ||||
|             </td> | ||||
|         </tr>`; | ||||
|  | ||||
| @@ -587,7 +652,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { | ||||
|                 <th>{% trans "Order Code" %}</th> | ||||
|                 <th>{% trans "Ordered" %}</th> | ||||
|                 <th>{% trans "Received" %}</th> | ||||
|                 <th style='min-width: 50px;'>{% trans "Receive" %}</th> | ||||
|                 <th style='min-width: 50px;'>{% trans "Quantity to Receive" %}</th> | ||||
|                 <th style='min-width: 150px;'>{% trans "Status" %}</th> | ||||
|                 <th style='min-width: 300px;'>{% trans "Destination" %}</th> | ||||
|                 <th></th> | ||||
| @@ -678,13 +743,23 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { | ||||
|                 var location = getFormFieldValue(`items_location_${pk}`, {}, opts); | ||||
|  | ||||
|                 if (quantity != null) { | ||||
|                     data.items.push({ | ||||
|  | ||||
|                     var line = { | ||||
|                         line_item: pk, | ||||
|                         quantity: quantity, | ||||
|                         status: status, | ||||
|                         location: location, | ||||
|                     }); | ||||
|                     }; | ||||
|  | ||||
|                     if (getFormFieldElement(`items_batch_code_${pk}`).exists()) { | ||||
|                         line.batch_code = getFormFieldValue(`items_batch_code_${pk}`); | ||||
|                     } | ||||
|  | ||||
|                     if (getFormFieldElement(`items_serial_numbers_${pk}`).exists()) { | ||||
|                         line.serial_numbers = getFormFieldValue(`items_serial_numbers_${pk}`); | ||||
|                     } | ||||
|  | ||||
|                     data.items.push(line); | ||||
|                     item_pk_values.push(pk); | ||||
|                 } | ||||
|  | ||||
| @@ -848,11 +923,17 @@ function loadPurchaseOrderTable(table, options) { | ||||
|                 field: 'creation_date', | ||||
|                 title: '{% trans "Date" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'target_date', | ||||
|                 title: '{% trans "Target Date" %}', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'line_items', | ||||
| @@ -930,6 +1011,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { | ||||
|                         reference: {}, | ||||
|                         purchase_price: {}, | ||||
|                         purchase_price_currency: {}, | ||||
|                         target_date: {}, | ||||
|                         destination: {}, | ||||
|                         notes: {}, | ||||
|                     }, | ||||
| @@ -971,7 +1053,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) { | ||||
|                     ], | ||||
|                     { | ||||
|                         success: function() { | ||||
|                             // Reload the line item table | ||||
|                             $(table).bootstrapTable('refresh'); | ||||
|  | ||||
|                             // Reload the "received stock" table | ||||
|                             $('#stock-table').bootstrapTable('refresh'); | ||||
|                         } | ||||
|                     } | ||||
|                 ); | ||||
| @@ -1111,6 +1197,28 @@ function loadPurchaseOrderLineItemTable(table, options={}) { | ||||
|                     return formatter.format(total); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 sortable: true, | ||||
|                 field: 'target_date', | ||||
|                 switchable: true, | ||||
|                 title: '{% trans "Target Date" %}', | ||||
|                 formatter: function(value, row) { | ||||
|                     if (row.target_date) { | ||||
|                         var html = renderDate(row.target_date); | ||||
|  | ||||
|                         if (row.overdue) { | ||||
|                             html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`; | ||||
|                         } | ||||
|  | ||||
|                         return html; | ||||
|  | ||||
|                     } else if (row.order_detail && row.order_detail.target_date) { | ||||
|                         return `<em>${renderDate(row.order_detail.target_date)}</em>`; | ||||
|                     } else { | ||||
|                         return '-'; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 sortable: false, | ||||
|                 field: 'received', | ||||
| @@ -1157,15 +1265,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) { | ||||
|      | ||||
|                     var pk = row.pk; | ||||
|      | ||||
|                     if (options.allow_receive && row.received < row.quantity) { | ||||
|                         html += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Receive line item" %}'); | ||||
|                     } | ||||
|  | ||||
|                     if (options.allow_edit) { | ||||
|                         html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); | ||||
|                         html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); | ||||
|                     } | ||||
|  | ||||
|                     if (options.allow_receive && row.received < row.quantity) { | ||||
|                         html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}'); | ||||
|                     } | ||||
|          | ||||
|                     html += `</div>`; | ||||
|      | ||||
|                     return html; | ||||
| @@ -1269,16 +1377,25 @@ function loadSalesOrderTable(table, options) { | ||||
|                 sortable: true, | ||||
|                 field: 'creation_date', | ||||
|                 title: '{% trans "Creation Date" %}', | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 sortable: true, | ||||
|                 field: 'target_date', | ||||
|                 title: '{% trans "Target Date" %}', | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 sortable: true, | ||||
|                 field: 'shipment_date', | ||||
|                 title: '{% trans "Shipment Date" %}', | ||||
|                 formatter: function(value) { | ||||
|                     return renderDate(value); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 sortable: true, | ||||
| @@ -1430,9 +1547,9 @@ function loadSalesOrderShipmentTable(table, options={}) { | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     if (value) { | ||||
|                         return value; | ||||
|                         return renderDate(value); | ||||
|                     } else { | ||||
|                         return '{% trans "Not shipped" %}'; | ||||
|                         return '<em>{% trans "Not shipped" %}</em>'; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
| @@ -2208,6 +2325,28 @@ function loadSalesOrderLineItemTable(table, options={}) { | ||||
|                 return formatter.format(total); | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             field: 'target_date', | ||||
|             title: '{% trans "Target Date" %}', | ||||
|             sortable: true, | ||||
|             switchable: true, | ||||
|             formatter: function(value, row) { | ||||
|                 if (row.target_date) { | ||||
|                     var html = renderDate(row.target_date); | ||||
|  | ||||
|                     if (row.overdue) { | ||||
|                         html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`; | ||||
|                     } | ||||
|  | ||||
|                     return html; | ||||
|  | ||||
|                 } else if (row.order_detail && row.order_detail.target_date) { | ||||
|                     return `<em>${renderDate(row.order_detail.target_date)}</em>`; | ||||
|                 } else { | ||||
|                     return '-'; | ||||
|                 }  | ||||
|             } | ||||
|         } | ||||
|     ]; | ||||
|  | ||||
|     if (pending) { | ||||
| @@ -2351,6 +2490,7 @@ function loadSalesOrderLineItemTable(table, options={}) { | ||||
|                     reference: {}, | ||||
|                     sale_price: {}, | ||||
|                     sale_price_currency: {}, | ||||
|                     target_date: {}, | ||||
|                     notes: {}, | ||||
|                 }, | ||||
|                 title: '{% trans "Edit Line Item" %}', | ||||
|   | ||||
| @@ -905,6 +905,28 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { | ||||
|                 field: 'quantity', | ||||
|                 title: '{% trans "Quantity" %}', | ||||
|             }, | ||||
|             { | ||||
|                 field: 'target_date', | ||||
|                 title: '{% trans "Target Date" %}', | ||||
|                 switchable: true, | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     if (row.target_date) { | ||||
|                         var html = row.target_date; | ||||
|  | ||||
|                         if (row.overdue) { | ||||
|                             html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`; | ||||
|                         } | ||||
|  | ||||
|                         return html; | ||||
|  | ||||
|                     } else if (row.order_detail && row.order_detail.target_date) { | ||||
|                         return `<em>${row.order_detail.target_date}</em>`; | ||||
|                     } else { | ||||
|                         return '-'; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'received', | ||||
|                 title: '{% trans "Received" %}', | ||||
|   | ||||
| @@ -25,7 +25,6 @@ | ||||
|     modalSetContent, | ||||
|     modalSetTitle, | ||||
|     modalSubmit, | ||||
|     moment, | ||||
|     openModal, | ||||
|     printStockItemLabels, | ||||
|     printTestReports, | ||||
| @@ -1820,6 +1819,9 @@ function loadStockTable(table, options) { | ||||
|     col = { | ||||
|         field: 'stocktake_date', | ||||
|         title: '{% trans "Stocktake" %}', | ||||
|         formatter: function(value) { | ||||
|             return renderDate(value); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (!options.params.ordering) { | ||||
| @@ -1833,6 +1835,9 @@ function loadStockTable(table, options) { | ||||
|         title: '{% trans "Expiry Date" %}', | ||||
|         visible: global_settings.STOCK_ENABLE_EXPIRY, | ||||
|         switchable: global_settings.STOCK_ENABLE_EXPIRY, | ||||
|         formatter: function(value) { | ||||
|             return renderDate(value); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (!options.params.ordering) { | ||||
| @@ -1844,6 +1849,9 @@ function loadStockTable(table, options) { | ||||
|     col = { | ||||
|         field: 'updated', | ||||
|         title: '{% trans "Last Updated" %}', | ||||
|         formatter: function(value) { | ||||
|             return renderDate(value); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (!options.params.ordering) { | ||||
| @@ -2649,14 +2657,7 @@ function loadStockTrackingTable(table, options) { | ||||
|         title: '{% trans "Date" %}', | ||||
|         sortable: true, | ||||
|         formatter: function(value) { | ||||
|             var m = moment(value); | ||||
|  | ||||
|             if (m.isValid()) { | ||||
|                 var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a'); | ||||
|                 return html; | ||||
|             } | ||||
|  | ||||
|             return '<i>{% trans "Invalid date" %}</i>'; | ||||
|             return renderDate(value, {showTime: true}); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -108,7 +108,7 @@ | ||||
|         <ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'> | ||||
|           {% if user.is_authenticated %} | ||||
|           {% if user.is_staff and not demo %} | ||||
|           <li><a class='dropdown-item' href="/admin/"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li> | ||||
|           <li><a class='dropdown-item' href="{% url 'admin:index' %}"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li> | ||||
|           {% endif %} | ||||
|           <li><a class='dropdown-item' href="{% url 'settings' %}"><span class="fas fa-cog"></span> {% trans "Settings" %}</a></li> | ||||
|           <li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| InvenTree-Version: {% inventree_version %} | ||||
| Django Version: {% django_version %} | ||||
| {% inventree_commit_hash as hash %}{% if hash %}Commit Hash: {{ hash }}{% endif %} | ||||
| {% inventree_commit_date as commit_date %}{% if commit_date %}Commit Date: {{ commit_date }}{% endif %} | ||||
| {% inventree_commit_date as commit_date %}{% if commit_date %}Commit Date: {% render_date commit_date %}{% endif %} | ||||
| Database: {% inventree_db_engine %} | ||||
| Debug-Mode: {% inventree_in_debug_mode %} | ||||
| Deployed using Docker: {% inventree_docker_mode %} | ||||
		Reference in New Issue
	
	Block a user