diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index d48999189b..bf9711ebe3 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -2,11 +2,14 @@
 
 
 # InvenTree API version
-INVENTREE_API_VERSION = 81
+INVENTREE_API_VERSION = 82
 
 """
 Increment this API version number whenever there is a significant change to the API that any clients need to know about
 
+v82 -> 2022-11-16 : https://github.com/inventree/InvenTree/pull/3931
+    - Add support for structural Part categories
+
 v81 -> 2022-11-08 : https://github.com/inventree/InvenTree/pull/3710
     - Adds cached pricing information to Part API
     - Adds cached pricing information to BomItem API
diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index d5b9c9b908..f3be374e34 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -950,16 +950,27 @@ def strip_html_tags(value: str, raise_error=True, field_name=None):
     return cleaned
 
 
-def remove_non_printable_characters(value: str, remove_ascii=True, remove_unicode=True):
+def remove_non_printable_characters(value: str, remove_newline=True, remove_ascii=True, remove_unicode=True):
     """Remove non-printable / control characters from the provided string"""
 
+    cleaned = value
+
     if remove_ascii:
         # Remove ASCII control characters
-        cleaned = regex.sub(u'[\x01-\x1F]+', '', value)
+        # Note that we do not sub out 0x0A (\n) here, it is done separately below
+        cleaned = regex.sub(u'[\x01-\x09]+', '', cleaned)
+        cleaned = regex.sub(u'[\x0b-\x1F]+', '', cleaned)
+
+    if remove_newline:
+        cleaned = regex.sub(u'[\x0a]+', '', cleaned)
 
     if remove_unicode:
         # Remove Unicode control characters
-        cleaned = regex.sub(u'[^\P{C}]+', '', value)
+        if remove_newline:
+            cleaned = regex.sub(u'[^\P{C}]+', '', cleaned)
+        else:
+            # Use 'negative-lookahead' to exclude newline character
+            cleaned = regex.sub(u'(?![\x0A])[^\P{C}]+', '', cleaned)
 
     return cleaned
 
diff --git a/InvenTree/InvenTree/mixins.py b/InvenTree/InvenTree/mixins.py
index d503d2dc39..e484c76f5b 100644
--- a/InvenTree/InvenTree/mixins.py
+++ b/InvenTree/InvenTree/mixins.py
@@ -1,8 +1,11 @@
 """Mixins for (API) views in the whole project."""
 
+from django.core.exceptions import FieldDoesNotExist
+
 from rest_framework import generics, mixins, status
 from rest_framework.response import Response
 
+from InvenTree.fields import InvenTreeNotesField
 from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
 
 
@@ -44,10 +47,35 @@ class CleanMixin():
         Nominally, the only thing that will be "cleaned" will be HTML tags
 
         Ref: https://github.com/mozilla/bleach/issues/192
+
         """
 
         cleaned = strip_html_tags(data, field_name=field)
-        cleaned = remove_non_printable_characters(cleaned)
+
+        # By default, newline characters are removed
+        remove_newline = True
+
+        try:
+            if hasattr(self, 'serializer_class'):
+                model = self.serializer_class.Meta.model
+                field = model._meta.get_field(field)
+
+                # The following field types allow newline characters
+                allow_newline = [
+                    InvenTreeNotesField,
+                ]
+
+                for field_type in allow_newline:
+                    if issubclass(type(field), field_type):
+                        remove_newline = False
+                        break
+
+        except AttributeError:
+            pass
+        except FieldDoesNotExist:
+            pass
+
+        cleaned = remove_non_printable_characters(cleaned, remove_newline=remove_newline)
 
         return cleaned
 
diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py
index b22902bd65..ea64cb800c 100644
--- a/InvenTree/build/serializers.py
+++ b/InvenTree/build/serializers.py
@@ -702,7 +702,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
         ]
 
     def validate(self, data):
-        """Perfofrm data validation for this item"""
+        """Perform data validation for this item"""
         super().validate(data)
 
         build = self.context['build']
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 0c65203574..eed93d57d5 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -160,7 +160,8 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
 
     filterset_fields = [
         'name',
-        'description'
+        'description',
+        'structural'
     ]
 
     ordering_fields = [
diff --git a/InvenTree/part/migrations/0090_auto_20221115_0816.py b/InvenTree/part/migrations/0090_auto_20221115_0816.py
new file mode 100644
index 0000000000..4965f36d69
--- /dev/null
+++ b/InvenTree/part/migrations/0090_auto_20221115_0816.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.16 on 2022-11-15 08:16
+
+import common.settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0089_auto_20221112_0128'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='partcategory',
+            name='structural',
+            field=models.BooleanField(default=False, help_text="Parts may not be directly assigned to a structural category, but may be assigned to it's child categories.", verbose_name='Structural'),
+        ),
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 8e5d42add0..6111696f71 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -117,6 +117,14 @@ class PartCategory(MetadataMixin, InvenTreeTree):
         help_text=_('Default location for parts in this category')
     )
 
+    structural = models.BooleanField(
+        default=False,
+        verbose_name=_('Structural'),
+        help_text=_(
+            'Parts may not be directly assigned to a structural category, '
+            'but may be assigned to it\'s child categories.'),
+    )
+
     default_keywords = models.CharField(null=True, blank=True, max_length=250, verbose_name=_('Default keywords'), help_text=_('Default keywords for parts in this category'))
 
     icon = models.CharField(
@@ -135,6 +143,17 @@ class PartCategory(MetadataMixin, InvenTreeTree):
         """Return the web URL associated with the detail view for this PartCategory instance"""
         return reverse('category-detail', kwargs={'pk': self.id})
 
+    def clean(self):
+        """Custom clean action for the PartCategory model:
+
+        - Ensure that the structural parameter cannot get set if products already assigned to the category
+        """
+        if self.pk and self.structural and self.item_count > 0:
+            raise ValidationError(
+                _("You cannot make this part category structural because some parts "
+                  "are already assigned to it!"))
+        super().clean()
+
     class Meta:
         """Metaclass defines extra model properties"""
         verbose_name = _("Part Category")
@@ -424,6 +443,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
         If not, it is considered "orphaned" and will be deleted.
         """
         # Get category templates settings
+
         add_category_templates = kwargs.pop('add_category_templates', False)
 
         if self.pk:
@@ -754,11 +774,17 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
     def clean(self):
         """Perform cleaning operations for the Part model.
 
-        Update trackable status:
+        - Check if the PartCategory is not structural
+
+        - Update trackable status:
             If this part is trackable, and it is used in the BOM
             for a parent part which is *not* trackable,
             then we will force the parent part to be trackable.
         """
+        if self.category is not None and self.category.structural:
+            raise ValidationError(
+                {'category': _("Parts cannot be assigned to structural part categories!")})
+
         super().clean()
 
         # Strip IPN field
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 6f0707df46..45a0a5e55e 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -75,6 +75,7 @@ class CategorySerializer(InvenTreeModelSerializer):
             'pathstring',
             'starred',
             'url',
+            'structural',
             'icon',
         ]
 
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index 9b5cbb0649..173e2fd2af 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -4,6 +4,7 @@ from decimal import Decimal
 from enum import IntEnum
 from random import randint
 
+from django.core.exceptions import ValidationError
 from django.urls import reverse
 
 import PIL
@@ -403,6 +404,59 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
                     child.refresh_from_db()
                     self.assertEqual(child.parent, parent_category)
 
+    def test_structural(self):
+        """Test the effectiveness of structural categories
+
+        Make sure:
+        - Parts cannot be created in structural categories
+        - Parts cannot be assigned to structural categories
+        """
+
+        # Create our structural part category
+        structural_category = PartCategory.objects.create(
+            name='Structural category',
+            description='This is the structural category',
+            parent=None,
+            structural=True
+        )
+
+        part_count_before = Part.objects.count()
+
+        # Make sure that we get an error if we try to create part in the structural category
+        with self.assertRaises(ValidationError):
+            part = Part.objects.create(
+                name="Part which shall not be created",
+                description="-",
+                category=structural_category
+            )
+
+        # Ensure that the part really did not get created in the structural category
+        self.assertEqual(part_count_before, Part.objects.count())
+
+        # Create a non structural category for test part category change
+        non_structural_category = PartCategory.objects.create(
+            name='Non-structural category',
+            description='This is a non-structural category',
+            parent=None,
+            structural=False
+        )
+
+        # Create the test part assigned to a non-structural category
+        part = Part.objects.create(
+            name="Part which category will be changed to structural",
+            description="-",
+            category=non_structural_category
+        )
+
+        # Assign the test part to a structural category and make sure it gives an error
+        part.category = structural_category
+        with self.assertRaises(ValidationError):
+            part.save()
+
+        # Ensure that the part did not get saved to the DB
+        part.refresh_from_db()
+        self.assertEqual(part.category.pk, non_structural_category.pk)
+
 
 class PartOptionsAPITest(InvenTreeAPITestCase):
     """Tests for the various OPTIONS endpoints in the /part/ API.
@@ -1544,8 +1598,24 @@ class PartDetailTests(InvenTreeAPITestCase):
         self.assertFalse('hello' in part.metadata)
         self.assertEqual(part.metadata['x'], 'y')
 
-    def test_part_notes(self):
-        """Unit tests for the part 'notes' field"""
+
+class PartNotesTests(InvenTreeAPITestCase):
+    """Tests for the 'notes' field (markdown field)"""
+
+    fixtures = [
+        'category',
+        'part',
+        'location',
+        'company',
+    ]
+
+    roles = [
+        'part.change',
+        'part.add',
+    ]
+
+    def test_long_notes(self):
+        """Test that very long notes field is rejected"""
 
         # Ensure that we cannot upload a very long piece of text
         url = reverse('api-part-detail', kwargs={'pk': 1})
@@ -1560,6 +1630,36 @@ class PartDetailTests(InvenTreeAPITestCase):
 
         self.assertIn('Ensure this field has no more than 50000 characters', str(response.data['notes']))
 
+    def test_multiline_formatting(self):
+        """Ensure that markdown formatting is retained"""
+
+        url = reverse('api-part-detail', kwargs={'pk': 1})
+
+        notes = """
+        ### Title
+
+        1. Numbered list
+        2. Another item
+        3. Another item again
+
+        [A link](http://link.com.go)
+
+        """
+
+        response = self.patch(
+            url,
+            {
+                'notes': notes,
+            },
+            expected_code=200
+        )
+
+        # Ensure that newline chars have not been removed
+        self.assertIn('\n', response.data['notes'])
+
+        # Entire notes field should match original value
+        self.assertEqual(response.data['notes'], notes.strip())
+
 
 class PartPricingDetailTests(InvenTreeAPITestCase):
     """Tests for the part pricing API endpoint"""
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 8af3b0bc04..1896cd7ee7 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -81,6 +81,9 @@ function partFields(options={}) {
 
                     return fields;
                 }
+            },
+            filters: {
+                structural: false,
             }
         },
         name: {},
@@ -298,6 +301,7 @@ function categoryFields() {
         default_keywords: {
             icon: 'fa-key',
         },
+        structural: {},
         icon: {
             help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`,
             placeholder: 'fas fa-tag',