diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index d6b03dbad4a0..f4548b32c984 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 312 +INVENTREE_API_VERSION = 313 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v313 - 2025-02-20 : https://github.com/inventree/InvenTree/pull/8596 + - Adds ReferenceSource and Reference endpoints to the API + v312 - 2025-02-15 : https://github.com/inventree/InvenTree/pull/9079 - Remove old API endpoints associated with legacy BOM import functionality diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index 132608508210..b8c010ad1415 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -118,3 +118,5 @@ class NewsFeedEntryAdmin(admin.ModelAdmin): admin.site.register(common.models.WebhookMessage, admin.ModelAdmin) +admin.site.register(common.models.Reference, admin.ModelAdmin) +admin.site.register(common.models.ReferenceSource, admin.ModelAdmin) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index aa6af12662dc..4f8e2ecd6b79 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -882,6 +882,117 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI): path('', SelectionListList.as_view(), name='api-selectionlist-list'), ] + +class ReferenceSourceList(ListCreateAPI): + """List view for all reference sources.""" + + queryset = common.models.ReferenceSource.objects.all() + serializer_class = common.serializers.ReferenceSourceSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + filter_backends = SEARCH_ORDER_FILTER + + ordering_fields = [ + 'name', + 'description', + 'slug', + 'locked', + 'active', + 'source_plugin', + 'created', + 'last_updated', + ] + search_fields = ['name', 'description', 'slug'] + + +class ReferenceSourceDetail(RetrieveUpdateDestroyAPI): + """Detail view for a particular reference source.""" + + queryset = common.models.ReferenceSource.objects.all() + serializer_class = common.serializers.ReferenceSourceSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + + +class ReferenceList(ListCreateAPI): + """List view for all references.""" + + queryset = common.models.Reference.objects.all() + serializer_class = common.serializers.ReferenceSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + filter_backends = SEARCH_ORDER_FILTER + + ordering_fields = [ + 'source', + 'target', + 'value', + 'locked', + 'created', + 'last_updated', + 'checked', + 'last_checked', + ] + search_fields = ['source', 'target', 'value'] + + def get_queryset(self): + """Return prefetched queryset.""" + queryset = ( + super() + .get_queryset() + .prefetch_related('target_content_type', 'target_object_id') + ) + + return queryset + + +class ReferenceDetail(RetrieveUpdateDestroyAPI): + """Detail view for a particular reference.""" + + queryset = common.models.Reference.objects.all() + serializer_class = common.serializers.ReferenceSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + + def get_queryset(self): + """Return prefetched queryset.""" + queryset = ( + super() + .get_queryset() + .prefetch_related('target_content_type', 'target_object_id') + ) + + return queryset + + +reference_urls = [ + path( + 'source/', + include([ + path( + '/', + include([ + path( + '', + ReferenceSourceDetail.as_view(), + name='api-reference-source-detail', + ) + ]), + ), + path('', ReferenceSourceList.as_view(), name='api-reference-source-list'), + ]), + ), + # TODO add api endpoint to get all references for a target + path( + '', + include([ + path( + '/', + include([ + path('', ReferenceDetail.as_view(), name='api-reference-detail') + ]), + ), + path('', ReferenceList.as_view(), name='api-reference-list'), + ]), + ), +] + # API URL patterns settings_api_urls = [ # User settings @@ -1093,6 +1204,8 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI): path('icons/', IconList.as_view(), name='api-icon-list'), # Selection lists path('selection/', include(selection_urls)), + # References + path('reference/', include(reference_urls)), ] admin_api_urls = [ diff --git a/src/backend/InvenTree/common/migrations/0033_referencesource_reference.py b/src/backend/InvenTree/common/migrations/0033_referencesource_reference.py new file mode 100644 index 000000000000..265052d79692 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0033_referencesource_reference.py @@ -0,0 +1,250 @@ +# Generated by Django 4.2.16 on 2024-12-03 23:54 + +import InvenTree.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("plugin", "0009_alter_pluginconfig_key"), + ("common", "0032_selectionlist_selectionlistentry_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ReferenceSource", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "name", + models.CharField( + help_text="Name of the reference source", + max_length=100, + unique=True, + verbose_name="Name", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Description of the reference source", + max_length=250, + verbose_name="Description", + ), + ), + ( + "locked", + models.BooleanField( + default=False, + help_text="Is this reference source locked?", + verbose_name="Locked", + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Can this reference source be used?", + verbose_name="Active", + ), + ), + ( + "source_string", + models.CharField( + blank=True, + help_text="Optional string identifying the source used for this reference source", + max_length=1000, + verbose_name="Source String", + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, + help_text="Date and time that the reference source was created", + verbose_name="Created", + ), + ), + ( + "last_updated", + models.DateTimeField( + auto_now=True, + help_text="Date and time that the reference source was last updated", + verbose_name="Last Updated", + ), + ), + ( + "validation_pattern", + models.CharField( + blank=True, + help_text="Regular expression pattern to validate a reference", + max_length=250, + verbose_name="Validation Pattern", + ), + ), + ( + "max_length", + models.PositiveIntegerField( + default=100, + help_text="Maximum length of the reference string", + verbose_name="Max Length", + ), + ), + ( + "reference_is_unique_global", + models.BooleanField( + default=False, + help_text="Are references unique globally?", + verbose_name="Unique Globally", + ), + ), + ( + "reference_is_link", + models.BooleanField( + default=False, + help_text="Are references required to be valid URIs as per RFC 3986?", + verbose_name="Reference is Link", + ), + ), + ( + "source_plugin", + models.ForeignKey( + blank=True, + help_text="Plugin which provides the reference source", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="plugin.pluginconfig", + verbose_name="Source Plugin", + ), + ), + ], + options={ + "verbose_name": "Reference Source", + "verbose_name_plural": "Reference Sources", + }, + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + migrations.CreateModel( + name="Reference", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "target_object_id", + models.PositiveIntegerField( + help_text="ID of the target object", + verbose_name="Target Object ID", + ), + ), + ( + "value", + models.CharField( + help_text="Raw value of the reference", + max_length=255, + verbose_name="Value", + ), + ), + ( + "locked", + models.BooleanField( + default=False, + help_text="Is this reference locked?", + verbose_name="Locked", + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, + help_text="Date and time that the reference was created", + verbose_name="Created", + ), + ), + ( + "last_updated", + models.DateTimeField( + auto_now=True, + help_text="Date and time that the reference was last updated", + verbose_name="Last Updated", + ), + ), + ( + "checked", + models.BooleanField( + default=False, + help_text="Was this reference checked to be valid?", + verbose_name="Checked", + ), + ), + ( + "last_checked", + models.DateTimeField( + blank=True, + help_text="Date and time that the reference was last checked", + null=True, + verbose_name="Last Checked", + ), + ), + ( + "source", + models.ForeignKey( + help_text="Reference source that defined this reference", + on_delete=django.db.models.deletion.CASCADE, + to="common.referencesource", + verbose_name="Source", + ), + ), + ( + "target_content_type", + models.ForeignKey( + help_text="Content type of the target object", + on_delete=django.db.models.deletion.CASCADE, + related_name="reference_target", + to="contenttypes.contenttype", + verbose_name="Target Content Type", + ), + ), + ], + options={ + "abstract": False, + }, + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 8e6890441a27..28b61ec542dd 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -8,6 +8,7 @@ import hmac import json import os +import re import uuid from datetime import timedelta, timezone from enum import Enum @@ -24,7 +25,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.files.storage import default_storage -from django.core.validators import MinValueValidator +from django.core.validators import MinValueValidator, URLValidator from django.db import models, transaction from django.db.models.signals import post_delete, post_save from django.db.utils import IntegrityError, OperationalError, ProgrammingError @@ -1493,6 +1494,7 @@ class WebhookMessage(models.Model): ) +# region Notifications class NotificationEntry(MetaMixin): """A NotificationEntry records the last time a particular notification was sent out. @@ -1602,6 +1604,9 @@ def age_human(self) -> str: return naturaltime(self.creation) +# endregion + + class NewsFeedEntry(models.Model): """A NewsFeedEntry represents an entry on the RSS/Atom feed that is generated for InvenTree news. @@ -1765,6 +1770,9 @@ def after_custom_unit_updated(sender, instance, **kwargs): reload_unit_registry() +# region Files + + def rename_attachment(instance, filename): """Callback function to rename an uploaded attachment file. @@ -1946,6 +1954,9 @@ def check_permission(self, permission, user): return model_class.check_attachment_permission(permission, user) +# endregion + + class InvenTreeCustomUserStateModel(models.Model): """Custom model to extends any registered state with extra custom, user defined states. @@ -2086,6 +2097,9 @@ def get_status_class(self): return cls +# region Linked data + + class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): """Class which represents a list of selectable items for parameters. @@ -2249,6 +2263,242 @@ def __str__(self): return self.label +class ReferenceSource(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): + """A source for references linking model instances to strings. + + Attributes: + - name: The name of the reference source + - description: A description of the reference source + - slug: The slug of the reference source + - locked: Is this reference source locked (i.e. cannot be modified by the user)? + - active: Is this reference source active? + - source_plugin: The plugin which provides the reference source + - source_string: The string representation of the reference source - might be used by plugins to + provide extra information + - created: The date/time that the reference source was created + - last_updated: The date/time that the reference source was last updated + + - validation_pattern: A regular expression pattern to validate a + - max_length: The maximum length of the reference string + reference (None if no regex validation is required) + - reference_is_unique_global: Are references unique globally? + - reference_is_link: Are references required to be valid URIs as per RFC 3986? + """ + + class Meta: + """Meta options for SelectionList.""" + + verbose_name = _('Reference Source') + verbose_name_plural = _('Reference Sources') + + name = models.CharField( + max_length=100, + verbose_name=_('Name'), + help_text=_('Name of the reference source'), + unique=True, + ) + + description = models.CharField( + max_length=250, + verbose_name=_('Description'), + help_text=_('Description of the reference source'), + blank=True, + ) + + locked = models.BooleanField( + default=False, + verbose_name=_('Locked'), + help_text=_('Is this reference source locked?'), + ) + + active = models.BooleanField( + default=True, + verbose_name=_('Active'), + help_text=_('Can this reference source be used?'), + ) + + source_plugin = models.ForeignKey( + 'plugin.PluginConfig', + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Source Plugin'), + help_text=_('Plugin which provides the reference source'), + ) + + source_string = models.CharField( + max_length=1000, + verbose_name=_('Source String'), + help_text=_( + 'Optional string identifying the source used for this reference source' + ), + blank=True, + ) + + created = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Created'), + help_text=_('Date and time that the reference source was created'), + ) + + last_updated = models.DateTimeField( + auto_now=True, + verbose_name=_('Last Updated'), + help_text=_('Date and time that the reference source was last updated'), + ) + + validation_pattern = models.CharField( + max_length=250, + verbose_name=_('Validation Pattern'), + help_text=_('Regular expression pattern to validate a reference'), + blank=True, + ) + + max_length = models.PositiveIntegerField( + verbose_name=_('Max Length'), + help_text=_('Maximum length of the reference string'), + default=100, + ) + + reference_is_unique_global = models.BooleanField( + default=False, + verbose_name=_('Unique Globally'), + help_text=_('Are references unique globally?'), + ) + + reference_is_link = models.BooleanField( + default=False, + verbose_name=_('Reference is Link'), + help_text=_('Are references required to be valid URIs as per RFC 3986?'), + ) + + def __str__(self): + """Return string representation of the reference source.""" + if not self.active: + return f'{self.name} (Inactive)' + return self.name + + def clean(self): + """Ensure that the reference source is valid before saving.""" + if self.validation_pattern: + try: + re.compile(self.validation_pattern) + except re.error as exc: + raise ValidationError({'validation_pattern': str(exc)}) + + return super().clean() + + +class Reference(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): + """Reference to a specific model. + + Attributes: + - source: ReferenceSource that defined this Reference + - target: GenericObject that links to the targeted object + - value: raw value + - locked: Is this reference locked? + - created: The date/time that the reference source was created + - last_updated: The date/time that the reference source was last updated + - checked: Was this reference checked to be valid? + - last_checked: The date/time that the reference was last checked + """ + + source = models.ForeignKey( + ReferenceSource, + on_delete=models.CASCADE, + verbose_name=_('Source'), + help_text=_('Reference source that defined this reference'), + ) + + target_content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name='reference_target', + verbose_name=_('Target Content Type'), + help_text=_('Content type of the target object'), + ) + + target_object_id = models.PositiveIntegerField( + verbose_name=_('Target Object ID'), help_text=_('ID of the target object') + ) + + target = GenericForeignKey('target_content_type', 'target_object_id') + + value = models.CharField( + max_length=255, + verbose_name=_('Value'), + help_text=_('Raw value of the reference'), + ) + + locked = models.BooleanField( + default=False, + verbose_name=_('Locked'), + help_text=_('Is this reference locked?'), + ) + + created = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Created'), + help_text=_('Date and time that the reference was created'), + ) + + last_updated = models.DateTimeField( + auto_now=True, + verbose_name=_('Last Updated'), + help_text=_('Date and time that the reference was last updated'), + ) + + checked = models.BooleanField( + default=False, + verbose_name=_('Checked'), + help_text=_('Was this reference checked to be valid?'), + ) + + last_checked = models.DateTimeField( + null=True, + blank=True, + verbose_name=_('Last Checked'), + help_text=_('Date and time that the reference was last checked'), + ) + + def __str__(self): + """Return string representation of the reference.""" + return f'{self.source.name}|{self.target}: {self.value}' + + def clean_value(self, *args, **kwargs): + """Ensure that the reference is valid before saving.""" + reference = self.value + source = self.source + + if source.max_length and len(reference) > source.max_length: + raise ValidationError({ + 'value': _(f'Value longer than max_length of {source.max_length}') + }) + + if source.validation_pattern: + comp = re.compile(source.validation_pattern) + if not comp.fullmatch(reference): + raise ValidationError({ + 'value': _('Value does not match validation pattern') + }) + + if source.reference_is_link: + validator = URLValidator() + try: + validator(reference) + except ValidationError: + raise ValidationError({'value': _('Value is not a valid URL')}) + + if source.reference_is_unique_global: + if Reference.objects.filter(source=source, value=reference).exists(): + raise ValidationError({'value': _('Value is not unique globally')}) + + return super().clean(*args, **kwargs) + + +# endregion + + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 407097388b60..a413c2a82678 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -782,3 +782,51 @@ def validate(self, attrs): if self.instance and self.instance.locked: raise serializers.ValidationError({'locked': _('Selection list is locked')}) return ret + + +class ReferenceSourceSerializer(InvenTreeModelSerializer): + """Serializer for the ReferenceSource model.""" + + class Meta: + """Meta options for ReferenceSourceSerializer.""" + + model = common_models.ReferenceSource + fields = '__all__' + + def validate(self, attrs): + """Ensure that the reference source is not locked.""" + ret = super().validate(attrs) + if self.instance and self.instance.locked: + raise serializers.ValidationError({ + 'locked': _('Reference source is locked') + }) + return ret + + +class ReferenceSerializer(InvenTreeModelSerializer): + """Serializer for the Reference model.""" + + class Meta: + """Meta options for ReferenceSerializer.""" + + model = common_models.Reference + fields = [ + 'pk', + 'value', + 'source', + 'target', + 'locked', + 'created', + 'last_updated', + 'checked', + 'last_checked', + ] + + source = serializers.PrimaryKeyRelatedField( + queryset=common_models.Reference.objects.all(), many=False + ) + target = serializers.SerializerMethodField() # read_only=True) + + def get_target(self, obj) -> dict: + """Function to resolve generic object reference to target.""" + return get_objectreference(obj, 'target_content_type', 'target_object_id') diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 8cd9f9718ebf..99498523f1b1 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -44,6 +44,7 @@ NotificationEntry, NotificationMessage, ProjectCode, + ReferenceSource, SelectionList, SelectionListEntry, WebhookEndpoint, @@ -1822,6 +1823,146 @@ def test_parameter(self): self.assertEqual(response.data['data'], self.entry1.value) +class ReferenceStuffTest(InvenTreeAPITestCase): + """Test Reference and ReferenceSource things.""" + + def setUp(self): + """Setup for all tests.""" + self.source1 = ReferenceSource.objects.create(name='Test Source') + return super().setUp() + + def test_ReferenceSource_lock_api(self): + """Test that the reference source can be locked.""" + self.assertEqual(self.source1.name, str(self.source1)) + + url = reverse('api-reference-source-detail', kwargs={'pk': self.source1.pk}) + response = self.patch(url, {'locked': True, 'active': False}, expected_code=200) + self.assertTrue(response.data['locked']) + self.source1.refresh_from_db() + self.assertEqual(self.source1.name + ' (Inactive)', str(self.source1)) + + # Should not be able to edit + response = self.patch(url, {'name': 'New Name'}, expected_code=400) + self.assertIn('Reference source is locked', response.data['locked']) + + # Unlock + response = self.patch(url, {'locked': False}, expected_code=400) + self.assertIn('Reference source is locked', response.data['locked']) + self.source1.locked = False + self.source1.save() + + # Should be able to edit + response = self.patch( + url, {'name': 'New Name', 'active': False}, expected_code=200 + ) + self.assertEqual(response.data['name'], 'New Name') + + def test_ReferenceSource_api(self): + """Test the ReferenceSource API.""" + url = reverse('api-reference-source-list') + response = self.get(url, expected_code=200) + self.assertEqual(len(response.data), 1) + + url = reverse('api-reference-source-detail', kwargs={'pk': self.source1.pk}) + response = self.get(url, expected_code=200) + self.assertEqual(response.data['name'], 'Test Source') + + def test_Reference_api(self): + """Test the Reference API.""" + url = reverse('api-reference-list') + response = self.get(url, expected_code=200) + self.assertEqual(len(response.data), 0) + + # Add reference + response = self.post( + url, + { + 'source': self.source1.pk, + 'target': self.source1, + 'value': 'Test Reference', + }, + expected_code=201, + ) + self.assertEqual(response.data['value'], 'Test Reference') + self.assertEqual(response.data['source'], self.source1.pk) + + # Edit reference + url = reverse('api-reference-detail', kwargs={'pk': response.data['pk']}) + response = self.patch(url, {'value': 'New Reference'}, expected_code=200) + self.assertEqual(response.data['value'], 'New Reference') + + def test_Reference_validation(self): + """Test the Reference validation.""" + url = reverse('api-reference-list') + + # Valid reference + response = self.post( + url, + { + 'source': self.source1.pk, + 'target': self.source1.pk, + 'value': 'Test Reference', + }, + expected_code=201, + ) + self.assertEqual(response.data['value'], 'Test Reference') + + # No empty value + response = self.post( + url, {'source': self.source1.pk, 'value': ''}, expected_code=400 + ) + self.assertIn('This field may not be blank.', response.data['value']) + + # Test max_length + self.source1.max_length = 10 + self.source1.save() + response = self.post( + url, {'source': self.source1.pk, 'value': 'a' * 100}, expected_code=400 + ) + self.assertIn( + 'Ensure this field has no more than 10 characters.', response.data['value'] + ) + self.source1.max_length = 100 + + # Test validation_pattern + self.source1.validation_pattern = '[0-9]+' + self.source1.save() + response = self.post( + url, {'source': self.source1.pk, 'value': 'abc'}, expected_code=400 + ) + self.assertIn('Enter a valid value.', response.data['value']) + self.source1.validation_pattern = None + + # Test reference_is_link + self.source1.reference_is_link = True + self.source1.save() + response = self.post( + url, {'source': self.source1.pk, 'value': 'abc'}, expected_code=400 + ) + self.assertIn('This value must be a valid URL.', response.data['value']) + # Test valid URL + response = self.post( + url, + {'source': self.source1.pk, 'value': 'https://www.example.com'}, + expected_code=201, + ) + self.assertEqual(response.data['value'], 'https://www.example.com') + self.source1.reference_is_link = False + + # Test reference_is_unique_global + self.source1.reference_is_unique_global = True + self.source1.save() + response = self.post( + url, + {'source': self.source1.pk, 'value': 'Test Reference'}, + expected_code=400, + ) + self.assertIn( + 'Reference with this Source and Value already exists.', + response.data['non_field_errors'], + ) + + class AdminTest(AdminTestCase): """Tests for the admin interface integration.""" diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index a7b1756905fe..c0ad1de529bf 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -348,6 +348,8 @@ def get_ruleset_ignore(): 'common_inventreecustomuserstatemodel', 'common_selectionlistentry', 'common_selectionlist', + 'common_referencesource', + 'common_reference', 'users_owner', # Third-party tables 'error_report_error',