From 95c5c4b57535bd01e6d178dd255f87eecc34c4bd Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 17 Sep 2020 22:44:17 +1000
Subject: [PATCH 01/10] Fix issues with circular imports

---
 InvenTree/InvenTree/fields.py     |  4 ++--
 InvenTree/InvenTree/helpers.py    |  7 ++++---
 InvenTree/InvenTree/validators.py |  4 ++--
 InvenTree/InvenTree/version.py    |  5 +++--
 InvenTree/build/models.py         |  4 ++--
 InvenTree/common/models.py        | 15 +++++++++++++++
 tasks.py                          |  8 ++++++++
 7 files changed, 36 insertions(+), 11 deletions(-)

diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py
index ba3648da30..884bd0c49f 100644
--- a/InvenTree/InvenTree/fields.py
+++ b/InvenTree/InvenTree/fields.py
@@ -11,7 +11,7 @@ from django.core import validators
 from django import forms
 from decimal import Decimal
 
-from InvenTree.helpers import normalize
+import InvenTree.helpers
 
 
 class InvenTreeURLFormField(FormURLField):
@@ -55,7 +55,7 @@ class RoundingDecimalFormField(forms.DecimalField):
         """
 
         if type(value) == Decimal:
-            return normalize(value)
+            return InvenTree.helpers.normalize(value)
         else:
             return value
 
diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 4ec84c7912..9b470902b1 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -15,7 +15,8 @@ from django.http import StreamingHttpResponse
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext as _
 
-from .version import inventreeVersion, inventreeInstanceName
+import InvenTree.version
+
 from .settings import MEDIA_URL, STATIC_URL
 
 
@@ -263,8 +264,8 @@ def MakeBarcode(object_name, object_pk, object_data, **kwargs):
         data[object_name] = object_pk
     else:
         data['tool'] = 'InvenTree'
-        data['version'] = inventreeVersion()
-        data['instance'] = inventreeInstanceName()
+        data['version'] = InvenTree.version.inventreeVersion()
+        data['instance'] = InvenTree.version.inventreeInstanceName()
 
         # Ensure PK is included
         object_data['id'] = object_pk
diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py
index b1e455283b..548bce12ab 100644
--- a/InvenTree/InvenTree/validators.py
+++ b/InvenTree/InvenTree/validators.py
@@ -6,7 +6,7 @@ from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
-from common.models import InvenTreeSetting
+import common.models
 
 import re
 
@@ -43,7 +43,7 @@ def validate_part_name(value):
 def validate_part_ipn(value):
     """ Validate the Part IPN against regex rule """
 
-    pattern = InvenTreeSetting.get_setting('part_ipn_regex')
+    pattern = common.models.InvenTreeSetting.get_setting('part_ipn_regex')
 
     if pattern:
         match = re.search(pattern, value)
diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py
index e000f40076..4aac01c8df 100644
--- a/InvenTree/InvenTree/version.py
+++ b/InvenTree/InvenTree/version.py
@@ -3,15 +3,16 @@ Provides information on the current InvenTree version
 """
 
 import subprocess
-from common.models import InvenTreeSetting
 import django
 
+import common.models
+
 INVENTREE_SW_VERSION = "0.1.3 pre"
 
 
 def inventreeInstanceName():
     """ Returns the InstanceName settings for the current database """
-    return InvenTreeSetting.get_setting("InstanceName", "")
+    return common.modelsInvenTreeSetting.get_setting("InstanceName", "")
 
 
 def inventreeVersion():
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 89e79761e8..35870adde4 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -22,8 +22,8 @@ from markdownx.models import MarkdownxField
 from mptt.models import MPTTModel, TreeForeignKey
 
 from InvenTree.status_codes import BuildStatus
-from InvenTree.fields import InvenTreeURLField
 from InvenTree.helpers import decimal2string
+import InvenTree.fields
 
 from stock import models as StockModels
 from part import models as PartModels
@@ -151,7 +151,7 @@ class Build(MPTTModel):
         related_name='builds_completed'
     )
     
-    link = InvenTreeURLField(
+    link = InvenTree.fields.InvenTreeURLField(
         verbose_name=_('External Link'),
         blank=True, help_text=_('Link to external URL')
     )
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index b8e9959f01..774bba2443 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -14,6 +14,8 @@ from django.utils.translation import ugettext as _
 from django.core.validators import MinValueValidator, MaxValueValidator
 from django.core.exceptions import ValidationError
 
+import InvenTree.fields
+
 
 class InvenTreeSetting(models.Model):
     """
@@ -159,6 +161,19 @@ class Currency(models.Model):
         super().save(*args, **kwargs)
 
 
+class PriceBreak(models.Model):
+
+    class Meta:
+        abstract = True
+
+    quantity = InvenTree.fields.RoundingDecimalField(max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)])
+
+    cost = InvenTree.fields.RoundingDecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)])
+
+    currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
+
+
+
 class ColorTheme(models.Model):
     """ Color Theme Setting """
 
diff --git a/tasks.py b/tasks.py
index 51c5a68849..cbf9d1722c 100644
--- a/tasks.py
+++ b/tasks.py
@@ -108,6 +108,14 @@ def superuser(c):
 
     manage(c, 'createsuperuser', pty=True)
 
+@task
+def check(c):
+    """
+    Check validity of django codebase
+    """
+
+    manage(c, "check")
+
 @task
 def migrate(c):
     """

From 805e8daa57f7779bc8ee24737b86c43b74718921 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 17 Sep 2020 22:47:31 +1000
Subject: [PATCH 02/10] Convert SupplierPriceBreak model to use the abstract
 PriceBreak class

---
 InvenTree/common/models.py  | 16 ++++++++++++++++
 InvenTree/company/models.py | 25 ++++---------------------
 2 files changed, 20 insertions(+), 21 deletions(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 774bba2443..e92d55d111 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -7,6 +7,7 @@ These models are 'generic' and do not fit a particular business logic object.
 from __future__ import unicode_literals
 
 import os
+import decimal
 
 from django.db import models
 from django.conf import settings
@@ -162,6 +163,9 @@ class Currency(models.Model):
 
 
 class PriceBreak(models.Model):
+    """
+    Represents a PriceBreak model
+    """
 
     class Meta:
         abstract = True
@@ -172,6 +176,18 @@ class PriceBreak(models.Model):
 
     currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
 
+    @property
+    def converted_cost(self):
+        """
+        Return the cost of this price break, converted to the base currency
+        """
+
+        scaler = decimal.Decimal(1.0)
+
+        if self.currency:
+            scaler = self.currency.value
+
+        return self.cost * scaler
 
 
 class ColorTheme(models.Model):
diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py
index b938266bd5..2a8d907b76 100644
--- a/InvenTree/company/models.py
+++ b/InvenTree/company/models.py
@@ -8,7 +8,6 @@ from __future__ import unicode_literals
 import os
 
 import math
-from decimal import Decimal
 
 from django.utils.translation import gettext_lazy as _
 from django.core.validators import MinValueValidator
@@ -24,9 +23,10 @@ from stdimage.models import StdImageField
 
 from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
 from InvenTree.helpers import normalize
-from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
+from InvenTree.fields import InvenTreeURLField
 from InvenTree.status_codes import PurchaseOrderStatus
-from common.models import Currency
+
+import common.models
 
 
 def rename_company_image(instance, filename):
@@ -433,7 +433,7 @@ class SupplierPart(models.Model):
         return s
 
 
-class SupplierPriceBreak(models.Model):
+class SupplierPriceBreak(common.models.PriceBreak):
     """ Represents a quantity price break for a SupplierPart.
     - Suppliers can offer discounts at larger quantities
     - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
@@ -447,23 +447,6 @@ class SupplierPriceBreak(models.Model):
 
     part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
 
-    quantity = RoundingDecimalField(max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)])
-
-    cost = RoundingDecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)])
-
-    currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
-
-    @property
-    def converted_cost(self):
-        """ Return the cost of this price break, converted to the base currency """
-
-        scaler = Decimal(1.0)
-
-        if self.currency:
-            scaler = self.currency.value
-
-        return self.cost * scaler
-
     class Meta:
         unique_together = ("part", "quantity")
 

From e51fee081bd058f61ae99deadce4ad8baa32ea48 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 17 Sep 2020 23:19:50 +1000
Subject: [PATCH 03/10] SupplierPart price break table now uses API rather than
 django template

---
 InvenTree/InvenTree/version.py                |   2 +-
 InvenTree/common/models.py                    |   8 +
 InvenTree/company/serializers.py              |  13 +-
 .../company/supplier_part_pricing.html        | 138 ++++++++++--------
 4 files changed, 100 insertions(+), 61 deletions(-)

diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py
index 4aac01c8df..b64a23a6fb 100644
--- a/InvenTree/InvenTree/version.py
+++ b/InvenTree/InvenTree/version.py
@@ -12,7 +12,7 @@ INVENTREE_SW_VERSION = "0.1.3 pre"
 
 def inventreeInstanceName():
     """ Returns the InstanceName settings for the current database """
-    return common.modelsInvenTreeSetting.get_setting("InstanceName", "")
+    return common.models.InvenTreeSetting.get_setting("InstanceName", "")
 
 
 def inventreeVersion():
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index e92d55d111..b0a836118d 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -176,6 +176,14 @@ class PriceBreak(models.Model):
 
     currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL)
 
+    @property
+    def symbol(self):
+        return self.currency.symbol if self.currency else ''
+
+    @property
+    def suffix(self):
+        return self.currency.suffix if self.currency else ''
+
     @property
     def converted_cost(self):
         """
diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py
index a80b3b55a9..f6de7d4f50 100644
--- a/InvenTree/company/serializers.py
+++ b/InvenTree/company/serializers.py
@@ -137,11 +137,22 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
 class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
     """ Serializer for SupplierPriceBreak object """
 
+    symbol = serializers.CharField(read_only=True)
+
+    suffix = serializers.CharField(read_only=True)
+
+    quantity = serializers.FloatField()
+
+    cost = serializers.FloatField()
+
     class Meta:
         model = SupplierPriceBreak
         fields = [
             'pk',
             'part',
             'quantity',
-            'cost'
+            'cost',
+            'currency',
+            'symbol',
+            'suffix',
         ]
diff --git a/InvenTree/company/templates/company/supplier_part_pricing.html b/InvenTree/company/templates/company/supplier_part_pricing.html
index 080871ace7..28cc917e1a 100644
--- a/InvenTree/company/templates/company/supplier_part_pricing.html
+++ b/InvenTree/company/templates/company/supplier_part_pricing.html
@@ -10,45 +10,12 @@
 <hr>
 
 <h4>{% trans "Pricing Information" %}</h4>
-<table class="table table-striped table-condensed">
-    <tr><td>{% trans "Order Multiple" %}</td><td>{{ part.multiple }}</td></tr>
-    {% if part.base_cost > 0 %}
-    <tr><td>{% trans "Base Price (Flat Fee)" %}</td><td>{{ part.base_cost }}</td></tr>
-    {% endif %}
-    <tr>
-        <th>{% trans "Price Breaks" %}</th>
-        <th>
-            <div style='float: right;'>
-                <button class='btn btn-primary' id='new-price-break' type='button'>{% trans "New Price Break" %}</button>
-            </div>
-        </th>
-    </tr>
-    <tr>
-        <th>{% trans "Quantity" %}</th>
-        <th>{% trans "Price" %}</th>
-    </tr>
-    {% if part.price_breaks.all %}
-    {% for pb in part.price_breaks.all %}
-        <tr>
-            <td>{% decimal pb.quantity %}</td>
-            <td>
-                {% if pb.currency %}{{ pb.currency.symbol }}{% endif %}
-                {% decimal pb.cost %}
-                {% if pb.currency %}{{ pb.currency.suffix }}{% endif %}
-                <div class='btn-group' style='float: right;'>
-                    <button title='Edit Price Break' class='btn btn-default btn-sm pb-edit-button' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='fas fa-edit icon-green'></span></button>
-                    <button title='Delete Price Break' class='btn btn-default btn-sm pb-delete-button' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='fas fa-trash-alt icon-red'></span></button>
-                </div>
-            </td>
-        </tr>
-    {% endfor %}
-    {% else %}
-    <tr>
-        <td colspan='2'>
-            <span class='warning-msg'><i>{% trans "No price breaks have been added for this part" %}</i></span>
-        </td>
-    </tr>
-    {% endif %}
+
+<div id='price-break-toolbar' class='btn-group'>
+    <button class='btn btn-primary' id='new-price-break' type='button'>{% trans "Add Price Break" %}</button>
+</div>
+
+<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>    
 </table>
 
 {% endblock %}
@@ -56,7 +23,80 @@
 {% block js_ready %}
 {{ block.super }}
 
+function reloadPriceBreaks() {
+    $("#price-break-table").bootstrapTable("refresh");
+}
 
+$('#price-break-table').inventreeTable({
+    name: 'buypricebreaks',
+    formatNoMatches: function() { return "{% trans "No price break information found" %}"; },
+    queryParams: {
+        part: {{ part.id }},
+    },
+    url: "{% url 'api-part-supplier-price' %}",
+    onLoadSuccess: function() {
+        var table = $('#price-break-table');
+
+        table.find('.button-price-break-delete').click(function() {
+            var pk = $(this).attr('pk');
+
+            launchModalForm(
+                `/price-break/${pk}/delete/`,
+                {
+                    success: reloadPriceBreaks
+                }
+            );
+        });
+
+        table.find('.button-price-break-edit').click(function() {
+            var pk = $(this).attr('pk');
+
+            launchModalForm(
+                `/price-break/${pk}/edit/`,
+                {
+                    success: reloadPriceBreaks
+                }
+            );
+        });
+    },
+    columns: [
+        {
+            field: 'pk',
+            title: 'ID',
+            visible: false,
+            switchable: false,
+        },
+        {
+            field: 'quantity',
+            title: '{% trans "Quantity" %}',
+            sortable: true,
+        },
+        {
+            field: 'cost',
+            title: '{% trans "Price" %}',
+            sortable: true,
+            formatter: function(value, row, index) {
+                var html = '';
+
+                html += row.symbol || '';
+                html += value;
+
+                if (row.suffix) {
+                    html += ' ' + row.suffix || '';
+                }
+
+                html += `<div class='btn-group float-right' role='group'>`
+
+                html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
+                html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
+
+                html += `</div>`;
+    
+                return html;
+            }
+        },
+    ]
+});
 
 $('#new-price-break').click(function() {
     launchModalForm("{% url 'price-break-create' %}",
@@ -69,24 +109,4 @@ $('#new-price-break').click(function() {
     );
 });
 
-$('.pb-edit-button').click(function() {
-    var button = $(this);
-
-    launchModalForm(button.attr('url'),
-        {
-            reload: true,
-        }
-    );
-});
-
-$('.pb-delete-button').click(function() {
-    var button = $(this);
-
-    launchModalForm(button.attr('url'),
-        {
-            reload: true,
-        }
-    );
-});
-
 {% endblock %}
\ No newline at end of file

From 8f1b018f0ab952a02e98c07323dd6c745fa02813 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 17 Sep 2020 23:22:37 +1000
Subject: [PATCH 04/10] Add table for price breaks for selling a part

---
 .../migrations/0049_partsellpricebreak.py     | 30 +++++++++++++++++++
 InvenTree/part/models.py                      | 16 ++++++++++
 2 files changed, 46 insertions(+)
 create mode 100644 InvenTree/part/migrations/0049_partsellpricebreak.py

diff --git a/InvenTree/part/migrations/0049_partsellpricebreak.py b/InvenTree/part/migrations/0049_partsellpricebreak.py
new file mode 100644
index 0000000000..1d49dcbfac
--- /dev/null
+++ b/InvenTree/part/migrations/0049_partsellpricebreak.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.0.7 on 2020-09-17 13:22
+
+import InvenTree.fields
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('common', '0007_colortheme'),
+        ('part', '0048_auto_20200902_1404'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PartSellPriceBreak',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(1)])),
+                ('cost', InvenTree.fields.RoundingDecimalField(decimal_places=5, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+                ('currency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Currency')),
+                ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='salepricebreaks', to='part.Part')),
+            ],
+            options={
+                'unique_together': {('part', 'quantity')},
+            },
+        ),
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index f1b0890cba..a3c707bfec 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -46,6 +46,8 @@ from order import models as OrderModels
 from company.models import SupplierPart
 from stock import models as StockModels
 
+import common.models
+
 
 class PartCategory(InvenTreeTree):
     """ PartCategory provides hierarchical organization of Part objects.
@@ -1227,6 +1229,20 @@ class PartAttachment(InvenTreeAttachment):
                              related_name='attachments')
 
 
+class PartSellPriceBreak(common.models.PriceBreak):
+    """
+    Represents a price break for selling this part
+    """
+
+    part = models.ForeignKey(
+        Part, on_delete=models.CASCADE,
+        related_name='salepricebreaks'
+    )
+
+    class Meta:
+        unique_together = ('part', 'quantity')
+
+
 class PartStar(models.Model):
     """ A PartStar object creates a relationship between a User and a Part.
 

From 71c0406cf3cbff2488e09b683911aed346aaaa08 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 18 Sep 2020 09:16:41 +1000
Subject: [PATCH 05/10] Register new model in the admin interface

---
 InvenTree/part/admin.py | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py
index 568a48034f..fce80eb219 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -13,6 +13,7 @@ from .models import PartAttachment, PartStar
 from .models import BomItem
 from .models import PartParameterTemplate, PartParameter
 from .models import PartTestTemplate
+from .models import PartSellPriceBreak
 
 from stock.models import StockLocation
 from company.models import SupplierPart
@@ -257,6 +258,14 @@ class ParameterAdmin(ImportExportModelAdmin):
     list_display = ('part', 'template', 'data')
 
 
+class PartSellPriceBreakAdmin(admin.ModelAdmin):
+
+    class Meta:
+        model = PartSellPriceBreak
+
+    list_display = ('part', 'quantity', 'cost', 'currency')
+
+
 admin.site.register(Part, PartAdmin)
 admin.site.register(PartCategory, PartCategoryAdmin)
 admin.site.register(PartAttachment, PartAttachmentAdmin)
@@ -265,3 +274,4 @@ admin.site.register(BomItem, BomItemAdmin)
 admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
 admin.site.register(PartParameter, ParameterAdmin)
 admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
+admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)

From a95dd865407e4612221bb282d8f0cbf5936acd25 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 18 Sep 2020 09:16:59 +1000
Subject: [PATCH 06/10] Limit choices for the 'Part' reference in the new model

---
 .../migrations/0050_auto_20200917_2315.py     | 19 +++++++++++++++++++
 InvenTree/part/models.py                      |  3 ++-
 2 files changed, 21 insertions(+), 1 deletion(-)
 create mode 100644 InvenTree/part/migrations/0050_auto_20200917_2315.py

diff --git a/InvenTree/part/migrations/0050_auto_20200917_2315.py b/InvenTree/part/migrations/0050_auto_20200917_2315.py
new file mode 100644
index 0000000000..635294093d
--- /dev/null
+++ b/InvenTree/part/migrations/0050_auto_20200917_2315.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.7 on 2020-09-17 23:15
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0049_partsellpricebreak'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='partsellpricebreak',
+            name='part',
+            field=models.ForeignKey(limit_choices_to={'salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='salepricebreaks', to='part.Part'),
+        ),
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index a3c707bfec..312745344e 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -1236,7 +1236,8 @@ class PartSellPriceBreak(common.models.PriceBreak):
 
     part = models.ForeignKey(
         Part, on_delete=models.CASCADE,
-        related_name='salepricebreaks'
+        related_name='salepricebreaks',
+        limit_choices_to={'salable': True}
     )
 
     class Meta:

From 1a90106bac473db9d26b6156b3a09dd6f1cea2e7 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 18 Sep 2020 21:20:28 +1000
Subject: [PATCH 07/10] Add a tab for part sale prices

---
 .../part/templates/part/sale_prices.html      | 28 +++++++++++++++++++
 InvenTree/part/templates/part/tabs.html       |  3 ++
 InvenTree/part/urls.py                        |  1 +
 3 files changed, 32 insertions(+)
 create mode 100644 InvenTree/part/templates/part/sale_prices.html

diff --git a/InvenTree/part/templates/part/sale_prices.html b/InvenTree/part/templates/part/sale_prices.html
new file mode 100644
index 0000000000..0d46542c6d
--- /dev/null
+++ b/InvenTree/part/templates/part/sale_prices.html
@@ -0,0 +1,28 @@
+{% extends "part/part_base.html" %}
+{% load static %}
+{% load i18n %}
+
+{% block details %}
+
+{% include 'part/tabs.html' with tab='sales-prices' %}
+
+<h4>{% trans "Sale Price" %}</h4>
+<hr>
+
+<div id='price-break-bar' class='btn-group'>
+    <button class='btn btn-primary' id='new-price-break' type='button'>{% trans "Add Price Break" %}</button>
+</div>
+
+<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
+</table>
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+$('#new-price-break').click(function() {
+    // TODO
+})
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html
index ffb92cf191..1eab299ed5 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -46,6 +46,9 @@
     </li>
     {% endif %}
     {% if part.salable %}
+    <li {% if tab == 'sales-prices' %}class='active'{% endif %}>
+        <a href="{% url 'part-sale-prices' part.id %}">{% trans "Sale Price" %}</a>
+    </li>
     <li{% ifequal tab 'sales-orders' %} class='active'{% endifequal %}>
         <a href="{% url 'part-sales-orders' part.id %}">{% trans "Sales Orders" %} <span class='badge'>{{ part.sales_orders|length }}</span></a>
     </li>
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 2707a563c7..39afd28f38 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -52,6 +52,7 @@ part_detail_urls = [
     url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
     url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
     url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
+    url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
     url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
     url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
     url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),

From ff7570aea4721f8b64143a11850aa364343e55a4 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 18 Sep 2020 21:49:56 +1000
Subject: [PATCH 08/10] VIews / forms / etc

---
 InvenTree/company/views.py                    |  2 +-
 InvenTree/part/forms.py                       | 21 ++++++
 .../part/templates/part/sale_prices.html      | 15 +++-
 InvenTree/part/urls.py                        | 10 ++-
 InvenTree/part/views.py                       | 73 +++++++++++++++++++
 5 files changed, 117 insertions(+), 4 deletions(-)

diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py
index 457cf74ec2..9ef6adea0e 100644
--- a/InvenTree/company/views.py
+++ b/InvenTree/company/views.py
@@ -401,7 +401,7 @@ class PriceBreakCreate(AjaxCreateView):
 
     def get_data(self):
         return {
-            'success': 'Added new price break'
+            'success': _('Added new price break')
         }
 
     def get_part(self):
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index ffcb46114a..1e87419026 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -17,6 +17,8 @@ from .models import Part, PartCategory, PartAttachment
 from .models import BomItem
 from .models import PartParameterTemplate, PartParameter
 from .models import PartTestTemplate
+from .models import PartSellPriceBreak
+
 
 from common.models import Currency
 
@@ -253,3 +255,22 @@ class PartPriceForm(forms.Form):
             'quantity',
             'currency',
         ]
+
+
+class EditPartSalePriceBreakForm(HelperForm):
+    """
+    Form for creating / editing a sale price for a part
+    """
+
+    quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
+
+    cost = RoundingDecimalFormField(max_digits=10, decimal_places=5)
+
+    class Meta:
+        model = PartSellPriceBreak
+        fields = [
+            'part',
+            'quantity',
+            'cost',
+            'currency',
+        ]
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/sale_prices.html b/InvenTree/part/templates/part/sale_prices.html
index 0d46542c6d..b8992e093e 100644
--- a/InvenTree/part/templates/part/sale_prices.html
+++ b/InvenTree/part/templates/part/sale_prices.html
@@ -21,8 +21,19 @@
 {% block js_ready %}
 {{ block.super }}
 
+function reloadTable() {
+    $("#price-break-table").bootstrapTable("refresh");
+}
+
 $('#new-price-break').click(function() {
-    // TODO
-})
+    launchModalForm("{% url 'sale-price-break-create' %}",
+        {
+            success: reloadTable,
+            data: {
+                part: {{ part.id }},
+            }
+        }
+    );
+});
 
 {% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 39afd28f38..e61947e243 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -18,6 +18,12 @@ part_attachment_urls = [
     url(r'^(?P<pk>\d+)/delete/?', views.PartAttachmentDelete.as_view(), name='part-attachment-delete'),
 ]
 
+sale_price_break_urls = [
+    url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
+    url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
+    url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
+]
+
 part_parameter_urls = [
     
     url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
@@ -27,7 +33,6 @@ part_parameter_urls = [
     url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'),
     url(r'^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'),
     url(r'^(?P<pk>\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'),
-
 ]
 
 part_detail_urls = [
@@ -109,6 +114,9 @@ part_urls = [
     # Part attachments
     url(r'^attachment/', include(part_attachment_urls)),
 
+    # Part price breaks
+    url(r'^sale-price/', include(sale_price_break_urls)),
+
     # Part test templates
     url(r'^test-template/', include([
         url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 58ece9d0b0..ccf607afc0 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -26,6 +26,7 @@ from .models import PartParameterTemplate, PartParameter
 from .models import BomItem
 from .models import match_part_names
 from .models import PartTestTemplate
+from .models import PartSellPriceBreak
 
 from common.models import Currency, InvenTreeSetting
 from company.models import SupplierPart
@@ -2097,3 +2098,75 @@ class BomItemDelete(AjaxDeleteView):
     ajax_template_name = 'part/bom-delete.html'
     context_object_name = 'item'
     ajax_form_title = _('Confim BOM item deletion')
+
+
+class PartSalePriceBreakCreate(AjaxCreateView):
+    """ View for creating a sale price break for a part """
+
+    model = PartSellPriceBreak
+    form_class = part_forms.EditPartSalePriceBreakForm
+    ajax_form_title = _('Add Price Break')
+    
+    def get_data(self):
+        return {
+            'success': _('Added new price break')
+        }
+
+    def get_part(self):
+        try:
+            part = Part.objects.get(id=self.request.GET.get('part'))
+        except (ValueError, Part.DoesNotExist):
+            part = None
+        
+        if part is None:
+            try:
+                part = Part.objects.get(id=self.request.POST.get('part'))
+            except (ValueError, Part.DoesNotExist):
+                part = None
+
+        return part
+
+    def get_form(self):
+
+        form = super(AjaxCreateView, self).get_form()
+        form.fields['part'].widget = HiddenInput()
+
+        return form
+
+    def get_initial(self):
+
+        initials = super(AjaxCreateView, self).get_initial()
+
+        initials['part'] = self.get_part()
+
+        # Pre-select the default currency
+        try:
+            base = Currency.objects.get(base=True)
+            initials['currency'] = base
+        except Currency.DoesNotExist:
+            pass
+
+        return initials
+
+
+class PartSalePriceBreakEdit(AjaxUpdateView):
+    """ View for editing a sale price break """
+
+    model = PartSellPriceBreak
+    form_class = part_forms.EditPartSalePriceBreakForm
+    ajax_form_title = _('Edit Price Break')
+
+    def get_form(self):
+
+        form = super().get_form()
+        form.fields['part'].widget = HiddenInput()
+
+        return form
+
+    
+class PartSalePriceBreakDelete(AjaxDeleteView):
+    """ View for deleting a sale price break """
+
+    model = PartSellPriceBreak
+    ajax_form_title = _("Delete Price Break")
+    ajax_template_name = "modal_delete_form.html"

From ca1281ee109c7d184b5f294988274d9feed5a9fc Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 18 Sep 2020 22:11:51 +1000
Subject: [PATCH 09/10] Adds ajax table for part sale price information

---
 InvenTree/part/api.py                         | 27 +++++++
 InvenTree/part/serializers.py                 | 27 +++++++
 .../part/templates/part/sale_prices.html      | 77 ++++++++++++++++++-
 3 files changed, 128 insertions(+), 3 deletions(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index abc4181895..225e2402e1 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -20,6 +20,7 @@ from django.urls import reverse
 from .models import Part, PartCategory, BomItem, PartStar
 from .models import PartParameter, PartParameterTemplate
 from .models import PartAttachment, PartTestTemplate
+from .models import PartSellPriceBreak
 
 from . import serializers as part_serializers
 
@@ -107,6 +108,27 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
     queryset = PartCategory.objects.all()
 
 
+class PartSalePriceList(generics.ListCreateAPIView):
+    """
+    API endpoint for list view of PartSalePriceBreak model
+    """
+
+    queryset = PartSellPriceBreak.objects.all()
+    serializer_class = part_serializers.PartSalePriceSerializer
+
+    permission_classes = [
+        permissions.IsAuthenticated,
+    ]
+
+    filter_backends = [
+        DjangoFilterBackend
+    ]
+
+    filter_fields = [
+        'part',
+    ]
+
+
 class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
     """
     API endpoint for listing (and creating) a PartAttachment (file upload).
@@ -755,6 +777,11 @@ part_api_urls = [
         url(r'^(?P<pk>\d+)/?', PartStarDetail.as_view(), name='api-part-star-detail'),
         url(r'^$', PartStarList.as_view(), name='api-part-star-list'),
     ])),
+
+    # Base URL for part sale pricing
+    url(r'^sale-price/', include([
+        url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
+    ])),
     
     # Base URL for PartParameter API endpoints
     url(r'^parameter/', include([
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 8adb26680e..62f7eebbbb 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -12,6 +12,7 @@ from .models import BomItem
 from .models import PartParameter, PartParameterTemplate
 from .models import PartAttachment
 from .models import PartTestTemplate
+from .models import PartSellPriceBreak
 
 from stock.models import StockItem
 
@@ -87,6 +88,32 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
         ]
 
 
+class PartSalePriceSerializer(InvenTreeModelSerializer):
+    """
+    Serializer for sale prices for Part model.
+    """
+
+    symbol = serializers.CharField(read_only=True)
+
+    suffix = serializers.CharField(read_only=True)
+
+    quantity = serializers.FloatField()
+
+    cost = serializers.FloatField()
+
+    class Meta:
+        model = PartSellPriceBreak
+        fields = [
+            'pk',
+            'part',
+            'quantity',
+            'cost',
+            'currency',
+            'symbol',
+            'suffix',
+        ]    
+
+
 class PartThumbSerializer(serializers.Serializer):
     """
     Serializer for the 'image' field of the Part model.
diff --git a/InvenTree/part/templates/part/sale_prices.html b/InvenTree/part/templates/part/sale_prices.html
index b8992e093e..8d3cc61afd 100644
--- a/InvenTree/part/templates/part/sale_prices.html
+++ b/InvenTree/part/templates/part/sale_prices.html
@@ -9,7 +9,7 @@
 <h4>{% trans "Sale Price" %}</h4>
 <hr>
 
-<div id='price-break-bar' class='btn-group'>
+<div id='price-break-toolbar' class='btn-group'>
     <button class='btn btn-primary' id='new-price-break' type='button'>{% trans "Add Price Break" %}</button>
 </div>
 
@@ -21,14 +21,14 @@
 {% block js_ready %}
 {{ block.super }}
 
-function reloadTable() {
+function reloadPriceBreaks() {
     $("#price-break-table").bootstrapTable("refresh");
 }
 
 $('#new-price-break').click(function() {
     launchModalForm("{% url 'sale-price-break-create' %}",
         {
-            success: reloadTable,
+            success: reloadPriceBreaks,
             data: {
                 part: {{ part.id }},
             }
@@ -36,4 +36,75 @@ $('#new-price-break').click(function() {
     );
 });
 
+$('#price-break-table').inventreeTable({
+    name: 'saleprice',
+    formatNoMatches: function() { return "{% trans 'No price break information found' %}"; },
+    queryParams: {
+        part: {{ part.id }},
+    },
+    url: "{% url 'api-part-sale-price-list' %}",
+    onLoadSuccess: function() {
+        var table = $('#price-break-table');
+
+        table.find('.button-price-break-delete').click(function() {
+            var pk = $(this).attr('pk');
+
+            launchModalForm(
+                `/part/sale-price/${pk}/delete/`,
+                {
+                    success: reloadPriceBreaks
+                }
+            );
+        });
+
+        table.find('.button-price-break-edit').click(function() {
+            var pk = $(this).attr('pk');
+
+            launchModalForm(
+                `/part/sale-price/${pk}/edit/`,
+                {
+                    success: reloadPriceBreaks
+                }
+            );
+        });
+    },
+    columns: [
+        {
+            field: 'pk',
+            title: 'ID',
+            visible: false,
+            switchable: false,
+        },
+        {
+            field: 'quantity',
+            title: '{% trans "Quantity" %}',
+            sortable: true,
+        },
+        {
+            field: 'cost',
+            title: '{% trans "Price" %}',
+            sortable: true,
+            formatter: function(value, row, index) {
+                var html = '';
+
+                html += row.symbol || '';
+                html += value;
+
+                if (row.suffix) {
+                    html += ' ' + row.suffix || '';
+                }
+
+                html += `<div class='btn-group float-right' role='group'>`
+
+                html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
+                html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
+
+                html += `</div>`;
+
+                return html;
+            }
+        },
+    ]
+})
+
 {% endblock %}
\ No newline at end of file

From 86660a5f17637dc27bb54414cee19637478e70ff Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 19 Sep 2020 19:52:48 +1000
Subject: [PATCH 10/10] style fixes

---
 InvenTree/part/forms.py       | 2 +-
 InvenTree/part/serializers.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 1e87419026..0a15d598bd 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -273,4 +273,4 @@ class EditPartSalePriceBreakForm(HelperForm):
             'quantity',
             'cost',
             'currency',
-        ]
\ No newline at end of file
+        ]
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 62f7eebbbb..7c73e9f98b 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -111,7 +111,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
             'currency',
             'symbol',
             'suffix',
-        ]    
+        ]
 
 
 class PartThumbSerializer(serializers.Serializer):