diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1a75b97af0..55585c7670 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,31 +1,47 @@ --- -name: Bug report -about: Create a bug report to help us improve InvenTree +name: Bug +about: Create a bug report to help us improve InvenTree! title: "[BUG] Enter bug description" labels: bug, question assignees: '' --- -**Describe the bug** -A clear and concise description of what the bug is. + + + +**Describe the bug** + + +**Steps to Reproduce** -**To Reproduce** Steps to reproduce the behavior: + **Expected behavior** + + **Deployment Method** -Docker -Bare Metal +- [ ] Docker +- [ ] Bare Metal **Version Information** -You can get this by going to the "About InvenTree" section in the upper right corner and cicking on to the "copy version information" + diff --git a/.github/workflows/html.yaml b/.github/workflows/html.yaml index 069da7cbb4..d0084ae032 100644 --- a/.github/workflows/html.yaml +++ b/.github/workflows/html.yaml @@ -43,7 +43,6 @@ jobs: run: | npm install markuplint npx markuplint InvenTree/build/templates/build/*.html - npx markuplint InvenTree/common/templates/common/*.html npx markuplint InvenTree/company/templates/company/*.html npx markuplint InvenTree/order/templates/order/*.html npx markuplint InvenTree/part/templates/part/*.html diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 31a887d736..5f347dd1e5 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -76,6 +76,12 @@ class InvenTreeConfig(AppConfig): minutes=30, ) + # Delete old notification records + InvenTree.tasks.schedule_task( + 'common.tasks.delete_old_notifications', + schedule_type=Schedule.DAILY, + ) + def update_exchange_rates(self): """ Update exchange rates each time the server is started, *if*: diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 319b88cb09..fd86306627 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -69,6 +69,35 @@ def getStaticUrl(filename): return os.path.join(STATIC_URL, str(filename)) +def construct_absolute_url(*arg): + """ + Construct (or attempt to construct) an absolute URL from a relative URL. + + This is useful when (for example) sending an email to a user with a link + to something in the InvenTree web framework. + + This requires the BASE_URL configuration option to be set! + """ + + base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL')) + + url = '/'.join(arg) + + if not base: + return url + + # Strip trailing slash from base url + if base.endswith('/'): + base = base[:-1] + + if url.startswith('/'): + url = url[1:] + + url = f"{base}/{url}" + + return url + + def getBlankImage(): """ Return the qualified path for the 'blank image' placeholder. diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py index 07e700a1cf..bf36a612d1 100644 --- a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py +++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py @@ -17,7 +17,7 @@ from company.models import Company from part.models import Part -logger = logging.getLogger("inventree-thumbnails") +logger = logging.getLogger('inventree') class Command(BaseCommand): diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index e7f78554f9..4294c943ba 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -98,7 +98,7 @@ class InvenTreeMetadata(SimpleMetadata): Override get_serializer_info so that we can add 'default' values to any fields whose Meta.model specifies a default value """ - + serializer_info = super().get_serializer_info(serializer) model_class = None @@ -108,6 +108,13 @@ class InvenTreeMetadata(SimpleMetadata): model_fields = model_meta.get_field_info(model_class) + model_default_func = getattr(model_class, 'api_defaults', None) + + if model_default_func: + model_default_values = model_class.api_defaults(self.request) + else: + model_default_values = {} + # Iterate through simple fields for name, field in model_fields.fields.items(): @@ -123,6 +130,9 @@ class InvenTreeMetadata(SimpleMetadata): serializer_info[name]['default'] = default + elif name in model_default_values: + serializer_info[name]['default'] = model_default_values[name] + # Iterate through relations for name, relation in model_fields.relations.items(): @@ -141,6 +151,9 @@ class InvenTreeMetadata(SimpleMetadata): if 'help_text' not in serializer_info[name] and hasattr(relation.model_field, 'help_text'): serializer_info[name]['help_text'] = relation.model_field.help_text + if name in model_default_values: + serializer_info[name]['default'] = model_default_values[name] + except AttributeError: pass diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 34d3685a7e..cd9ce410de 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -15,30 +15,26 @@ } .login-screen { - background-image: url("/static/img/paper_splash.jpg"); + background: url(/static/img/paper_splash.jpg) no-repeat center fixed; background-size: cover; - background-repeat: no-repeat; - height: 100%; + height: 100vh; font-family: 'Numans', sans-serif; color: #eee; } .login-container { - left: 50%; - position: fixed; - top: 50%; - transform: translate(-50%, -50%); - width: 30%; - align-content: center; + align-self: center; border-radius: 15px; padding: 20px; padding-bottom: 35px; background-color: rgba(50, 50, 50, 0.75); + width: 100%; + max-width: 550px; + margin: auto; } .login-header { - padding-right: 30px; - margin-right: 30px; + margin-right: 5px; } .login-container input { @@ -128,21 +124,24 @@ align-content: center; } -.qr-container { - width: 100%; - align-content: center; - object-fit: fill; -} - .navbar { border-bottom: 1px solid #ccc; background-color: var(--secondary-color); + box-shadow: 0px 5px 5px rgb(0 0 0 / 5%); +} + +.inventree-navbar-menu { + position: absolute !important; } .navbar-brand { float: left; } +.navbar-spacer { + height: 60px; +} + #navbar-barcode-li { border-left: none; border-right: none; @@ -178,10 +177,6 @@ float: right; } -.starred-part { - color: #ffbb00; -} - .red-cell { background-color: #ec7f7f; } @@ -297,8 +292,6 @@ vertical-align: middle; margin: 1px; padding: 2px; - background: #eee; - border: 1px solid #eee; border-radius: 3px; } @@ -310,7 +303,13 @@ transform: translate(0%, -25%); } -.filter-list .close:hover {background: #bbb;} +.filter-list .close:hover { + background: #bbb; +} + +.filter-list .form-control { + width: initial; +} .filter-tag { display: inline-block; @@ -318,8 +317,6 @@ zoom: 1; padding-left: 3px; padding-right: 3px; - padding-top: 2px; - padding-bottom: 2px; border: 1px solid #aaa; border-radius: 3px; background: #eee; @@ -328,6 +325,12 @@ margin-right: 5px; } +.filter-button { + padding: 2px; + padding-left: 4px; + padding-right: 4px; +} + .filter-input { display: inline-block; *display: inline; @@ -539,7 +542,7 @@ .inventree-body { width: 100%; padding: 5px; - margin-top: 10px; + padding-right: 0; } .inventree-pre-content { @@ -556,8 +559,10 @@ transition: 0.1s; } -.body { - padding-top: 50px; +.search-autocomplete-item { + border-top: 1px solid #EEE; + margin-bottom: 2px; + overflow-x: hidden; } .modal { @@ -740,13 +745,7 @@ input[type="submit"] { } .notification-area { - position: fixed; - top: 0px; - margin-top: 20px; - width: 100%; - padding: 20px; - z-index: 5000; - pointer-events: none; /* Prevent this div from blocking links underneath */ + opacity: 0.8; } .notes { @@ -756,7 +755,6 @@ input[type="submit"] { } .alert { - display: none; border-radius: 5px; opacity: 0.9; pointer-events: all; @@ -766,9 +764,8 @@ input[type="submit"] { display: block; } -.btn { - margin-left: 2px; - margin-right: 2px; +.navbar .btn { + margin-left: 5px; } .btn-secondary { @@ -831,11 +828,12 @@ input[type="submit"] { color: var(--bs-body-color); background-color: var(--secondary-color); border-bottom: 1px solid var(--border-color); + box-shadow: 0px 5px 5px rgb(0 0 0 / 5%); } .panel { box-shadow: 2px 2px #DDD; - margin-bottom: 20px; + margin-bottom: .75rem; background-color: #fff; border: 1px solid #ccc; } diff --git a/InvenTree/templates/js/dynamic/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js similarity index 71% rename from InvenTree/templates/js/dynamic/inventree.js rename to InvenTree/InvenTree/static/script/inventree/inventree.js index 0172e47706..85ae042728 100644 --- a/InvenTree/templates/js/dynamic/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -1,5 +1,3 @@ -{% load inventree_extras %} - /* globals ClipboardJS, inventreeFormDataUpload, @@ -130,61 +128,79 @@ function inventreeDocReady() { attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text'); // Add autocomplete to the search-bar - $('#search-bar').autocomplete({ - source: function(request, response) { - $.ajax({ - url: '/api/part/', - data: { + if ($('#search-bar').exists()) { + $('#search-bar').autocomplete({ + source: function(request, response) { + + var params = { search: request.term, limit: user_settings.SEARCH_PREVIEW_RESULTS, - offset: 0 - }, - success: function(data) { + offset: 0, + }; - var transformed = $.map(data.results, function(el) { - return { - label: el.full_name, - id: el.pk, - thumbnail: el.thumbnail, - data: el, - }; - }); - response(transformed); - }, - error: function() { - response([]); - } - }); - }, - create: function() { - $(this).data('ui-autocomplete')._renderItem = function(ul, item) { - - var html = ``; - - html += ` `; - html += item.label; - - html += ''; - - if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { - html += partStockLabel(item.data); + if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) { + // Limit to active parts + params.active = true; } - html += ''; + $.ajax({ + url: '/api/part/', + data: params, + success: function(data) { - return $('
  • ').append(html).appendTo(ul); - }; - }, - select: function( event, ui ) { - window.location = '/part/' + ui.item.id + '/'; - }, - minLength: 2, - classes: { - 'ui-autocomplete': 'dropdown-menu search-menu', - }, - }); + var transformed = $.map(data.results, function(el) { + return { + label: el.full_name, + id: el.pk, + thumbnail: el.thumbnail, + data: el, + }; + }); + response(transformed); + }, + error: function() { + response([]); + } + }); + }, + create: function() { + $(this).data('ui-autocomplete')._renderItem = function(ul, item) { + + var html = ` +
    + + ${item.label} + + + `; + + if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { + html += partStockLabel( + item.data, + { + classes: 'badge-right', + } + ); + } + + html += '
    '; + + return $('
  • ').append(html).appendTo(ul); + }; + }, + select: function( event, ui ) { + window.location = '/part/' + ui.item.id + '/'; + }, + minLength: 2, + classes: { + 'ui-autocomplete': 'dropdown-menu search-menu', + }, + position: { + my : "right top", + at: "right bottom" + } + }); + } // Generate brand-icons $('.brand-icon').each(function(i, obj) { @@ -197,6 +213,9 @@ function inventreeDocReady() { location.href = url; }); + + // Display any cached alert messages + showCachedAlerts(); } function isFileTransfer(transfer) { diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index 01754bceaf..f6bdf3bc57 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -1,44 +1,120 @@ -function showAlert(target, message, timeout=5000) { +/* + * Add a cached alert message to sesion storage + */ +function addCachedAlert(message, options={}) { - $(target).find(".alert-msg").html(message); - $(target).show(); - $(target).delay(timeout).slideUp(200, function() { + var alerts = sessionStorage.getItem('inventree-alerts'); + + if (alerts) { + alerts = JSON.parse(alerts); + } else { + alerts = []; + } + + alerts.push({ + message: message, + style: options.style || 'success', + icon: options.icon, + }); + + sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts)); +} + + +/* + * Remove all cached alert messages + */ +function clearCachedAlerts() { + sessionStorage.removeItem('inventree-alerts'); +} + + +/* + * Display an alert, or cache to display on reload + */ +function showAlertOrCache(message, cache, options={}) { + + if (cache) { + addCachedAlert(message, options); + } else { + + showMessage(message, options); + } +} + + +/* + * Display cached alert messages when loading a page + */ +function showCachedAlerts() { + + var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || []; + + alerts.forEach(function(alert) { + showMessage( + alert.message, + { + style: alert.style || 'success', + icon: alert.icon, + } + ); + }); + + clearCachedAlerts(); +} + + +/* + * Display an alert message at the top of the screen. + * The message will contain a "close" button, + * and also dismiss automatically after a certain amount of time. + * + * arguments: + * - message: Text / HTML content to display + * + * options: + * - style: alert style e.g. 'success' / 'warning' + * - timeout: Time (in milliseconds) after which the message will be dismissed + */ +function showMessage(message, options={}) { + + var style = options.style || 'info'; + + var timeout = options.timeout || 5000; + + var details = ''; + + if (options.details) { + details = `

    ${options.details}

    `; + } + + // Hacky function to get the next available ID + var id = 1; + + while ($(`#alert-${id}`).exists()) { + id++; + } + + var icon = ''; + + if (options.icon) { + icon = ``; + } + + // Construct the alert + var html = ` + + `; + + $('#alerts').append(html); + + // Remove the alert automatically after a specified period of time + $(`#alert-${id}`).delay(timeout).slideUp(200, function() { $(this).alert(close); }); } - -function showAlertOrCache(alertType, message, cache, timeout=5000) { - if (cache) { - sessionStorage.setItem("inventree-" + alertType, message); - } - else { - showAlert('#' + alertType, message, timeout); - } -} - -function showCachedAlerts() { - - // Success Message - if (sessionStorage.getItem("inventree-alert-success")) { - showAlert("#alert-success", sessionStorage.getItem("inventree-alert-success")); - sessionStorage.removeItem("inventree-alert-success"); - } - - // Info Message - if (sessionStorage.getItem("inventree-alert-info")) { - showAlert("#alert-info", sessionStorage.getItem("inventree-alert-info")); - sessionStorage.removeItem("inventree-alert-info"); - } - - // Warning Message - if (sessionStorage.getItem("inventree-alert-warning")) { - showAlert("#alert-warning", sessionStorage.getItem("inventree-alert-warning")); - sessionStorage.removeItem("inventree-alert-warning"); - } - - // Danger Message - if (sessionStorage.getItem("inventree-alert-danger")) { - showAlert("#alert-danger", sessionStorage.getItem("inventree-alert-danger")); - sessionStorage.removeItem("inventree-alert-danger"); - } -} \ No newline at end of file diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index aa17ef8603..801c75aa26 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -52,7 +52,7 @@ def schedule_task(taskname, **kwargs): pass -def offload_task(taskname, force_sync=False, *args, **kwargs): +def offload_task(taskname, *args, force_sync=False, **kwargs): """ Create an AsyncTask if workers are running. This is different to a 'scheduled' task, @@ -108,7 +108,7 @@ def offload_task(taskname, force_sync=False, *args, **kwargs): return # Workers are not running: run it as synchronous task - _func() + _func(*args, **kwargs) def heartbeat(): @@ -290,7 +290,7 @@ def update_exchange_rates(): Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() -def send_email(subject, body, recipients, from_email=None): +def send_email(subject, body, recipients, from_email=None, html_message=None): """ Send an email with the specified subject and body, to the specified recipients list. @@ -306,4 +306,5 @@ def send_email(subject, body, recipients, from_email=None): from_email, recipients, fail_silently=False, + html_message=html_message ) diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index dfe94c034e..6ace21b576 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -102,9 +102,9 @@ class APITests(InvenTreeAPITestCase): fixtures = [ 'location', - 'stock', - 'part', 'category', + 'part', + 'stock' ] token = None diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 77a0e06a0c..648600265c 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -42,8 +42,6 @@ from .views import CurrencyRefreshView from .views import AppearanceSelectView, SettingCategorySelectView from .views import DynamicJsView -from common.views import SettingEdit, UserSettingEdit - from .api import InfoView, NotFoundView from .api import ActionPluginView @@ -53,7 +51,7 @@ admin.site.site_header = "InvenTree Admin" apipatterns = [ url(r'^barcode/', include(barcode_api_urls)), - url(r'^common/', include(common_api_urls)), + url(r'^settings/', include(common_api_urls)), url(r'^part/', include(part_api_urls)), url(r'^bom/', include(bom_api_urls)), url(r'^company/', include(company_api_urls)), @@ -85,16 +83,12 @@ settings_urls = [ url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'), - url(r'^(?P\d+)/edit/user', UserSettingEdit.as_view(), name='user-setting-edit'), - url(r'^(?P\d+)/edit/', SettingEdit.as_view(), name='setting-edit'), - # Catch any other urls url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), ] # These javascript files are served "dynamically" - i.e. rendered on demand dynamic_javascript_urls = [ - url(r'^inventree.js', DynamicJsView.as_view(template_name='js/dynamic/inventree.js'), name='inventree.js'), url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'), url(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'), url(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'), diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 28c623ba4a..a340f9b18d 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,15 +12,19 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 17 +INVENTREE_API_VERSION = 18 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about -v17 -> 2021-10-26 +v18 -> 2021-11-11 - Adds support for multiple "Shipments" against a SalesOrder - Refactors process for stock allocation against a SalesOrder +v17 -> 2021-11-09 + - Adds API endpoints for GLOBAL and USER settings objects + - Ref: https://github.com/inventree/InvenTree/pull/2275 + v16 -> 2021-10-17 - Adds API endpoint for completing build order outputs diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 00f38ce89d..989fb1bc9d 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -655,17 +655,6 @@ class IndexView(TemplateView): context = super(TemplateView, self).get_context_data(**kwargs) - # TODO - Re-implement this when a less expensive method is worked out - # context['starred'] = [star.part for star in self.request.user.starred_parts.all()] - - # Generate a list of orderable parts which have stock below their minimum values - # TODO - Is there a less expensive way to get these from the database - # context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()] - - # Generate a list of assembly parts which have stock below their minimum values - # TODO - Is there a less expensive way to get these from the database - # context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()] - return context diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 403b3a9430..4fe22f7e0e 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -9,16 +9,16 @@ import decimal import os from datetime import datetime -from django.utils.translation import ugettext_lazy as _ - from django.contrib.auth.models import User from django.core.exceptions import ValidationError - -from django.urls import reverse +from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Sum, Q from django.db.models.functions import Coalesce -from django.core.validators import MinValueValidator +from django.db.models.signals import post_save +from django.dispatch.dispatcher import receiver +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ from markdownx.models import MarkdownxField @@ -27,16 +27,17 @@ from mptt.exceptions import InvalidMove from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode -from InvenTree.validators import validate_build_order_reference from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin +from InvenTree.validators import validate_build_order_reference import common.models import InvenTree.fields import InvenTree.helpers +import InvenTree.tasks -from stock import models as StockModels from part import models as PartModels +from stock import models as StockModels from users import models as UserModels @@ -46,7 +47,7 @@ def get_next_build_number(): """ if Build.objects.count() == 0: - return + return '0001' build = Build.objects.exclude(reference=None).last() @@ -99,13 +100,28 @@ class Build(MPTTModel, ReferenceIndexingMixin): return reverse('api-build-list') def api_instance_filters(self): - + return { 'parent': { 'exclude_tree': self.pk, } } + @classmethod + def api_defaults(cls, request): + """ + Return default values for this model when issuing an API OPTIONS request + """ + + defaults = { + 'reference': get_next_build_number(), + } + + if request and request.user: + defaults['issued_by'] = request.user.pk + + return defaults + def save(self, *args, **kwargs): self.rebuild_reference_field() @@ -1014,6 +1030,19 @@ class Build(MPTTModel, ReferenceIndexingMixin): return self.status == BuildStatus.COMPLETE +@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') +def after_save_build(sender, instance: Build, created: bool, **kwargs): + """ + Callback function to be executed after a Build instance is saved + """ + + if created: + # A new Build has just been created + + # Run checks on required parts + InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance) + + class BuildOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a BuildOrder object diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py new file mode 100644 index 0000000000..6fe4be5119 --- /dev/null +++ b/InvenTree/build/tasks.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal +import logging + +from django.utils.translation import ugettext_lazy as _ +from django.template.loader import render_to_string + +from allauth.account.models import EmailAddress + +import build.models +import InvenTree.helpers +import InvenTree.tasks +import part.models as part_models + + +logger = logging.getLogger('inventree') + + +def check_build_stock(build: build.models.Build): + """ + Check the required stock for a newly created build order, + and send an email out to any subscribed users if stock is low. + """ + + # Iterate through each of the parts required for this build + + lines = [] + + if not build: + logger.error("Invalid build passed to 'build.tasks.check_build_stock'") + return + + try: + part = build.part + except part_models.Part.DoesNotExist: + # Note: This error may be thrown during unit testing... + logger.error("Invalid build.part passed to 'build.tasks.check_build_stock'") + return + + for bom_item in part.get_bom_items(): + + sub_part = bom_item.sub_part + + # The 'in stock' quantity depends on whether the bom_item allows variants + in_stock = sub_part.get_stock_count(include_variants=bom_item.allow_variants) + + allocated = sub_part.allocation_count() + + available = max(0, in_stock - allocated) + + required = Decimal(bom_item.quantity) * Decimal(build.quantity) + + if available < required: + # There is not sufficient stock for this part + + lines.append({ + 'link': InvenTree.helpers.construct_absolute_url(sub_part.get_absolute_url()), + 'part': sub_part, + 'in_stock': in_stock, + 'allocated': allocated, + 'available': available, + 'required': required, + }) + + if len(lines) == 0: + # Nothing to do + return + + # Are there any users subscribed to these parts? + subscribers = build.part.get_subscribers() + + emails = EmailAddress.objects.filter( + user__in=subscribers, + ) + + if len(emails) > 0: + + logger.info(f"Notifying users of stock required for build {build.pk}") + + context = { + 'link': InvenTree.helpers.construct_absolute_url(build.get_absolute_url()), + 'build': build, + 'part': build.part, + 'lines': lines, + } + + # Render the HTML message + html_message = render_to_string('email/build_order_required_stock.html', context) + + subject = "[InvenTree] " + _("Stock required for build order") + + recipients = emails.values_list('email', flat=True) + + InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 826baf13ea..22a126fcdf 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -34,6 +34,7 @@ src="{% static 'img/blank_image.png' %}" {% include "admin_button.html" with url=url %} {% endif %} +{% if report_enabled %}
    +{% endif %} {% if roles.build.change %}
    @@ -224,9 +226,11 @@ src="{% static 'img/blank_image.png' %}" {% endif %} }); + {% if report_enabled %} $('#print-build-report').click(function() { printBuildReports([{{ build.pk }}]); }); + {% endif %} $("#build-delete").on('click', function() { launchModalForm( diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index c299c7e1b9..31e9f38080 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -142,7 +142,7 @@ {% trans "Completed" %} {% if build.completion_date %} - {{ build.completion_date }}{% if build.completed_by %}{{ build.completed_by }}{% endif %} + {{ build.completion_date }}{% if build.completed_by %}{{ build.completed_by }}{% endif %} {% else %} {% trans "Build not complete" %} {% endif %} @@ -160,9 +160,7 @@
    -
    - -
    + {% include "filter_list.html" with id='sub-build' %}
    @@ -171,7 +169,7 @@
    -
    +

    {% trans "Allocate Stock to Build" %}

    {% include "spacer.html" %}
    @@ -210,9 +208,7 @@ -
    - -
    + {% include "filter_list.html" with id='builditems' %}
    @@ -227,7 +223,7 @@
    -
    +

    {% trans "Incomplete Build Outputs" %}

    {% include "spacer.html" %}
    @@ -276,7 +274,7 @@
    -
    +

    {% trans "Attachments" %}

    {% include "spacer.html" %}
    diff --git a/InvenTree/build/templates/build/index.html b/InvenTree/build/templates/build/index.html index 01970c45aa..b2c237c149 100644 --- a/InvenTree/build/templates/build/index.html +++ b/InvenTree/build/templates/build/index.html @@ -27,6 +27,7 @@
    + {% if report_enabled %}
  • + {% endif %} -
    - -
    + {% include "filter_list.html" with id="build" %} @@ -183,6 +183,7 @@ loadBuildTable($("#build-table"), { url: "{% url 'api-build-list' %}", }); +{% if report_enabled %} $('#multi-build-print').click(function() { var rows = $("#build-table").bootstrapTable('getSelections'); @@ -194,5 +195,6 @@ $('#multi-build-print').click(function() { printBuildReports(build_ids); }); +{% endif %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py index 1eda18e869..4df2499177 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -5,7 +5,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from .models import InvenTreeSetting, InvenTreeUserSetting +import common.models class SettingsAdmin(ImportExportModelAdmin): @@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin): list_display = ('key', 'value', 'user', ) -admin.site.register(InvenTreeSetting, SettingsAdmin) -admin.site.register(InvenTreeUserSetting, UserSettingsAdmin) +class NotificationEntryAdmin(admin.ModelAdmin): + + list_display = ('key', 'uid', 'updated', ) + + +admin.site.register(common.models.InvenTreeSetting, SettingsAdmin) +admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin) +admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 8a2dfbd6a7..6dd51bdff1 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -5,5 +5,149 @@ Provides a JSON API for common components. # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.conf.urls import url, include + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, generics, permissions + +import common.models +import common.serializers + + +class SettingsList(generics.ListAPIView): + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + ordering_fields = [ + 'pk', + 'key', + 'name', + ] + + search_fields = [ + 'key', + ] + + +class GlobalSettingsList(SettingsList): + """ + API endpoint for accessing a list of global settings objects + """ + + queryset = common.models.InvenTreeSetting.objects.all() + serializer_class = common.serializers.GlobalSettingsSerializer + + +class GlobalSettingsPermissions(permissions.BasePermission): + """ + Special permission class to determine if the user is "staff" + """ + + def has_permission(self, request, view): + """ + Check that the requesting user is 'admin' + """ + + try: + user = request.user + + return user.is_staff + except AttributeError: + return False + + +class GlobalSettingsDetail(generics.RetrieveUpdateAPIView): + """ + Detail view for an individual "global setting" object. + + - User must have 'staff' status to view / edit + """ + + queryset = common.models.InvenTreeSetting.objects.all() + serializer_class = common.serializers.GlobalSettingsSerializer + + permission_classes = [ + GlobalSettingsPermissions, + ] + + +class UserSettingsList(SettingsList): + """ + API endpoint for accessing a list of user settings objects + """ + + queryset = common.models.InvenTreeUserSetting.objects.all() + serializer_class = common.serializers.UserSettingsSerializer + + def filter_queryset(self, queryset): + """ + Only list settings which apply to the current user + """ + + try: + user = self.request.user + except AttributeError: + return common.models.InvenTreeUserSetting.objects.none() + + queryset = super().filter_queryset(queryset) + + queryset = queryset.filter(user=user) + + return queryset + + +class UserSettingsPermissions(permissions.BasePermission): + """ + Special permission class to determine if the user can view / edit a particular setting + """ + + def has_object_permission(self, request, view, obj): + + try: + user = request.user + except AttributeError: + return False + + return user == obj.user + + +class UserSettingsDetail(generics.RetrieveUpdateAPIView): + """ + Detail view for an individual "user setting" object + + - User can only view / edit settings their own settings objects + """ + + queryset = common.models.InvenTreeUserSetting.objects.all() + serializer_class = common.serializers.UserSettingsSerializer + + permission_classes = [ + UserSettingsPermissions, + ] + + common_api_urls = [ + + # User settings + url(r'^user/', include([ + # User Settings Detail + url(r'^(?P\d+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'), + + # User Settings List + url(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'), + ])), + + # Global settings + url(r'^global/', include([ + # Global Settings Detail + url(r'^(?P\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'), + + # Global Settings List + url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), + ])) + ] diff --git a/InvenTree/common/migrations/0012_notificationentry.py b/InvenTree/common/migrations/0012_notificationentry.py new file mode 100644 index 0000000000..77439c9f8c --- /dev/null +++ b/InvenTree/common/migrations/0012_notificationentry.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2021-11-03 13:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0011_auto_20210722_2114'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=250)), + ('uid', models.IntegerField()), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'unique_together': {('key', 'uid')}, + }, + ), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 3dae13c3e0..374cac24af 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -9,6 +9,7 @@ from __future__ import unicode_literals import os import decimal import math +from datetime import datetime, timedelta from django.db import models, transaction from django.contrib.auth.models import User, Group @@ -33,6 +34,19 @@ import logging logger = logging.getLogger('inventree') +class EmptyURLValidator(URLValidator): + + def __call__(self, value): + + value = str(value).strip() + + if len(value) == 0: + pass + + else: + super().__call__(value) + + class BaseInvenTreeSetting(models.Model): """ An base InvenTreeSetting object is a key:value pair used for storing @@ -44,6 +58,16 @@ class BaseInvenTreeSetting(models.Model): class Meta: abstract = True + def save(self, *args, **kwargs): + """ + Enforce validation and clean before saving + """ + + self.clean() + self.validate_unique() + + super().save() + @classmethod def allValues(cls, user=None): """ @@ -342,6 +366,11 @@ class BaseInvenTreeSetting(models.Model): except (ValueError): raise ValidationError(_('Must be an integer value')) + options = self.valid_options() + + if options and self.value not in options: + raise ValidationError(_("Chosen value is not a valid option")) + if validator is not None: self.run_validator(validator) @@ -408,6 +437,18 @@ class BaseInvenTreeSetting(models.Model): return self.__class__.get_setting_choices(self.key) + def valid_options(self): + """ + Return a list of valid options for this setting + """ + + choices = self.choices() + + if not choices: + return None + + return [opt[0] for opt in choices] + def is_bool(self): """ Check if this setting is required to be a boolean value @@ -426,6 +467,20 @@ class BaseInvenTreeSetting(models.Model): return InvenTree.helpers.str2bool(self.value) + def setting_type(self): + """ + Return the field type identifier for this setting object + """ + + if self.is_bool(): + return 'boolean' + + elif self.is_int(): + return 'integer' + + else: + return 'string' + @classmethod def validator_is_bool(cls, validator): @@ -530,7 +585,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'INVENTREE_BASE_URL': { 'name': _('Base URL'), 'description': _('Base URL for server instance'), - 'validator': URLValidator(), + 'validator': EmptyURLValidator(), 'default': '', }, @@ -713,6 +768,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': InvenTree.validators.validate_part_name_format }, + 'REPORT_ENABLE': { + 'name': _('Enable Reports'), + 'description': _('Enable generation of reports'), + 'default': False, + 'validator': bool, + }, + 'REPORT_DEBUG_MODE': { 'name': _('Debug Mode'), 'description': _('Generate reports in debug mode (HTML output)'), @@ -807,19 +869,19 @@ class InvenTreeSetting(BaseInvenTreeSetting): # login / SSO 'LOGIN_ENABLE_PWD_FORGOT': { 'name': _('Enable password forgot'), - 'description': _('Enable password forgot function on the login-pages'), + 'description': _('Enable password forgot function on the login pages'), 'default': True, 'validator': bool, }, 'LOGIN_ENABLE_REG': { 'name': _('Enable registration'), - 'description': _('Enable self-registration for users on the login-pages'), + 'description': _('Enable self-registration for users on the login pages'), 'default': False, 'validator': bool, }, 'LOGIN_ENABLE_SSO': { 'name': _('Enable SSO'), - 'description': _('Enable SSO on the login-pages'), + 'description': _('Enable SSO on the login pages'), 'default': False, 'validator': bool, }, @@ -849,7 +911,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): }, 'SIGNUP_GROUP': { 'name': _('Group on signup'), - 'description': _('Group new user are asigned on registration'), + 'description': _('Group to which new users are assigned on registration'), 'default': '', 'choices': settings_group_options }, @@ -866,6 +928,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): help_text=_('Settings key (must be unique - case insensitive'), ) + def to_native_value(self): + """ + Return the "pythonic" value, + e.g. convert "True" to True, and "1" to 1 + """ + + return self.__class__.get_setting(self.key) + class InvenTreeUserSetting(BaseInvenTreeSetting): """ @@ -874,8 +944,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): GLOBAL_SETTINGS = { 'HOMEPAGE_PART_STARRED': { - 'name': _('Show starred parts'), - 'description': _('Show starred parts on the homepage'), + 'name': _('Show subscribed parts'), + 'description': _('Show subscribed parts on the homepage'), + 'default': True, + 'validator': bool, + }, + 'HOMEPAGE_CATEGORY_STARRED': { + 'name': _('Show subscribed categories'), + 'description': _('Show subscribed part categories on the homepage'), 'default': True, 'validator': bool, }, @@ -1005,6 +1081,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'SEARCH_HIDE_INACTIVE_PARTS': { + 'name': _("Hide Inactive Parts"), + 'description': _('Hide inactive parts in search preview window'), + 'default': False, + 'validator': bool, + }, + 'PART_SHOW_QUANTITY_IN_FORMS': { 'name': _('Show Quantity in Forms'), 'description': _('Display available part quantity in some forms'), @@ -1063,6 +1146,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'user__id': kwargs['user'].id } + def to_native_value(self): + """ + Return the "pythonic" value, + e.g. convert "True" to True, and "1" to 1 + """ + + return self.__class__.get_setting(self.key, user=self.user) + class PriceBreak(models.Model): """ @@ -1220,3 +1311,63 @@ class ColorTheme(models.Model): return True return False + + +class NotificationEntry(models.Model): + """ + A NotificationEntry records the last time a particular notifaction was sent out. + + It is recorded to ensure that notifications are not sent out "too often" to users. + + Attributes: + - key: A text entry describing the notification e.g. 'part.notify_low_stock' + - uid: An (optional) numerical ID for a particular instance + - date: The last time this notification was sent + """ + + class Meta: + unique_together = [ + ('key', 'uid'), + ] + + key = models.CharField( + max_length=250, + blank=False, + ) + + uid = models.IntegerField( + ) + + updated = models.DateTimeField( + auto_now=True, + null=False, + ) + + @classmethod + def check_recent(cls, key: str, uid: int, delta: timedelta): + """ + Test if a particular notification has been sent in the specified time period + """ + + since = datetime.now().date() - delta + + entries = cls.objects.filter( + key=key, + uid=uid, + updated__gte=since + ) + + return entries.exists() + + @classmethod + def notify(cls, key: str, uid: int): + """ + Notify the database that a particular notification has been sent out + """ + + entry, created = cls.objects.get_or_create( + key=key, + uid=uid + ) + + entry.save() diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 99ac03cdfd..4a27e3f30e 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -1,3 +1,85 @@ """ JSON serializers for common components """ + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from InvenTree.serializers import InvenTreeModelSerializer + +from rest_framework import serializers + +from common.models import InvenTreeSetting, InvenTreeUserSetting + + +class SettingsSerializer(InvenTreeModelSerializer): + """ + Base serializer for a settings object + """ + + key = serializers.CharField(read_only=True) + + name = serializers.CharField(read_only=True) + + description = serializers.CharField(read_only=True) + + type = serializers.CharField(source='setting_type', read_only=True) + + choices = serializers.SerializerMethodField() + + def get_choices(self, obj): + """ + Returns the choices available for a given item + """ + + results = [] + + choices = obj.choices() + + if choices: + for choice in choices: + results.append({ + 'value': choice[0], + 'display_name': choice[1], + }) + + return results + + +class GlobalSettingsSerializer(SettingsSerializer): + """ + Serializer for the InvenTreeSetting model + """ + + class Meta: + model = InvenTreeSetting + fields = [ + 'pk', + 'key', + 'value', + 'name', + 'description', + 'type', + 'choices', + ] + + +class UserSettingsSerializer(SettingsSerializer): + """ + Serializer for the InvenTreeUserSetting model + """ + + user = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = InvenTreeUserSetting + fields = [ + 'pk', + 'key', + 'value', + 'name', + 'description', + 'user', + 'type', + 'choices', + ] diff --git a/InvenTree/common/tasks.py b/InvenTree/common/tasks.py new file mode 100644 index 0000000000..409acf5a13 --- /dev/null +++ b/InvenTree/common/tasks.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging +from datetime import timedelta, datetime + +from django.core.exceptions import AppRegistryNotReady + + +logger = logging.getLogger('inventree') + + +def delete_old_notifications(): + """ + Remove old notifications from the database. + + Anything older than ~3 months is removed + """ + + try: + from common.models import NotificationEntry + except AppRegistryNotReady: + logger.info("Could not perform 'delete_old_notifications' - App registry not ready") + return + + before = datetime.now() - timedelta(days=90) + + # Delete notification records before the specified date + NotificationEntry.objects.filter(updated__lte=before).delete() diff --git a/InvenTree/common/templates/common/edit_setting.html b/InvenTree/common/templates/common/edit_setting.html deleted file mode 100644 index c479e268a5..0000000000 --- a/InvenTree/common/templates/common/edit_setting.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -{{ block.super }} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/common/test_views.py b/InvenTree/common/test_views.py index 76a0a4516e..7d7bfde87e 100644 --- a/InvenTree/common/test_views.py +++ b/InvenTree/common/test_views.py @@ -4,156 +4,3 @@ Unit tests for the views associated with the 'common' app # -*- coding: utf-8 -*- from __future__ import unicode_literals - -import json - -from django.test import TestCase -from django.urls import reverse -from django.contrib.auth import get_user_model - -from common.models import InvenTreeSetting - - -class SettingsViewTest(TestCase): - """ - Tests for the settings management views - """ - - fixtures = [ - 'settings', - ] - - def setUp(self): - super().setUp() - - # Create a user (required to access the views / forms) - self.user = get_user_model().objects.create_user( - username='username', - email='me@email.com', - password='password', - ) - - self.client.login(username='username', password='password') - - def get_url(self, pk): - return reverse('setting-edit', args=(pk,)) - - def get_setting(self, title): - - return InvenTreeSetting.get_setting_object(title) - - def get(self, url, status=200): - - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - self.assertEqual(response.status_code, status) - - data = json.loads(response.content) - - return response, data - - def post(self, url, data, valid=None): - - response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - json_data = json.loads(response.content) - - # If a particular status code is required - if valid is not None: - if valid: - self.assertEqual(json_data['form_valid'], True) - else: - self.assertEqual(json_data['form_valid'], False) - - form_errors = json.loads(json_data['form_errors']) - - return json_data, form_errors - - def test_instance_name(self): - """ - Test that we can get the settings view for particular setting objects. - """ - - # Start with something basic - load the settings view for INVENTREE_INSTANCE - setting = self.get_setting('INVENTREE_INSTANCE') - - self.assertIsNotNone(setting) - self.assertEqual(setting.value, 'My very first InvenTree Instance') - - url = self.get_url(setting.pk) - - self.get(url) - - new_name = 'A new instance name!' - - # Change the instance name via the form - data, errors = self.post(url, {'value': new_name}, valid=True) - - name = InvenTreeSetting.get_setting('INVENTREE_INSTANCE') - - self.assertEqual(name, new_name) - - def test_choices(self): - """ - Tests for a setting which has choices - """ - - setting = InvenTreeSetting.get_setting_object('PURCHASEORDER_REFERENCE_PREFIX') - - # Default value! - self.assertEqual(setting.value, 'PO') - - url = self.get_url(setting.pk) - - # Try posting an invalid currency option - data, errors = self.post(url, {'value': 'Purchase Order'}, valid=True) - - def test_binary_values(self): - """ - Test for binary value - """ - - setting = InvenTreeSetting.get_setting_object('PART_COMPONENT') - - self.assertTrue(setting.as_bool()) - - url = self.get_url(setting.pk) - - setting.value = True - setting.save() - - # Try posting some invalid values - # The value should be "cleaned" and stay the same - for value in ['', 'abc', 'cat', 'TRUETRUETRUE']: - self.post(url, {'value': value}, valid=True) - - # Try posting some valid (True) values - for value in [True, 'True', '1', 'yes']: - self.post(url, {'value': value}, valid=True) - self.assertTrue(InvenTreeSetting.get_setting('PART_COMPONENT')) - - # Try posting some valid (False) values - for value in [False, 'False']: - self.post(url, {'value': value}, valid=True) - self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT')) - - def test_part_name_format(self): - """ - Try posting some valid and invalid name formats for PART_NAME_FORMAT - """ - setting = InvenTreeSetting.get_setting_object('PART_NAME_FORMAT') - - # test default value - self.assertEqual(setting.value, "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}" - "{{ ' | ' if part.revision }}{{ part.revision if part.revision }}") - - url = self.get_url(setting.pk) - - # Try posting an invalid part name format - invalid_values = ['{{asset.IPN}}', '{{part}}', '{{"|"}}', '{{part.falcon}}'] - for invalid_value in invalid_values: - self.post(url, {'value': invalid_value}, valid=False) - - # try posting valid value - new_format = "{{ part.name if part.name }} {{ ' with revision ' if part.revision }} {{ part.revision }}" - self.post(url, {'value': new_format}, valid=True) diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index d20f76baa0..c20dc5d126 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from datetime import timedelta + from django.test import TestCase from django.contrib.auth import get_user_model from .models import InvenTreeSetting +from .models import NotificationEntry class SettingsTest(TestCase): @@ -85,3 +88,23 @@ class SettingsTest(TestCase): if setting.default_value not in [True, False]: raise ValueError(f'Non-boolean default value specified for {key}') + + +class NotificationTest(TestCase): + + def test_check_notification_entries(self): + + # Create some notification entries + + self.assertEqual(NotificationEntry.objects.count(), 0) + + NotificationEntry.notify('test.notification', 1) + + self.assertEqual(NotificationEntry.objects.count(), 1) + + delta = timedelta(days=1) + + self.assertFalse(NotificationEntry.check_recent('test.notification', 2, delta)) + self.assertFalse(NotificationEntry.check_recent('test.notification2', 1, delta)) + + self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta)) diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 6cac8bbb19..4b8310ddd2 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -8,138 +8,18 @@ from __future__ import unicode_literals import os from django.utils.translation import ugettext_lazy as _ -from django.forms import CheckboxInput, Select from django.conf import settings from django.core.files.storage import FileSystemStorage from formtools.wizard.views import SessionWizardView from crispy_forms.helper import FormHelper -from InvenTree.views import AjaxUpdateView, AjaxView -from InvenTree.helpers import str2bool +from InvenTree.views import AjaxView -from . import models from . import forms from .files import FileManager -class SettingEdit(AjaxUpdateView): - """ - View for editing an InvenTree key:value settings object, - (or creating it if the key does not already exist) - """ - - model = models.InvenTreeSetting - ajax_form_title = _('Change Setting') - form_class = forms.SettingEditForm - ajax_template_name = "common/edit_setting.html" - - def get_context_data(self, **kwargs): - """ - Add extra context information about the particular setting object. - """ - - ctx = super().get_context_data(**kwargs) - - setting = self.get_object() - - ctx['key'] = setting.key - ctx['value'] = setting.value - ctx['name'] = self.model.get_setting_name(setting.key) - ctx['description'] = self.model.get_setting_description(setting.key) - - return ctx - - def get_data(self): - """ - Custom data to return to the client after POST success - """ - - data = {} - - setting = self.get_object() - - data['pk'] = setting.pk - data['key'] = setting.key - data['value'] = setting.value - data['is_bool'] = setting.is_bool() - data['is_int'] = setting.is_int() - - return data - - def get_form(self): - """ - Override default get_form behaviour - """ - - form = super().get_form() - - setting = self.get_object() - - choices = setting.choices() - - if choices is not None: - form.fields['value'].widget = Select(choices=choices) - elif setting.is_bool(): - form.fields['value'].widget = CheckboxInput() - - self.object.value = str2bool(setting.value) - form.fields['value'].value = str2bool(setting.value) - - name = self.model.get_setting_name(setting.key) - - if name: - form.fields['value'].label = name - - description = self.model.get_setting_description(setting.key) - - if description: - form.fields['value'].help_text = description - - return form - - def validate(self, setting, form): - """ - Perform custom validation checks on the form data. - """ - - data = form.cleaned_data - - value = data.get('value', None) - - if setting.choices(): - """ - If a set of choices are provided for a given setting, - the provided value must be one of those choices. - """ - - choices = [choice[0] for choice in setting.choices()] - - if value not in choices: - form.add_error('value', _('Supplied value is not allowed')) - - if setting.is_bool(): - """ - If a setting is defined as a boolean setting, - the provided value must look somewhat like a boolean value! - """ - - if not str2bool(value, test=True) and not str2bool(value, test=False): - form.add_error('value', _('Supplied value must be a boolean')) - - -class UserSettingEdit(SettingEdit): - """ - View for editing an InvenTree key:value user settings object, - (or creating it if the key does not already exist) - """ - - model = models.InvenTreeUserSetting - ajax_form_title = _('Change User Setting') - form_class = forms.SettingEditForm - ajax_template_name = "common/edit_setting.html" - - class MultiStepFormView(SessionWizardView): """ Setup basic methods of multi-step form diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 14bc79b277..4181d81b6f 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -11,7 +11,7 @@
    -
    +

    {% trans "Supplier Parts" %}

    {% include "spacer.html" %}
    @@ -46,9 +46,7 @@
    -
    - -
    + {% include "filter_list.html" with id="supplier-part" %}
    {% endif %} @@ -60,7 +58,7 @@
    -
    +

    {% trans "Manufacturer Parts" %}

    {% include "spacer.html" %}
    @@ -95,9 +93,7 @@
    -
    - -
    + {% include "filter_list.html" with id="supplier-part" %}
    {% endif %} @@ -117,7 +113,7 @@
    -
    +

    {% trans "Purchase Orders" %}

    {% include "spacer.html" %}
    @@ -132,9 +128,7 @@
    -
    - -
    + {% include "filter_list.html" with id="purchaseorder" %}
    @@ -145,7 +139,7 @@
    -
    +

    {% trans "Sales Orders" %}

    {% include "spacer.html" %}
    @@ -160,9 +154,7 @@
    -
    - -
    + {% include "filter_list.html" with id="salesorder" %}
    @@ -177,9 +169,7 @@
    -
    - -
    + {% include "filter_list.html" with id="stock" %}
    diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index dad72e5558..fcfa22ee2d 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -104,7 +104,7 @@ src="{% static 'img/blank_image.png' %}"
    -
    +

    {% trans "Suppliers" %}

    {% include "spacer.html" %}
    @@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}"
    -
    +

    {% trans "Parameters" %}

    {% include "spacer.html" %}
    diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 2f68f9b12b..276a9f7ebc 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -134,7 +134,15 @@ src="{% static 'img/blank_image.png' %}"
    -

    {% trans "Supplier Part Stock" %}

    + +

    {% trans "Supplier Part Stock" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% include "stock_table.html" %} @@ -143,7 +151,7 @@ src="{% static 'img/blank_image.png' %}"
    -
    +

    {% trans "Supplier Part Orders" %}

    {% include "spacer.html" %}
    @@ -167,7 +175,7 @@ src="{% static 'img/blank_image.png' %}"
    -
    +

    {% trans "Pricing Information" %}

    {% include "spacer.html" %}
    @@ -314,7 +322,6 @@ $("#item-create").click(function() { part: {{ part.part.id }}, supplier_part: {{ part.id }}, }, - reload: true, }); }); diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index ed9bc74201..07a8e0ad0a 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -83,7 +83,9 @@ class POLineItemResource(ModelResource): class SOLineItemResource(ModelResource): - """ Class for managing import / export of SOLineItem data """ + """ + Class for managing import / export of SOLineItem data + """ part_name = Field(attribute='part__name', readonly=True) @@ -93,6 +95,17 @@ class SOLineItemResource(ModelResource): fulfilled = Field(attribute='fulfilled_quantity', readonly=True) + def dehydrate_sale_price(self, item): + """ + Return a string value of the 'sale_price' field, rather than the 'Money' object. + Ref: https://github.com/inventree/InvenTree/issues/2207 + """ + + if item.sale_price: + return str(item.sale_price) + else: + return '' + class Meta: model = SalesOrderLineItem skip_unchanged = True diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 89585e218f..c88943f9ea 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -37,7 +37,7 @@ def get_next_po_number(): """ if PurchaseOrder.objects.count() == 0: - return "001" + return '0001' order = PurchaseOrder.objects.exclude(reference=None).last() @@ -66,7 +66,7 @@ def get_next_so_number(): """ if SalesOrder.objects.count() == 0: - return "001" + return '0001' order = SalesOrder.objects.exclude(reference=None).last() diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 992d12d1d9..d2fcaf7009 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -241,6 +241,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): help_text=_('Unique identifier field'), default='', required=False, + allow_null=True, allow_blank=True, ) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 9e94b379f8..42733d1178 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -29,7 +29,9 @@
    @@ -123,7 +125,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Created" %} - {{ order.creation_date }}{{ order.created_by }} + {{ order.creation_date }}{{ order.created_by }} {% if order.issue_date %} @@ -143,7 +145,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Received" %} - {{ order.complete_date }}{{ order.received_by }} + {{ order.complete_date }}{{ order.received_by }} {% endif %} {% if order.responsible %} @@ -169,9 +171,11 @@ $("#place-order").click(function() { }); {% endif %} +{% if report_enabled %} $('#print-order-report').click(function() { printPurchaseOrderReports([{{ order.pk }}]); }); +{% endif %} $("#edit-order").click(function() { diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 6a016d110a..257707347a 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -14,7 +14,7 @@
    -
    +

    {% trans "Purchase Order Items" %}

    {% include "spacer.html" %}
    @@ -37,9 +37,7 @@
    -
    - -
    + {% include "filter_list.html" with id="order-lines" %}
    @@ -52,13 +50,13 @@

    {% trans "Received Items" %}

    - {% include "stock_table.html" with prevent_new_stock=True %} + {% include "stock_table.html" %}
    -
    +

    {% trans "Attachments" %}

    {% include "spacer.html" %}
    diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html index 4be817f8d2..8ddae982b9 100644 --- a/InvenTree/order/templates/order/purchase_orders.html +++ b/InvenTree/order/templates/order/purchase_orders.html @@ -26,19 +26,18 @@
    - + {% if report_enabled %} + {% endif %} -
    - -
    + {% include "filter_list.html" with id="purchaseorder" %}
    @@ -171,6 +170,7 @@ $("#view-list").click(function() { $("#view-calendar").show(); }); +{% if report_enabled %} $("#order-print").click(function() { var rows = $("#purchase-order-table").bootstrapTable('getSelections'); @@ -182,6 +182,7 @@ $("#order-print").click(function() { printPurchaseOrderReports(orders); }) +{% endif %} $("#po-create").click(function() { createPurchaseOrder(); diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 42a09e8ede..368c3a2e47 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -16,7 +16,7 @@ {% block thumbnail %}
    + {% include "filter_list.html" with id="salesorder" %}
    @@ -175,6 +174,7 @@ loadSalesOrderTable("#sales-order-table", { url: "{% url 'api-so-list' %}", }); +{% if report_enabled %} $("#order-print").click(function() { var rows = $("#sales-order-table").bootstrapTable('getSelections'); @@ -186,6 +186,7 @@ $("#order-print").click(function() { printSalesOrderReports(orders); }) +{% endif %} $("#so-create").click(function() { createSalesOrder(); diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 0f29e5bd16..143a51bdb1 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -350,6 +350,31 @@ class PurchaseOrderReceiveTest(OrderTest): # No new stock items have been created self.assertEqual(self.n, StockItem.objects.count()) + def test_null_barcode(self): + """ + Test than a "null" barcode field can be provided + """ + + # Set stock item barcode + item = StockItem.objects.get(pk=1) + item.save() + + # Test with "null" value + self.post( + self.url, + { + 'items': [ + { + 'line_item': 1, + 'quantity': 50, + 'barcode': None, + } + ], + 'location': 1, + }, + expected_code=201 + ) + def test_invalid_barcodes(self): """ Tests for checking in items with invalid barcodes: diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 2e434d928d..90543d429d 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -8,13 +8,7 @@ from import_export.resources import ModelResource from import_export.fields import Field import import_export.widgets as widgets -from .models import PartCategory, Part -from .models import PartAttachment, PartStar, PartRelated -from .models import BomItem -from .models import PartParameterTemplate, PartParameter -from .models import PartCategoryParameterTemplate -from .models import PartTestTemplate -from .models import PartSellPriceBreak, PartInternalPriceBreak +import part.models as models from stock.models import StockLocation from company.models import SupplierPart @@ -24,7 +18,7 @@ class PartResource(ModelResource): """ Class for managing Part data import/export """ # ForeignKey fields - category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory)) + category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory)) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) @@ -32,7 +26,7 @@ class PartResource(ModelResource): category_name = Field(attribute='category__name', readonly=True) - variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part)) + variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part)) suppliers = Field(attribute='supplier_count', readonly=True) @@ -48,7 +42,7 @@ class PartResource(ModelResource): building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget()) class Meta: - model = Part + model = models.Part skip_unchanged = True report_skipped = False clean_model_instances = True @@ -86,14 +80,14 @@ class PartAdmin(ImportExportModelAdmin): class PartCategoryResource(ModelResource): """ Class for managing PartCategory data import/export """ - parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(PartCategory)) + parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory)) parent_name = Field(attribute='parent__name', readonly=True) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) class Meta: - model = PartCategory + model = models.PartCategory skip_unchanged = True report_skipped = False clean_model_instances = True @@ -108,14 +102,14 @@ class PartCategoryResource(ModelResource): super().after_import(dataset, result, using_transactions, dry_run, **kwargs) # Rebuild the PartCategory tree(s) - PartCategory.objects.rebuild() + models.PartCategory.objects.rebuild() class PartCategoryInline(admin.TabularInline): """ Inline for PartCategory model """ - model = PartCategory + model = models.PartCategory class PartCategoryAdmin(ImportExportModelAdmin): @@ -146,6 +140,11 @@ class PartStarAdmin(admin.ModelAdmin): list_display = ('part', 'user') +class PartCategoryStarAdmin(admin.ModelAdmin): + + list_display = ('category', 'user') + + class PartTestTemplateAdmin(admin.ModelAdmin): list_display = ('part', 'test_name', 'required') @@ -159,7 +158,7 @@ class BomItemResource(ModelResource): bom_id = Field(attribute='pk') # ID of the parent part - parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) + parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) # IPN of the parent part parent_part_ipn = Field(attribute='part__IPN', readonly=True) @@ -168,7 +167,7 @@ class BomItemResource(ModelResource): parent_part_name = Field(attribute='part__name', readonly=True) # ID of the sub-part - part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(Part)) + part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part)) # IPN of the sub-part part_ipn = Field(attribute='sub_part__IPN', readonly=True) @@ -233,7 +232,7 @@ class BomItemResource(ModelResource): return fields class Meta: - model = BomItem + model = models.BomItem skip_unchanged = True report_skipped = False clean_model_instances = True @@ -262,16 +261,16 @@ class ParameterTemplateAdmin(ImportExportModelAdmin): class ParameterResource(ModelResource): """ Class for managing PartParameter data import/export """ - part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) + part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) part_name = Field(attribute='part__name', readonly=True) - template = Field(attribute='template', widget=widgets.ForeignKeyWidget(PartParameterTemplate)) + template = Field(attribute='template', widget=widgets.ForeignKeyWidget(models.PartParameterTemplate)) template_name = Field(attribute='template__name', readonly=True) class Meta: - model = PartParameter + model = models.PartParameter skip_unchanged = True report_skipped = False clean_model_instance = True @@ -292,7 +291,7 @@ class PartCategoryParameterAdmin(admin.ModelAdmin): class PartSellPriceBreakAdmin(admin.ModelAdmin): class Meta: - model = PartSellPriceBreak + model = models.PartSellPriceBreak list_display = ('part', 'quantity', 'price',) @@ -300,20 +299,21 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin): class PartInternalPriceBreakAdmin(admin.ModelAdmin): class Meta: - model = PartInternalPriceBreak + model = models.PartInternalPriceBreak list_display = ('part', 'quantity', 'price',) -admin.site.register(Part, PartAdmin) -admin.site.register(PartCategory, PartCategoryAdmin) -admin.site.register(PartRelated, PartRelatedAdmin) -admin.site.register(PartAttachment, PartAttachmentAdmin) -admin.site.register(PartStar, PartStarAdmin) -admin.site.register(BomItem, BomItemAdmin) -admin.site.register(PartParameterTemplate, ParameterTemplateAdmin) -admin.site.register(PartParameter, ParameterAdmin) -admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin) -admin.site.register(PartTestTemplate, PartTestTemplateAdmin) -admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin) -admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin) +admin.site.register(models.Part, PartAdmin) +admin.site.register(models.PartCategory, PartCategoryAdmin) +admin.site.register(models.PartRelated, PartRelatedAdmin) +admin.site.register(models.PartAttachment, PartAttachmentAdmin) +admin.site.register(models.PartStar, PartStarAdmin) +admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin) +admin.site.register(models.BomItem, BomItemAdmin) +admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin) +admin.site.register(models.PartParameter, ParameterAdmin) +admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin) +admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin) +admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin) +admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a11bb1b088..b08834445c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -58,6 +58,18 @@ class CategoryList(generics.ListCreateAPIView): queryset = PartCategory.objects.all() serializer_class = part_serializers.CategorySerializer + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + try: + ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + except AttributeError: + # Error is thrown if the view does not have an associated request + ctx['starred_categories'] = [] + + return ctx + def filter_queryset(self, queryset): """ Custom filtering: @@ -110,6 +122,18 @@ class CategoryList(generics.ListCreateAPIView): except (ValueError, PartCategory.DoesNotExist): pass + # Filter by "starred" status + starred = params.get('starred', None) + + if starred is not None: + starred = str2bool(starred) + starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()] + + if starred: + queryset = queryset.filter(pk__in=starred_categories) + else: + queryset = queryset.exclude(pk__in=starred_categories) + return queryset filter_backends = [ @@ -149,6 +173,29 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = part_serializers.CategorySerializer queryset = PartCategory.objects.all() + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + try: + ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + except AttributeError: + # Error is thrown if the view does not have an associated request + ctx['starred_categories'] = [] + + return ctx + + def update(self, request, *args, **kwargs): + + if 'starred' in request.data: + starred = str2bool(request.data.get('starred', False)) + + self.get_object().set_starred(request.user, starred) + + response = super().update(request, *args, **kwargs) + + return response + class CategoryParameterList(generics.ListAPIView): """ API endpoint for accessing a list of PartCategoryParameterTemplate objects. @@ -389,7 +436,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView): # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() - # Pass a list of "starred" parts fo the current user to the serializer + # Pass a list of "starred" parts of the current user to the serializer # We do this to reduce the number of database queries required! if self.starred_parts is None and self.request is not None: self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] @@ -418,9 +465,9 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView): """ if 'starred' in request.data: - starred = str2bool(request.data.get('starred', None)) + starred = str2bool(request.data.get('starred', False)) - self.get_object().setStarred(request.user, starred) + self.get_object().set_starred(request.user, starred) response = super().update(request, *args, **kwargs) diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index f67e4ffe8f..fd31aba339 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -7,7 +7,7 @@ from collections import OrderedDict from django.utils.translation import gettext as _ -from InvenTree.helpers import DownloadFile, GetExportFormats +from InvenTree.helpers import DownloadFile, GetExportFormats, normalize from .admin import BomItemResource from .models import BomItem @@ -59,7 +59,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa uids = [] - def add_items(items, level): + def add_items(items, level, cascade): # Add items at a given layer for item in items: @@ -71,21 +71,13 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa bom_items.append(item) - if item.sub_part.assembly: + if cascade and item.sub_part.assembly: if max_levels is None or level < max_levels: add_items(item.sub_part.bom_items.all().order_by('id'), level + 1) - if cascade: - # Cascading (multi-level) BOM + top_level_items = part.get_bom_items().order_by('id') - # Start with the top level - items_to_process = part.bom_items.all().order_by('id') - - add_items(items_to_process, 1) - - else: - # No cascading needed - just the top-level items - bom_items = [item for item in part.bom_items.all().order_by('id')] + add_items(top_level_items, 1, cascade) dataset = BomItemResource().export(queryset=bom_items, cascade=cascade) @@ -148,8 +140,9 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa stock_data.append('') except AttributeError: stock_data.append('') + # Get part current stock - stock_data.append(str(bom_item.sub_part.available_stock)) + stock_data.append(str(normalize(bom_item.sub_part.available_stock))) for s_idx, header in enumerate(stock_headers): try: @@ -160,171 +153,108 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa # Add stock columns to dataset add_columns_to_dataset(stock_cols, len(bom_items)) - if manufacturer_data and supplier_data: + if manufacturer_data or supplier_data: """ If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item """ - # Expand dataset with manufacturer parts - manufacturer_headers = [ - _('Manufacturer'), - _('MPN'), - ] - - supplier_headers = [ - _('Supplier'), - _('SKU'), - ] + # Keep track of the supplier parts we have already exported + supplier_parts_used = set() manufacturer_cols = {} - for b_idx, bom_item in enumerate(bom_items): + for bom_idx, bom_item in enumerate(bom_items): # Get part instance b_part = bom_item.sub_part - # Filter manufacturer parts - manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk) - manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts') + # Include manufacturer data for each BOM item + if manufacturer_data: - # Process manufacturer part - for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts): + # Filter manufacturer parts + manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts') + + for mp_idx, mp_part in enumerate(manufacturer_parts): - if manufacturer_part and manufacturer_part.manufacturer: - manufacturer_name = manufacturer_part.manufacturer.name - else: - manufacturer_name = '' + # Extract the "name" field of the Manufacturer (Company) + if mp_part and mp_part.manufacturer: + manufacturer_name = mp_part.manufacturer.name + else: + manufacturer_name = '' - if manufacturer_part: - manufacturer_mpn = manufacturer_part.MPN - else: - manufacturer_mpn = '' + # Extract the "MPN" field from the Manufacturer Part + if mp_part: + manufacturer_mpn = mp_part.MPN + else: + manufacturer_mpn = '' - # Generate column names for this manufacturer - k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx) - k_mpn = manufacturer_headers[1] + "_" + str(manufacturer_idx) + # Generate a column name for this manufacturer + k_man = f'{_("Manufacturer")}_{mp_idx}' + k_mpn = f'{_("MPN")}_{mp_idx}' + + try: + manufacturer_cols[k_man].update({bom_idx: manufacturer_name}) + manufacturer_cols[k_mpn].update({bom_idx: manufacturer_mpn}) + except KeyError: + manufacturer_cols[k_man] = {bom_idx: manufacturer_name} + manufacturer_cols[k_mpn] = {bom_idx: manufacturer_mpn} - try: - manufacturer_cols[k_man].update({b_idx: manufacturer_name}) - manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn}) - except KeyError: - manufacturer_cols[k_man] = {b_idx: manufacturer_name} - manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn} + # We wish to include supplier data for this manufacturer part + if supplier_data: + + for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()): - # Process supplier parts - for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()): + supplier_parts_used.add(sp_part) - if supplier_part.supplier and supplier_part.supplier: - supplier_name = supplier_part.supplier.name + if sp_part.supplier and sp_part.supplier: + supplier_name = sp_part.supplier.name + else: + supplier_name = '' + + if sp_part: + supplier_sku = sp_part.SKU + else: + supplier_sku = '' + + # Generate column names for this supplier + k_sup = str(_("Supplier")) + "_" + str(mp_idx) + "_" + str(sp_idx) + k_sku = str(_("SKU")) + "_" + str(mp_idx) + "_" + str(sp_idx) + + try: + manufacturer_cols[k_sup].update({bom_idx: supplier_name}) + manufacturer_cols[k_sku].update({bom_idx: supplier_sku}) + except KeyError: + manufacturer_cols[k_sup] = {bom_idx: supplier_name} + manufacturer_cols[k_sku] = {bom_idx: supplier_sku} + + if supplier_data: + # Add in any extra supplier parts, which are not associated with a manufacturer part + + for sp_idx, sp_part in enumerate(SupplierPart.objects.filter(part__pk=b_part.pk)): + + if sp_part in supplier_parts_used: + continue + + supplier_parts_used.add(sp_part) + + if sp_part.supplier: + supplier_name = sp_part.supplier.name else: supplier_name = '' - if supplier_part: - supplier_sku = supplier_part.SKU - else: - supplier_sku = '' + supplier_sku = sp_part.SKU # Generate column names for this supplier - k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) - k_sku = str(supplier_headers[1]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) + k_sup = str(_("Supplier")) + "_" + str(sp_idx) + k_sku = str(_("SKU")) + "_" + str(sp_idx) try: - manufacturer_cols[k_sup].update({b_idx: supplier_name}) - manufacturer_cols[k_sku].update({b_idx: supplier_sku}) + manufacturer_cols[k_sup].update({bom_idx: supplier_name}) + manufacturer_cols[k_sku].update({bom_idx: supplier_sku}) except KeyError: - manufacturer_cols[k_sup] = {b_idx: supplier_name} - manufacturer_cols[k_sku] = {b_idx: supplier_sku} + manufacturer_cols[k_sup] = {bom_idx: supplier_name} + manufacturer_cols[k_sku] = {bom_idx: supplier_sku} - # Add manufacturer columns to dataset - add_columns_to_dataset(manufacturer_cols, len(bom_items)) - - elif manufacturer_data: - """ - If requested, add extra columns for each ManufacturerPart associated with each line item - """ - - # Expand dataset with manufacturer parts - manufacturer_headers = [ - _('Manufacturer'), - _('MPN'), - ] - - manufacturer_cols = {} - - for b_idx, bom_item in enumerate(bom_items): - # Get part instance - b_part = bom_item.sub_part - - # Filter supplier parts - manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk) - - for idx, manufacturer_part in enumerate(manufacturer_parts): - - if manufacturer_part: - manufacturer_name = manufacturer_part.manufacturer.name - else: - manufacturer_name = '' - - manufacturer_mpn = manufacturer_part.MPN - - # Add manufacturer data to the manufacturer columns - - # Generate column names for this manufacturer - k_man = manufacturer_headers[0] + "_" + str(idx) - k_mpn = manufacturer_headers[1] + "_" + str(idx) - - try: - manufacturer_cols[k_man].update({b_idx: manufacturer_name}) - manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn}) - except KeyError: - manufacturer_cols[k_man] = {b_idx: manufacturer_name} - manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn} - - # Add manufacturer columns to dataset - add_columns_to_dataset(manufacturer_cols, len(bom_items)) - - elif supplier_data: - """ - If requested, add extra columns for each SupplierPart associated with each line item - """ - - # Expand dataset with manufacturer parts - manufacturer_headers = [ - _('Supplier'), - _('SKU'), - ] - - manufacturer_cols = {} - - for b_idx, bom_item in enumerate(bom_items): - # Get part instance - b_part = bom_item.sub_part - - # Filter supplier parts - supplier_parts = SupplierPart.objects.filter(part__pk=b_part.pk) - - for idx, supplier_part in enumerate(supplier_parts): - - if supplier_part.supplier: - supplier_name = supplier_part.supplier.name - else: - supplier_name = '' - - supplier_sku = supplier_part.SKU - - # Add manufacturer data to the manufacturer columns - - # Generate column names for this supplier - k_sup = manufacturer_headers[0] + "_" + str(idx) - k_sku = manufacturer_headers[1] + "_" + str(idx) - - try: - manufacturer_cols[k_sup].update({b_idx: supplier_name}) - manufacturer_cols[k_sku].update({b_idx: supplier_sku}) - except KeyError: - manufacturer_cols[k_sup] = {b_idx: supplier_name} - manufacturer_cols[k_sku] = {b_idx: supplier_sku} - - # Add manufacturer columns to dataset + # Add supplier columns to dataset add_columns_to_dataset(manufacturer_cols, len(bom_items)) data = dataset.export(fmt) diff --git a/InvenTree/part/migrations/0074_partcategorystar.py b/InvenTree/part/migrations/0074_partcategorystar.py new file mode 100644 index 0000000000..0015212d2e --- /dev/null +++ b/InvenTree/part/migrations/0074_partcategorystar.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.5 on 2021-11-03 07:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('part', '0073_auto_20211013_1048'), + ] + + operations = [ + migrations.CreateModel( + name='PartCategoryStar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.partcategory', verbose_name='Category')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_categories', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'unique_together': {('category', 'user')}, + }, + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 5cd9fa3180..e77008b076 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -20,7 +20,7 @@ from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from django.contrib.auth.models import User -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, post_save from django.dispatch import receiver from jinja2 import Template @@ -47,6 +47,7 @@ from InvenTree import validators from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2string, normalize, decimal2money +import InvenTree.tasks from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus @@ -56,6 +57,7 @@ from company.models import SupplierPart from stock import models as StockModels import common.models + import part.settings as part_settings @@ -102,11 +104,11 @@ class PartCategory(InvenTreeTree): if cascade: """ Select any parts which exist in this category or any child categories """ - query = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) + queryset = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) else: - query = Part.objects.filter(category=self.pk) + queryset = Part.objects.filter(category=self.pk) - return query + return queryset @property def item_count(self): @@ -201,6 +203,60 @@ class PartCategory(InvenTreeTree): return prefetch.filter(category=self.id) + def get_subscribers(self, include_parents=True): + """ + Return a list of users who subscribe to this PartCategory + """ + + cats = self.get_ancestors(include_self=True) + + subscribers = set() + + if include_parents: + queryset = PartCategoryStar.objects.filter( + category__pk__in=[cat.pk for cat in cats] + ) + else: + queryset = PartCategoryStar.objects.filter( + category=self, + ) + + for result in queryset: + subscribers.add(result.user) + + return [s for s in subscribers] + + def is_starred_by(self, user, **kwargs): + """ + Returns True if the specified user subscribes to this category + """ + + return user in self.get_subscribers(**kwargs) + + def set_starred(self, user, status): + """ + Set the "subscription" status of this PartCategory against the specified user + """ + + if not user: + return + + if self.is_starred_by(user) == status: + return + + if status: + PartCategoryStar.objects.create( + category=self, + user=user + ) + else: + # Note that this won't actually stop the user being subscribed, + # if the user is subscribed to a parent category + PartCategoryStar.objects.filter( + category=self, + user=user, + ).delete() + @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') def before_delete_part_category(sender, instance, using, **kwargs): @@ -332,9 +388,16 @@ class Part(MPTTModel): context = {} - context['starred'] = self.isStarredBy(request.user) context['disabled'] = not self.active + # Subscription status + context['starred'] = self.is_starred_by(request.user) + context['starred_directly'] = context['starred'] and self.is_starred_by( + request.user, + include_variants=False, + include_categories=False + ) + # Pre-calculate complex queries so they only need to be performed once context['total_stock'] = self.total_stock @@ -1040,30 +1103,65 @@ class Part(MPTTModel): return self.total_stock - self.allocation_count() + self.on_order - def isStarredBy(self, user): - """ Return True if this part has been starred by a particular user """ - - try: - PartStar.objects.get(part=self, user=user) - return True - except PartStar.DoesNotExist: - return False - - def setStarred(self, user, starred): + def get_subscribers(self, include_variants=True, include_categories=True): """ - Set the "starred" status of this Part for the given user + Return a list of users who are 'subscribed' to this part. + + A user may 'subscribe' to this part in the following ways: + + a) Subscribing to the part instance directly + b) Subscribing to a template part "above" this part (if it is a variant) + c) Subscribing to the part category that this part belongs to + d) Subscribing to a parent category of the category in c) + + """ + + subscribers = set() + + # Start by looking at direct subscriptions to a Part model + queryset = PartStar.objects.all() + + if include_variants: + queryset = queryset.filter( + part__pk__in=[part.pk for part in self.get_ancestors(include_self=True)] + ) + else: + queryset = queryset.filter(part=self) + + for star in queryset: + subscribers.add(star.user) + + if include_categories and self.category: + + for sub in self.category.get_subscribers(): + subscribers.add(sub) + + return [s for s in subscribers] + + def is_starred_by(self, user, **kwargs): + """ + Return True if the specified user subscribes to this part + """ + + return user in self.get_subscribers(**kwargs) + + def set_starred(self, user, status): + """ + Set the "subscription" status of this Part against the specified user """ if not user: return - # Do not duplicate efforts - if self.isStarredBy(user) == starred: + # Already subscribed? + if self.is_starred_by(user) == status: return - if starred: + if status: PartStar.objects.create(part=self, user=user) else: + # Note that this won't actually stop the user being subscribed, + # if the user is subscribed to a parent part or category PartStar.objects.filter(part=self, user=user).delete() def need_to_restock(self): @@ -1226,6 +1324,17 @@ class Part(MPTTModel): return query + def get_stock_count(self, include_variants=True): + """ + Return the total "in stock" count for this part + """ + + entries = self.stock_entries(in_stock=True, include_variants=include_variants) + + query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) + + return query['t'] + @property def total_stock(self): """ Return the total stock quantity for this part. @@ -1234,11 +1343,7 @@ class Part(MPTTModel): - If this part is a "template" (variants exist) then these are counted too """ - entries = self.stock_entries(in_stock=True) - - query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) - - return query['t'] + return self.get_stock_count() def get_bom_item_filter(self, include_inherited=True): """ @@ -1287,6 +1392,27 @@ class Part(MPTTModel): return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited)) + def get_installed_part_options(self, include_inherited=True, include_variants=True): + """ + Return a set of all Parts which can be "installed" into this part, based on the BOM. + + arguments: + include_inherited - If set, include BomItem entries defined for parent parts + include_variants - If set, include variant parts for BomItems which allow variants + """ + + parts = set() + + for bom_item in self.get_bom_items(include_inherited=include_inherited): + + if include_variants and bom_item.allow_variants: + for part in bom_item.sub_part.get_descendants(include_self=True): + parts.add(part) + else: + parts.add(bom_item.sub_part) + + return parts + def get_used_in_filter(self, include_inherited=True): """ Return a query filter for all parts that this part is used in. @@ -1945,10 +2071,10 @@ class Part(MPTTModel): if self.variant_of: parts.append(self.variant_of) - siblings = self.get_siblings(include_self=False) + siblings = self.get_siblings(include_self=False) - for sib in siblings: - parts.append(sib) + for sib in siblings: + parts.append(sib) filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts]) @@ -1988,6 +2114,26 @@ class Part(MPTTModel): def related_count(self): return len(self.get_related_parts()) + def is_part_low_on_stock(self): + """ + Returns True if the total stock for this part is less than the minimum stock level + """ + + return self.get_stock_count() < self.minimum_stock + + +@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') +def after_save_part(sender, instance: Part, created, **kwargs): + """ + Function to be executed after a Part is saved + """ + + if not created: + # Check part stock only if we are *updating* the part (not creating it) + + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) + def attach_file(instance, filename): """ Function for storing a file for a PartAttachment @@ -2059,10 +2205,9 @@ class PartInternalPriceBreak(common.models.PriceBreak): class PartStar(models.Model): - """ A PartStar object creates a relationship between a User and a Part. + """ A PartStar object creates a subscription relationship between a User and a Part. - It is used to designate a Part as 'starred' (or favourited) for a given User, - so that the user can track a list of their favourite parts. + It is used to designate a Part as 'subscribed' for a given User. Attributes: part: Link to a Part object @@ -2074,7 +2219,30 @@ class PartStar(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts') class Meta: - unique_together = ['part', 'user'] + unique_together = [ + 'part', + 'user' + ] + + +class PartCategoryStar(models.Model): + """ + A PartCategoryStar creates a subscription relationship between a User and a PartCategory. + + Attributes: + category: Link to a PartCategory object + user: Link to a User object + """ + + category = models.ForeignKey(PartCategory, on_delete=models.CASCADE, verbose_name=_('Category'), related_name='starred_users') + + user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories') + + class Meta: + unique_together = [ + 'category', + 'user', + ] class PartTestTemplate(models.Model): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index ff1fb2c8c6..47ce3f66c8 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -33,12 +33,25 @@ from .models import (BomItem, BomItemSubstitute, class CategorySerializer(InvenTreeModelSerializer): """ Serializer for PartCategory """ + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + def get_starred(self, category): + """ + Return True if the category is directly "starred" by the current user + """ + + return category in self.context.get('starred_categories', []) + url = serializers.CharField(source='get_absolute_url', read_only=True) parts = serializers.IntegerField(source='item_count', read_only=True) level = serializers.IntegerField(read_only=True) + starred = serializers.SerializerMethodField() + class Meta: model = PartCategory fields = [ @@ -51,6 +64,7 @@ class CategorySerializer(InvenTreeModelSerializer): 'parent', 'parts', 'pathstring', + 'starred', 'url', ] @@ -241,6 +255,9 @@ class PartSerializer(InvenTreeModelSerializer): to reduce database trips. """ + # TODO: Update the "in_stock" annotation to include stock for variants of the part + # Ref: https://github.com/inventree/InvenTree/issues/2240 + # Annotate with the total 'in stock' quantity queryset = queryset.annotate( in_stock=Coalesce( diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py new file mode 100644 index 0000000000..0cd9cf09a7 --- /dev/null +++ b/InvenTree/part/tasks.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging +from datetime import timedelta + +from django.utils.translation import ugettext_lazy as _ +from django.template.loader import render_to_string + +from allauth.account.models import EmailAddress + +from common.models import NotificationEntry + +import InvenTree.helpers +import InvenTree.tasks + +import part.models + +logger = logging.getLogger("inventree") + + +def notify_low_stock(part: part.models.Part): + """ + Notify users who have starred a part when its stock quantity falls below the minimum threshold + """ + + # Check if we have notified recently... + delta = timedelta(days=1) + + if NotificationEntry.check_recent('part.notify_low_stock', part.pk, delta): + logger.info(f"Low stock notification has recently been sent for '{part.full_name}' - SKIPPING") + return + + logger.info(f"Sending low stock notification email for {part.full_name}") + + # Get a list of users who are subcribed to this part + subscribers = part.get_subscribers() + + emails = EmailAddress.objects.filter( + user__in=subscribers, + ) + + # TODO: In the future, include the part image in the email template + + if len(emails) > 0: + logger.info(f"Notify users regarding low stock of {part.name}") + context = { + # Pass the "Part" object through to the template context + 'part': part, + 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), + } + + subject = "[InvenTree] " + _("Low stock notification") + html_message = render_to_string('email/low_stock_notification.html', context) + recipients = emails.values_list('email', flat=True) + + InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) + + NotificationEntry.notify('part.notify_low_stock', part.pk) + + +def notify_low_stock_if_required(part: part.models.Part): + """ + Check if the stock quantity has fallen below the minimum threshold of part. + + If true, notify the users who have subscribed to the part + """ + + # Run "up" the tree, to allow notification for "parent" parts + parts = part.get_ancestors(include_self=True, ascending=True) + + for p in parts: + if p.is_part_low_on_stock(): + InvenTree.tasks.offload_task( + 'part.tasks.notify_low_stock', + p + ) diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index aa01dd64eb..7a3ab1ad6d 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -34,11 +34,8 @@
  • {% trans "Delete Items" %}
  • - {% endif %} - -
    - -
    + {% endif %} + {% include "filter_list.html" with id="bom" %} diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html index c8add61f49..ab3b245010 100644 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -8,58 +8,55 @@ {% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %} {% endblock %} -{% block page_content %} +{% block heading %} +{% trans "Upload Bill of Materials" %} +{% endblock %} -
    -
    - {% block heading %} -

    {% trans "Upload Bill of Materials" %}

    - {{ wizard.form.media }} - {% endblock %} +{% block actions %} +{% endblock %} + +{% block page_info %} +
    +

    {% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} + {% if description %}- {{ description }}{% endif %}

    + +
    + {% csrf_token %} + {% load crispy_forms_tags %} + + {% block form_buttons_top %} + {% endblock form_buttons_top %} + + {% block form_alert %} +
    + {% trans "Requirements for BOM upload" %}: +
      +
    • {% trans "The BOM file must contain the required named columns as provided in the " %} {% trans "BOM Upload Template" %}
    • +
    • {% trans "Each part must already exist in the database" %}
    • +
    -
    - {% block details %} + {% endblock %} -

    {% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} - {% if description %}- {{ description }}{% endif %}

    +
    + {{ wizard.management_form }} + {% block form_content %} + {% crispy wizard.form %} + {% endblock form_content %} +
    - - {% csrf_token %} - {% load crispy_forms_tags %} - - {% block form_buttons_top %} - {% endblock form_buttons_top %} - - {% block form_alert %} -
    - {% trans "Requirements for BOM upload" %}: -
      -
    • {% trans "The BOM file must contain the required named columns as provided in the " %} {% trans "BOM Upload Template" %}
    • -
    • {% trans "Each part must already exist in the database" %}
    • -
    -
    - {% endblock %} - - - {{ wizard.management_form }} - {% block form_content %} - {% crispy wizard.form %} - {% endblock form_content %} -
    - - {% block form_buttons_bottom %} - {% if wizard.steps.prev %} - - {% endif %} - - - {% endblock form_buttons_bottom %} - - {% endblock details %} -
    - -{% endblock page_content %} + {% block form_buttons_bottom %} + {% if wizard.steps.prev %} + + {% endif %} + + + {% endblock form_buttons_bottom %} +
    +{% endblock page_info %} {% block js_ready %} {{ block.super }} + +enableSidebar('bom-upload'); + {% endblock js_ready %} diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 249c053801..bc8a99a3dd 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -20,15 +20,37 @@ {% include "admin_button.html" with url=url %} {% endif %} {% if category %} -{% if roles.part_category.change %} - +{% elif starred %} + +{% else %} + {% endif %} -{% if roles.part_category.delete %} - +{% if roles.part_category.change or roles.part_category.delete %} +
    + + +
    {% endif %} {% endif %} {% if roles.part_category.add %} @@ -116,7 +138,7 @@
    -
    +

    {% trans "Parts" %}

    {% include "spacer.html" %}
    @@ -142,13 +164,13 @@
  • {% trans "Set Category" %}
  • {% endif %}
  • {% trans "Order Parts" %}
  • + {% if report_enabled %}
  • {% trans "Print Labels" %}
  • + {% endif %}
  • {% trans "Export Data" %}
  • -
    - -
    + {% include "filter_list.html" with id="parts" %}
    @@ -174,9 +196,7 @@
    -
    - -
    + {% include "filter_list.html" with id="category" %}
    @@ -202,6 +222,14 @@ data: {{ parameters|safe }}, } ); + + $("#toggle-starred").click(function() { + toggleStar({ + url: '{% url "api-part-category-detail" category.pk %}', + button: '#category-star-icon' + }); + }); + {% endif %} enableSidebar('category'); @@ -214,7 +242,8 @@ {% else %} parent: null, {% endif %} - } + }, + allowTreeView: true, } ); diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index b5008ae699..39ed011861 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -20,13 +20,6 @@ - {% if part.IPN %} - - - - - - {% endif %} @@ -37,6 +30,22 @@ + {% if part.category %} + + + + + + {% endif %} + {% if part.IPN %} + + + + + + {% endif %} {% if part.revision %} @@ -44,6 +53,20 @@ {% endif %} + {% if part.units %} + + + + + + {% endif %} + {% if part.minimum_stock %} + + + + + + {% endif %} {% if part.keywords %} @@ -64,7 +87,7 @@ @@ -79,7 +102,9 @@ - + {% endif %} {% if part.default_supplier %} @@ -95,7 +120,15 @@
    -

    {% trans "Part Stock" %}

    +
    +

    {% trans "Part Stock" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% if part.is_template %} @@ -109,7 +142,7 @@
    -
    +

    {% trans "Part Test Templates" %}

    {% include "spacer.html" %}
    @@ -123,10 +156,8 @@
    -
    -
    - -
    +
    + {% include "filter_list.html" with id="parttests" %}
    @@ -136,7 +167,7 @@
    -
    +

    {% trans "Purchase Orders" %}

    {% include "spacer.html" %}
    @@ -149,9 +180,7 @@
    -
    - -
    + {% include "filter_list.html" with id="purchaseorder" %}
    @@ -166,13 +195,8 @@
    -
    - {% if 0 %} - - {% endif %} -
    - -
    +
    + {% include "filter_list.html" with id="salesorder" %}
    @@ -221,7 +245,7 @@
    -
    +

    {% trans "Part Variants" %}

    {% include "spacer.html" %}
    @@ -238,9 +262,7 @@
    -
    - -
    + {% include "filter_list.html" with id="variants" %}
    @@ -251,7 +273,7 @@
    -
    +

    {% trans "Parameters" %}

    {% include "spacer.html" %}
    @@ -274,7 +296,7 @@
    -
    +

    {% trans "Attachments" %}

    {% include "spacer.html" %}
    @@ -289,7 +311,7 @@
    + {% if part.minimum_stock %} + + + + + + {% endif %} {% if on_order > 0 %} @@ -310,7 +325,7 @@ $("#toggle-starred").click(function() { toggleStar({ - part: {{ part.id }}, + url: '{% url "api-part-detail" part.pk %}', button: '#part-star-icon', }); }); diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 2230077a81..590ea20a6f 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -122,6 +122,12 @@ def inventree_title(*args, **kwargs): return version.inventreeInstanceTitle() +@register.simple_tag() +def inventree_base_url(*args, **kwargs): + """ Return the INVENTREE_BASE_URL setting """ + return InvenTreeSetting.get_setting('INVENTREE_BASE_URL') + + @register.simple_tag() def python_version(*args, **kwargs): """ diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 1bd9fdf87d..755bd45cea 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError import os -from .models import Part, PartCategory, PartTestTemplate +from .models import Part, PartCategory, PartCategoryStar, PartStar, PartTestTemplate from .models import rename_part_image from .templatetags import inventree_extras @@ -347,3 +347,120 @@ class PartSettingsTest(TestCase): with self.assertRaises(ValidationError): part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C') part.full_clean() + + +class PartSubscriptionTests(TestCase): + + fixtures = [ + 'location', + 'category', + 'part', + ] + + def setUp(self): + # Create a user for auth + user = get_user_model() + + self.user = user.objects.create_user( + username='testuser', + email='test@testing.com', + password='password', + is_staff=True + ) + + # electronics / IC / MCU + self.category = PartCategory.objects.get(pk=4) + + self.part = Part.objects.create( + category=self.category, + name='STM32F103', + description='Currently worth a lot of money', + is_template=True, + ) + + def test_part_subcription(self): + """ + Test basic subscription against a part + """ + + # First check that the user is *not* subscribed to the part + self.assertFalse(self.part.is_starred_by(self.user)) + + # Now, subscribe directly to the part + self.part.set_starred(self.user, True) + + self.assertEqual(PartStar.objects.count(), 1) + + self.assertTrue(self.part.is_starred_by(self.user)) + + # Now, unsubscribe + self.part.set_starred(self.user, False) + + self.assertFalse(self.part.is_starred_by(self.user)) + + def test_variant_subscription(self): + """ + Test subscription against a parent part + """ + + # Construct a sub-part to star against + sub_part = Part.objects.create( + name='sub_part', + description='a sub part', + variant_of=self.part, + ) + + self.assertFalse(sub_part.is_starred_by(self.user)) + + # Subscribe to the "parent" part + self.part.set_starred(self.user, True) + + self.assertTrue(self.part.is_starred_by(self.user)) + self.assertTrue(sub_part.is_starred_by(self.user)) + + def test_category_subscription(self): + """ + Test subscription against a PartCategory + """ + + self.assertEqual(PartCategoryStar.objects.count(), 0) + + self.assertFalse(self.part.is_starred_by(self.user)) + self.assertFalse(self.category.is_starred_by(self.user)) + + # Subscribe to the direct parent category + self.category.set_starred(self.user, True) + + self.assertEqual(PartStar.objects.count(), 0) + self.assertEqual(PartCategoryStar.objects.count(), 1) + + self.assertTrue(self.category.is_starred_by(self.user)) + self.assertTrue(self.part.is_starred_by(self.user)) + + # Check that the "parent" category is not starred + self.assertFalse(self.category.parent.is_starred_by(self.user)) + + # Un-subscribe + self.category.set_starred(self.user, False) + + self.assertFalse(self.category.is_starred_by(self.user)) + self.assertFalse(self.part.is_starred_by(self.user)) + + def test_parent_category_subscription(self): + """ + Check that a parent category can be subscribed to + """ + + # Top-level "electronics" category + cat = PartCategory.objects.get(pk=1) + + cat.set_starred(self.user, True) + + # Check base category + self.assertTrue(cat.is_starred_by(self.user)) + + # Check lower level category + self.assertTrue(self.category.is_starred_by(self.user)) + + # Check part + self.assertTrue(self.part.is_starred_by(self.user)) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 5a4167ea05..65c42f7e36 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -42,11 +42,12 @@ from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView from common.forms import UploadFileForm, MatchFieldForm -from stock.models import StockLocation +from stock.models import StockItem, StockLocation import common.settings as inventree_settings from . import forms as part_forms +from . import settings as part_settings from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem @@ -245,6 +246,7 @@ class PartImport(FileManagementFormView): 'Category', 'default_location', 'default_supplier', + 'variant_of', ] OPTIONAL_HEADERS = [ @@ -256,6 +258,17 @@ class PartImport(FileManagementFormView): 'minimum_stock', 'Units', 'Notes', + 'Active', + 'base_cost', + 'Multiple', + 'Assembly', + 'Component', + 'is_template', + 'Purchaseable', + 'Salable', + 'Trackable', + 'Virtual', + 'Stock', ] name = 'part' @@ -284,6 +297,18 @@ class PartImport(FileManagementFormView): 'category': 'category', 'default_location': 'default_location', 'default_supplier': 'default_supplier', + 'variant_of': 'variant_of', + 'active': 'active', + 'base_cost': 'base_cost', + 'multiple': 'multiple', + 'assembly': 'assembly', + 'component': 'component', + 'is_template': 'is_template', + 'purchaseable': 'purchaseable', + 'salable': 'salable', + 'trackable': 'trackable', + 'virtual': 'virtual', + 'stock': 'stock', } file_manager_class = PartFileManager @@ -299,6 +324,8 @@ class PartImport(FileManagementFormView): self.matches['default_location'] = ['name__contains'] self.allowed_items['default_supplier'] = SupplierPart.objects.all() self.matches['default_supplier'] = ['SKU__contains'] + self.allowed_items['variant_of'] = Part.objects.all() + self.matches['variant_of'] = ['name__contains'] # setup self.file_manager.setup() @@ -364,9 +391,29 @@ class PartImport(FileManagementFormView): category=optional_matches['Category'], default_location=optional_matches['default_location'], default_supplier=optional_matches['default_supplier'], + variant_of=optional_matches['variant_of'], + active=str2bool(part_data.get('active', True)), + base_cost=part_data.get('base_cost', 0), + multiple=part_data.get('multiple', 1), + assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())), + component=str2bool(part_data.get('component', part_settings.part_component_default())), + is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())), + purchaseable=str2bool(part_data.get('purchaseable', part_settings.part_purchaseable_default())), + salable=str2bool(part_data.get('salable', part_settings.part_salable_default())), + trackable=str2bool(part_data.get('trackable', part_settings.part_trackable_default())), + virtual=str2bool(part_data.get('virtual', part_settings.part_virtual_default())), ) try: new_part.save() + + # add stock item if set + if part_data.get('stock', None): + stock = StockItem( + part=new_part, + location=new_part.default_location, + quantity=int(part_data.get('stock', 1)), + ) + stock.save() import_done += 1 except ValidationError as _e: import_error.append(', '.join(set(_e.messages))) @@ -412,6 +459,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView): part = self.get_object() ctx = part.get_context_data(self.request) + context.update(**ctx) # Pricing information @@ -1469,18 +1517,29 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView): if category: cascade = kwargs.get('cascade', True) + # Prefetch parts parameters parts_parameters = category.prefetch_parts_parameters(cascade=cascade) + # Get table headers (unique parameters names) context['headers'] = category.get_unique_parameters(cascade=cascade, prefetch=parts_parameters) + # Insert part information context['headers'].insert(0, 'description') context['headers'].insert(0, 'part') + # Get parameters data context['parameters'] = category.get_parts_parameters(cascade=cascade, prefetch=parts_parameters) + # Insert "starred" information + context['starred'] = category.is_starred_by(self.request.user) + context['starred_directly'] = context['starred'] and category.is_starred_by( + self.request.user, + include_parents=False, + ) + return context diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 36a99079ad..af88f4799f 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -257,7 +257,6 @@ class ReportPrintMixin: pages = [] try: - pdf = outputs[0].get_document().copy(pages).write_pdf() if len(outputs) > 1: # If more than one output is generated, merge them into a single file @@ -265,6 +264,8 @@ class ReportPrintMixin: doc = output.get_document() for page in doc.pages: pages.append(page) + + pdf = outputs[0].get_document().copy(pages).write_pdf() else: pdf = outputs[0].get_document().write_pdf() diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 45d11c1d2d..9593db1885 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -14,6 +14,8 @@ from stock.models import StockItem from common.models import InvenTreeSetting +import InvenTree.helpers + register = template.Library() @@ -119,18 +121,10 @@ def internal_link(link, text): text = str(text) - base_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL') + url = InvenTree.helpers.construct_absolute_url(link) # If the base URL is not set, just return the text - if not base_url: + if not url: return text - if not base_url.endswith('/'): - base_url += '/' - - if base_url.endswith('/') and link.startswith('/'): - link = link[1:] - - url = f"{base_url}{link}" - return mark_safe(f'{text}') diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 5f1b134966..abd3db20cb 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -117,6 +117,8 @@ class StockItemResource(ModelResource): exclude = [ # Exclude MPTT internal model fields 'lft', 'rght', 'tree_id', 'level', + # Exclude internal fields + 'serial_int', ] diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1207312688..2ffc2e8d69 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -7,42 +7,44 @@ from __future__ import unicode_literals from datetime import datetime, timedelta +from django.core.exceptions import ValidationError as DjangoValidationError from django.conf.urls import url, include from django.http import JsonResponse from django.db.models import Q +from django.db import transaction +from django.utils.translation import ugettext_lazy as _ + +from django_filters.rest_framework import DjangoFilterBackend +from django_filters import rest_framework as rest_filters from rest_framework import status from rest_framework.serializers import ValidationError from rest_framework.response import Response from rest_framework import generics, filters -from django_filters.rest_framework import DjangoFilterBackend -from django_filters import rest_framework as rest_filters - -from .models import StockLocation, StockItem -from .models import StockItemTracking -from .models import StockItemAttachment -from .models import StockItemTestResult - -from part.models import BomItem, Part, PartCategory -from part.serializers import PartBriefSerializer +import common.settings +import common.models from company.models import Company, SupplierPart from company.serializers import CompanySerializer, SupplierPartSerializer +from InvenTree.helpers import str2bool, isNull, extract_serial_numbers +from InvenTree.api import AttachmentMixin +from InvenTree.filters import InvenTreeOrderingFilter + from order.models import PurchaseOrder from order.models import SalesOrder, SalesOrderAllocation from order.serializers import POSerializer -import common.settings -import common.models +from part.models import BomItem, Part, PartCategory +from part.serializers import PartBriefSerializer +from stock.models import StockLocation, StockItem +from stock.models import StockItemTracking +from stock.models import StockItemAttachment +from stock.models import StockItemTestResult import stock.serializers as StockSerializers -from InvenTree.helpers import str2bool, isNull -from InvenTree.api import AttachmentMixin -from InvenTree.filters import InvenTreeOrderingFilter - class StockDetail(generics.RetrieveUpdateDestroyAPIView): """ API detail endpoint for Stock object @@ -99,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): instance.mark_for_deletion() +class StockItemSerialize(generics.CreateAPIView): + """ + API endpoint for serializing a stock item + """ + + queryset = StockItem.objects.none() + serializer_class = StockSerializers.SerializeStockItemSerializer + + def get_serializer_context(self): + + context = super().get_serializer_context() + context['request'] = self.request + + try: + context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return context + + class StockAdjustView(generics.CreateAPIView): """ A generic class for handling stocktake actions. @@ -380,28 +403,91 @@ class StockList(generics.ListCreateAPIView): """ user = request.user + data = request.data - serializer = self.get_serializer(data=request.data) + serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) - item = serializer.save() + # Check if a set of serial numbers was provided + serial_numbers = data.get('serial_numbers', '') - # A location was *not* specified - try to infer it - if 'location' not in request.data: - item.location = item.part.get_default_location() + quantity = data.get('quantity', None) - # An expiry date was *not* specified - try to infer it! - if 'expiry_date' not in request.data: + if quantity is None: + raise ValidationError({ + 'quantity': _('Quantity is required'), + }) - if item.part.default_expiry > 0: - item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) + notes = data.get('notes', '') - # Finally, save the item - item.save(user=user) + serials = None - # Return a response - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + if serial_numbers: + # If serial numbers are specified, check that they match! + try: + serials = extract_serial_numbers(serial_numbers, data['quantity']) + except DjangoValidationError as e: + raise ValidationError({ + 'quantity': e.messages, + 'serial_numbers': e.messages, + }) + + with transaction.atomic(): + + # Create an initial stock item + item = serializer.save() + + # A location was *not* specified - try to infer it + if 'location' not in data: + item.location = item.part.get_default_location() + + # An expiry date was *not* specified - try to infer it! + if 'expiry_date' not in data: + + if item.part.default_expiry > 0: + item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) + + # Finally, save the item (with user information) + item.save(user=user) + + if serials: + """ + Serialize the stock, if required + + - Note that the "original" stock item needs to be created first, so it can be serialized + - It is then immediately deleted + """ + + try: + item.serializeStock( + quantity, + serials, + user, + notes=notes, + location=item.location, + ) + + headers = self.get_success_headers(serializer.data) + + # Delete the original item + item.delete() + + response_data = { + 'quantity': quantity, + 'serial_numbers': serials, + } + + return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + + except DjangoValidationError as e: + raise ValidationError({ + 'quantity': e.messages, + 'serial_numbers': e.messages, + }) + + # Return a response + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def list(self, request, *args, **kwargs): """ @@ -790,6 +876,7 @@ class StockList(generics.ListCreateAPIView): ordering_field_aliases = { 'SKU': 'supplier_part__SKU', + 'stock': ['quantity', 'serial_int', 'serial'], } ordering_fields = [ @@ -801,6 +888,7 @@ class StockList(generics.ListCreateAPIView): 'stocktake_date', 'expiry_date', 'quantity', + 'stock', 'status', 'SKU', ] @@ -1085,8 +1173,11 @@ stock_api_urls = [ url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), ])), - # Detail for a single stock item - url(r'^(?P\d+)/', StockDetail.as_view(), name='api-stock-detail'), + # Detail views for a single stock item + url(r'^(?P\d+)/', include([ + url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), + url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), + ])), # Anything else url(r'^.*$', StockList.as_view(), name='api-stock-list'), diff --git a/InvenTree/stock/migrations/0067_alter_stockitem_part.py b/InvenTree/stock/migrations/0067_alter_stockitem_part.py new file mode 100644 index 0000000000..7f00b8f7b1 --- /dev/null +++ b/InvenTree/stock/migrations/0067_alter_stockitem_part.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.5 on 2021-11-04 12:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0074_partcategorystar'), + ('stock', '0066_stockitem_scheduled_for_deletion'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='part', + field=models.ForeignKey(help_text='Base part', limit_choices_to={'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.part', verbose_name='Base Part'), + ), + ] diff --git a/InvenTree/stock/migrations/0068_stockitem_serial_int.py b/InvenTree/stock/migrations/0068_stockitem_serial_int.py new file mode 100644 index 0000000000..874978dc61 --- /dev/null +++ b/InvenTree/stock/migrations/0068_stockitem_serial_int.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-11-09 23:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0067_alter_stockitem_part'), + ] + + operations = [ + migrations.AddField( + model_name='stockitem', + name='serial_int', + field=models.IntegerField(default=0), + ), + ] diff --git a/InvenTree/stock/migrations/0069_auto_20211109_2347.py b/InvenTree/stock/migrations/0069_auto_20211109_2347.py new file mode 100644 index 0000000000..f4cdde7794 --- /dev/null +++ b/InvenTree/stock/migrations/0069_auto_20211109_2347.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.5 on 2021-11-09 23:47 + +import re + +from django.db import migrations + + +def update_serials(apps, schema_editor): + """ + Rebuild the integer serial number field for existing StockItem objects + """ + + StockItem = apps.get_model('stock', 'stockitem') + + for item in StockItem.objects.all(): + + if item.serial is None: + # Skip items without existing serial numbers + continue + + serial = 0 + + result = re.match(r"^(\d+)", str(item.serial)) + + if result and len(result.groups()) == 1: + try: + serial = int(result.groups()[0]) + except: + serial = 0 + + + item.serial_int = serial + item.save() + + +def nupdate_serials(apps, schema_editor): + """ + Provided only for reverse migration compatibility + """ + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0068_stockitem_serial_int'), + ] + + operations = [ + migrations.RunPython( + update_serials, + reverse_code=nupdate_serials, + ) + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 1372e63406..8e07074a76 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -7,6 +7,7 @@ Stock database model definitions from __future__ import unicode_literals import os +import re from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError, FieldError @@ -17,7 +18,7 @@ from django.db.models import Sum, Q from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from django.contrib.auth.models import User -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, post_save, post_delete from django.dispatch import receiver from markdownx.models import MarkdownxField @@ -27,7 +28,9 @@ from mptt.managers import TreeManager from decimal import Decimal, InvalidOperation from datetime import datetime, timedelta + from InvenTree import helpers +import InvenTree.tasks import common.models import report.models @@ -221,6 +224,32 @@ class StockItem(MPTTModel): self.scheduled_for_deletion = True self.save() + def update_serial_number(self): + """ + Update the 'serial_int' field, to be an integer representation of the serial number. + This is used for efficient numerical sorting + """ + + serial = getattr(self, 'serial', '') + + # Default value if we cannot convert to an integer + serial_int = 0 + + if serial is not None: + + serial = str(serial) + + # Look at the start of the string - can it be "integerized"? + result = re.match(r'^(\d+)', serial) + + if result and len(result.groups()) == 1: + try: + serial_int = int(result.groups()[0]) + except: + serial_int = 0 + + self.serial_int = serial_int + def save(self, *args, **kwargs): """ Save this StockItem to the database. Performs a number of checks: @@ -232,6 +261,8 @@ class StockItem(MPTTModel): self.validate_unique() self.clean() + self.update_serial_number() + user = kwargs.pop('user', None) # If 'add_note = False' specified, then no tracking note will be added for item creation @@ -454,7 +485,6 @@ class StockItem(MPTTModel): verbose_name=_('Base Part'), related_name='stock_items', help_text=_('Base part'), limit_choices_to={ - 'active': True, 'virtual': False }) @@ -503,6 +533,8 @@ class StockItem(MPTTModel): help_text=_('Serial number for this item') ) + serial_int = models.IntegerField(default=0) + link = InvenTreeURLField( verbose_name=_('External Link'), max_length=125, blank=True, @@ -1651,6 +1683,26 @@ def before_delete_stock_item(sender, instance, using, **kwargs): child.save() +@receiver(post_delete, sender=StockItem, dispatch_uid='stock_item_post_delete_log') +def after_delete_stock_item(sender, instance: StockItem, **kwargs): + """ + Function to be executed after a StockItem object is deleted + """ + + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) + + +@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') +def after_save_stock_item(sender, instance: StockItem, **kwargs): + """ + Hook function to be executed after StockItem object is saved/updated + """ + + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) + + class StockItemAttachment(InvenTreeAttachment): """ Model for storing file attachments against a StockItem object. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index d97e81331a..850ebcea3b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -9,6 +9,7 @@ from decimal import Decimal from datetime import datetime, timedelta from django.db import transaction +from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import ugettext_lazy as _ from django.db.models.functions import Coalesce from django.db.models import Case, When, Value @@ -27,14 +28,15 @@ from .models import StockItemTestResult import common.models from common.settings import currency_code_default, currency_code_mappings - from company.serializers import SupplierPartSerializer + +import InvenTree.helpers +import InvenTree.serializers + from part.serializers import PartBriefSerializer -from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer, InvenTreeMoneySerializer -from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField -class LocationBriefSerializer(InvenTreeModelSerializer): +class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Provides a brief serializer for a StockLocation object """ @@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer): ] -class StockItemSerializerBrief(InvenTreeModelSerializer): +class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): """ Brief serializers for a StockItem """ location_name = serializers.CharField(source='location', read_only=True) @@ -58,19 +60,19 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): class Meta: model = StockItem fields = [ - 'pk', - 'uid', 'part', 'part_name', - 'supplier_part', + 'pk', 'location', 'location_name', 'quantity', 'serial', + 'supplier_part', + 'uid', ] -class StockItemSerializer(InvenTreeModelSerializer): +class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for a StockItem: - Includes serialization for the linked part @@ -134,7 +136,7 @@ class StockItemSerializer(InvenTreeModelSerializer): tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False) - quantity = serializers.FloatField() + # quantity = serializers.FloatField() allocated = serializers.FloatField(source='allocation_count', required=False) @@ -142,19 +144,22 @@ class StockItemSerializer(InvenTreeModelSerializer): stale = serializers.BooleanField(required=False, read_only=True) - serial = serializers.CharField(required=False) + # serial = serializers.CharField(required=False) required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) - purchase_price = InvenTreeMoneySerializer( + purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Purchase Price'), - allow_null=True + max_digits=19, decimal_places=4, + allow_null=True, + help_text=_('Purchase price of this stock item'), ) purchase_price_currency = serializers.ChoiceField( choices=currency_code_mappings(), default=currency_code_default, label=_('Currency'), + help_text=_('Purchase currency of this stock item'), ) purchase_price_string = serializers.SerializerMethodField() @@ -196,6 +201,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'belongs_to', 'build', 'customer', + 'delete_on_deplete', 'expired', 'expiry_date', 'in_stock', @@ -204,6 +210,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'location', 'location_detail', 'notes', + 'owner', 'packaging', 'part', 'part_detail', @@ -242,14 +249,130 @@ class StockItemSerializer(InvenTreeModelSerializer): ] -class StockQuantitySerializer(InvenTreeModelSerializer): +class SerializeStockItemSerializer(serializers.Serializer): + """ + A DRF serializer for "serializing" a StockItem. + + (Sorry for the confusing naming...) + + Here, "serializing" means splitting out a single StockItem, + into multiple single-quantity items with an assigned serial number + + Note: The base StockItem object is provided to the serializer context + """ class Meta: - model = StockItem - fields = ('quantity',) + fields = [ + 'quantity', + 'serial_numbers', + 'destination', + 'notes', + ] + + quantity = serializers.IntegerField( + min_value=0, + required=True, + label=_('Quantity'), + help_text=_('Enter number of stock items to serialize'), + ) + + def validate_quantity(self, quantity): + """ + Validate that the quantity value is correct + """ + + item = self.context['item'] + + if quantity < 0: + raise ValidationError(_("Quantity must be greater than zero")) + + if quantity > item.quantity: + q = item.quantity + raise ValidationError(_(f"Quantity must not exceed available stock quantity ({q})")) + + return quantity + + serial_numbers = serializers.CharField( + label=_('Serial Numbers'), + help_text=_('Enter serial numbers for new items'), + allow_blank=False, + required=True, + ) + + destination = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + help_text=_('Destination stock location'), + ) + + notes = serializers.CharField( + required=False, + allow_blank=True, + label=_("Notes"), + help_text=_("Optional note field") + ) + + def validate(self, data): + """ + Check that the supplied serial numbers are valid + """ + + data = super().validate(data) + + item = self.context['item'] + + if not item.part.trackable: + raise ValidationError(_("Serial numbers cannot be assigned to this part")) + + # Ensure the serial numbers are valid! + quantity = data['quantity'] + serial_numbers = data['serial_numbers'] + + try: + serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + existing = item.part.find_conflicting_serial_numbers(serials) + + if len(existing) > 0: + exists = ','.join([str(x) for x in existing]) + error = _('Serial numbers already exist') + ": " + exists + + raise ValidationError({ + 'serial_numbers': error, + }) + + return data + + def save(self): + + item = self.context['item'] + request = self.context['request'] + user = request.user + + data = self.validated_data + + serials = InvenTree.helpers.extract_serial_numbers( + data['serial_numbers'], + data['quantity'], + ) + + item.serializeStock( + data['quantity'], + serials, + user, + notes=data.get('notes', ''), + location=data['destination'], + ) -class LocationSerializer(InvenTreeModelSerializer): +class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Detailed information about a stock location """ @@ -273,7 +396,7 @@ class LocationSerializer(InvenTreeModelSerializer): ] -class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): +class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): """ Serializer for StockItemAttachment model """ def __init__(self, *args, **kwargs): @@ -284,9 +407,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): if user_detail is not True: self.fields.pop('user_detail') - user_detail = UserSerializerBrief(source='user', read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) - attachment = InvenTreeAttachmentSerializerField(required=True) + attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True) # TODO: Record the uploading user when creating or updating an attachment! @@ -311,14 +434,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): ] -class StockItemTestResultSerializer(InvenTreeModelSerializer): +class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for the StockItemTestResult model """ - user_detail = UserSerializerBrief(source='user', read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) key = serializers.CharField(read_only=True) - attachment = InvenTreeAttachmentSerializerField(required=False) + attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False) def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) @@ -352,7 +475,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): ] -class StockTrackingSerializer(InvenTreeModelSerializer): +class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for StockItemTracking model """ def __init__(self, *args, **kwargs): @@ -372,7 +495,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer): item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) - user_detail = UserSerializerBrief(source='user', many=False, read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', many=False, read_only=True) deltas = serializers.JSONField(read_only=True) diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 4991855297..9bafc2633c 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -14,7 +14,7 @@
    -
    +

    {% trans "Stock Tracking Information" %}

    {% include "spacer.html" %}
    @@ -60,7 +60,7 @@
    -
    +

    {% trans "Test Data" %}

    {% include "spacer.html" %}
    @@ -80,12 +80,8 @@
    -
    -
    -
    -
    - -
    +
    + {% include "filter_list.html" with id="stocktests" %}
    @@ -95,7 +91,7 @@
    -
    +

    {% trans "Attachments" %}

    {% include "spacer.html" %}
    @@ -133,7 +129,7 @@
    -
    +

    {% trans "Installed Stock Items" %}

    {% include "spacer.html" %}
    diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 5a58e2e04f..0f8d81203a 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -53,6 +53,12 @@
    + +{% 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 roles.stock.change and not item.is_building %}
    @@ -393,7 +399,7 @@
    {% if item.stocktake_date %} - + {% else %} {% endif %} @@ -410,20 +416,33 @@ {% endif %} + {% if item.owner %} + + + + + + {% endif %}
    {% trans "IPN" %}{{ part.IPN }}{% include "clip.html"%}
    {% trans "Name" %}{% trans "Description" %} {{ part.description }}{% include "clip.html"%}
    {% trans "Category" %} + {{ part.category.name }} +
    {% trans "IPN" %}{{ part.IPN }}{% include "clip.html"%}
    {{ part.revision }}{% include "clip.html"%}
    {% trans "Units" %}{{ part.units }}
    {% trans "Minimum stock level" %}{{ part.minimum_stock }}
    {{ part.creation_date }} {% if part.creation_user %} - {{ part.creation_user }} + {{ part.creation_user }} {% endif %}
    {% trans "Default Location" %}{{ part.default_location }} + {{ part.default_location }} +
    {% trans "In Stock" %} {% include "part/stock_count.html" %}
    {% trans "Minimum Stock" %}{{ part.minimum_stock }}
    {% trans "Last Stocktake" %}{{ item.stocktake_date }} {{ item.stocktake_user }}{{ item.stocktake_date }} {{ item.stocktake_user }}{% trans "No stocktake performed" %}{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}
    {% trans "Owner" %}{{ item.owner }}
    -{% endblock %} +{% endblock details_right %} {% block js_ready %} {{ block.super }} $("#stock-serialize").click(function() { - launchModalForm( - "{% url 'stock-item-serialize' item.id %}", - { - reload: true, + + serializeStockItem({{ item.pk }}, { + reload: true, + data: { + quantity: {{ item.quantity }}, + {% if item.location %} + destination: {{ item.location.pk }}, + {% elif item.part.default_location %} + destination: {{ item.part.default_location.pk }}, + {% endif %} } - ); + }); }); $('#stock-install-in').click(function() { @@ -463,22 +482,16 @@ $("#print-label").click(function() { {% if roles.stock.change %} $("#stock-duplicate").click(function() { - createNewStockItem({ + // Duplicate a stock item + duplicateStockItem({{ item.pk }}, { follow: true, - data: { - copy: {{ item.id }}, - } }); }); -$("#stock-edit").click(function () { - launchModalForm( - "{% url 'stock-item-edit' item.id %}", - { - reload: true, - submit_text: '{% trans "Save" %}', - } - ); +$('#stock-edit').click(function() { + editStockItem({{ item.pk }}, { + reload: true, + }); }); $('#stock-edit-status').click(function () { diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 7dfd4e3025..18b78b2290 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -140,7 +140,15 @@
    -

    {% trans "Stock Items" %}

    +
    +

    {% trans "Stock Items" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% include "stock_table.html" %} @@ -163,9 +171,7 @@
  • {% trans "Print labels" %}
  • -
    - -
    + {% include "filter_list.html" with id="location" %}
    @@ -185,7 +191,8 @@ {% else %} parent: 'null', {% endif %} - } + }, + allowTreeView: true, }); linkButtonsToSelection( @@ -224,33 +231,21 @@ }); $('#location-create').click(function () { - launchModalForm("{% url 'stock-location-create' %}", - { - data: { - {% if location %} - location: {{ location.id }} - {% endif %} - }, - follow: true, - secondary: [ - { - field: 'parent', - label: '{% trans "New Location" %}', - title: '{% trans "Create new location" %}', - url: "{% url 'stock-location-create' %}", - }, - ] - }); - return false; + + createStockLocation({ + {% if location %} + parent: {{ location.pk }}, + {% endif %} + follow: true, + }); }); {% if location %} + $('#location-edit').click(function() { - launchModalForm("{% url 'stock-location-edit' location.id %}", - { - reload: true - }); - return false; + editStockLocation({{ location.id }}, { + reload: true, + }); }); $('#location-delete').click(function() { @@ -313,12 +308,11 @@ $('#item-create').click(function () { createNewStockItem({ - follow: true, data: { {% if location %} location: {{ location.id }} {% endif %} - } + }, }); }); diff --git a/InvenTree/stock/templates/stock/location_delete.html b/InvenTree/stock/templates/stock/location_delete.html index 22b4168173..9c560e58c5 100644 --- a/InvenTree/stock/templates/stock/location_delete.html +++ b/InvenTree/stock/templates/stock/location_delete.html @@ -36,7 +36,7 @@ If this location is deleted, these items will be moved to the top level 'Stock'
      {% for item in location.stock_items.all %} -
    • {{ item.part.full_name }} - {{ item.part.description }}{% decimal item.quantity %}
    • +
    • {{ item.part.full_name }} - {{ item.part.description }}{% decimal item.quantity %}
    • {% endfor %}
    {% endif %} diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index d07c35aaf7..422f9f11ab 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -364,24 +364,22 @@ class StockItemTest(StockAPITestCase): 'part': 1, 'location': 1, }, - expected_code=201, + expected_code=400 ) - # Item should have been created with default quantity - self.assertEqual(response.data['quantity'], 1) + self.assertIn('Quantity is required', str(response.data)) # POST with quantity and part and location - response = self.client.post( + response = self.post( self.list_url, data={ 'part': 1, 'location': 1, 'quantity': 10, - } + }, + expected_code=201 ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - def test_default_expiry(self): """ Test that the "default_expiry" functionality works via the API. diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 9494598430..36042b9bc2 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -7,11 +7,6 @@ from django.contrib.auth.models import Group from common.models import InvenTreeSetting -import json -from datetime import datetime, timedelta - -from InvenTree.status_codes import StockStatus - class StockViewTestCase(TestCase): @@ -63,149 +58,6 @@ class StockListTest(StockViewTestCase): self.assertEqual(response.status_code, 200) -class StockLocationTest(StockViewTestCase): - """ Tests for StockLocation views """ - - def test_location_edit(self): - response = self.client.get(reverse('stock-location-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_qr_code(self): - # Request the StockLocation QR view - response = self.client.get(reverse('stock-location-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Test for an invalid StockLocation - response = self.client.get(reverse('stock-location-qr', args=(999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create(self): - # Test StockLocation creation view - response = self.client.get(reverse('stock-location-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create with a parent - response = self.client.get(reverse('stock-location-create'), {'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create with an invalid parent - response = self.client.get(reverse('stock-location-create'), {'location': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - -class StockItemTest(StockViewTestCase): - """" Tests for StockItem views """ - - def test_qr_code(self): - # QR code for a valid item - response = self.client.get(reverse('stock-item-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # QR code for an invalid item - response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_edit_item(self): - # Test edit view for StockItem - response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Test with a non-purchaseable part - response = self.client.get(reverse('stock-item-edit', args=(100,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create_item(self): - """ - Test creation of StockItem - """ - - url = reverse('stock-item-create') - - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Copy from a valid item, valid location - response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Copy from an invalid item, invalid location - response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create_stock_with_expiry(self): - """ - Test creation of stock item of a part with an expiry date. - The initial value for the "expiry_date" field should be pre-filled, - and should be in the future! - """ - - # First, ensure that the expiry date feature is enabled! - InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user) - - url = reverse('stock-item-create') - - response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - self.assertEqual(response.status_code, 200) - - # We are expecting 10 days in the future - expiry = datetime.now().date() + timedelta(10) - - expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"' - - self.assertIn(expected, str(response.content)) - - # Now check with a part which does *not* have a default expiry period - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"' - - self.assertIn(expected, str(response.content)) - - def test_serialize_item(self): - # Test the serialization view - - url = reverse('stock-item-serialize', args=(100,)) - - # GET the form - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data_valid = { - 'quantity': 5, - 'serial_numbers': '1-5', - 'destination': 4, - 'notes': 'Serializing stock test' - } - - data_invalid = { - 'quantity': 4, - 'serial_numbers': 'dd-23-adf', - 'destination': 'blorg' - } - - # POST - response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertTrue(data['form_valid']) - - # Try again to serialize with the same numbers - response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - # POST with invalid data - response = self.client.post(url, data_invalid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - class StockOwnershipTest(StockViewTestCase): """ Tests for stock ownership views """ @@ -248,52 +100,39 @@ class StockOwnershipTest(StockViewTestCase): InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user) self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')) + """ + TODO: Refactor this following test to use the new API form def test_owner_control(self): # Test stock location and item ownership - from .models import StockLocation, StockItem + from .models import StockLocation from users.models import Owner - user_group = self.user.groups.all()[0] - user_group_owner = Owner.get_owner(user_group) new_user_group = self.new_user.groups.all()[0] new_user_group_owner = Owner.get_owner(new_user_group) user_as_owner = Owner.get_owner(self.user) new_user_as_owner = Owner.get_owner(self.new_user) - test_location_id = 4 - test_item_id = 11 - # Enable ownership control self.enable_ownership() - # Set ownership on existing location - response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)), - {'name': 'Office', 'owner': user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - + test_location_id = 4 + test_item_id = 11 # Set ownership on existing item (and change location) response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertContains(response, '"form_valid": true', status_code=200) + # Logout self.client.logout() # Login with new user self.client.login(username='john', password='custom123') - # Test location edit - response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)), - {'name': 'Office', 'owner': new_user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - # Make sure the location's owner is unchanged - location = StockLocation.objects.get(pk=test_location_id) - self.assertEqual(location.owner, user_group_owner) - + # TODO: Refactor this following test to use the new API form # Test item edit response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk}, @@ -310,38 +149,6 @@ class StockOwnershipTest(StockViewTestCase): 'owner': new_user_group_owner.pk, } - # Create new parent location - response = self.client.post(reverse('stock-location-create'), - parent_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - - # Retrieve created location - parent_location = StockLocation.objects.get(name=parent_location['name']) - - # Create new child location - new_location = { - 'name': 'Upper Left Drawer', - 'description': 'John\'s desk - Upper left drawer', - } - - # Try to create new location with neither parent or owner - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": false', status_code=200) - - # Try to create new location with invalid owner - new_location['parent'] = parent_location.id - new_location['owner'] = user_group_owner.pk - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": false', status_code=200) - - # Try to create new location with valid owner - new_location['owner'] = new_user_group_owner.pk - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - # Retrieve created location location_created = StockLocation.objects.get(name=new_location['name']) @@ -372,16 +179,4 @@ class StockOwnershipTest(StockViewTestCase): # Logout self.client.logout() - - # Login with admin - self.client.login(username='username', password='password') - - # Switch owner of location - response = self.client.post(reverse('stock-location-edit', args=(location_created.pk,)), - {'name': new_location['name'], 'owner': user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - - # Check that owner was updated for item in this location - stock_item = StockItem.objects.all().last() - self.assertEqual(stock_item.owner, user_group_owner) + """ diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 434acde84e..b28104f388 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -8,10 +8,7 @@ from stock import views location_urls = [ - url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'), - url(r'^(?P\d+)/', include([ - url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'), url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'), url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'), @@ -22,9 +19,7 @@ location_urls = [ ] stock_item_detail_urls = [ - url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), - url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), @@ -50,8 +45,6 @@ stock_urls = [ # Stock location url(r'^location/', include(location_urls)), - url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), - url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'), url(r'^track/', include(stock_tracking_urls)), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index eb5fabcc25..647c123130 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -149,6 +149,10 @@ class StockLocationEdit(AjaxUpdateView): """ View for editing details of a StockLocation. This view is used with the EditStockLocationForm to deliver a modal form to the web view + + TODO: Remove this code as location editing has been migrated to the API forms + - Have to still validate that all form functionality (as below) as been ported + """ model = StockLocation @@ -556,9 +560,8 @@ class StockItemInstall(AjaxUpdateView): # Filter for parts to install in this item if self.install_item: - # Get parts used in this part's BOM - bom_items = self.part.get_bom_items() - allowed_parts = [item.sub_part for item in bom_items] + # Get all parts which can be installed into this part + allowed_parts = self.part.get_installed_part_options() # Filter items = items.filter(part__in=allowed_parts) @@ -927,6 +930,10 @@ class StockLocationCreate(AjaxCreateView): """ View for creating a new StockLocation A parent location (another StockLocation object) can be passed as a query parameter + + TODO: Remove this class entirely, as it has been migrated to the API forms + - Still need to check that all the functionality (as below) has been implemented + """ model = StockLocation @@ -1019,89 +1026,6 @@ class StockLocationCreate(AjaxCreateView): pass -class StockItemSerialize(AjaxUpdateView): - """ View for manually serializing a StockItem """ - - model = StockItem - ajax_template_name = 'stock/item_serialize.html' - ajax_form_title = _('Serialize Stock') - form_class = StockForms.SerializeStockForm - - def get_form(self): - - context = self.get_form_kwargs() - - # Pass the StockItem object through to the form - context['item'] = self.get_object() - - form = StockForms.SerializeStockForm(**context) - - return form - - def get_initial(self): - - initials = super().get_initial().copy() - - item = self.get_object() - - initials['quantity'] = item.quantity - initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity) - if item.location is not None: - initials['destination'] = item.location.pk - - return initials - - def get(self, request, *args, **kwargs): - - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - - form = self.get_form() - - item = self.get_object() - - quantity = request.POST.get('quantity', 0) - serials = request.POST.get('serial_numbers', '') - dest_id = request.POST.get('destination', None) - notes = request.POST.get('note', '') - user = request.user - - valid = True - - try: - destination = StockLocation.objects.get(pk=dest_id) - except (ValueError, StockLocation.DoesNotExist): - destination = None - - try: - numbers = extract_serial_numbers(serials, quantity) - except ValidationError as e: - form.add_error('serial_numbers', e.messages) - valid = False - numbers = [] - - if valid: - try: - item.serializeStock(quantity, numbers, user, notes=notes, location=destination) - except ValidationError as e: - messages = e.message_dict - - for k in messages.keys(): - if k in ['quantity', 'destination', 'serial_numbers']: - form.add_error(k, messages[k]) - else: - form.add_error(None, messages[k]) - - valid = False - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data=data) - - class StockItemCreate(AjaxCreateView): """ View for creating a new StockItem diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index eec261e056..44bc70fc37 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -7,6 +7,8 @@ {% inventree_title %} | {% trans "Index" %} {% endblock %} +{% block breadcrumb_list %} +{% endblock %} {% block sidebar %} @@ -74,6 +76,7 @@ function addHeaderAction(label, title, icon, options) { } {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %} +{% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %} {% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %} {% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %} {% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %} @@ -82,15 +85,25 @@ function addHeaderAction(label, title, icon, options) { addHeaderTitle('{% trans "Parts" %}'); {% if setting_part_starred %} -addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star'); +addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell'); loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { params: { - "starred": true, + starred: true, }, name: 'starred_parts', }); {% endif %} +{% if setting_category_starred %} +addHeaderAction('starred-categories', '{% trans "Subscribed Categories" %}', 'fa-bell'); +loadPartCategoryTable($('#table-starred-categories'), { + params: { + starred: true, + }, + name: 'starred_categories' +}); +{% endif %} + {% if setting_part_latest %} addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper'); loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { @@ -126,8 +139,7 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", { {% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %} {% endif %} -{% if roles.stock.view and True in settings_list_stock %} -addHeaderTitle('{% trans "Stock" %}'); +{% if roles.stock.view %} {% if setting_stock_recent %} addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock'); @@ -143,7 +155,7 @@ loadStockTable($('#table-recently-updated-stock'), { {% endif %} {% if setting_stock_low %} -addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); +addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-flag'); loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", { params: { low_stock: true, diff --git a/InvenTree/templates/InvenTree/search.html b/InvenTree/templates/InvenTree/search.html index 34f4a541fe..191a5a5b4c 100644 --- a/InvenTree/templates/InvenTree/search.html +++ b/InvenTree/templates/InvenTree/search.html @@ -8,6 +8,9 @@ {% inventree_title %} | {% trans "Search Results" %} {% endblock %} +{% block breadcrumb_list %} +{% endblock %} + {% block content %}
    diff --git a/InvenTree/templates/InvenTree/settings/category.html b/InvenTree/templates/InvenTree/settings/category.html index 9eb595ddde..f90d1e8d11 100644 --- a/InvenTree/templates/InvenTree/settings/category.html +++ b/InvenTree/templates/InvenTree/settings/category.html @@ -7,6 +7,12 @@ {% trans "Category Settings" %} {% endblock %} +{% block actions %} + +{% endblock %} + {% block content %}
    @@ -21,12 +27,6 @@
    -
    - -
    -
    diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index ba6e782508..706f836317 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -13,29 +13,31 @@ {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %} - -
    - - + - + + {% for rate in rates %} - + + + + {% endfor %} + - diff --git a/InvenTree/templates/InvenTree/settings/login.html b/InvenTree/templates/InvenTree/settings/login.html index d3cba1180f..96d986d6c7 100644 --- a/InvenTree/templates/InvenTree/settings/login.html +++ b/InvenTree/templates/InvenTree/settings/login.html @@ -17,7 +17,7 @@ {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} - + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %} diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index 351810b7dc..1b2a3e5498 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -9,8 +9,6 @@ {% block content %} -

    {% trans "Part Options" %}

    -
    {% trans "Base Currency" %} {{ base_currency }}
    {% trans "Exchange Rates" %}{% trans "Exchange Rates" %}
    {{ rate.currency }} {{ rate.value }}{{ rate.currency }}
    {% trans "Last Update" %} + {% if rates_updated %} {{ rates_updated }} {% else %} @@ -44,7 +46,7 @@
    {% csrf_token %} - +
    {% trans 'Signup' %}
    {% trans 'Signup' %}
    {% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %} @@ -40,12 +38,17 @@
    -

    {% trans "Part Import" %}

    - - - +
    +
    +

    {% trans "Part Import" %}

    + {% include "spacer.html" %} +
    + +
    +
    +
    @@ -53,14 +56,16 @@
    - - -

    {% trans "Part Parameter Templates" %}

    - -
    - +
    + +

    {% trans "Part Parameter Templates" %}

    + {% include "spacer.html" %} +
    + +
    +
    diff --git a/InvenTree/templates/InvenTree/settings/report.html b/InvenTree/templates/InvenTree/settings/report.html index 84de0f074b..89d26feba6 100644 --- a/InvenTree/templates/InvenTree/settings/report.html +++ b/InvenTree/templates/InvenTree/settings/report.html @@ -12,6 +12,7 @@
    + {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %} diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 4a506b46e6..7419b7ff34 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -21,15 +21,13 @@ {% else %}
    - {% if setting.value %} - {{ setting.value }} + {{ setting.value }} {% else %} - {% trans "No value set" %} + {% trans "No value set" %} {% endif %} - {{ setting.units }}
    {% endif %} diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 7599733975..7b141d2139 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -4,6 +4,9 @@ {% load static %} {% load inventree_extras %} +{% block breadcrumb_list %} +{% endblock %} + {% block page_title %} {% inventree_title %} | {% trans "Settings" %} {% endblock %} @@ -50,26 +53,17 @@ $('table').find('.btn-edit-setting').click(function() { var setting = $(this).attr('setting'); var pk = $(this).attr('pk'); - var url = `/settings/${pk}/edit/`; + + var is_global = true; if ($(this).attr('user')){ - url += `user/`; + is_global = false; } - launchModalForm( - url, - { - success: function(response) { - - if (response.is_bool) { - var enabled = response.value.toLowerCase() == 'true'; - $(`#setting-value-${setting}`).prop('checked', enabled); - } else { - $(`#setting-value-${setting}`).html(response.value); - } - } - } - ); + editSetting(pk, { + global: is_global, + title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}', + }); }); $("#edit-user").on('click', function() { diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index d6cbf998a7..89fc67865a 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -11,18 +11,18 @@ {% trans "Account Settings" %} {% endblock %} +{% block actions %} +
    + {% trans "Edit" %} +
    +
    + {% trans "Set Password" %} +
    +{% endblock %} + {% block content %} {% mail_configured as mail_conf %} -
    -
    - {% trans "Edit" %} -
    -
    - {% trans "Set Password" %} -
    -
    -
    @@ -39,61 +39,81 @@
    {% trans "Username" %}
    -

    {% trans "Email" %}

    +
    +

    {% trans "Email" %}

    + {% include "spacer.html" %} +
    -
    - {% if user.emailaddress_set.all %} -

    {% trans 'The following email addresses are associated with your account:' %}

    +
    +
    + {% if user.emailaddress_set.all %} +

    {% trans 'The following email addresses are associated with your account:' %}

    - + - {% else %} -

    {% trans 'Warning:'%} - {% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %} -

    + {% else %} +

    {% trans 'Warning:'%} + {% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %} +

    - {% endif %} - - {% if can_add_email %} -
    -

    {% trans "Add Email Address" %}

    + {% endif %} +
    +
    + {% if can_add_email %} +
    {% trans "Add Email Address" %}
    {% csrf_token %} - {{ add_email_form|crispy }} - + + +
    +
    @
    + +
    + +
    +
    +
    {% endif %} -
    +
    @@ -135,7 +155,9 @@ {% else %} -

    {% trans 'You currently have no social network accounts connected to this account.' %}

    +
    + {% trans "There are no social network accounts connected to your InvenTree account" %} +
    {% endif %}
    @@ -155,26 +177,26 @@
    -
    - {% csrf_token %} - -
    -
    -
    - +
    + + {% csrf_token %} + + +
    + +
    +
    -
    -
    - -
    - - + +
    @@ -186,29 +208,43 @@
    {% csrf_token %} -
    - {% get_current_language as LANGUAGE_CODE %} {% get_available_languages as LANGUAGES %} {% get_language_info_list for LANGUAGES as languages %} + {% if 'alllang' in request.GET %}{% define True as ALL_LANG %}{% endif %} {% for language in languages %} {% define language.code as lang_code %} {% define locale_stats|keyvalue:lang_code as lang_translated %} + {% if lang_translated > 10 or lang_code == 'en' or lang_code == LANGUAGE_CODE %}{% define True as use_lang %}{% else %}{% define False as use_lang %}{% endif %} + {% if ALL_LANG or use_lang %} + {% endif %} {% endfor %} -
    -
    - +
    + +
    +

    {% trans "Some languages are not complete" %} + {% if ALL_LANG %} + . {% trans "Show only sufficent" %} + {% else %} + and hidden. {% trans "Show them too" %} + {% endif %} +

    -
    +

    {% trans "Help the translation efforts!" %}

    diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html index 7eed850ad5..54e3bdcefd 100644 --- a/InvenTree/templates/InvenTree/settings/user_homepage.html +++ b/InvenTree/templates/InvenTree/settings/user_homepage.html @@ -14,7 +14,8 @@
    - {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-star' user_setting=True %} + {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bell' user_setting=True %} + {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_CATEGORY_STARRED" icon='fa-bell' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %} diff --git a/InvenTree/templates/InvenTree/settings/user_search.html b/InvenTree/templates/InvenTree/settings/user_search.html index 43eab057c3..51df53ee6b 100644 --- a/InvenTree/templates/InvenTree/settings/user_search.html +++ b/InvenTree/templates/InvenTree/settings/user_search.html @@ -16,6 +16,7 @@ {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %} + {% include "InvenTree/settings/setting.html" with key="SEARCH_HIDE_INACTIVE_PARTS" user_setting=True icon='fa-eye-slash' %}
    diff --git a/InvenTree/templates/about.html b/InvenTree/templates/about.html index 34d4bf25e4..34884da9d1 100644 --- a/InvenTree/templates/about.html +++ b/InvenTree/templates/about.html @@ -22,12 +22,12 @@ {% inventree_version %}{% include "clip.html" %} {% inventree_is_development as dev %} {% if dev %} - {% trans "Development Version" %} + {% trans "Development Version" %} {% else %} {% if up_to_date %} - {% trans "Up to Date" %} + {% trans "Up to Date" %} {% else %} - {% trans "Update Available" %} + {% trans "Update Available" %} {% endif %} {% endif %} diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index 7ad0447cd5..ea3795e87c 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -10,10 +10,30 @@ + + + + + + + + + + + + + + + + + + + + - + @@ -33,41 +53,60 @@ +
    +
    + +
    +
    + +