From 4bfdf211072cb7f3b08fbe967e8054bab383a1be Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Tue, 2 Nov 2021 15:07:20 +1100
Subject: [PATCH 01/20] Change "star" icon to "bullhorn" icon

---
 InvenTree/InvenTree/views.py                          | 11 -----------
 InvenTree/common/models.py                            |  4 ++--
 InvenTree/part/templates/part/part_base.html          |  4 ++--
 InvenTree/templates/InvenTree/index.html              |  2 +-
 .../templates/InvenTree/settings/user_homepage.html   |  2 +-
 InvenTree/templates/js/translated/part.js             |  4 ++--
 6 files changed, 8 insertions(+), 19 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 00f38ce89d..989fb1bc9d 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -655,17 +655,6 @@ class IndexView(TemplateView):
 
         context = super(TemplateView, self).get_context_data(**kwargs)
 
-        # TODO - Re-implement this when a less expensive method is worked out
-        # context['starred'] = [star.part for star in self.request.user.starred_parts.all()]
-
-        # Generate a list of orderable parts which have stock below their minimum values
-        # TODO - Is there a less expensive way to get these from the database
-        # context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()]
-
-        # Generate a list of assembly parts which have stock below their minimum values
-        # TODO - Is there a less expensive way to get these from the database
-        # context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()]
-
         return context
 
 
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 1809f437f7..125941be14 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -874,8 +874,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
 
     GLOBAL_SETTINGS = {
         'HOMEPAGE_PART_STARRED': {
-            'name': _('Show starred parts'),
-            'description': _('Show starred parts on the homepage'),
+            'name': _('Show subscribed parts'),
+            'description': _('Show subscribed parts on the homepage'),
             'default': True,
             'validator': bool,
         },
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 16b119e02d..3e00b56158 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -23,8 +23,8 @@
 {% include "admin_button.html" with url=url %}
 {% endif %}
 
-<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Star this part" %}'>
-    <span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/>
+<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'>
+    <span id='part-star-icon' class='fas fa-bullhorn {% if starred %}icon-green{% endif %}'/>
 </button>
 
 {% if barcodes %}
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index 95b6c15bf9..6847e41095 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -84,7 +84,7 @@ function addHeaderAction(label, title, icon, options) {
 addHeaderTitle('{% trans "Parts" %}');
 
 {% if setting_part_starred %}
-addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star');
+addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bullhorn');
 loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
     params: {
         "starred": true,
diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html
index 7eed850ad5..455f7f2a8b 100644
--- a/InvenTree/templates/InvenTree/settings/user_homepage.html
+++ b/InvenTree/templates/InvenTree/settings/user_homepage.html
@@ -14,7 +14,7 @@
 <div class='row'>
     <table class='table table-striped table-condensed'>
         <tbody>
-            {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-star' user_setting=True %}
+            {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bullhorn' user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %}
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index ec72d2682c..0ff6bd400c 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -398,9 +398,9 @@ function toggleStar(options) {
                     method: 'PATCH',
                     success: function(response) {
                         if (response.starred) {
-                            $(options.button).addClass('icon-yellow');
+                            $(options.button).addClass('icon-green');
                         } else {
-                            $(options.button).removeClass('icon-yellow');
+                            $(options.button).removeClass('icon-green');
                         }
                     }
                 }

From 85adf842f69cd953bbf87c713a653f1f983b16ad Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 3 Nov 2021 16:59:59 +1100
Subject: [PATCH 02/20] Change bullhorn icon to bell icon

---
 InvenTree/part/templates/part/part_base.html  |  6 +++++-
 InvenTree/templates/InvenTree/index.html      |  2 +-
 .../InvenTree/settings/user_homepage.html     |  2 +-
 InvenTree/templates/js/translated/part.js     | 20 +++++++++----------
 4 files changed, 17 insertions(+), 13 deletions(-)

diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 3e00b56158..6cbd3f92db 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -24,7 +24,11 @@
 {% endif %}
 
 <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'>
-    <span id='part-star-icon' class='fas fa-bullhorn {% if starred %}icon-green{% endif %}'/>
+    {% if starred %}
+    <span id='part-star-icon' class='fas fa-bell icon-green'/>
+    {% else %}
+    <span id='part-star-icon' class='fa fa-bell-slash'/>
+    {% endif %}
 </button>
 
 {% if barcodes %}
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index 6847e41095..2c407fdcd9 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -84,7 +84,7 @@ function addHeaderAction(label, title, icon, options) {
 addHeaderTitle('{% trans "Parts" %}');
 
 {% if setting_part_starred %}
-addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bullhorn');
+addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell');
 loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
     params: {
         "starred": true,
diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html
index 455f7f2a8b..8219187044 100644
--- a/InvenTree/templates/InvenTree/settings/user_homepage.html
+++ b/InvenTree/templates/InvenTree/settings/user_homepage.html
@@ -14,7 +14,7 @@
 <div class='row'>
     <table class='table table-striped table-condensed'>
         <tbody>
-            {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bullhorn' user_setting=True %}
+            {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bell' user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %}
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 5863855a33..2c59723f14 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -373,15 +373,15 @@ function duplicatePart(pk, options={}) {
 }
 
 
+/* Toggle the 'starred' status of a part.
+ * Performs AJAX queries and updates the display on the button.
+ * 
+ * options:
+ * - button: ID of the button (default = '#part-star-icon')
+ * - part: pk of the part object
+ * - user: pk of the user
+ */
 function toggleStar(options) {
-    /* Toggle the 'starred' status of a part.
-     * Performs AJAX queries and updates the display on the button.
-     * 
-     * options:
-     * - button: ID of the button (default = '#part-star-icon')
-     * - part: pk of the part object
-     * - user: pk of the user
-     */
 
     var url = `/api/part/${options.part}/`;
 
@@ -398,9 +398,9 @@ function toggleStar(options) {
                     method: 'PATCH',
                     success: function(response) {
                         if (response.starred) {
-                            $(options.button).addClass('icon-green');
+                            $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green');
                         } else {
-                            $(options.button).removeClass('icon-green');
+                            $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash');
                         }
                     }
                 }

From e7f6268640b82f7cb84e3a3a3e3c36383ff48043 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 3 Nov 2021 17:55:30 +1100
Subject: [PATCH 03/20] Improvements for alert notifications

- Dismissable
- Delete after a certain amount of time
---
 InvenTree/InvenTree/static/css/inventree.css  | 14 +--
 .../static/script/inventree/notification.js   | 90 +++++++++++++++----
 InvenTree/part/templates/part/part_base.html  | 12 +--
 InvenTree/templates/base.html                 |  8 +-
 InvenTree/templates/js/translated/part.js     | 10 +++
 InvenTree/templates/notification.html         | 18 ----
 6 files changed, 99 insertions(+), 53 deletions(-)
 delete mode 100644 InvenTree/templates/notification.html

diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css
index 61037c9c54..670d577497 100644
--- a/InvenTree/InvenTree/static/css/inventree.css
+++ b/InvenTree/InvenTree/static/css/inventree.css
@@ -745,13 +745,7 @@ input[type="submit"] {
 }
 
 .notification-area {
-    position: fixed;
-    top: 0px;
-    margin-top: 20px;
-    width: 100%;
-    padding: 20px;
-    z-index: 5000;
-    pointer-events: none; /* Prevent this div from blocking links underneath */
+    opacity: 0.8;
 }
 
 .notes {
@@ -761,7 +755,6 @@ input[type="submit"] {
 }
 
 .alert {
-    display: none;
     border-radius: 5px;
     opacity: 0.9;
     pointer-events: all;
@@ -771,9 +764,8 @@ input[type="submit"] {
     display: block;
 }
 
-.btn {
-    margin-left: 2px;
-    margin-right: 2px;
+.navbar .btn {
+    margin-left: 5px;
 }
 
 .btn-secondary {
diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js
index 01754bceaf..0e8a19ed87 100644
--- a/InvenTree/InvenTree/static/script/inventree/notification.js
+++ b/InvenTree/InvenTree/static/script/inventree/notification.js
@@ -16,29 +16,83 @@ function showAlertOrCache(alertType, message, cache, timeout=5000) {
     }
 }
 
+
+/*
+ * Display cached alert messages when loading a page
+ */
 function showCachedAlerts() {
 
-    // Success Message
-    if (sessionStorage.getItem("inventree-alert-success")) {
-        showAlert("#alert-success", sessionStorage.getItem("inventree-alert-success"));
-        sessionStorage.removeItem("inventree-alert-success");
+    var styles = [
+        'primary',
+        'secondary',
+        'success',
+        'info',
+        'warning',
+        'danger',
+    ];
+
+    styles.forEach(function(style) {
+
+        var msg = sessionStorage.getItem(`inventree-alert-${style}`);
+
+        if (msg) {
+            showMessage(msg, {
+                style: style,
+            });
+        }
+    });
+}
+
+
+/* 
+ * Display an alert message at the top of the screen.
+ * The message will contain a "close" button,
+ * and also dismiss automatically after a certain amount of time.
+ * 
+ * arguments:
+ * - message: Text / HTML content to display
+ * 
+ * options:
+ * - style: alert style e.g. 'success' / 'warning'
+ * - timeout: Time (in milliseconds) after which the message will be dismissed
+ */
+function showMessage(message, options={}) {
+
+    var style = options.style || 'info';
+
+    var timeout = options.timeout || 5000;
+
+    // Hacky function to get the next available ID
+    var id = 1;
+
+    while ($(`#alert-${id}`).exists()) {
+        id++;
     }
 
-    // Info Message
-    if (sessionStorage.getItem("inventree-alert-info")) {
-        showAlert("#alert-info", sessionStorage.getItem("inventree-alert-info"));
-        sessionStorage.removeItem("inventree-alert-info");
+    var icon = '';
+
+    if (options.icon) {
+        icon = `<span class='${options.icon}></span>`;
     }
 
-    // Warning Message
-    if (sessionStorage.getItem("inventree-alert-warning")) {
-        showAlert("#alert-warning", sessionStorage.getItem("inventree-alert-warning"));
-        sessionStorage.removeItem("inventree-alert-warning");
-    }
+    // Construct the alert
+    var html = `
+    <div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
+        ${icon}
+        ${message}
+        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>  
+    </div>
+    `;
 
-    // Danger Message
-    if (sessionStorage.getItem("inventree-alert-danger")) {
-        showAlert("#alert-danger", sessionStorage.getItem("inventree-alert-danger"));
-        sessionStorage.removeItem("inventree-alert-danger");
-    }
+    $('#alerts').append(html);
+
+    // Remove the alert automatically after a specified period of time
+    setInterval(function() {
+        $(`#alert-${id}`).animate({
+            'opacity': 0.0,
+            'height': '0px',
+        }, 250, function() {
+            $(`#alert-${id}`).remove();
+        });
+    }, timeout);
 }
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 6cbd3f92db..1dcb509a59 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -23,13 +23,15 @@
 {% include "admin_button.html" with url=url %}
 {% endif %}
 
-<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'>
-    {% if starred %}
+{% if starred %}
+<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to nofications for this part" %}'>
     <span id='part-star-icon' class='fas fa-bell icon-green'/>
-    {% else %}
-    <span id='part-star-icon' class='fa fa-bell-slash'/>
-    {% endif %}
 </button>
+{% else %}
+<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'>
+    <span id='part-star-icon' class='fa fa-bell-slash'/>
+</button>
+{% endif %}
 
 {% if barcodes %}
 <!-- Barcode actions menu -->
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index 3b6350e40b..d01d5051f6 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -84,6 +84,13 @@
             </div>
         </div>
         <main class='col ps-md-2 pt-2'>
+
+            {% block alerts %}
+            <div class='notification-area' id='alerts'>
+                <!-- Div for displayed alerts -->
+            </div>
+            {% endblock %}
+
             {% block breadcrumb_list %}
             <div class='container-fluid navigation'>
                 <nav aria-label='breadcrumb'>
@@ -102,7 +109,6 @@
     </div>
     {% include 'modals.html' %}
     {% include 'about.html' %}
-    {% include 'notification.html' %}
 </div>
 
 <!-- Scripts -->
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 2c59723f14..adc10566d7 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -399,8 +399,18 @@ function toggleStar(options) {
                     success: function(response) {
                         if (response.starred) {
                             $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green');
+                            $(options.button).attr('title', '{% trans "You are subscribed to notifications for this part" %}');
+
+                            showMessage('{% trans "You have subscribed to notifications for this part" %}', {
+                                style: 'success',
+                            });
                         } else {
                             $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash');
+                            $(options.button).attr('title', '{% trans "Subscribe to notifications for this part" %}');
+
+                            showMessage('{% trans "You have unsubscribed to notifications for this part" %}', {
+                                style: 'warning',
+                            });
                         }
                     }
                 }
diff --git a/InvenTree/templates/notification.html b/InvenTree/templates/notification.html
deleted file mode 100644
index 9919b0d6f5..0000000000
--- a/InvenTree/templates/notification.html
+++ /dev/null
@@ -1,18 +0,0 @@
-<div class='notification-area'>
-<div class="alert alert-success alert-dismissable" id="alert-success">
-     <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
-     <div class='alert-msg'>Success alert</div>
-</div>
-<div class='alert alert-info alert-dismissable' id='alert-info'>
-     <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
-     <div class='alert-msg'>Info alert</div>
-</div>
-<div class='alert alert-warning alert-dismissable' id='alert-warning'>
-     <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
-     <div class='alert-msg'>Warning alert</div>
-</div>
-<div class='alert alert-danger alert-dismissable' id='alert-danger'>
-    <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">&times;</a>
-    <div class='alert-msg'>Danger alert</div>
-</div>
-</div>
\ No newline at end of file

From 4cf6b9bd319fa2d8d49a97db23f0068b6aefc399 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 3 Nov 2021 17:59:08 +1100
Subject: [PATCH 04/20] Remove old function

---
 .../static/script/inventree/notification.js   | 21 ++++---------------
 1 file changed, 4 insertions(+), 17 deletions(-)

diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js
index 0e8a19ed87..4ed1333ac6 100644
--- a/InvenTree/InvenTree/static/script/inventree/notification.js
+++ b/InvenTree/InvenTree/static/script/inventree/notification.js
@@ -1,18 +1,10 @@
-function showAlert(target, message, timeout=5000) {
-
-    $(target).find(".alert-msg").html(message);
-    $(target).show();
-    $(target).delay(timeout).slideUp(200, function() {
-        $(this).alert(close);
-    });
-}
 
 function showAlertOrCache(alertType, message, cache, timeout=5000) {
     if (cache) {
         sessionStorage.setItem("inventree-" + alertType, message);
     }
     else {
-        showAlert('#' + alertType, message, timeout);
+        showMessage('#' + alertType, message, timeout);
     }
 }
 
@@ -87,12 +79,7 @@ function showMessage(message, options={}) {
     $('#alerts').append(html);
 
     // Remove the alert automatically after a specified period of time
-    setInterval(function() {
-        $(`#alert-${id}`).animate({
-            'opacity': 0.0,
-            'height': '0px',
-        }, 250, function() {
-            $(`#alert-${id}`).remove();
-        });
-    }, timeout);
+    $(`#alert-${id}`).delay(timeout).slideUp(200, function() {
+        $(this).alert(close);
+    });
 }
\ No newline at end of file

From cf023e2cc17541e082ea85726bf22d487e5360e7 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 3 Nov 2021 18:10:34 +1100
Subject: [PATCH 05/20] Create new model for "PartCategory"

---
 .../part/migrations/0074_partcategorystar.py  | 27 +++++++++++++++++
 InvenTree/part/models.py                      | 30 ++++++++++++++++---
 2 files changed, 53 insertions(+), 4 deletions(-)
 create mode 100644 InvenTree/part/migrations/0074_partcategorystar.py

diff --git a/InvenTree/part/migrations/0074_partcategorystar.py b/InvenTree/part/migrations/0074_partcategorystar.py
new file mode 100644
index 0000000000..0015212d2e
--- /dev/null
+++ b/InvenTree/part/migrations/0074_partcategorystar.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.5 on 2021-11-03 07:03
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('part', '0073_auto_20211013_1048'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PartCategoryStar',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.partcategory', verbose_name='Category')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_categories', to=settings.AUTH_USER_MODEL, verbose_name='User')),
+            ],
+            options={
+                'unique_together': {('category', 'user')},
+            },
+        ),
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 050b46058a..fc7382ac62 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -2062,10 +2062,9 @@ class PartInternalPriceBreak(common.models.PriceBreak):
 
 
 class PartStar(models.Model):
-    """ A PartStar object creates a relationship between a User and a Part.
+    """ A PartStar object creates a subscription relationship between a User and a Part.
 
-    It is used to designate a Part as 'starred' (or favourited) for a given User,
-    so that the user can track a list of their favourite parts.
+    It is used to designate a Part as 'subscribed' for a given User.
 
     Attributes:
         part: Link to a Part object
@@ -2077,7 +2076,30 @@ class PartStar(models.Model):
     user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
 
     class Meta:
-        unique_together = ['part', 'user']
+        unique_together = [
+            'part',
+            'user'
+        ]
+
+
+class PartCategoryStar(models.Model):
+    """
+    A PartCategoryStar creates a subscription relationship between a User and a PartCategory.
+
+    Attributes:
+        category: Link to a PartCategory object
+        user: Link to a User object
+    """
+
+    category = models.ForeignKey(PartCategory, on_delete=models.CASCADE, verbose_name=_('Category'), related_name='starred_users')
+
+    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories')
+
+    class Meta:
+        unique_together = [
+            'category',
+            'user',
+        ]
 
 
 class PartTestTemplate(models.Model):

From f9a00b7a903011e899b6518886ab2e2541c713e4 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 3 Nov 2021 22:57:49 +1100
Subject: [PATCH 06/20] Adds extra subsctiption functionality for Part and
 PartCategory

- Allows variants and templates
- Allows categories and sub-categories
- Unit testing
---
 InvenTree/InvenTree/static/css/inventree.css |   4 -
 InvenTree/part/api.py                        |   2 +-
 InvenTree/part/models.py                     | 121 ++++++++++++++++---
 InvenTree/part/test_part.py                  | 119 +++++++++++++++++-
 4 files changed, 224 insertions(+), 22 deletions(-)

diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css
index 670d577497..273f2ec527 100644
--- a/InvenTree/InvenTree/static/css/inventree.css
+++ b/InvenTree/InvenTree/static/css/inventree.css
@@ -180,10 +180,6 @@
     float: right;
 }
 
-.starred-part {
-    color: #ffbb00;
-}
-
 .red-cell {
     background-color: #ec7f7f;
 }
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index a11bb1b088..0b754dffe8 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -420,7 +420,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
         if 'starred' in request.data:
             starred = str2bool(request.data.get('starred', None))
 
-            self.get_object().setStarred(request.user, starred)
+            self.get_object().set_subscription(request.user, starred)
 
         response = super().update(request, *args, **kwargs)
 
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index fc7382ac62..0b99b8dac5 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -15,7 +15,7 @@ from django.urls import reverse
 
 from django.db import models, transaction
 from django.db.utils import IntegrityError
-from django.db.models import Q, Sum, UniqueConstraint
+from django.db.models import Q, Sum, UniqueConstraint, query
 from django.db.models.functions import Coalesce
 from django.core.validators import MinValueValidator
 
@@ -201,6 +201,60 @@ class PartCategory(InvenTreeTree):
 
         return prefetch.filter(category=self.id)
 
+    def get_subscribers(self, include_parents=True):
+        """
+        Return a list of users who subscribe to this PartCategory
+        """
+
+        cats = self.get_ancestors(include_self=True)
+
+        subscribers = set()
+
+        if include_parents:
+            queryset = PartCategoryStar.objects.filter(
+                category__pk__in=[cat.pk for cat in cats]
+            )
+        else:
+            queryset = PartCategoryStar.objects.filter(
+                category=self,
+            )
+
+        for result in queryset:
+            subscribers.add(result.user)
+
+        return [s for s in subscribers]
+
+    def is_subscribed_by(self, user, **kwargs):
+        """
+        Returns True if the specified user subscribes to this category
+        """
+
+        return user in self.get_subscribers(**kwargs)
+
+    def set_subscription(self, user, status):
+        """
+        Set the "subscription" status of this PartCategory against the specified user
+        """
+
+        if not user:
+            return
+
+        if self.is_subscribed_by(user) == status:
+            return
+
+        if status:
+            PartCategoryStar.objects.create(
+                category=self,
+                user=user
+            )
+        else:
+            # Note that this won't actually stop the user being subscribed,
+            # if the user is subscribed to a parent category
+            PartCategoryStar.objects.filter(
+                category=self,
+                user=user,
+            ).delete()
+
 
 @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
 def before_delete_part_category(sender, instance, using, **kwargs):
@@ -332,7 +386,7 @@ class Part(MPTTModel):
 
         context = {}
 
-        context['starred'] = self.isStarredBy(request.user)
+        context['starred'] = self.is_subscribed_by(request.user)
         context['disabled'] = not self.active
 
         # Pre-calculate complex queries so they only need to be performed once
@@ -1040,30 +1094,65 @@ class Part(MPTTModel):
 
         return self.total_stock - self.allocation_count() + self.on_order
 
-    def isStarredBy(self, user):
-        """ Return True if this part has been starred by a particular user """
-
-        try:
-            PartStar.objects.get(part=self, user=user)
-            return True
-        except PartStar.DoesNotExist:
-            return False
-
-    def setStarred(self, user, starred):
+    def get_subscribers(self, include_variants=True, include_categories=True):
         """
-        Set the "starred" status of this Part for the given user
+        Return a list of users who are 'subscribed' to this part.
+
+        A user may 'subscribe' to this part in the following ways:
+
+        a) Subscribing to the part instance directly
+        b) Subscribing to a template part "above" this part (if it is a variant)
+        c) Subscribing to the part category that this part belongs to
+        d) Subscribing to a parent category of the category in c)
+
+        """
+
+        subscribers = set()
+
+        # Start by looking at direct subscriptions to a Part model
+        queryset = PartStar.objects.all()
+
+        if include_variants:
+            queryset = queryset.filter(
+                part__pk__in=[part.pk for part in self.get_ancestors(include_self=True)]
+            )
+        else:
+            queryset = queryset.filter(part=self)
+
+        for star in queryset:
+            subscribers.add(star.user)
+
+        if include_categories and self.category:
+
+            for sub in self.category.get_subscribers():
+                subscribers.add(sub)
+
+        return [s for s in subscribers]
+
+    def is_subscribed_by(self, user, **kwargs):
+        """
+        Return True if the specified user subscribes to this part
+        """
+
+        return user in self.get_subscribers(**kwargs)
+
+    def set_subscription(self, user, status):
+        """
+        Set the "subscription" status of this Part against the specified user
         """
 
         if not user:
             return
 
-        # Do not duplicate efforts
-        if self.isStarredBy(user) == starred:
+        # Already subscribed?
+        if self.is_subscribed_by(user) == status:
             return
 
-        if starred:
+        if status:
             PartStar.objects.create(part=self, user=user)
         else:
+            # Note that this won't actually stop the user being subscribed,
+            # if the user is subscribed to a parent part or category
             PartStar.objects.filter(part=self, user=user).delete()
 
     def need_to_restock(self):
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index 1bd9fdf87d..39bb6a39af 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
 
 import os
 
-from .models import Part, PartCategory, PartTestTemplate
+from .models import Part, PartCategory, PartCategoryStar, PartStar, PartTestTemplate
 from .models import rename_part_image
 from .templatetags import inventree_extras
 
@@ -347,3 +347,120 @@ class PartSettingsTest(TestCase):
         with self.assertRaises(ValidationError):
             part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
             part.full_clean()
+
+
+class PartSubscriptionTests(TestCase):
+
+    fixtures = [
+        'location',
+        'category',
+        'part',
+    ]
+
+    def setUp(self):
+        # Create a user for auth
+        user = get_user_model()
+
+        self.user = user.objects.create_user(
+            username='testuser',
+            email='test@testing.com',
+            password='password',
+            is_staff=True
+        )
+
+        # electronics / IC / MCU
+        self.category = PartCategory.objects.get(pk=4)
+        
+        self.part = Part.objects.create(
+            category=self.category,
+            name='STM32F103',
+            description='Currently worth a lot of money',
+            is_template=True,
+        )
+
+    def test_part_subcription(self):
+        """
+        Test basic subscription against a part
+        """
+        
+        # First check that the user is *not* subscribed to the part
+        self.assertFalse(self.part.is_subscribed_by(self.user))
+
+        # Now, subscribe directly to the part
+        self.part.set_subscription(self.user, True)
+
+        self.assertEqual(PartStar.objects.count(), 1)
+
+        self.assertTrue(self.part.is_subscribed_by(self.user))
+
+        # Now, unsubscribe
+        self.part.set_subscription(self.user, False)
+
+        self.assertFalse(self.part.is_subscribed_by(self.user))
+
+    def test_variant_subscription(self):
+        """
+        Test subscription against a parent part
+        """
+
+        # Construct a sub-part to star against
+        sub_part = Part.objects.create(
+            name='sub_part',
+            description='a sub part',
+            variant_of=self.part,
+        )
+
+        self.assertFalse(sub_part.is_subscribed_by(self.user))
+
+        # Subscribe to the "parent" part
+        self.part.set_subscription(self.user, True)
+
+        self.assertTrue(self.part.is_subscribed_by(self.user))
+        self.assertTrue(sub_part.is_subscribed_by(self.user))
+
+    def test_category_subscription(self):
+        """
+        Test subscription against a PartCategory
+        """
+
+        self.assertEqual(PartCategoryStar.objects.count(), 0)
+
+        self.assertFalse(self.part.is_subscribed_by(self.user))
+        self.assertFalse(self.category.is_subscribed_by(self.user))
+
+        # Subscribe to the direct parent category
+        self.category.set_subscription(self.user, True)
+
+        self.assertEqual(PartStar.objects.count(), 0)
+        self.assertEqual(PartCategoryStar.objects.count(), 1)
+
+        self.assertTrue(self.category.is_subscribed_by(self.user))
+        self.assertTrue(self.part.is_subscribed_by(self.user))
+
+        # Check that the "parent" category is not starred
+        self.assertFalse(self.category.parent.is_subscribed_by(self.user))
+
+        # Un-subscribe
+        self.category.set_subscription(self.user, False)
+
+        self.assertFalse(self.category.is_subscribed_by(self.user))
+        self.assertFalse(self.part.is_subscribed_by(self.user))
+
+    def test_parent_category_subscription(self):
+        """
+        Check that a parent category can be subscribed to
+        """
+        
+        # Top-level "electronics" category
+        cat = PartCategory.objects.get(pk=1)
+
+        cat.set_subscription(self.user, True)
+
+        # Check base category
+        self.assertTrue(cat.is_subscribed_by(self.user))
+
+        # Check lower level category
+        self.assertTrue(self.category.is_subscribed_by(self.user))
+
+        # Check part
+        self.assertTrue(self.part.is_subscribed_by(self.user))

From 7567b8dd63d1d8c26a663cf7eb2146bb4eb38ab4 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 3 Nov 2021 23:22:31 +1100
Subject: [PATCH 07/20] MOAR FEATURES:

- Add admin view for PartCategoryStar
- Add starred status to partcategory API
- Can filter by "starred" status
- Rename internal functions back to using "starred" (front-end now uses the term "subscribe")
---
 InvenTree/part/admin.py                      | 68 ++++++++++----------
 InvenTree/part/api.py                        | 32 ++++++++-
 InvenTree/part/models.py                     | 29 +++++----
 InvenTree/part/serializers.py                | 16 +++++
 InvenTree/part/templates/part/part_base.html |  6 +-
 InvenTree/part/test_part.py                  | 44 ++++++-------
 InvenTree/part/views.py                      |  1 +
 7 files changed, 126 insertions(+), 70 deletions(-)

diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py
index 2e434d928d..90543d429d 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -8,13 +8,7 @@ from import_export.resources import ModelResource
 from import_export.fields import Field
 import import_export.widgets as widgets
 
-from .models import PartCategory, Part
-from .models import PartAttachment, PartStar, PartRelated
-from .models import BomItem
-from .models import PartParameterTemplate, PartParameter
-from .models import PartCategoryParameterTemplate
-from .models import PartTestTemplate
-from .models import PartSellPriceBreak, PartInternalPriceBreak
+import part.models as models
 
 from stock.models import StockLocation
 from company.models import SupplierPart
@@ -24,7 +18,7 @@ class PartResource(ModelResource):
     """ Class for managing Part data import/export """
 
     # ForeignKey fields
-    category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory))
+    category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
 
     default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
 
@@ -32,7 +26,7 @@ class PartResource(ModelResource):
 
     category_name = Field(attribute='category__name', readonly=True)
 
-    variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part))
+    variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part))
 
     suppliers = Field(attribute='supplier_count', readonly=True)
 
@@ -48,7 +42,7 @@ class PartResource(ModelResource):
     building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
 
     class Meta:
-        model = Part
+        model = models.Part
         skip_unchanged = True
         report_skipped = False
         clean_model_instances = True
@@ -86,14 +80,14 @@ class PartAdmin(ImportExportModelAdmin):
 class PartCategoryResource(ModelResource):
     """ Class for managing PartCategory data import/export """
 
-    parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(PartCategory))
+    parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
 
     parent_name = Field(attribute='parent__name', readonly=True)
 
     default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
 
     class Meta:
-        model = PartCategory
+        model = models.PartCategory
         skip_unchanged = True
         report_skipped = False
         clean_model_instances = True
@@ -108,14 +102,14 @@ class PartCategoryResource(ModelResource):
         super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
 
         # Rebuild the PartCategory tree(s)
-        PartCategory.objects.rebuild()
+        models.PartCategory.objects.rebuild()
 
 
 class PartCategoryInline(admin.TabularInline):
     """
     Inline for PartCategory model
     """
-    model = PartCategory
+    model = models.PartCategory
 
 
 class PartCategoryAdmin(ImportExportModelAdmin):
@@ -146,6 +140,11 @@ class PartStarAdmin(admin.ModelAdmin):
     list_display = ('part', 'user')
 
 
+class PartCategoryStarAdmin(admin.ModelAdmin):
+
+    list_display = ('category', 'user')
+
+
 class PartTestTemplateAdmin(admin.ModelAdmin):
 
     list_display = ('part', 'test_name', 'required')
@@ -159,7 +158,7 @@ class BomItemResource(ModelResource):
     bom_id = Field(attribute='pk')
 
     # ID of the parent part
-    parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
+    parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
 
     # IPN of the parent part
     parent_part_ipn = Field(attribute='part__IPN', readonly=True)
@@ -168,7 +167,7 @@ class BomItemResource(ModelResource):
     parent_part_name = Field(attribute='part__name', readonly=True)
 
     # ID of the sub-part
-    part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(Part))
+    part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part))
 
     # IPN of the sub-part
     part_ipn = Field(attribute='sub_part__IPN', readonly=True)
@@ -233,7 +232,7 @@ class BomItemResource(ModelResource):
         return fields
 
     class Meta:
-        model = BomItem
+        model = models.BomItem
         skip_unchanged = True
         report_skipped = False
         clean_model_instances = True
@@ -262,16 +261,16 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
 class ParameterResource(ModelResource):
     """ Class for managing PartParameter data import/export """
 
-    part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
+    part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
 
     part_name = Field(attribute='part__name', readonly=True)
 
-    template = Field(attribute='template', widget=widgets.ForeignKeyWidget(PartParameterTemplate))
+    template = Field(attribute='template', widget=widgets.ForeignKeyWidget(models.PartParameterTemplate))
 
     template_name = Field(attribute='template__name', readonly=True)
 
     class Meta:
-        model = PartParameter
+        model = models.PartParameter
         skip_unchanged = True
         report_skipped = False
         clean_model_instance = True
@@ -292,7 +291,7 @@ class PartCategoryParameterAdmin(admin.ModelAdmin):
 class PartSellPriceBreakAdmin(admin.ModelAdmin):
 
     class Meta:
-        model = PartSellPriceBreak
+        model = models.PartSellPriceBreak
 
     list_display = ('part', 'quantity', 'price',)
 
@@ -300,20 +299,21 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
 class PartInternalPriceBreakAdmin(admin.ModelAdmin):
 
     class Meta:
-        model = PartInternalPriceBreak
+        model = models.PartInternalPriceBreak
 
     list_display = ('part', 'quantity', 'price',)
 
 
-admin.site.register(Part, PartAdmin)
-admin.site.register(PartCategory, PartCategoryAdmin)
-admin.site.register(PartRelated, PartRelatedAdmin)
-admin.site.register(PartAttachment, PartAttachmentAdmin)
-admin.site.register(PartStar, PartStarAdmin)
-admin.site.register(BomItem, BomItemAdmin)
-admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
-admin.site.register(PartParameter, ParameterAdmin)
-admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
-admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
-admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
-admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin)
+admin.site.register(models.Part, PartAdmin)
+admin.site.register(models.PartCategory, PartCategoryAdmin)
+admin.site.register(models.PartRelated, PartRelatedAdmin)
+admin.site.register(models.PartAttachment, PartAttachmentAdmin)
+admin.site.register(models.PartStar, PartStarAdmin)
+admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin)
+admin.site.register(models.BomItem, BomItemAdmin)
+admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
+admin.site.register(models.PartParameter, ParameterAdmin)
+admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin)
+admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
+admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
+admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 0b754dffe8..20447a4d26 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -58,6 +58,14 @@ class CategoryList(generics.ListCreateAPIView):
     queryset = PartCategory.objects.all()
     serializer_class = part_serializers.CategorySerializer
 
+    def get_serializer_context(self):
+
+        ctx = super().get_serializer_context()
+
+        ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
+
+        return ctx
+
     def filter_queryset(self, queryset):
         """
         Custom filtering:
@@ -110,6 +118,18 @@ class CategoryList(generics.ListCreateAPIView):
             except (ValueError, PartCategory.DoesNotExist):
                 pass
 
+        # Filter by "starred" status
+        starred = params.get('starred', None)
+
+        if starred is not None:
+            starred = str2bool(starred)
+            starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()]
+
+            if starred:
+                queryset = queryset.filter(pk__in=starred_categories)
+            else:
+                queryset = queryset.exclude(pk__in=starred_categories)
+
         return queryset
 
     filter_backends = [
@@ -149,6 +169,14 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
     serializer_class = part_serializers.CategorySerializer
     queryset = PartCategory.objects.all()
 
+    def get_serializer_context(self):
+
+        ctx = super().get_serializer_context()
+
+        ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
+
+        return ctx
+
 
 class CategoryParameterList(generics.ListAPIView):
     """ API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@@ -389,7 +417,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
         # Ensure the request context is passed through
         kwargs['context'] = self.get_serializer_context()
 
-        # Pass a list of "starred" parts fo the current user to the serializer
+        # Pass a list of "starred" parts of the current user to the serializer
         # We do this to reduce the number of database queries required!
         if self.starred_parts is None and self.request is not None:
             self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
@@ -420,7 +448,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
         if 'starred' in request.data:
             starred = str2bool(request.data.get('starred', None))
 
-            self.get_object().set_subscription(request.user, starred)
+            self.get_object().set_starred(request.user, starred)
 
         response = super().update(request, *args, **kwargs)
 
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 0b99b8dac5..dada6f125b 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -15,7 +15,7 @@ from django.urls import reverse
 
 from django.db import models, transaction
 from django.db.utils import IntegrityError
-from django.db.models import Q, Sum, UniqueConstraint, query
+from django.db.models import Q, Sum, UniqueConstraint
 from django.db.models.functions import Coalesce
 from django.core.validators import MinValueValidator
 
@@ -102,11 +102,11 @@ class PartCategory(InvenTreeTree):
 
         if cascade:
             """ Select any parts which exist in this category or any child categories """
-            query = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True))
+            queryset = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True))
         else:
-            query = Part.objects.filter(category=self.pk)
+            queryset = Part.objects.filter(category=self.pk)
 
-        return query
+        return queryset
 
     @property
     def item_count(self):
@@ -224,14 +224,14 @@ class PartCategory(InvenTreeTree):
 
         return [s for s in subscribers]
 
-    def is_subscribed_by(self, user, **kwargs):
+    def is_starred_by(self, user, **kwargs):
         """
         Returns True if the specified user subscribes to this category
         """
 
         return user in self.get_subscribers(**kwargs)
 
-    def set_subscription(self, user, status):
+    def set_starred(self, user, status):
         """
         Set the "subscription" status of this PartCategory against the specified user
         """
@@ -239,7 +239,7 @@ class PartCategory(InvenTreeTree):
         if not user:
             return
 
-        if self.is_subscribed_by(user) == status:
+        if self.is_starred_by(user) == status:
             return
 
         if status:
@@ -386,9 +386,16 @@ class Part(MPTTModel):
 
         context = {}
 
-        context['starred'] = self.is_subscribed_by(request.user)
         context['disabled'] = not self.active
 
+        # Subscription status
+        context['starred'] = self.is_starred_by(request.user)
+        context['starred_directly'] = context['starred'] and self.is_starred_by(
+            request.user,
+            include_variants=False,
+            include_categories=False
+        )
+
         # Pre-calculate complex queries so they only need to be performed once
         context['total_stock'] = self.total_stock
 
@@ -1129,14 +1136,14 @@ class Part(MPTTModel):
 
         return [s for s in subscribers]
 
-    def is_subscribed_by(self, user, **kwargs):
+    def is_starred_by(self, user, **kwargs):
         """
         Return True if the specified user subscribes to this part
         """
 
         return user in self.get_subscribers(**kwargs)
 
-    def set_subscription(self, user, status):
+    def set_starred(self, user, status):
         """
         Set the "subscription" status of this Part against the specified user
         """
@@ -1145,7 +1152,7 @@ class Part(MPTTModel):
             return
 
         # Already subscribed?
-        if self.is_subscribed_by(user) == status:
+        if self.is_starred_by(user) == status:
             return
 
         if status:
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index ff1fb2c8c6..981d143507 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -33,12 +33,27 @@ from .models import (BomItem, BomItemSubstitute,
 class CategorySerializer(InvenTreeModelSerializer):
     """ Serializer for PartCategory """
 
+    def __init__(self, *args, **kwargs):
+
+        self.starred_categories = kwargs.pop('starred_categories', [])
+
+        super().__init__(*args, **kwargs)
+
+    def get_starred(self, category):
+        """
+        Return True if the category is directly "starred" by the current user
+        """
+
+        return category in self.starred_categories
+
     url = serializers.CharField(source='get_absolute_url', read_only=True)
 
     parts = serializers.IntegerField(source='item_count', read_only=True)
 
     level = serializers.IntegerField(read_only=True)
 
+    starred = serializers.SerializerMethodField()
+
     class Meta:
         model = PartCategory
         fields = [
@@ -51,6 +66,7 @@ class CategorySerializer(InvenTreeModelSerializer):
             'parent',
             'parts',
             'pathstring',
+            'starred',
             'url',
         ]
 
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 1dcb509a59..21e26c64c6 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -23,10 +23,14 @@
 {% include "admin_button.html" with url=url %}
 {% endif %}
 
-{% if starred %}
+{% if starred_directly %}
 <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to nofications for this part" %}'>
     <span id='part-star-icon' class='fas fa-bell icon-green'/>
 </button>
+{% elif starred %}
+<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this part" %}' disabled='true'>
+    <span class='fas fa-bell icon-green'></span>
+</button>
 {% else %}
 <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'>
     <span id='part-star-icon' class='fa fa-bell-slash'/>
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index 39bb6a39af..755bd45cea 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -384,19 +384,19 @@ class PartSubscriptionTests(TestCase):
         """
         
         # First check that the user is *not* subscribed to the part
-        self.assertFalse(self.part.is_subscribed_by(self.user))
+        self.assertFalse(self.part.is_starred_by(self.user))
 
         # Now, subscribe directly to the part
-        self.part.set_subscription(self.user, True)
+        self.part.set_starred(self.user, True)
 
         self.assertEqual(PartStar.objects.count(), 1)
 
-        self.assertTrue(self.part.is_subscribed_by(self.user))
+        self.assertTrue(self.part.is_starred_by(self.user))
 
         # Now, unsubscribe
-        self.part.set_subscription(self.user, False)
+        self.part.set_starred(self.user, False)
 
-        self.assertFalse(self.part.is_subscribed_by(self.user))
+        self.assertFalse(self.part.is_starred_by(self.user))
 
     def test_variant_subscription(self):
         """
@@ -410,13 +410,13 @@ class PartSubscriptionTests(TestCase):
             variant_of=self.part,
         )
 
-        self.assertFalse(sub_part.is_subscribed_by(self.user))
+        self.assertFalse(sub_part.is_starred_by(self.user))
 
         # Subscribe to the "parent" part
-        self.part.set_subscription(self.user, True)
+        self.part.set_starred(self.user, True)
 
-        self.assertTrue(self.part.is_subscribed_by(self.user))
-        self.assertTrue(sub_part.is_subscribed_by(self.user))
+        self.assertTrue(self.part.is_starred_by(self.user))
+        self.assertTrue(sub_part.is_starred_by(self.user))
 
     def test_category_subscription(self):
         """
@@ -425,26 +425,26 @@ class PartSubscriptionTests(TestCase):
 
         self.assertEqual(PartCategoryStar.objects.count(), 0)
 
-        self.assertFalse(self.part.is_subscribed_by(self.user))
-        self.assertFalse(self.category.is_subscribed_by(self.user))
+        self.assertFalse(self.part.is_starred_by(self.user))
+        self.assertFalse(self.category.is_starred_by(self.user))
 
         # Subscribe to the direct parent category
-        self.category.set_subscription(self.user, True)
+        self.category.set_starred(self.user, True)
 
         self.assertEqual(PartStar.objects.count(), 0)
         self.assertEqual(PartCategoryStar.objects.count(), 1)
 
-        self.assertTrue(self.category.is_subscribed_by(self.user))
-        self.assertTrue(self.part.is_subscribed_by(self.user))
+        self.assertTrue(self.category.is_starred_by(self.user))
+        self.assertTrue(self.part.is_starred_by(self.user))
 
         # Check that the "parent" category is not starred
-        self.assertFalse(self.category.parent.is_subscribed_by(self.user))
+        self.assertFalse(self.category.parent.is_starred_by(self.user))
 
         # Un-subscribe
-        self.category.set_subscription(self.user, False)
+        self.category.set_starred(self.user, False)
 
-        self.assertFalse(self.category.is_subscribed_by(self.user))
-        self.assertFalse(self.part.is_subscribed_by(self.user))
+        self.assertFalse(self.category.is_starred_by(self.user))
+        self.assertFalse(self.part.is_starred_by(self.user))
 
     def test_parent_category_subscription(self):
         """
@@ -454,13 +454,13 @@ class PartSubscriptionTests(TestCase):
         # Top-level "electronics" category
         cat = PartCategory.objects.get(pk=1)
 
-        cat.set_subscription(self.user, True)
+        cat.set_starred(self.user, True)
 
         # Check base category
-        self.assertTrue(cat.is_subscribed_by(self.user))
+        self.assertTrue(cat.is_starred_by(self.user))
 
         # Check lower level category
-        self.assertTrue(self.category.is_subscribed_by(self.user))
+        self.assertTrue(self.category.is_starred_by(self.user))
 
         # Check part
-        self.assertTrue(self.part.is_subscribed_by(self.user))
+        self.assertTrue(self.part.is_starred_by(self.user))
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 5a4167ea05..de4bbf5443 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -412,6 +412,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
         part = self.get_object()
 
         ctx = part.get_context_data(self.request)
+        
         context.update(**ctx)
 
         # Pricing information

From 193d6b334c7f70e7222bf8e725b37d3717e63c1c Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 3 Nov 2021 23:29:36 +1100
Subject: [PATCH 08/20] Add option to display "starred categories" on the index
 page

---
 InvenTree/common/models.py                          |  6 ++++++
 InvenTree/templates/InvenTree/index.html            | 13 ++++++++++++-
 .../templates/InvenTree/settings/user_homepage.html |  1 +
 InvenTree/templates/js/dynamic/inventree.js         |  7 ++++++-
 InvenTree/templates/js/translated/part.js           |  6 +++---
 5 files changed, 28 insertions(+), 5 deletions(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 125941be14..bbc8a6721a 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -879,6 +879,12 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
             'default': True,
             'validator': bool,
         },
+        'HOMEPAGE_CATEGORY_STARRED': {
+            'name': _('Show subscribed categories'),
+            'description': _('Show subscribed part categories on the homepage'),
+            'default': True,
+            'validator': bool,
+        },
         'HOMEPAGE_PART_LATEST': {
             'name': _('Show latest parts'),
             'description': _('Show latest parts on the homepage'),
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index 2c407fdcd9..b87ec6d0dc 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -76,6 +76,7 @@ function addHeaderAction(label, title, icon, options) {
 }
 
 {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %}
+{% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %}
 {% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %}
 {% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %}
 {% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %}
@@ -87,12 +88,22 @@ addHeaderTitle('{% trans "Parts" %}');
 addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell');
 loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
     params: {
-        "starred": true,
+        starred: true,
     },
     name: 'starred_parts',
 });
 {% endif %}
 
+{% if setting_category_starred %}
+addHeaderAction('starred-categories', '{% trans "Subscribed Categories" %}', 'fa-bell');
+loadPartCategoryTable($('#table-starred-categories'), {
+    params: {
+        starred: true,
+    },
+    name: 'starred_categories'
+});
+{% endif %}
+
 {% if setting_part_latest %}
 addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper');
 loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html
index 8219187044..54e3bdcefd 100644
--- a/InvenTree/templates/InvenTree/settings/user_homepage.html
+++ b/InvenTree/templates/InvenTree/settings/user_homepage.html
@@ -15,6 +15,7 @@
     <table class='table table-striped table-condensed'>
         <tbody>
             {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bell' user_setting=True %}
+            {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_CATEGORY_STARRED" icon='fa-bell' user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %}
             {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %}
diff --git a/InvenTree/templates/js/dynamic/inventree.js b/InvenTree/templates/js/dynamic/inventree.js
index 0172e47706..1774ba6f3d 100644
--- a/InvenTree/templates/js/dynamic/inventree.js
+++ b/InvenTree/templates/js/dynamic/inventree.js
@@ -169,7 +169,12 @@ function inventreeDocReady() {
                 html += '</span>';
                 
                 if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
-                    html += partStockLabel(item.data);
+                    html += partStockLabel(
+                        item.data,
+                        {
+                            classes: 'badge-right',
+                        }
+                    );
                 }
 
                 html += '</a>';
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index adc10566d7..b87e90dcc8 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -420,12 +420,12 @@ function toggleStar(options) {
 }
 
 
-function partStockLabel(part) {
+function partStockLabel(part, options={}) {
 
     if (part.in_stock) {
-        return `<span class='badge rounded-pill bg-success'>{% trans "Stock" %}: ${part.in_stock}</span>`;
+        return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Stock" %}: ${part.in_stock}</span>`;
     } else {
-        return `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
+        return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`;
     }
 }
 

From 1c6eb41341cbc564d47d212a87c3381ea2099021 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 00:01:52 +1100
Subject: [PATCH 09/20] Ability to toggle part category "star" status via the
 API

---
 InvenTree/build/templates/build/detail.html   |  4 +-
 InvenTree/part/api.py                         | 13 +++++-
 InvenTree/part/serializers.py                 |  4 +-
 InvenTree/part/templates/part/category.html   | 44 ++++++++++++++++---
 InvenTree/part/templates/part/part_base.html  |  2 +-
 InvenTree/part/views.py                       | 11 +++++
 InvenTree/templates/js/translated/part.js     | 30 ++++++++-----
 .../templates/js/translated/table_filters.js  |  6 ++-
 8 files changed, 88 insertions(+), 26 deletions(-)

diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html
index d53122cdd1..31e9f38080 100644
--- a/InvenTree/build/templates/build/detail.html
+++ b/InvenTree/build/templates/build/detail.html
@@ -247,7 +247,9 @@
                             <span class='fas fa-tools'></span> <span class='caret'></span>
                         </button>
                         <ul class='dropdown-menu'>
-                            <li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li>
+                            <li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'>
+                                <span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
+                            </a></li>
                         </ul>
                     </div>
                 </div>
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 20447a4d26..dc521b42c6 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -177,6 +177,17 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
 
         return ctx
 
+    def update(self, request, *args, **kwargs):
+
+        if 'starred' in request.data:
+            starred = str2bool(request.data.get('starred', False))
+
+            self.get_object().set_starred(request.user, starred)
+
+        response = super().update(request, *args, **kwargs)
+
+        return response
+
 
 class CategoryParameterList(generics.ListAPIView):
     """ API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@@ -446,7 +457,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
         """
 
         if 'starred' in request.data:
-            starred = str2bool(request.data.get('starred', None))
+            starred = str2bool(request.data.get('starred', False))
 
             self.get_object().set_starred(request.user, starred)
 
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 981d143507..3b6d823ddc 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -35,8 +35,6 @@ class CategorySerializer(InvenTreeModelSerializer):
 
     def __init__(self, *args, **kwargs):
 
-        self.starred_categories = kwargs.pop('starred_categories', [])
-
         super().__init__(*args, **kwargs)
 
     def get_starred(self, category):
@@ -44,7 +42,7 @@ class CategorySerializer(InvenTreeModelSerializer):
         Return True if the category is directly "starred" by the current user
         """
 
-        return category in self.starred_categories
+        return category in self.context.get('starred_categories', [])
 
     url = serializers.CharField(source='get_absolute_url', read_only=True)
 
diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 03369b093d..48677ee71d 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -20,15 +20,37 @@
 {% include "admin_button.html" with url=url %}
 {% endif %}
 {% if category %}
-{% if roles.part_category.change %}
-<button class='btn btn-outline-secondary' id='cat-edit' title='{% trans "Edit part category" %}'>
-    <span class='fas fa-edit'/>
+{% if starred_directly %}
+<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'>
+    <span id='category-star-icon' class='fas fa-bell icon-green'></span>
+</button>
+{% elif starred %}
+<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this category" %}' disabled='true'>
+    <span class='fas fa-bell icon-green'></span>
+</button>
+{% else %}
+<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this category" %}'>
+    <span id='category-star-icon' class='fa fa-bell-slash'/>
 </button>
 {% endif %}
-{% if roles.part_category.delete %}
-<button class='btn btn-outline-secondary' id='cat-delete' title='{% trans "Delete part category" %}'>
-    <span class='fas fa-trash-alt icon-red'/>
-</button>
+{% if roles.part_category.change or roles.part_category.delete %}
+<div class='btn-group' role='group'>
+    <button id='category-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Category Actions" %}'>
+        <span class='fas fa-tools'></span> <span class='caret'></span>
+    </button>
+    <ul class='dropdown-menu'>
+        {% if roles.part_category.change %}
+        <li><a class='dropdown-item' href='#' id='cat-edit' title='{% trans "Edit category" %}'>
+            <span class='fas fa-edit icon-green'></span> {% trans "Edit Category" %}
+        </a></li>
+        {% endif %}
+        {% if roles.part_category.delete %}
+        <li><a class='dropdown-item' href='#' id='cat-delete' title='{% trans "Delete category" %}'>
+            <span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Category" %}
+        </a></li>
+        {% endif %}
+    </ul>
+</div>
 {% endif %}
 {% endif %}
 {% if roles.part_category.add %}
@@ -198,6 +220,14 @@
             data: {{ parameters|safe }},
         }
     );
+
+    $("#toggle-starred").click(function() {
+        toggleStar({
+            url: '{% url "api-part-category-detail" category.pk %}',
+            button: '#category-star-icon'
+        });
+    });
+
     {% endif %}
 
     enableSidebar('category');
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 21e26c64c6..a4087a3ece 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -320,7 +320,7 @@
 
     $("#toggle-starred").click(function() {
         toggleStar({
-            part: {{ part.id }},
+            url: '{% url "api-part-detail" part.pk %}',
             button: '#part-star-icon',
         });
     });
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index de4bbf5443..56ab98004d 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -1470,18 +1470,29 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
 
         if category:
             cascade = kwargs.get('cascade', True)
+
             # Prefetch parts parameters
             parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
+            
             # Get table headers (unique parameters names)
             context['headers'] = category.get_unique_parameters(cascade=cascade,
                                                                 prefetch=parts_parameters)
+            
             # Insert part information
             context['headers'].insert(0, 'description')
             context['headers'].insert(0, 'part')
+
             # Get parameters data
             context['parameters'] = category.get_parts_parameters(cascade=cascade,
                                                                   prefetch=parts_parameters)
 
+            # Insert "starred" information
+            context['starred'] = category.is_starred_by(self.request.user)
+            context['starred_directly'] = context['starred'] and category.is_starred_by(
+                self.request.user,
+                include_parents=False,
+            )
+
         return context
 
 
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index b87e90dcc8..e00f04aebd 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -378,19 +378,18 @@ function duplicatePart(pk, options={}) {
  * 
  * options:
  * - button: ID of the button (default = '#part-star-icon')
- * - part: pk of the part object
+ * - URL: API url of the object
  * - user: pk of the user
  */
 function toggleStar(options) {
 
-    var url = `/api/part/${options.part}/`;
-
-    inventreeGet(url, {}, {
+    inventreeGet(options.url, {}, {
         success: function(response) {
+
             var starred = response.starred;
 
             inventreePut(
-                url,
+                options.url,
                 {
                     starred: !starred,
                 },
@@ -399,16 +398,16 @@ function toggleStar(options) {
                     success: function(response) {
                         if (response.starred) {
                             $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green');
-                            $(options.button).attr('title', '{% trans "You are subscribed to notifications for this part" %}');
+                            $(options.button).attr('title', '{% trans "You are subscribed to notifications for this item" %}');
 
-                            showMessage('{% trans "You have subscribed to notifications for this part" %}', {
+                            showMessage('{% trans "You have subscribed to notifications for this item" %}', {
                                 style: 'success',
                             });
                         } else {
                             $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash');
-                            $(options.button).attr('title', '{% trans "Subscribe to notifications for this part" %}');
+                            $(options.button).attr('title', '{% trans "Subscribe to notifications for this item" %}');
 
-                            showMessage('{% trans "You have unsubscribed to notifications for this part" %}', {
+                            showMessage('{% trans "You have unsubscribed to notifications for this item" %}', {
                                 style: 'warning',
                             });
                         }
@@ -453,7 +452,7 @@ function makePartIcons(part) {
     }
 
     if (part.starred) {
-        html += makeIconBadge('fa-star', '{% trans "Starred part" %}');
+        html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed part" %}');
     }
 
     if (part.salable) {
@@ -461,7 +460,7 @@ function makePartIcons(part) {
     }
 
     if (!part.active) {
-        html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span>`; 
+        html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span> `; 
     }
 
     return html;
@@ -1268,10 +1267,17 @@ function loadPartCategoryTable(table, options) {
                 switchable: true,
                 sortable: true,
                 formatter: function(value, row) {
-                    return renderLink(
+
+                    var html = renderLink(
                         value,
                         `/part/category/${row.pk}/`
                     );
+
+                    if (row.starred) {
+                        html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed category" %}');
+                    }
+
+                    return html;
                 }
             },
             {
diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js
index 4d12f69780..537adefee9 100644
--- a/InvenTree/templates/js/translated/table_filters.js
+++ b/InvenTree/templates/js/translated/table_filters.js
@@ -103,6 +103,10 @@ function getAvailableTableFilters(tableKey) {
                 title: '{% trans "Include subcategories" %}',
                 description: '{% trans "Include subcategories" %}',
             },
+            starred: {
+                type: 'bool',
+                title: '{% trans "Subscribed" %}',
+            },
         };
     }
 
@@ -368,7 +372,7 @@ function getAvailableTableFilters(tableKey) {
             },
             starred: {
                 type: 'bool',
-                title: '{% trans "Starred" %}',
+                title: '{% trans "Subscribed" %}',
             },
             salable: {
                 type: 'bool',

From 476a1342c1f1536eb352228ea6a14f503b99e83b Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 00:28:10 +1100
Subject: [PATCH 10/20] Improve notification of 'low stock' parts:

- Traverse up the variant tree
- Enable subscription by "category"
---
 InvenTree/part/models.py                      | 19 +++++++++++-
 InvenTree/part/tasks.py                       | 31 ++++++++++++-------
 InvenTree/stock/models.py                     | 11 ++++---
 .../email/low_stock_notification.html         |  4 ++-
 4 files changed, 48 insertions(+), 17 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index dada6f125b..a3c294ea17 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -20,7 +20,7 @@ from django.db.models.functions import Coalesce
 from django.core.validators import MinValueValidator
 
 from django.contrib.auth.models import User
-from django.db.models.signals import pre_delete
+from django.db.models.signals import pre_delete, post_save
 from django.dispatch import receiver
 
 from jinja2 import Template
@@ -47,6 +47,7 @@ from InvenTree import validators
 from InvenTree.models import InvenTreeTree, InvenTreeAttachment
 from InvenTree.fields import InvenTreeURLField
 from InvenTree.helpers import decimal2string, normalize, decimal2money
+import InvenTree.tasks
 
 from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
 
@@ -56,6 +57,7 @@ from company.models import SupplierPart
 from stock import models as StockModels
 
 import common.models
+
 import part.settings as part_settings
 
 
@@ -2085,9 +2087,24 @@ class Part(MPTTModel):
         return len(self.get_related_parts())
 
     def is_part_low_on_stock(self):
+        """
+        Returns True if the total stock for this part is less than the minimum stock level
+        """
+        
         return self.total_stock <= self.minimum_stock
 
 
+
+@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log')
+def after_save_part(sender, instance: Part, **kwargs):
+    """
+    Function to be executed after a Part is saved
+    """
+
+    # Run this check in the background
+    InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance)
+
+
 def attach_file(instance, filename):
     """ Function for storing a file for a PartAttachment
 
diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py
index 72d996e772..779027a96d 100644
--- a/InvenTree/part/tasks.py
+++ b/InvenTree/part/tasks.py
@@ -13,23 +13,28 @@ from common.models import InvenTree
 import InvenTree.helpers
 import InvenTree.tasks
 
-from part.models import Part
+import part.models
 
 logger = logging.getLogger("inventree")
 
 
-def notify_low_stock(part: Part):
+def notify_low_stock(part: part.models.Part):
     """
     Notify users who have starred a part when its stock quantity falls below the minimum threshold
     """
 
     logger.info(f"Sending low stock notification email for {part.full_name}")
 
-    starred_users_email = EmailAddress.objects.filter(user__starred_parts__part=part)
+    # Get a list of users who are subcribed to this part
+    subscribers = part.get_subscribers()
+
+    emails = EmailAddress.objects.filter(
+        user__in=subscribers,
+    )
 
     # TODO: In the future, include the part image in the email template
 
-    if len(starred_users_email) > 0:
+    if len(emails) > 0:
         logger.info(f"Notify users regarding low stock of {part.name}")
         context = {
             # Pass the "Part" object through to the template context
@@ -39,20 +44,24 @@ def notify_low_stock(part: Part):
 
         subject = _(f'[InvenTree] {part.name} is low on stock')
         html_message = render_to_string('email/low_stock_notification.html', context)
-        recipients = starred_users_email.values_list('email', flat=True)
+        recipients = emails.values_list('email', flat=True)
 
         InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
 
 
-def notify_low_stock_if_required(part: Part):
+def notify_low_stock_if_required(part: part.models.Part):
     """
     Check if the stock quantity has fallen below the minimum threshold of part.
     
     If true, notify the users who have subscribed to the part
     """
 
-    if part.is_part_low_on_stock():
-        InvenTree.tasks.offload_task(
-            'part.tasks.notify_low_stock',
-            part
-        )
+    # Run "up" the tree, to allow notification for "parent" parts
+    parts = part.get_ancestors(include_self=True, ascending=True)
+
+    for p in parts:
+        if p.is_part_low_on_stock():
+            InvenTree.tasks.offload_task(
+                'part.tasks.notify_low_stock',
+                p
+            )
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 657469a744..eb0e6aa12f 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -27,7 +27,9 @@ from mptt.managers import TreeManager
 
 from decimal import Decimal, InvalidOperation
 from datetime import datetime, timedelta
+
 from InvenTree import helpers
+import InvenTree.tasks
 
 import common.models
 import report.models
@@ -41,7 +43,6 @@ from users.models import Owner
 
 from company import models as CompanyModels
 from part import models as PartModels
-from part import tasks as part_tasks
 
 
 class StockLocation(InvenTreeTree):
@@ -1658,16 +1659,18 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
     Function to be executed after a StockItem object is deleted
     """
 
-    part_tasks.notify_low_stock_if_required(instance.part)
+    # Run this check in the background
+    InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
 
 
 @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
 def after_save_stock_item(sender, instance: StockItem, **kwargs):
     """
-   Hook function to be executed after StockItem object is saved/updated
+    Hook function to be executed after StockItem object is saved/updated
     """
 
-    part_tasks.notify_low_stock_if_required(instance.part)
+    # Run this check in the background
+    InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part)
 
 
 class StockItemAttachment(InvenTreeAttachment):
diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html
index ecb350925a..4db9c2ddaa 100644
--- a/InvenTree/templates/email/low_stock_notification.html
+++ b/InvenTree/templates/email/low_stock_notification.html
@@ -17,13 +17,15 @@
 {% block body %}
 <tr style="height: 3rem; border-bottom: 1px solid">
     <th>{% trans "Part Name" %}</th>
-    <th>{% trans "Available Quantity" %}</th>
+    <th>{% trans "Total Stock" %}</th>
+    <th>{% trans "Available" %}</th>
     <th>{% trans "Minimum Quantity" %}</th>
 </tr>
 
 <tr style="height: 3rem">
     <td style="text-align: center;">{{ part.full_name }}</td>
     <td style="text-align: center;">{{ part.total_stock }}</td>
+    <td style="text-align: center;">{{ part.available_stock }}</td>
     <td style="text-align: center;">{{ part.minimum_stock }}</td>
 </tr>
 {% endblock %}

From ee7c3ae0664cb38673efccded74e195314fe5b5e Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 00:38:34 +1100
Subject: [PATCH 11/20] Update index page

---
 InvenTree/part/serializers.py             | 3 +++
 InvenTree/part/templates/part/detail.html | 2 +-
 InvenTree/templates/InvenTree/index.html  | 5 ++---
 3 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 3b6d823ddc..47ce3f66c8 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -255,6 +255,9 @@ class PartSerializer(InvenTreeModelSerializer):
         to reduce database trips.
         """
 
+        # TODO: Update the "in_stock" annotation to include stock for variants of the part
+        # Ref: https://github.com/inventree/InvenTree/issues/2240
+
         # Annotate with the total 'in stock' quantity
         queryset = queryset.annotate(
             in_stock=Coalesce(
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index fc9795b6e0..d3da4df514 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -62,7 +62,7 @@
             {% endif %}
             {% if part.minimum_stock %}
             <tr>
-                <td><span class='fas fa-less-than-equal'></span></td>
+                <td><span class='fas fa-flag'></span></td>
                 <td>{% trans "Minimum stock level" %}</td>
                 <td>{{ part.minimum_stock }}</td>
             </tr>
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index b87ec6d0dc..44bc70fc37 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -139,8 +139,7 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
 {% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %}
 {% endif %}
 
-{% if roles.stock.view and True in settings_list_stock %}
-addHeaderTitle('{% trans "Stock" %}');
+{% if roles.stock.view %}
 
 {% if setting_stock_recent %}
 addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
@@ -156,7 +155,7 @@ loadStockTable($('#table-recently-updated-stock'), {
 {% endif %}
 
 {% if setting_stock_low %}
-addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart');
+addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-flag');
 loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", {
     params: {
         low_stock: true,

From 55425322232835ead9ba419bb8e0023091c314ce Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 00:44:16 +1100
Subject: [PATCH 12/20] Template tweaks

---
 InvenTree/part/models.py                     | 1 -
 InvenTree/part/templates/part/detail.html    | 2 +-
 InvenTree/part/templates/part/part_base.html | 9 +++++++--
 3 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index a3c294ea17..1c50bc321e 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -2094,7 +2094,6 @@ class Part(MPTTModel):
         return self.total_stock <= self.minimum_stock
 
 
-
 @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log')
 def after_save_part(sender, instance: Part, **kwargs):
     """
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index d3da4df514..706bf5e329 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -35,7 +35,7 @@
                 <td><span class='fas fa-sitemap'></span></td>
                 <td>{% trans "Category" %}</td>
                 <td>
-                    <a href='{% url "category-detail" part.category.pk %}'>{{ part.category }}</a>
+                    <a href='{% url "category-detail" part.category.pk %}'>{{ part.category.name }}</a>
                 </td>
             </tr>
             {% endif %}
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index a4087a3ece..bb7aea3abb 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -147,8 +147,6 @@
     </div>
 </h4>
 
-
-
 <!-- Part info messages -->
 <div class='info-messages'>
     {% if part.variant_of %}
@@ -174,6 +172,13 @@
             <td>{% trans "In Stock" %}</td>
             <td>{% include "part/stock_count.html" %}</td>
         </tr>
+        {% if part.minimum_stock %}
+        <tr>
+            <td><span class='fas fa-flag'></span></td>
+            <td>{% trans "Minimum Stock" %}</td>
+            <td>{{ part.minimum_stock }}</td>
+        </tr>
+        {% endif %}
         {% if on_order > 0 %}
         <tr>
             <td><span class='fas fa-shopping-cart'></span></td>

From ef2307aeaa5e5700bdb8da01acc0ee10b55e0c8c Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 00:46:23 +1100
Subject: [PATCH 13/20] Add new model to permissions table

---
 InvenTree/users/models.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index d31f2a9905..a7016cbf96 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -77,6 +77,7 @@ class RuleSet(models.Model):
         'part_category': [
             'part_partcategory',
             'part_partcategoryparametertemplate',
+            'part_partcategorystar',
         ],
         'part': [
             'part_part',
@@ -90,6 +91,7 @@ class RuleSet(models.Model):
             'part_partparameter',
             'part_partrelated',
             'part_partstar',
+            'part_partcategorystar',
             'company_supplierpart',
             'company_manufacturerpart',
             'company_manufacturerpartparameter',

From e7b93a54d82e542be6bcdac218c7b9bf0a96ea98 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 00:55:43 +1100
Subject: [PATCH 14/20] Add new model "NotificationEntry"

- Keep track of past notifications
---
 .../migrations/0012_notificationentry.py      | 25 +++++++++++++++
 InvenTree/common/models.py                    | 31 +++++++++++++++++++
 2 files changed, 56 insertions(+)
 create mode 100644 InvenTree/common/migrations/0012_notificationentry.py

diff --git a/InvenTree/common/migrations/0012_notificationentry.py b/InvenTree/common/migrations/0012_notificationentry.py
new file mode 100644
index 0000000000..77439c9f8c
--- /dev/null
+++ b/InvenTree/common/migrations/0012_notificationentry.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.5 on 2021-11-03 13:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('common', '0011_auto_20210722_2114'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='NotificationEntry',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('key', models.CharField(max_length=250)),
+                ('uid', models.IntegerField()),
+                ('updated', models.DateTimeField(auto_now=True)),
+            ],
+            options={
+                'unique_together': {('key', 'uid')},
+            },
+        ),
+    ]
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index bbc8a6721a..559a8dc003 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1226,3 +1226,34 @@ class ColorTheme(models.Model):
                 return True
 
         return False
+
+
+class NotificationEntry(models.Model):
+    """
+    A NotificationEntry records the last time a particular notifaction was sent out.
+
+    It is recorded to ensure that notifications are not sent out "too often" to users.
+
+    Attributes:
+    - key: A text entry describing the notification e.g. 'part.notify_low_stock'
+    - uid: An (optional) numerical ID for a particular instance
+    - date: The last time this notification was sent
+    """
+
+    class Meta:
+        unique_together = [
+            ('key', 'uid'),
+        ]
+
+    key = models.CharField(
+        max_length=250,
+        blank=False,
+    )
+
+    uid = models.IntegerField(
+    )
+
+    updated = models.DateTimeField(
+        auto_now=True,
+        null=False,
+    )

From 1f7676ee6581b19731d439ef621e7675e31d638f Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 01:06:57 +1100
Subject: [PATCH 15/20] Add admin entry for new model

---
 InvenTree/common/admin.py | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py
index 1eda18e869..4df2499177 100644
--- a/InvenTree/common/admin.py
+++ b/InvenTree/common/admin.py
@@ -5,7 +5,7 @@ from django.contrib import admin
 
 from import_export.admin import ImportExportModelAdmin
 
-from .models import InvenTreeSetting, InvenTreeUserSetting
+import common.models
 
 
 class SettingsAdmin(ImportExportModelAdmin):
@@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin):
     list_display = ('key', 'value', 'user', )
 
 
-admin.site.register(InvenTreeSetting, SettingsAdmin)
-admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)
+class NotificationEntryAdmin(admin.ModelAdmin):
+
+    list_display = ('key', 'uid', 'updated', )
+
+
+admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
+admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
+admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)

From bebf368d06f339dfb5fa285efd642fb0566507c7 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 01:11:42 +1100
Subject: [PATCH 16/20] Add functionality and unit testing for new model

---
 InvenTree/common/models.py | 30 ++++++++++++++++++++++++++++++
 InvenTree/common/tests.py  | 25 +++++++++++++++++++++++++
 InvenTree/users/models.py  |  1 +
 3 files changed, 56 insertions(+)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 559a8dc003..bc1463ca00 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -9,6 +9,7 @@ from __future__ import unicode_literals
 import os
 import decimal
 import math
+from datetime import datetime, timedelta
 
 from django.db import models, transaction
 from django.contrib.auth.models import User, Group
@@ -1257,3 +1258,32 @@ class NotificationEntry(models.Model):
         auto_now=True,
         null=False,
     )
+
+    @classmethod
+    def check_recent(cls, key: str, uid: int, delta: timedelta):
+        """
+        Test if a particular notification has been sent in the specified time period
+        """
+
+        since = datetime.now().date() - delta
+
+        entries = cls.objects.filter(
+            key=key,
+            uid=uid,
+            updated__gte=since
+        )
+
+        return entries.exists()
+
+    @classmethod
+    def notify(cls, key: str, uid: int):
+        """
+        Notify the database that a particular notification has been sent out
+        """
+
+        entry, created = cls.objects.get_or_create(
+            key=key,
+            uid=uid
+        )
+
+        entry.save()
diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index d20f76baa0..63023da5cb 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -1,10 +1,13 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+from datetime import timedelta
+
 from django.test import TestCase
 from django.contrib.auth import get_user_model
 
 from .models import InvenTreeSetting
+from .models import NotificationEntry
 
 
 class SettingsTest(TestCase):
@@ -85,3 +88,25 @@ class SettingsTest(TestCase):
 
                 if setting.default_value not in [True, False]:
                     raise ValueError(f'Non-boolean default value specified for {key}')
+
+
+class NotificationTest(TestCase):
+
+    def test_check_notification_entries(self):
+
+        # Create some notification entries
+
+        self.assertEqual(NotificationEntry.objects.count(), 0)
+
+        NotificationEntry.notify('test.notification', 1)
+
+        self.assertEqual(NotificationEntry.objects.count(), 1)
+
+        delta = timedelta(days=1)
+
+        self.assertFalse(NotificationEntry.check_recent('test.notification', 2, delta))
+        self.assertFalse(NotificationEntry.check_recent('test.notification2', 1, delta))
+
+        self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))
+
+
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index a7016cbf96..4d1b46ae5d 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -151,6 +151,7 @@ class RuleSet(models.Model):
         'common_colortheme',
         'common_inventreesetting',
         'common_inventreeusersetting',
+        'common_notificationentry',
         'company_contact',
         'users_owner',
 

From a447e22108d8934a6b9f0834ff54cbea8a6836c0 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 01:18:00 +1100
Subject: [PATCH 17/20] Prevent low-stock notifications from overwhelming users

- Limit to once per day, per part
---
 InvenTree/part/tasks.py | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py
index 779027a96d..f4f1459214 100644
--- a/InvenTree/part/tasks.py
+++ b/InvenTree/part/tasks.py
@@ -2,13 +2,14 @@
 from __future__ import unicode_literals
 
 import logging
+from datetime import timedelta
 
 from django.utils.translation import ugettext_lazy as _
 from django.template.loader import render_to_string
 
 from allauth.account.models import EmailAddress
 
-from common.models import InvenTree
+from common.models import NotificationEntry
 
 import InvenTree.helpers
 import InvenTree.tasks
@@ -23,6 +24,13 @@ def notify_low_stock(part: part.models.Part):
     Notify users who have starred a part when its stock quantity falls below the minimum threshold
     """
 
+    # Check if we have notified recently...
+    delta = timedelta(days=1)
+
+    if NotificationEntry.check_recent('part.notify_low_stock', part.pk, delta):
+        logger.info(f"Low stock notification has recently been sent for '{part.full_name}' - SKIPPING")
+        return
+
     logger.info(f"Sending low stock notification email for {part.full_name}")
 
     # Get a list of users who are subcribed to this part
@@ -48,6 +56,8 @@ def notify_low_stock(part: part.models.Part):
 
         InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
 
+        NotificationEntry.notify('part.notify_low_stock', part.pk)
+
 
 def notify_low_stock_if_required(part: part.models.Part):
     """

From 6c724556f1de5020a9b98e86e690177557ee3684 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 01:21:08 +1100
Subject: [PATCH 18/20] PEP fixes

---
 InvenTree/common/tests.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index 63023da5cb..c20dc5d126 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -108,5 +108,3 @@ class NotificationTest(TestCase):
         self.assertFalse(NotificationEntry.check_recent('test.notification2', 1, delta))
 
         self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))
-
-

From 3a61d11f5a64166e2d8e1c8283ed4e7f301a48ce Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 01:31:26 +1100
Subject: [PATCH 19/20] Adds a scheduled task to remove old notification
 entries from the database

---
 InvenTree/InvenTree/apps.py                   |  6 ++++
 .../management/commands/rebuild_thumbnails.py |  2 +-
 InvenTree/common/tasks.py                     | 29 +++++++++++++++++++
 tasks.py                                      |  1 +
 4 files changed, 37 insertions(+), 1 deletion(-)
 create mode 100644 InvenTree/common/tasks.py

diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py
index 31a887d736..5f347dd1e5 100644
--- a/InvenTree/InvenTree/apps.py
+++ b/InvenTree/InvenTree/apps.py
@@ -76,6 +76,12 @@ class InvenTreeConfig(AppConfig):
             minutes=30,
         )
 
+        # Delete old notification records
+        InvenTree.tasks.schedule_task(
+            'common.tasks.delete_old_notifications',
+            schedule_type=Schedule.DAILY,
+        )
+
     def update_exchange_rates(self):
         """
         Update exchange rates each time the server is started, *if*:
diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py
index 07e700a1cf..bf36a612d1 100644
--- a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py
+++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py
@@ -17,7 +17,7 @@ from company.models import Company
 from part.models import Part
 
 
-logger = logging.getLogger("inventree-thumbnails")
+logger = logging.getLogger('inventree')
 
 
 class Command(BaseCommand):
diff --git a/InvenTree/common/tasks.py b/InvenTree/common/tasks.py
new file mode 100644
index 0000000000..409acf5a13
--- /dev/null
+++ b/InvenTree/common/tasks.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import logging
+from datetime import timedelta, datetime
+
+from django.core.exceptions import AppRegistryNotReady
+
+
+logger = logging.getLogger('inventree')
+
+
+def delete_old_notifications():
+    """
+    Remove old notifications from the database.
+
+    Anything older than ~3 months is removed
+    """
+
+    try:
+        from common.models import NotificationEntry
+    except AppRegistryNotReady:
+        logger.info("Could not perform 'delete_old_notifications' - App registry not ready")
+        return
+
+    before = datetime.now() - timedelta(days=90)
+
+    # Delete notification records before the specified date
+    NotificationEntry.objects.filter(updated__lte=before).delete()
diff --git a/tasks.py b/tasks.py
index 59fa83e56b..c960fb8657 100644
--- a/tasks.py
+++ b/tasks.py
@@ -286,6 +286,7 @@ def content_excludes():
         "users.owner",
         "exchange.rate",
         "exchange.exchangebackend",
+        "common.notificationentry",
     ]
 
     output = ""

From 52242e7a00f01e3d3bfad6e7628d1c98ef04d18a Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 4 Nov 2021 08:40:38 +1100
Subject: [PATCH 20/20] Catch error

---
 InvenTree/part/api.py | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index dc521b42c6..b08834445c 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -62,7 +62,11 @@ class CategoryList(generics.ListCreateAPIView):
 
         ctx = super().get_serializer_context()
 
-        ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
+        try:
+            ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
+        except AttributeError:
+            # Error is thrown if the view does not have an associated request
+            ctx['starred_categories'] = []
 
         return ctx
 
@@ -173,7 +177,11 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
 
         ctx = super().get_serializer_context()
 
-        ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
+        try:
+            ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
+        except AttributeError:
+            # Error is thrown if the view does not have an associated request
+            ctx['starred_categories'] = []
 
         return ctx