diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82cfa4e9e2..c36c11b62b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ The HEAD of the "main" or "master" branch of InvenTree represents the current "l **No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature). -#### Feature Branches +### Feature Branches Feature branches should be branched *from* the *master* branch. @@ -45,7 +45,7 @@ The HEAD of the "stable" branch represents the latest stable release code. - The bugfix *must* also be cherry picked into the *master* branch. ## Environment -#### Target version +### Target version We are currently targeting: | Name | Minimum version | |---|---| @@ -65,7 +65,7 @@ pyupgrade `find . -name "*.py"` django-upgrade --target-version 3.2 `find . -name "*.py"` ``` -### Credits +## Credits If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree-docs/blob/master/docs/credits.md). Please try to do that as timely as possible. @@ -124,4 +124,41 @@ HTML and javascript files are passed through the django templating engine. Trans {% load i18n %} {% trans "This string will be translated" %} - this string will not! -``` \ No newline at end of file +``` + +## Github use +### Tags +The tags describe issues and PRs in multiple areas: +| Area | Name | Description | +|---|---|---| +| Type Labels | | | +| | bug | Identifies a bug which needs to be addressed | +| | dependency | Relates to a project dependency | +| | duplicate | Duplicate of another issue or PR | +| | enhancement | This is an suggested enhancement or new feature | +| | help wanted | Assistance required | +| | invalid | This issue or PR is considered invalid | +| | inactive | Indicates lack of activity | +| | question | This is a question | +| | roadmap | This is a roadmap feature with no immediate plans for implementation | +| | security | Relates to a security issue | +| | starter | Good issue for a developer new to the project | +| | wontfix | No work will be done against this issue or PR | +| Feature Labels | | | +| | API | Relates to the API | +| | barcode | Barcode scanning and integration | +| | build | Build orders | +| | importer | Data importing and processing | +| | order | Purchase order and sales orders | +| | part | Parts | +| | plugin | Plugin ecosystem | +| | pricing | Pricing functionality | +| | report | Report generation | +| | stock | Stock item management | +| | user interface | User interface | +| Ecosystem Labels | | | +| | demo | Relates to the InvenTree demo server or dataset | +| | docker | Docker / docker-compose | +| | CI | CI / unit testing ecosystem | +| | setup | Relates to the InvenTree setup / installation process | + diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index d9dfaa395d..36cd288232 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -537,7 +537,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): # The number of extracted serial numbers must match the expected quantity if not expected_quantity == len(numbers): - raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)]) + raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)]) return numbers diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 1fdf613b68..43bca0e238 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1177,7 +1177,7 @@ class BuildItem(models.Model): a = normalize(self.stock_item.quantity) raise ValidationError({ - 'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})') + 'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})') }) # Allocated quantity cannot cause the stock item to be over-allocated diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 07a0bcc29a..bed4b59203 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -387,7 +387,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer): default=False, required=False, label=_('Accept Incomplete Allocation'), - help_text=_('Complete ouputs if stock has not been fully allocated'), + help_text=_('Complete outputs if stock has not been fully allocated'), ) notes = serializers.CharField( diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 42bc51bb2f..2fb96e88e3 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -546,14 +546,6 @@ $('#allocate-selected-items').click(function() { ); }); -$("#btn-order-parts").click(function() { - launchModalForm("/order/purchase-order/order-parts/", { - data: { - build: {{ build.id }}, - }, - }); -}); - {% endif %} enableSidebar('buildorder'); diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index e4a589d9e5..b99fbd01fb 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -312,7 +312,7 @@ class SupplierPartList(generics.ListCreateAPIView): try: params = self.request.query_params kwargs['part_detail'] = str2bool(params.get('part_detail', None)) - kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None)) + kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', True)) kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None)) kwargs['pretty'] = str2bool(params.get('pretty', None)) except AttributeError: diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index fff38a8e99..9b881c227e 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -290,9 +290,6 @@ class Contact(models.Model): role = models.CharField(max_length=100, blank=True) - company = models.ForeignKey(Company, related_name='contacts', - on_delete=models.CASCADE) - class ManufacturerPart(models.Model): """ Represents a unique part as provided by a Manufacturer diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 489493fd06..c58ea63791 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -23,6 +23,8 @@ {% endif %} +{% define perms.company.change_company or perms.company.delete_company as has_permission %} +{% if has_permission %} @@ -38,6 +40,7 @@ {% endif %} +{% endif %} {% endblock actions %} {% block thumbnail %} diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 3d715e288c..4474278613 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -325,14 +325,14 @@ var parts = []; selections.forEach(function(item) { - parts.push(item.part); + var part = item.part_detail; + part.manufacturer_part = item.pk; + parts.push(part); }); - launchModalForm("/order/purchase-order/order-parts/", { - data: { - parts: parts, - }, - }); + orderParts( + parts, + ); }); {% endif %} @@ -396,14 +396,16 @@ var parts = []; selections.forEach(function(item) { - parts.push(item.part); + var part = item.part_detail; + parts.push(part); }); - launchModalForm("/order/purchase-order/order-parts/", { - data: { - parts: parts, - }, - }); + orderParts( + parts, + { + supplier: {{ company.pk }}, + } + ); }); {% endif %} diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index fb33128a77..5a0e741c1a 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -31,13 +31,11 @@ {% include "admin_button.html" with url=url %} {% endif %} {% if roles.purchase_order.change %} -{% comment "for later" %} -{% if roles.purchase_order.add %} +{% if roles.purchase_order.add and part.part.purchaseable %} {% endif %} -{% endcomment %} @@ -90,7 +88,14 @@ src="{% static 'img/blank_image.png' %}" {% trans "Manufacturer" %} - {{ part.manufacturer.name }}{% include "clip.html"%} + + {% if part.manufacturer %} + {{ part.manufacturer.name }}{% include "clip.html"%} + {% else %} + {% trans "No manufacturer information available" %} + {% endif %} + {% endif %} + @@ -130,6 +135,7 @@ src="{% static 'img/blank_image.png' %}"
  • {% trans "Delete" %}
  • + {% include "filter_list.html" with id='supplier-part' %} @@ -300,14 +306,20 @@ linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options']); linkButtonsToSelection($("#parameter-table"), ['#parameter-options']); $('#order-part, #order-part2').click(function() { - launchModalForm( - "{% url 'order-parts' %}", + + inventreeGet( + '{% url "api-part-detail" part.part.pk %}', {}, { - data: { - part: {{ part.part.id }}, - }, - reload: true, - }, + success: function(response) { + + orderParts([response], { + manufacturer_part: {{ part.pk }}, + {% if part.manufacturer %} + manufacturer: {{ part.manufacturer.pk }}, + {% endif %} + }); + } + } ); }); @@ -324,7 +336,11 @@ $('#delete-part').click(function() { deleteManufacturerPart({{ part.pk }}, { onSuccess: function() { + {% if part.manufacturer %} window.location.href = "{% url 'company-detail' part.manufacturer.id %}"; + {% else%} + window.location.href = "{% url 'index' %}"; + {% endif %} } }); }); diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 67902dc6f6..f990b66898 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -85,7 +85,13 @@ src="{% static 'img/blank_image.png' %}" {% trans "Supplier" %} - {{ part.supplier.name }}{% include "clip.html"%} + {% if part.supplier %} + {{ part.supplier.name }}{% include "clip.html"%} + {% else %} + {% trans "No supplier information available" %} + {% endif %} + + {% trans "SKU" %} @@ -165,7 +171,8 @@ src="{% static 'img/blank_image.png' %}"
    -
    +
    + {% include "filter_list.html" with id='purchaseorder' %}
    @@ -326,14 +333,19 @@ $("#item-create").click(function() { }); $('#order-part, #order-part2').click(function() { - launchModalForm( - "{% url 'order-parts' %}", + + inventreeGet( + '{% url "api-part-detail" part.part.pk %}', {}, { - data: { - part: {{ part.part.id }}, - }, - reload: true, - }, + success: function(response) { + orderParts([response], { + supplier_part: {{ part.pk }}, + {% if part.supplier %} + supplier: {{ part.supplier.pk }}, + {% endif %} + }); + } + } ); }); @@ -350,7 +362,9 @@ $('#delete-part').click(function() { deleteSupplierPart({{ part.pk }}, { onSuccess: function() { + {% if part.supplier %} window.location.href = "{% url 'company-detail' part.supplier.id %}"; + {% endif %} } }); }); diff --git a/InvenTree/order/migrations/0064_purchaseorderextraline_salesorderextraline.py b/InvenTree/order/migrations/0064_purchaseorderextraline_salesorderextraline.py index 53bf0621ed..1c3d2ff743 100644 --- a/InvenTree/order/migrations/0064_purchaseorderextraline_salesorderextraline.py +++ b/InvenTree/order/migrations/0064_purchaseorderextraline_salesorderextraline.py @@ -35,7 +35,7 @@ def _convert_model(apps, line_item_ref, extra_line_ref, price_ref): print(f'Done converting line items - now at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)') -def _reconvert_model(apps, line_item_ref, extra_line_ref): +def _reconvert_model(apps, line_item_ref, extra_line_ref): # pragma: no cover """Convert ExtraLine instances back to OrderLineItem instances""" OrderLineItem = apps.get_model('order', line_item_ref) OrderExtraLine = apps.get_model('order', extra_line_ref) diff --git a/InvenTree/order/migrations/0066_alter_purchaseorder_supplier.py b/InvenTree/order/migrations/0066_alter_purchaseorder_supplier.py new file mode 100644 index 0000000000..d37aedaeda --- /dev/null +++ b/InvenTree/order/migrations/0066_alter_purchaseorder_supplier.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-04-30 22:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0042_supplierpricebreak_updated'), + ('order', '0065_alter_purchaseorderlineitem_part'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorder', + name='supplier', + field=models.ForeignKey(help_text='Company from which the items are being ordered', limit_choices_to={'is_supplier': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders', to='company.company', verbose_name='Supplier'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 2b722ddecd..060d638de1 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -228,7 +228,7 @@ class PurchaseOrder(Order): prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX') - return f"{prefix}{self.reference} - {self.supplier.name}" + return f"{prefix}{self.reference} - {self.supplier.name if self.supplier else _('deleted')}" reference = models.CharField( unique=True, @@ -243,7 +243,8 @@ class PurchaseOrder(Order): help_text=_('Purchase order status')) supplier = models.ForeignKey( - Company, on_delete=models.CASCADE, + Company, on_delete=models.SET_NULL, + null=True, limit_choices_to={ 'is_supplier': True, }, @@ -575,7 +576,7 @@ class SalesOrder(Order): prefix = getSetting('SALESORDER_REFERENCE_PREFIX') - return f"{prefix}{self.reference} - {self.customer.name}" + return f"{prefix}{self.reference} - {self.customer.name if self.customer else _('deleted')}" def get_absolute_url(self): return reverse('so-detail', kwargs={'pk': self.id}) @@ -938,7 +939,7 @@ class PurchaseOrderLineItem(OrderLineItem): return "{n} x {part} from {supplier} (for {po})".format( n=decimal2string(self.quantity), part=self.part.SKU if self.part else 'unknown part', - supplier=self.order.supplier.name, + supplier=self.order.supplier.name if self.order.supplier else _('deleted'), po=self.order) order = models.ForeignKey( diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index a608cf3355..7d26ce741d 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -223,14 +223,30 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): if order_detail is not True: self.fields.pop('order_detail') - quantity = serializers.FloatField(default=1) - received = serializers.FloatField(default=0) + quantity = serializers.FloatField(min_value=0, required=True) + + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be greater than zero")) + + return quantity + + def validate_purchase_order(self, purchase_order): + + if purchase_order.status not in PurchaseOrderStatus.OPEN: + raise ValidationError(_('Order is not open')) + + return purchase_order + + received = serializers.FloatField(default=0, read_only=True) overdue = serializers.BooleanField(required=False, read_only=True) total_price = serializers.FloatField(read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) + supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) purchase_price = InvenTreeMoneySerializer( @@ -248,6 +264,32 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) + def validate(self, data): + + data = super().validate(data) + + supplier_part = data.get('part', None) + purchase_order = data.get('order', None) + + if not supplier_part: + raise ValidationError({ + 'part': _('Supplier part must be specified'), + }) + + if not purchase_order: + raise ValidationError({ + 'order': _('Purchase order must be specified'), + }) + + # Check that the supplier part and purchase order match + if supplier_part is not None and supplier_part.supplier != purchase_order.supplier: + raise ValidationError({ + 'part': _('Supplier must match purchase order'), + 'order': _('Purchase order must match supplier'), + }) + + return data + class Meta: model = order.models.PurchaseOrderLineItem diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index c2aa10f722..b80275b1f3 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -67,7 +67,7 @@ {% block thumbnail %} - + {% if order.supplier_reference %} diff --git a/InvenTree/order/templates/order/order_wizard/select_parts.html b/InvenTree/order/templates/order/order_wizard/select_parts.html deleted file mode 100644 index 9d0ccdfb82..0000000000 --- a/InvenTree/order/templates/order/order_wizard/select_parts.html +++ /dev/null @@ -1,85 +0,0 @@ -{% extends "modal_form.html" %} - -{% load inventree_extras %} -{% load i18n %} - -{% block form %} -{% default_currency as currency %} -{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %} - -

    - {% trans "Step 1 of 2 - Select Part Suppliers" %} -

    - -{% if parts|length > 0 %} - -{% else %} - -{% endif %} - - - {% csrf_token %} - {% load crispy_forms_tags %} - - - -
    {% trans "Supplier" %}{{ order.supplier.name }}{% include "clip.html"%} + {% if order.supplier %} + {{ order.supplier.name }}{% include "clip.html"%} + {% else %} + {% trans "No suppplier information available" %} + {% endif %} +
    - - - - - - - {% for part in parts %} - - - - - - - - {% endfor %} -
    {% trans "Part" %}{% trans "Select Supplier" %}{% trans "Quantity" %}
    - {% include "hover_image.html" with image=part.image hover=False %} - {{ part.full_name }} {{ part.description }} - - - -
    -
    - -
    - {% if not part.order_supplier %} - {% blocktrans with name=part.name %}Select a supplier for {{name}}{% endblocktrans %} - {% endif %} -
    -
    -
    -
    - -
    -
    -
    - -
    - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/select_pos.html b/InvenTree/order/templates/order/order_wizard/select_pos.html deleted file mode 100644 index 6ef2f6c910..0000000000 --- a/InvenTree/order/templates/order/order_wizard/select_pos.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "modal_form.html" %} - -{% load i18n %} - -{% block form %} - -

    - {% trans "Step 2 of 2 - Select Purchase Orders" %} -

    - - - -
    - {% csrf_token %} - {% load crispy_forms_tags %} - - - - {% for supplier in suppliers %} - {% for item in supplier.order_items %} - - - {% endfor %} - {% endfor %} - - - - - - - - {% for supplier in suppliers %} - - - - - - - {% endfor %} - -
    {% trans "Supplier" %}{% trans "Items" %}{% trans "Select Purchase Order" %}
    - {% include 'hover_image.html' with image=supplier.image hover=False %} - {{ supplier.name }} - {{ supplier.order_items|length }} - - -
    -
    - -
    - {% if not supplier.selected_purchase_order %} - {% blocktrans with name=supplier.name %}Select a purchase order for {{name}}{% endblocktrans %} - {% endif %} -
    -
    -
    -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 23bdd908e1..542a7a90ca 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -170,10 +170,12 @@ $('#new-po-line').click(function() { var fields = poLineItemFields({ order: {{ order.pk }}, + {% if order.supplier %} supplier: {{ order.supplier.pk }}, {% if order.supplier.currency %} currency: '{{ order.supplier.currency }}', {% endif %} + {% endif %} }); constructForm('{% url "api-po-line-list" %}', { @@ -210,7 +212,9 @@ $('#new-po-line').click(function() { loadPurchaseOrderLineItemTable('#po-line-table', { order: {{ order.pk }}, + {% if order.supplier %} supplier: {{ order.supplier.pk }}, + {% endif %} {% if roles.purchase_order.change %} allow_edit: true, {% else %} diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 54be93905f..f82a581828 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -23,7 +23,6 @@ purchase_order_detail_urls = [ purchase_order_urls = [ - re_path(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'), re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'), # Display detail view for a single purchase order diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 35f8b973f4..68b45ebe86 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -5,7 +5,6 @@ Django views for interacting with Order app # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import transaction from django.db.utils import IntegrityError from django.http.response import JsonResponse from django.shortcuts import get_object_or_404 @@ -21,9 +20,7 @@ from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource -from build.models import Build -from company.models import Company, SupplierPart # ManufacturerPart -from stock.models import StockItem +from company.models import SupplierPart # ManufacturerPart from part.models import Part from common.forms import UploadFileForm, MatchFieldForm @@ -37,8 +34,6 @@ from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile, str2bool from InvenTree.views import InvenTreeRoleMixin -from InvenTree.status_codes import PurchaseOrderStatus - logger = logging.getLogger("inventree") @@ -448,346 +443,6 @@ class PurchaseOrderExport(AjaxView): return DownloadFile(filedata, filename) -class OrderParts(AjaxView): - """ View for adding various SupplierPart items to a Purchase Order. - - SupplierParts can be selected from a variety of 'sources': - - - ?supplier_parts[]= -> Direct list of SupplierPart objects - - ?parts[]= -> List of base Part objects (user must then select supplier parts) - - ?stock[]= -> List of StockItem objects (user must select supplier parts) - - ?build= -> A Build object (user must select parts, then supplier parts) - - """ - - ajax_form_title = _("Order Parts") - ajax_template_name = 'order/order_wizard/select_parts.html' - - role_required = [ - 'part.view', - 'purchase_order.change', - ] - - # List of Parts we wish to order - parts = [] - suppliers = [] - - def get_context_data(self): - - ctx = {} - - ctx['parts'] = sorted(self.parts, key=lambda part: int(part.order_quantity), reverse=True) - ctx['suppliers'] = self.suppliers - - return ctx - - def get_data(self): - """ enrich respone json data """ - data = super().get_data() - # if in selection-phase, add a button to update the prices - if getattr(self, 'form_step', 'select_parts') == 'select_parts': - data['buttons'] = [{'name': 'update_price', 'title': _('Update prices')}] # set buttons - data['hideErrorMessage'] = '1' # hide the error message - return data - - def get_suppliers(self): - """ Calculates a list of suppliers which the user will need to create PurchaseOrders for. - This is calculated AFTER the user finishes selecting the parts to order. - Crucially, get_parts() must be called before get_suppliers() - """ - - suppliers = {} - - for supplier in self.suppliers: - supplier.order_items = [] - - suppliers[supplier.name] = supplier - - for part in self.parts: - supplier_part_id = part.order_supplier - - try: - supplier = SupplierPart.objects.get(pk=supplier_part_id).supplier - except SupplierPart.DoesNotExist: - continue - - if supplier.name not in suppliers: - supplier.order_items = [] - - # Attempt to auto-select a purchase order - orders = PurchaseOrder.objects.filter(supplier=supplier, status__in=PurchaseOrderStatus.OPEN) - - if orders.count() == 1: - supplier.selected_purchase_order = orders.first().id - else: - supplier.selected_purchase_order = None - - suppliers[supplier.name] = supplier - - suppliers[supplier.name].order_items.append(part) - - self.suppliers = [suppliers[key] for key in suppliers.keys()] - - def get_parts(self): - """ Determine which parts the user wishes to order. - This is performed on the initial GET request. - """ - - self.parts = [] - - part_ids = set() - - # User has passed a list of stock items - if 'stock[]' in self.request.GET: - - stock_id_list = self.request.GET.getlist('stock[]') - - """ Get a list of all the parts associated with the stock items. - - Base part must be purchaseable. - - Return a set of corresponding Part IDs - """ - stock_items = StockItem.objects.filter( - part__purchaseable=True, - id__in=stock_id_list) - - for item in stock_items: - part_ids.add(item.part.id) - - # User has passed a single Part ID - elif 'part' in self.request.GET: - try: - part_id = self.request.GET.get('part') - part = Part.objects.get(id=part_id) - - part_ids.add(part.id) - - except Part.DoesNotExist: - pass - - # User has passed a list of part ID values - elif 'parts[]' in self.request.GET: - part_id_list = self.request.GET.getlist('parts[]') - - parts = Part.objects.filter( - purchaseable=True, - id__in=part_id_list) - - for part in parts: - part_ids.add(part.id) - - # User has provided a Build ID - elif 'build' in self.request.GET: - build_id = self.request.GET.get('build') - try: - build = Build.objects.get(id=build_id) - - parts = build.required_parts - - for part in parts: - - # If ordering from a Build page, ignore parts that we have enough of - if part.quantity_to_order <= 0: - continue - part_ids.add(part.id) - except Build.DoesNotExist: - pass - - # Create the list of parts - for id in part_ids: - try: - part = Part.objects.get(id=id) - # Pre-fill the 'order quantity' value - part.order_quantity = part.quantity_to_order - - default_supplier = part.get_default_supplier() - - if default_supplier: - part.order_supplier = default_supplier.id - else: - part.order_supplier = None - except Part.DoesNotExist: - continue - - self.parts.append(part) - - def get(self, request, *args, **kwargs): - - self.request = request - - self.get_parts() - - return self.renderJsonResponse(request) - - def post(self, request, *args, **kwargs): - """ Handle the POST action for part selection. - - - Validates each part / quantity / supplier / etc - - Part selection form contains the following fields for each part: - - - supplier- : The ID of the selected supplier - - quantity- : The quantity to add to the order - """ - - self.request = request - - self.parts = [] - self.suppliers = [] - - # Any errors for the part selection form? - part_errors = False - supplier_errors = False - - # Extract part information from the form - for item in self.request.POST: - - if item.startswith('part-supplier-'): - - pk = item.replace('part-supplier-', '') - - # Check that the part actually exists - try: - part = Part.objects.get(id=pk) - except (Part.DoesNotExist, ValueError): - continue - - supplier_part_id = self.request.POST[item] - - quantity = self.request.POST.get('part-quantity-' + str(pk), 0) - - # Ensure a valid supplier has been passed - try: - supplier_part = SupplierPart.objects.get(id=supplier_part_id) - except (SupplierPart.DoesNotExist, ValueError): - supplier_part = None - - # Ensure a valid quantity is passed - try: - quantity = int(quantity) - - # Eliminate lines where the quantity is zero - if quantity == 0: - continue - except ValueError: - quantity = part.quantity_to_order - - part.order_supplier = supplier_part.id if supplier_part else None - part.order_quantity = quantity - - # set supplier-price - if supplier_part: - supplier_price = supplier_part.get_price(quantity) - if supplier_price: - part.purchase_price = supplier_price / quantity - if not hasattr(part, 'purchase_price'): - part.purchase_price = None - - self.parts.append(part) - - if supplier_part is None: - part_errors = True - - elif quantity < 0: - part_errors = True - - elif item.startswith('purchase-order-'): - # Which purchase order is selected for a given supplier? - pk = item.replace('purchase-order-', '') - - # Check that the Supplier actually exists - try: - supplier = Company.objects.get(id=pk) - except Company.DoesNotExist: - # Skip this item - continue - - purchase_order_id = self.request.POST[item] - - # Ensure that a valid purchase order has been passed - try: - purchase_order = PurchaseOrder.objects.get(pk=purchase_order_id) - except (PurchaseOrder.DoesNotExist, ValueError): - purchase_order = None - - supplier.selected_purchase_order = purchase_order.id if purchase_order else None - - self.suppliers.append(supplier) - - if supplier.selected_purchase_order is None: - supplier_errors = True - - form_step = request.POST.get('form_step') - - # Map parts to suppliers - self.get_suppliers() - - valid = False - - if form_step == 'select_parts': - # No errors? and the price-update button was not used to submit? Proceed to PO selection form - if part_errors is False and 'act-btn_update_price' not in request.POST: - self.ajax_template_name = 'order/order_wizard/select_pos.html' - self.form_step = 'select_purchase_orders' # set step (important for get_data) - - else: - self.ajax_template_name = 'order/order_wizard/select_parts.html' - - elif form_step == 'select_purchase_orders': - - self.ajax_template_name = 'order/order_wizard/select_pos.html' - - valid = part_errors is False and supplier_errors is False - - # Form wizard is complete! Add items to purchase orders - if valid: - self.order_items() - - data = { - 'form_valid': valid, - 'success': _('Ordered {n} parts').format(n=len(self.parts)) - } - - return self.renderJsonResponse(self.request, data=data) - - @transaction.atomic - def order_items(self): - """ Add the selected items to the purchase orders. """ - - for supplier in self.suppliers: - - # Check that the purchase order does actually exist - try: - order = PurchaseOrder.objects.get(pk=supplier.selected_purchase_order) - except PurchaseOrder.DoesNotExist: - logger.critical('Could not add items to purchase order {po} - Order does not exist'.format(po=supplier.selected_purchase_order)) - continue - - for item in supplier.order_items: - - # Ensure that the quantity is valid - try: - quantity = int(item.order_quantity) - if quantity <= 0: - continue - except ValueError: - logger.warning("Did not add part to purchase order - incorrect quantity") - continue - - # Check that the supplier part does actually exist - try: - supplier_part = SupplierPart.objects.get(pk=item.order_supplier) - except SupplierPart.DoesNotExist: - logger.critical("Could not add part '{part}' to purchase order - selected supplier part '{sp}' does not exist.".format( - part=item, - sp=item.order_supplier)) - continue - - # get purchase price - purchase_price = item.purchase_price - - order.add_line_item(supplier_part, quantity, purchase_price=purchase_price) - - class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 2f7076c850..41df553a5e 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -169,13 +169,18 @@
    {% include "filter_list.html" with id="parts" %} diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 5ec1821b3d..aa3ad4963a 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -754,12 +754,18 @@ $("#part-order2").click(function() { - launchModalForm("{% url 'order-parts' %}", { - data: { - part: {{ part.id }}, - }, - reload: true, - }); + inventreeGet( + '{% url "api-part-detail" part.pk %}', + {}, + { + success: function(part) { + orderParts( + [part], + {} + ); + } + } + ); }); onPanelLoad("test-templates", function() { diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 56a96c60ed..4e875c1f97 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -536,12 +536,19 @@ {% endif %} $("#part-order").click(function() { - launchModalForm("{% url 'order-parts' %}", { - data: { - part: {{ part.id }}, - }, - reload: true, - }); + + inventreeGet( + '{% url "api-part-detail" part.pk %}', + {}, + { + success: function(part) { + orderParts( + [part], + {} + ); + } + } + ); }); {% if roles.part.add %} diff --git a/InvenTree/part/templates/part/partial_delete.html b/InvenTree/part/templates/part/partial_delete.html index eb23fbee09..22c739b833 100644 --- a/InvenTree/part/templates/part/partial_delete.html +++ b/InvenTree/part/templates/part/partial_delete.html @@ -43,7 +43,7 @@

    {% blocktrans with count=part.manufacturer_parts.all|length %}There are {{count}} manufacturers defined for this part. If you delete this part, the following manufacturer parts will also be deleted:{% endblocktrans %}

    @@ -54,7 +54,9 @@

    {% blocktrans with count=part.supplier_parts.all|length %}There are {{count}} suppliers defined for this part. If you delete this part, the following supplier parts will also be deleted:{% endblocktrans %}

    diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index 8de4cb9b6c..521d42b743 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -35,7 +35,7 @@ class PluginAppConfig(AppConfig): if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False): # make sure all plugins are installed registry.install_plugin_file() - except: + except: # pragma: no cover pass # get plugins and init them diff --git a/InvenTree/plugin/builtin/barcode/mixins.py b/InvenTree/plugin/builtin/barcode/mixins.py index 693df4b662..417ca04bcd 100644 --- a/InvenTree/plugin/builtin/barcode/mixins.py +++ b/InvenTree/plugin/builtin/barcode/mixins.py @@ -69,7 +69,7 @@ class BarcodeMixin: Default implementation returns None """ - return None + return None # pragma: no cover def getStockItemByHash(self): """ @@ -97,7 +97,7 @@ class BarcodeMixin: Default implementation returns None """ - return None + return None # pragma: no cover def renderStockLocation(self, loc): """ @@ -113,7 +113,7 @@ class BarcodeMixin: Default implementation returns None """ - return None + return None # pragma: no cover def renderPart(self, part): """ @@ -143,4 +143,4 @@ class BarcodeMixin: """ Default implementation returns False """ - return False + return False # pragma: no cover diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 62ce38a673..ebe3ebf553 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -56,7 +56,7 @@ class SettingsMixin: if not plugin: # Cannot find associated plugin model, return - return + return # pragma: no cover PluginSetting.set_setting(key, value, user, plugin=plugin) @@ -171,7 +171,7 @@ class ScheduleMixin: if Schedule.objects.filter(name=task_name).exists(): # Scheduled task already exists - continue! - continue + continue # pragma: no cover logger.info(f"Adding scheduled task '{task_name}'") @@ -209,7 +209,7 @@ class ScheduleMixin: repeats=task.get('repeats', -1), ) - except (ProgrammingError, OperationalError): + except (ProgrammingError, OperationalError): # pragma: no cover # Database might not yet be ready logger.warning("register_tasks failed, database not ready") @@ -230,7 +230,7 @@ class ScheduleMixin: scheduled_task.delete() except Schedule.DoesNotExist: pass - except (ProgrammingError, OperationalError): + except (ProgrammingError, OperationalError): # pragma: no cover # Database might not yet be ready logger.warning("unregister_tasks failed, database not ready") @@ -408,7 +408,7 @@ class LabelPrintingMixin: """ MIXIN_NAME = 'Label printing' - def __init__(self): + def __init__(self): # pragma: no cover super().__init__() self.add_mixin('labels', True, __class__) @@ -426,7 +426,7 @@ class LabelPrintingMixin: """ # Unimplemented (to be implemented by the particular plugin class) - ... + ... # pragma: no cover class APICallMixin: diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py index 4948366bfa..b54581bf71 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -163,17 +163,15 @@ def after_save(sender, instance, created, **kwargs): if created: trigger_event( - 'instance.created', + f'{table}.created', id=instance.id, model=sender.__name__, - table=table, ) else: trigger_event( - 'instance.saved', + f'{table}.saved', id=instance.id, model=sender.__name__, - table=table, ) @@ -189,9 +187,8 @@ def after_delete(sender, instance, **kwargs): return trigger_event( - 'instance.deleted', + f'{table}.deleted', model=sender.__name__, - table=table, ) diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index 7f9f2be740..f1753b1b45 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -75,7 +75,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st path_parts.remove('plugin') path_parts.pop(0) else: - path_parts.remove('plugins') + path_parts.remove('plugins') # pragma: no cover package_name = '.'.join(path_parts) @@ -88,7 +88,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st if do_raise: # do a straight raise if we are playing with enviroment variables at execution time, ignore the broken sample if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError): - raise error + raise error # pragma: no cover raise IntegrationPluginError(package_name, str(error)) # endregion @@ -135,7 +135,7 @@ def check_git_version(): except ValueError: # pragma: no cover pass - return False + return False # pragma: no cover class GitStatus: diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index cab3e81a8b..c622c0402c 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -191,7 +191,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase): Path to the plugin """ if self._is_package: - return self.__module__ + return self.__module__ # pragma: no cover return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR) @property diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 1f50233976..d3e8000fd9 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -283,7 +283,7 @@ class PluginsRegistry: if not settings.PLUGIN_TESTING: raise error # pragma: no cover plugin_db_setting = None - except (IntegrityError) as error: + except (IntegrityError) as error: # pragma: no cover logger.error(f"Error initializing plugin: {error}") # Always activate if testing @@ -322,7 +322,7 @@ class PluginsRegistry: self.plugins[plugin.slug] = plugin else: # save for later reference - self.plugins_inactive[plug_key] = plugin_db_setting + self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover def _activate_plugins(self, force_reload=False): """ @@ -411,7 +411,7 @@ class PluginsRegistry: deleted_count += 1 if deleted_count > 0: - logger.info(f"Removed {deleted_count} old scheduled tasks") + logger.info(f"Removed {deleted_count} old scheduled tasks") # pragma: no cover except (ProgrammingError, OperationalError): # Database might not yet be ready logger.warning("activate_integration_schedule failed, database not ready") diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py index c8b1c4c5d0..9ec70e2795 100644 --- a/InvenTree/plugin/samples/integration/scheduled_task.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -8,11 +8,11 @@ from plugin.mixins import ScheduleMixin, SettingsMixin # Define some simple tasks to perform def print_hello(): - print("Hello") + print("Hello") # pragma: no cover def print_world(): - print("World") + print("World") # pragma: no cover class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase): @@ -36,7 +36,7 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase): 'minutes': 45, }, 'world': { - 'func': 'plugin.samples.integration.scheduled_task.print_hello', + 'func': 'plugin.samples.integration.scheduled_task.print_world', 'schedule': 'H', }, } @@ -58,3 +58,4 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase): t_or_f = self.get_setting('T_OR_F') print(f"Called member_func - value is {t_or_f}") + return t_or_f diff --git a/InvenTree/plugin/samples/integration/test_scheduled_task.py b/InvenTree/plugin/samples/integration/test_scheduled_task.py new file mode 100644 index 0000000000..314f3f3f1f --- /dev/null +++ b/InvenTree/plugin/samples/integration/test_scheduled_task.py @@ -0,0 +1,153 @@ +""" Unit tests for scheduled tasks""" + +from django.test import TestCase + +from plugin import registry, IntegrationPluginBase +from plugin.helpers import MixinImplementationError +from plugin.registry import call_function +from plugin.mixins import ScheduleMixin + + +class ExampleScheduledTaskPluginTests(TestCase): + """ Tests for provided ScheduledTaskPlugin """ + + def test_function(self): + """check if the scheduling works""" + # The plugin should be defined + self.assertIn('schedule', registry.plugins) + plg = registry.plugins['schedule'] + self.assertTrue(plg) + + # check that the built-in function is running + self.assertEqual(plg.member_func(), False) + + # check that the tasks are defined + self.assertEqual(plg.get_task_names(), ['plugin.schedule.member', 'plugin.schedule.hello', 'plugin.schedule.world']) + + # register + plg.register_tasks() + # check that schedule was registers + from django_q.models import Schedule + scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") + self.assertEqual(len(scheduled_plugin_tasks), 3) + + # delete middle task + # this is to check the system also deals with disappearing tasks + scheduled_plugin_tasks[1].delete() + # there should be one less now + scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") + self.assertEqual(len(scheduled_plugin_tasks), 2) + + # test unregistering + plg.unregister_tasks() + scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") + self.assertEqual(len(scheduled_plugin_tasks), 0) + + def test_calling(self): + """check if a function can be called without errors""" + self.assertEqual(call_function('schedule', 'member_func'), False) + + +class ScheduledTaskPluginTests(TestCase): + """ Tests for ScheduledTaskPluginTests mixin base """ + + def test_init(self): + """Check that all MixinImplementationErrors raise""" + class Base(ScheduleMixin, IntegrationPluginBase): + PLUGIN_NAME = 'APlugin' + + class NoSchedules(Base): + """Plugin without schedules""" + pass + + with self.assertRaises(MixinImplementationError): + NoSchedules() + + class WrongFuncSchedules(Base): + """ + Plugin with broken functions + + This plugin is missing a func + """ + + SCHEDULED_TASKS = { + 'test': { + 'schedule': 'I', + 'minutes': 30, + }, + } + + def test(self): + pass # pragma: no cover + + with self.assertRaises(MixinImplementationError): + WrongFuncSchedules() + + class WrongFuncSchedules1(WrongFuncSchedules): + """ + Plugin with broken functions + + This plugin is missing a schedule + """ + + SCHEDULED_TASKS = { + 'test': { + 'func': 'test', + 'minutes': 30, + }, + } + + with self.assertRaises(MixinImplementationError): + WrongFuncSchedules1() + + class WrongFuncSchedules2(WrongFuncSchedules): + """ + Plugin with broken functions + + This plugin is missing a schedule + """ + + SCHEDULED_TASKS = { + 'test': { + 'func': 'test', + 'minutes': 30, + }, + } + + with self.assertRaises(MixinImplementationError): + WrongFuncSchedules2() + + class WrongFuncSchedules3(WrongFuncSchedules): + """ + Plugin with broken functions + + This plugin has a broken schedule + """ + + SCHEDULED_TASKS = { + 'test': { + 'func': 'test', + 'schedule': 'XX', + 'minutes': 30, + }, + } + + with self.assertRaises(MixinImplementationError): + WrongFuncSchedules3() + + class WrongFuncSchedules4(WrongFuncSchedules): + """ + Plugin with broken functions + + This plugin is missing a minute marker for its schedule + """ + + SCHEDULED_TASKS = { + 'test': { + 'func': 'test', + 'schedule': 'I', + }, + } + + with self.assertRaises(MixinImplementationError): + WrongFuncSchedules4() diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py index 3e4c38f968..5b211e2d96 100644 --- a/InvenTree/plugin/test_integration.py +++ b/InvenTree/plugin/test_integration.py @@ -11,6 +11,8 @@ from plugin import IntegrationPluginBase from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin from plugin.urls import PLUGIN_BASE +from plugin.samples.integration.sample import SampleIntegrationPlugin + class BaseMixinDefinition: def test_mixin_name(self): @@ -238,6 +240,7 @@ class IntegrationPluginBaseTests(TestCase): LICENSE = 'MIT' self.plugin_name = NameIntegrationPluginBase() + self.plugin_sample = SampleIntegrationPlugin() def test_action_name(self): """check the name definition possibilities""" @@ -246,6 +249,10 @@ class IntegrationPluginBaseTests(TestCase): self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin') self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin') + # is_sampe + self.assertEqual(self.plugin.is_sample, False) + self.assertEqual(self.plugin_sample.is_sample, True) + # slug self.assertEqual(self.plugin.slug, '') self.assertEqual(self.plugin_simple.slug, 'simpleplugin') diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index f88b6e6176..c0835c2fb3 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -31,6 +31,10 @@ class InvenTreePluginTests(TestCase): self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123') self.assertEqual(self.named_plugin.plugin_name(), 'abc123') + def test_basic_is_active(self): + """check if a basic plugin is active""" + self.assertEqual(self.plugin.is_active(), False) + class PluginTagTests(TestCase): """ Tests for the plugin extras """ diff --git a/InvenTree/report/templates/report/inventree_po_report.html b/InvenTree/report/templates/report/inventree_po_report.html index d1cae75c3b..9e546fb70e 100644 --- a/InvenTree/report/templates/report/inventree_po_report.html +++ b/InvenTree/report/templates/report/inventree_po_report.html @@ -74,7 +74,7 @@ table td.expand {

    {% trans "Purchase Order" %} {{ prefix }}{{ reference }}

    - {{ supplier.name }} + {% if supplier %}{{ supplier.name }}{% endif %}{% else %}{% trans "Supplier was deleted" %}{% endif %}
    {% endblock %} diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index dd77d26d1c..4c8af402cf 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -379,7 +379,11 @@ {% trans "Supplier" %} - {{ item.supplier_part.supplier.name }} + + {% if item.supplier_part.supplier %} + {{ item.supplier_part.supplier.name }} + {% endif %} + diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index fccd6bf5ef..119376c310 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -105,7 +105,7 @@ function inventreeFormDataUpload(url, data, options={}) { } }, error: function(xhr, status, error) { - console.log('Form data upload failure: ' + status); + console.error('Form data upload failure: ' + status); if (options.error) { options.error(xhr, status, error); diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index eb8a7f98b7..a6305eb1df 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -86,7 +86,6 @@ function onCameraAvailable(hasCamera, options) { function onBarcodeScanCompleted(result, options) { if (result.data == '') return; - console.log('decoded qr code:', result.data); stopQrScanner(); postBarcodeData(result.data, options); } diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index e4674d5989..9bd66877da 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -129,7 +129,7 @@ function constructBomUploadTable(data, options={}) { var modal = createNewModal({ title: '{% trans "Row Data" %}', - cancelText: '{% trans "Close" %}', + closeText: '{% trans "Close" %}', hideSubmitButton: true }); @@ -617,7 +617,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) { }, }, preFormContent: html, - cancelText: '{% trans "Close" %}', + closeText: '{% trans "Close" %}', submitText: '{% trans "Add Substitute" %}', title: '{% trans "Edit BOM Item Substitutes" %}', afterRender: function(fields, opts) { @@ -1061,7 +1061,7 @@ function loadBomTable(table, options={}) { table.bootstrapTable('append', response); }, error: function(xhr) { - console.log('Error requesting BOM for part=' + part_pk); + console.error('Error requesting BOM for part=' + part_pk); showApiError(xhr); } } diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index c9ebbe0e22..d68b319a25 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1532,13 +1532,18 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var pk = $(this).attr('pk'); - launchModalForm('{% url "order-parts" %}', { - data: { - parts: [ - pk, - ] + inventreeGet( + `/api/part/${pk}/`, + {}, + { + success: function(part) { + orderParts( + [part], + {} + ); + } } - }); + ); }); // Callback for 'build' button diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index c92bb75d6f..d7b98841e4 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -115,10 +115,6 @@ function supplierPartFields() { return { part: {}, - supplier: {}, - SKU: { - icon: 'fa-hashtag', - }, manufacturer_part: { filters: { part_detail: true, @@ -126,6 +122,10 @@ function supplierPartFields() { }, auto_fill: true, }, + supplier: {}, + SKU: { + icon: 'fa-hashtag', + }, description: {}, link: { icon: 'fa-link', diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index ceef79f66d..dd45aa6628 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -62,7 +62,7 @@ function loadTableFilters(tableKey) { if (f.length == 2) { filters[f[0]] = f[1]; } else { - console.log(`Improperly formatted filter: ${item}`); + console.warn(`Improperly formatted filter: ${item}`); } } }); @@ -274,7 +274,7 @@ function setupFilterList(tableKey, table, target, options={}) { var element = $(target); if (!element || !element.exists()) { - console.log(`WARNING: setupFilterList could not find target '${target}'`); + console.warn(`setupFilterList could not find target '${target}'`); return; } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 88c9c5badb..cc138052ef 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -135,7 +135,7 @@ function getApiEndpointOptions(url, callback) { success: callback, error: function(xhr) { // TODO: Handle error - console.log(`ERROR in getApiEndpointOptions at '${url}'`); + console.error(`Error in getApiEndpointOptions at '${url}'`); showApiError(xhr, url); } }); @@ -227,7 +227,7 @@ function constructChangeForm(fields, options) { }, error: function(xhr) { // TODO: Handle error here - console.log(`ERROR in constructChangeForm at '${options.url}'`); + console.error(`Error in constructChangeForm at '${options.url}'`); showApiError(xhr, options.url); } @@ -268,7 +268,7 @@ function constructDeleteForm(fields, options) { }, error: function(xhr) { // TODO: Handle error here - console.log(`ERROR in constructDeleteForm at '${options.url}`); + console.error(`Error in constructDeleteForm at '${options.url}`); showApiError(xhr, options.url); } @@ -354,7 +354,7 @@ function constructForm(url, options) { icon: 'fas fa-user-times', }); - console.log(`'POST action unavailable at ${url}`); + console.warn(`'POST action unavailable at ${url}`); } break; case 'PUT': @@ -369,7 +369,7 @@ function constructForm(url, options) { icon: 'fas fa-user-times', }); - console.log(`${options.method} action unavailable at ${url}`); + console.warn(`${options.method} action unavailable at ${url}`); } break; case 'DELETE': @@ -383,7 +383,7 @@ function constructForm(url, options) { icon: 'fas fa-user-times', }); - console.log(`DELETE action unavailable at ${url}`); + console.warn(`DELETE action unavailable at ${url}`); } break; case 'GET': @@ -397,11 +397,11 @@ function constructForm(url, options) { icon: 'fas fa-user-times', }); - console.log(`GET action unavailable at ${url}`); + console.warn(`GET action unavailable at ${url}`); } break; default: - console.log(`constructForm() called with invalid method '${options.method}'`); + console.warn(`constructForm() called with invalid method '${options.method}'`); break; } }); @@ -731,7 +731,7 @@ function submitFormData(fields, options) { data[name] = value; } } else { - console.log(`WARNING: Could not find field matching '${name}'`); + console.warn(`Could not find field matching '${name}'`); } } @@ -776,7 +776,7 @@ function submitFormData(fields, options) { default: $(options.modal).modal('hide'); - console.log(`upload error at ${options.url}`); + console.error(`Upload error at ${options.url}`); showApiError(xhr, options.url); break; } @@ -827,7 +827,7 @@ function updateFieldValue(name, value, field, options) { var el = getFormFieldElement(name, options); if (!el) { - console.log(`WARNING: updateFieldValue could not find field '${name}'`); + console.warn(`updateFieldValue could not find field '${name}'`); return; } @@ -870,7 +870,7 @@ function getFormFieldElement(name, options) { } if (!el.exists) { - console.log(`ERROR: Could not find form element for field '${name}'`); + console.error(`Could not find form element for field '${name}'`); } return el; @@ -918,7 +918,7 @@ function getFormFieldValue(name, field={}, options={}) { var el = getFormFieldElement(name, options); if (!el.exists()) { - console.log(`ERROR: getFormFieldValue could not locate field '${name}'`); + console.error(`getFormFieldValue could not locate field '${name}'`); return null; } @@ -1104,7 +1104,7 @@ function handleNestedErrors(errors, field_name, options={}) { // Nest list must be provided! if (!nest_list) { - console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`); + console.warn(`handleNestedErrors missing nesting options for field '${fieldName}'`); return; } @@ -1113,7 +1113,7 @@ function handleNestedErrors(errors, field_name, options={}) { var error_item = error_list[idx]; if (idx >= nest_list.length) { - console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); + console.warn(`handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); break; } @@ -1218,29 +1218,26 @@ function handleFormErrors(errors, fields={}, options={}) { for (var field_name in errors) { - if (field_name in fields) { + var field = fields[field_name] || {}; - var field = fields[field_name]; + if ((field.type == 'field') && ('child' in field)) { + // This is a "nested" field + handleNestedErrors(errors, field_name, options); + } else { + // This is a "simple" field - if ((field.type == 'field') && ('child' in field)) { - // This is a "nested" field - handleNestedErrors(errors, field_name, options); - } else { - // This is a "simple" field + var field_errors = errors[field_name]; - var field_errors = errors[field_name]; + if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { + first_error_field = field_name; + } - if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { - first_error_field = field_name; - } + // Add an entry for each returned error message + for (var ii = field_errors.length-1; ii >= 0; ii--) { - // Add an entry for each returned error message - for (var ii = field_errors.length-1; ii >= 0; ii--) { + var error_text = field_errors[ii]; - var error_text = field_errors[ii]; - - addFieldErrorMessage(field_name, error_text, ii, options); - } + addFieldErrorMessage(field_name, error_text, ii, options); } } } @@ -1285,7 +1282,7 @@ function addFieldErrorMessage(name, error_text, error_idx=0, options={}) { field_dom.append(error_html); } else { - console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}'`); + console.warn(`addFieldErrorMessage could not locate field '${field_name}'`); } } @@ -1358,7 +1355,7 @@ function addClearCallback(name, field, options={}) { } if (!el) { - console.log(`WARNING: addClearCallback could not find field '${name}'`); + console.warn(`addClearCallback could not find field '${name}'`); return; } @@ -1582,7 +1579,7 @@ function initializeRelatedField(field, fields, options={}) { var name = field.name; if (!field.api_url) { - console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`); + console.warn(`Related field '${name}' missing 'api_url' parameter.`); return; } @@ -1712,7 +1709,7 @@ function initializeRelatedField(field, fields, options={}) { return $(html); } else { // Return a simple renderering - console.log(`WARNING: templateResult() missing 'field.model' for '${name}'`); + console.warn(`templateResult() missing 'field.model' for '${name}'`); return `${name} - ${item.id}`; } }, @@ -1742,7 +1739,7 @@ function initializeRelatedField(field, fields, options={}) { return $(html); } else { // Return a simple renderering - console.log(`WARNING: templateSelection() missing 'field.model' for '${name}'`); + console.warn(`templateSelection() missing 'field.model' for '${name}'`); return `${name} - ${item.id}`; } } @@ -1780,6 +1777,11 @@ function initializeRelatedField(field, fields, options={}) { // Only a single result is available, given the provided filters if (data.count == 1) { setRelatedFieldData(name, data.results[0], options); + + // Run "callback" function (if supplied) + if (field.onEdit) { + field.onEdit(data.results[0], name, field, options); + } } } }); @@ -1911,7 +1913,7 @@ function renderModelData(name, model, data, parameters, options) { if (html != null) { return html; } else { - console.log(`ERROR: Rendering not implemented for model '${model}'`); + console.error(`Rendering not implemented for model '${model}'`); // Simple text rendering return `${model} - ID ${data.id}`; } @@ -1924,6 +1926,10 @@ function renderModelData(name, model, data, parameters, options) { function getFieldName(name, options={}) { var field_name = name; + if (options.field_suffix) { + field_name += options.field_suffix; + } + if (options && options.depth) { field_name += `_${options.depth}`; } @@ -2196,7 +2202,7 @@ function constructInput(name, parameters, options={}) { if (func != null) { html = func(name, parameters, options); } else { - console.log(`WARNING: Unhandled form field type: '${parameters.type}'`); + console.warn(`Unhandled form field type: '${parameters.type}'`); } return html; @@ -2499,12 +2505,12 @@ function constructHelpText(name, parameters) { function selectImportFields(url, data={}, options={}) { if (!data.model_fields) { - console.log(`WARNING: selectImportFields is missing 'model_fields'`); + console.warn(`selectImportFields is missing 'model_fields'`); return; } if (!data.file_fields) { - console.log(`WARNING: selectImportFields is missing 'file_fields'`); + console.warn(`selectImportFields is missing 'file_fields'`); return; } @@ -2595,7 +2601,7 @@ function selectImportFields(url, data={}, options={}) { default: $(opts.modal).modal('hide'); - console.log(`upload error at ${opts.url}`); + console.error(`upload error at ${opts.url}`); showApiError(xhr, opts.url); break; } diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index f114f6f419..85f503682e 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -85,12 +85,25 @@ function createNewModal(options={}) { var modal_name = `#modal-form-${id}`; + // Callback *after* the modal has been rendered $(modal_name).on('shown.bs.modal', function() { $(modal_name + ' .modal-form-content').scrollTop(0); if (options.focus) { getFieldByName(modal_name, options.focus).focus(); } + + // Steal keyboard focus + $(modal_name).focus(); + + if (options.hideCloseButton) { + $(modal_name).find('#modal-form-cancel').hide(); + } + + if (options.preventSubmit || options.hideSubmitButton) { + $(modal_name).find('#modal-form-submit').hide(); + } + }); // Automatically remove the modal when it is deleted! @@ -102,8 +115,11 @@ function createNewModal(options={}) { $(modal_name).on('keydown', 'input', function(event) { if (event.keyCode == 13) { event.preventDefault(); - // Simulate a click on the 'Submit' button - $(modal_name).find('#modal-form-submit').click(); + + if (!options.preventSubmit) { + // Simulate a click on the 'Submit' button + $(modal_name).find('#modal-form-submit').click(); + } return false; } @@ -117,18 +133,7 @@ function createNewModal(options={}) { // Set labels based on supplied options modalSetTitle(modal_name, options.title || '{% trans "Form Title" %}'); modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}'); - modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}'); - - if (options.hideSubmitButton) { - $(modal_name).find('#modal-form-submit').hide(); - } - - if (options.hideCloseButton) { - $(modal_name).find('#modal-form-cancel').hide(); - } - - // Steal keyboard focus - $(modal_name).focus(); + modalSetCloseText(modal_name, options.closeText || '{% trans "Cancel" %}'); // Return the "name" of the modal return modal_name; @@ -274,7 +279,7 @@ function reloadFieldOptions(fieldName, options) { setFieldOptions(fieldName, opts); }, error: function() { - console.log('Error GETting field options'); + console.error('Error GETting field options'); } }); } @@ -581,7 +586,7 @@ function showAlertDialog(title, content, options={}) { var modal = createNewModal({ title: title, - cancelText: '{% trans "Close" %}', + closeText: '{% trans "Close" %}', hideSubmitButton: true, }); @@ -607,7 +612,7 @@ function showQuestionDialog(title, content, options={}) { var modal = createNewModal({ title: title, submitText: options.accept_text || '{% trans "Accept" %}', - cancelText: options.cancel_text || '{% trans "Cancel" %}', + closeText: options.cancel_text || '{% trans "Cancel" %}', }); modalSetContent(modal, content); @@ -842,7 +847,7 @@ function attachFieldCallback(modal, callback) { // Run the callback function with the new value of the field! callback.action(field.val(), field); } else { - console.log(`Value changed for field ${callback.field} - ${field.val()}`); + console.info(`Value changed for field ${callback.field} - ${field.val()} (no callback attached)`); } }); } @@ -1085,8 +1090,8 @@ function launchModalForm(url, options = {}) { showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr)); } - console.log('Modal form error: ' + xhr.status); - console.log('Message: ' + xhr.responseText); + console.error('Modal form error: ' + xhr.status); + console.info('Message: ' + xhr.responseText); } }; diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index b88de5af35..d55d93e531 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -34,7 +34,7 @@ // Should the ID be rendered for this string function renderId(title, pk, parameters={}) { - // Default = do not display + // Default = do not render var render = false; if ('render_pk' in parameters) { @@ -297,7 +297,12 @@ function renderSalesOrderShipment(name, data, parameters={}, options={}) { var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX; - var html = `${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}`; + var html = ` + ${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference} + + {% trans "Shipment ID" %}: ${data.pk} + + `; html += renderId('{% trans "Shipment ID" %}', data.pk, parameters); @@ -384,10 +389,18 @@ function renderSupplierPart(name, data, parameters={}, options={}) { var html = ''; html += select2Thumbnail(supplier_image); - html += select2Thumbnail(part_image); - html += ` ${data.supplier_detail.name} - ${data.SKU}`; - html += ` - ${data.part_detail.full_name}`; + if (data.part_detail) { + html += select2Thumbnail(part_image); + } + + if (data.supplier_detail) { + html += ` ${data.supplier_detail.name} - ${data.SKU}`; + } + + if (data.part_detail) { + html += ` - ${data.part_detail.full_name}`; + } html += renderId('{% trans "Supplier Part ID" %}', data.pk, parameters); diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 4aad54a65a..d5ca7caf42 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -35,6 +35,7 @@ loadSalesOrderTable, newPurchaseOrderFromOrderWizard, newSupplierPartFromOrderWizard, + orderParts, removeOrderRowFromOrderWizard, removePurchaseOrderLineItem, loadOrderTotal, @@ -259,8 +260,8 @@ function createPurchaseOrder(options={}) { } } }, - supplier_reference: {}, description: {}, + supplier_reference: {}, target_date: { icon: 'fa-calendar-alt', }, @@ -476,6 +477,328 @@ function exportOrder(redirect_url, options={}) { }); } + +/* + * Create a new form to order parts based on the list of provided parts. + */ +function orderParts(parts_list, options={}) { + + var parts = []; + + var parts_seen = {}; + + parts_list.forEach(function(part) { + if (part.purchaseable) { + + // Prevent duplicates + if (!(part.pk in parts_seen)) { + parts_seen[part.pk] = true; + parts.push(part); + } + } + }); + + if (parts.length == 0) { + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "At least one purchaseable part must be selected" %}', + ); + return; + } + + // Render a single part within the dialog + function renderPart(part, opts={}) { + + var pk = part.pk; + + var thumb = thumbnailImage(part.thumbnail || part.image); + + // The "quantity" field should have been provided for each part + var quantity = part.quantity || 1; + + if (quantity < 0) { + quantity = 0; + } + + var quantity_input = constructField( + `quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity, + title: '{% trans "Quantity to order" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var supplier_part_prefix = ` + + `; + + var supplier_part_input = constructField( + `part_${pk}`, + { + type: 'related field', + required: true, + prefixRaw: supplier_part_prefix, + }, + { + hideLabels: true, + } + ); + + var purchase_order_prefix = ` + + `; + + var purchase_order_input = constructField( + `order_${pk}`, + { + type: 'related field', + required: true, + prefixRaw: purchase_order_prefix, + }, + { + hideLabels: 'true', + } + ); + + var buttons = `
    `; + + if (parts.length > 1) { + buttons += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + } + + // Button to add row to purchase order + buttons += makeIconButton( + 'fa-shopping-cart icon-blue', + 'button-row-add', + pk, + '{% trans "Add to purchase order" %}', + ); + + buttons += `
    `; + + var html = ` + + ${thumb} ${part.full_name} + ${supplier_part_input} + ${purchase_order_input} + ${quantity_input} + ${buttons} + `; + + return html; + } + + // Remove a single row form this dialog + function removeRow(pk, opts) { + // Remove the row + $(opts.modal).find(`#order_row_${pk}`).remove(); + + // If the modal is now "empty", dismiss it + if (!($(opts.modal).find('.part-order-row').exists())) { + closeModal(opts.modal); + } + } + + var table_entries = ''; + + parts.forEach(function(part) { + table_entries += renderPart(part); + }); + + var html = ''; + + // Add table + html += ` + + + + + + + + + + + + ${table_entries} + +
    {% trans "Part" %}{% trans "Supplier Part" %}{% trans "Purchase Order" %}{% trans "Quantity" %}
    + `; + + // Construct API filters for the SupplierPart field + var supplier_part_filters = { + supplier_detail: true, + part_detail: true, + }; + + if (options.supplier) { + supplier_part_filters.supplier = options.supplier; + } + + if (options.manufacturer) { + supplier_part_filters.manufacturer = options.manufacturer; + } + + if (options.manufacturer_part) { + supplier_part_filters.manufacturer_part = options.manufacturer_part; + } + + // Construct API filtres for the PurchaseOrder field + var order_filters = { + status: {{ PurchaseOrderStatus.PENDING }}, + supplier_detail: true, + }; + + if (options.supplier) { + order_filters.supplier = options.supplier; + } + + constructFormBody({}, { + preFormContent: html, + title: '{% trans "Order Parts" %}', + preventSubmit: true, + closeText: '{% trans "Close" %}', + afterRender: function(fields, opts) { + parts.forEach(function(part) { + + // Filter by base part + supplier_part_filters.part = part.pk; + + if (part.manufacturer_part) { + // Filter by manufacturer part + supplier_part_filters.manufacturer_part = part.manufacturer_part; + } + + // Configure the "supplier part" field + initializeRelatedField({ + name: `part_${part.pk}`, + model: 'supplierpart', + api_url: '{% url "api-supplier-part-list" %}', + required: true, + type: 'related field', + auto_fill: true, + value: options.supplier_part, + filters: supplier_part_filters, + noResults: function(query) { + return '{% trans "No matching supplier parts" %}'; + } + }, null, opts); + + // Configure the "purchase order" field + initializeRelatedField({ + name: `order_${part.pk}`, + model: 'purchaseorder', + api_url: '{% url "api-po-list" %}', + required: true, + type: 'related field', + auto_fill: false, + value: options.order, + filters: order_filters, + noResults: function(query) { + return '{% trans "No matching purchase orders" %}'; + } + }, null, opts); + }); + + // Add callback for "add to purchase order" button + $(opts.modal).find('.button-row-add').click(function() { + var pk = $(this).attr('pk'); + + opts.field_suffix = null; + + // Extract information from the row + var data = { + quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts), + part: getFormFieldValue(`part_${pk}`, {}, opts), + order: getFormFieldValue(`order_${pk}`, {}, opts), + }; + + // Duplicate the form options, to prevent 'field_suffix' override + var row_opts = Object.assign(opts); + row_opts.field_suffix = `_${pk}`; + + inventreePut( + '{% url "api-po-line-list" %}', + data, + { + method: 'POST', + success: function(response) { + removeRow(pk, opts); + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, row_opts); + break; + default: + console.error(`Error adding line to purchase order`); + showApiError(xhr, options.url); + break; + } + } + } + ); + }); + + // Add callback for "remove row" button + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + removeRow(pk, opts); + }); + + // Add callback for "new supplier part" button + $(opts.modal).find('.button-row-new-sp').click(function() { + var pk = $(this).attr('pk'); + + // Launch dialog to create new supplier part + createSupplierPart({ + part: pk, + onSuccess: function(response) { + setRelatedFieldData( + `part_${pk}`, + response, + opts + ); + } + }); + }); + + // Add callback for "new purchase order" button + $(opts.modal).find('.button-row-new-po').click(function() { + var pk = $(this).attr('pk'); + + // Launch dialog to create new purchase order + createPurchaseOrder({ + onSuccess: function(response) { + setRelatedFieldData( + `order_${pk}`, + response, + opts + ); + } + }); + }); + } + }); + +} + function newPurchaseOrderFromOrderWizard(e) { /* Create a new purchase order directly from an order form. * Launches a secondary modal and (if successful), @@ -681,12 +1004,14 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ); } - buttons += makeIconButton( - 'fa-times icon-red', - 'button-row-remove', - pk, - '{% trans "Remove row" %}', - ); + if (line_items.length > 1) { + buttons += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + } buttons += '
    '; @@ -1155,7 +1480,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); if (!line_item) { - console.log('WARNING: getRowByUniqueId returned null'); + console.warn('getRowByUniqueId returned null'); return; } @@ -1414,12 +1739,12 @@ function loadPurchaseOrderExtraLineTable(table, options={}) { options.params = options.params || {}; if (!options.order) { - console.log('ERROR: function called without order ID'); + console.error('function called without order ID'); return; } if (!options.status) { - console.log('ERROR: function called without order status'); + console.error('function called without order status'); return; } @@ -2541,12 +2866,12 @@ function loadSalesOrderLineItemTable(table, options={}) { options.params = options.params || {}; if (!options.order) { - console.log('ERROR: function called without order ID'); + console.error('function called without order ID'); return; } if (!options.status) { - console.log('ERROR: function called without order status'); + console.error('function called without order status'); return; } @@ -2965,13 +3290,18 @@ function loadSalesOrderLineItemTable(table, options={}) { $(table).find('.button-buy').click(function() { var pk = $(this).attr('pk'); - launchModalForm('{% url "order-parts" %}', { - data: { - parts: [ - pk - ], - }, - }); + inventreeGet( + `/api/part/${pk}/`, + {}, + { + success: function(part) { + orderParts( + [part], + {} + ); + } + } + ); }); // Callback for displaying price @@ -3049,12 +3379,12 @@ function loadSalesOrderExtraLineTable(table, options={}) { options.params = options.params || {}; if (!options.order) { - console.log('ERROR: function called without order ID'); + console.error('function called without order ID'); return; } if (!options.status) { - console.log('ERROR: function called without order status'); + console.error('function called without order status'); return; } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 57f76dcae4..8d76b833a6 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -876,7 +876,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); if (!line_item) { - console.log('WARNING: getRowByUniqueId returned null'); + console.warn('getRowByUniqueId returned null'); return; } @@ -1564,15 +1564,16 @@ function loadPartTable(table, url, options={}) { var parts = []; - selections.forEach(function(item) { - parts.push(item.pk); + selections.forEach(function(part) { + parts.push(part); }); - launchModalForm('/order/purchase-order/order-parts/', { - data: { - parts: parts, - }, - }); + orderParts( + parts, + { + + } + ); }); $('#multi-part-category').click(function() { @@ -1603,19 +1604,6 @@ function loadPartTable(table, url, options={}) { printPartLabels(items); }); - - $('#multi-part-export').click(function() { - var selections = $(table).bootstrapTable('getSelections'); - - var parts = ''; - - selections.forEach(function(item) { - parts += item.pk; - parts += ','; - }); - - location.href = '/part/export/?parts=' + parts; - }); } diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index de42528142..94d21fe5b0 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -2043,17 +2043,17 @@ function loadStockTable(table, options) { $('#multi-item-order').click(function() { var selections = $(table).bootstrapTable('getSelections'); - var stock = []; + var parts = []; selections.forEach(function(item) { - stock.push(item.pk); + var part = item.part_detail; + + if (part) { + parts.push(part); + } }); - launchModalForm('/order/purchase-order/order-parts/', { - data: { - stock: stock, - }, - }); + orderParts(parts, {}); }); $('#multi-item-set-status').click(function() { diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 8a6674299c..96c561b49b 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -39,7 +39,7 @@ function downloadTableData(table, opts={}) { var url = table_options.url; if (!url) { - console.log('Error: downloadTableData could not find "url" parameter.'); + console.error('downloadTableData could not find "url" parameter.'); } var query_params = table_options.query_params || {}; @@ -343,7 +343,7 @@ $.fn.inventreeTable = function(options) { } }); } else { - console.log(`Could not get list of visible columns for table '${tableName}'`); + console.error(`Could not get list of visible columns for table '${tableName}'`); } } diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 3dc9834604..7c5c406687 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -565,14 +565,14 @@ class Owner(models.Model): try: owners.append(cls.objects.get(owner_id=user.pk, owner_type=user_type)) - except: + except: # pragma: no cover pass for group in user.groups.all(): try: owner = cls.objects.get(owner_id=group.pk, owner_type=group_type) owners.append(owner) - except: + except: # pragma: no cover pass return owners diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py index d9af560ed8..e6a4019481 100644 --- a/InvenTree/users/tests.py +++ b/InvenTree/users/tests.py @@ -197,6 +197,10 @@ class OwnerModelTest(TestCase): self.assertTrue(user_as_owner in related_owners) self.assertTrue(group_as_owner in related_owners) + # Check owner matching + owners = Owner.get_owners_matching_user(self.user) + self.assertEqual(owners, [user_as_owner, group_as_owner]) + # Delete user and verify owner was deleted too self.user.delete() user_as_owner = Owner.get_owner(self.user) diff --git a/README.md b/README.md index dd76fd41dd..a499ce8856 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,38 @@ +
    + InvenTree logo +

    InvenTree

    +

    Open Source Inventory Management System

    -InvenTree - -# InvenTree - -

    - follow on Twitter

    - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Coverage Status](https://coveralls.io/repos/github/inventree/InvenTree/badge.svg)](https://coveralls.io/github/inventree/InvenTree) -[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/inventree/inventree) ![CI](https://github.com/inventree/inventree/actions/workflows/qc_checks.yaml/badge.svg) +![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg) + +![Coveralls](https://img.shields.io/coveralls/github/inventree/InvenTree) +[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree) +![Lines of code](https://img.shields.io/tokei/lines/github/inventree/InvenTree) +![GitHub commit activity](https://img.shields.io/github/commit-activity/m/inventree/inventree) +![PyPI - Downloads](https://img.shields.io/pypi/dm/inventree) +[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree) + +![GitHub Org's stars](https://img.shields.io/github/stars/inventree?style=social) +![Twitter Follow](https://img.shields.io/twitter/follow/inventreedb?style=social) +![Subreddit subscribers](https://img.shields.io/reddit/subreddit-subscribers/inventree?style=social) + + +

    + View Demo + · + Documentation + · + Report Bug + · + Request Feature +

    +
    + + +## :star2: About the Project InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a REST API for interaction with external interfaces and applications. @@ -18,61 +40,133 @@ InvenTree is designed to be lightweight and easy to use for SME or hobbyist appl Powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information. -# Demo + -A demo instance of InvenTree is provided to allow users to explore the functionality of the software. [Read more here](https://inventree.readthedocs.io/en/latest/demo/) +### :dart: Features -# Docker +- Organize Parts and BOMs +- Manage Suppliers +- Instant Stock Knowledge +- Extend and Customize to fit your usage -[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree) -![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg) + +### :compass: Roadmap -InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details. +* [x] Plugins +* [ ] Improved Importers +* [ ] Custom Workflow Processes -# Mobile App + +### :hammer_and_wrench: Integration -InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality. - -- [**Download InvenTree from the Android Play Store**](https://play.google.com/store/apps/details?id=inventree.inventree_app) - -- [**Download InvenTree from the Apple App Store**](https://apps.apple.com/au/app/inventree/id1581731101#?platform=iphone) - -# Deploy to DigitalOcean -[![Deploy to DO](https://www.deploytodo.com/do-btn-blue-ghost.svg)](https://marketplace.digitalocean.com/apps/inventree?refcode=d6172576d014) - -# Documentation - -For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/). - -# Getting Started - -Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions. - -# Credits - -The credits for all used packages are part of the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/credits/). - -# Integration - -InvenTree is designed to be extensible, and provides multiple options for integration with external applications or addition of custom plugins: +InvenTree is designed to be **extensible**, and provides multiple options for **integration** with external applications or addition of custom plugins: * [InvenTree API](https://inventree.readthedocs.io/en/latest/extend/api/) * [Python module](https://inventree.readthedocs.io/en/latest/extend/python) * [Plugin interface](https://inventree.readthedocs.io/en/latest/extend/plugins) -* [Third party](https://inventree.readthedocs.io/en/latest/extend/integrate) +* [Third party tools](https://inventree.readthedocs.io/en/latest/extend/integrate) -# Contributing + +### :space_invader: Tech Stack -Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.readthedocs.io/en/latest/contribute/). +
    + Server + +
    -# Translation +
    +Database + +
    + +
    + Client + +
    + +
    +DevOps + +
    + + +## :toolbox: Getting Started + +Refer to the [getting started guide](https://inventree.readthedocs.io/en/latest/start/install/) for installation and setup instructions. + + +## :iphone: Mobile App + +InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality. + +

    + Android Play Store + · + Apple App Store +

    + + +## :train: Deploy + +There are several options to deploy InvenTree. + +

    + Docker + · + Deploy to DO + · + Bare Metal +

    + + +## :wave: Contributing + +Contributions are welcomed and encouraged. Please help to make this project even better! +Refer to the [contribution page in the docs ](https://inventree.readthedocs.io/en/latest/contribute/) and check out [contributing.md](CONTRIBUTING.md). + + +## :scroll: Translation Native language translation of the InvenTree web application is [community contributed via crowdin](https://crowdin.com/project/inventree). **Contributions are welcomed and encouraged**. To contribute to the translation effort, navigate to the [InvenTree crowdin project](https://crowdin.com/project/inventree), create a free account, and start making translations suggestions for your language of choice! -# Donate + +## :money_with_wings: Sponsor If you use InvenTree and find it to be useful, please consider making a donation toward its continued development. [Donate via PayPal](https://paypal.me/inventree?locale.x=en_AU) + + +## :gem: Acknowledgements + +We would like to acknowledge a few special projects: + - [PartKeepr](https://github.com/partkeepr/PartKeepr) as a valuable predecessor and inspiration + - [Readme Template](https://github.com/Louis3797/awesome-readme-template) for the template of this page + +Find a full list of used third-party libraries in [our documentation](https://inventree.readthedocs.io/en/latest/credits/). + + +## :warning: License + +Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License. See LICENSE.txt for more information.