Internal company support (#13017)

* Add internal company flag

* Unit tests

* Add changelog entry and update API version

* add global company table

* Expose model_filters for internal settings

* Update API version

* Update CHANGELOG

* update assert

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
OliverandMatthias Mair authored and GitHub committed 2026-10-15 11:20:59 +11:00
1 parent 720ba157bb
commit 9b8fdeb06d
22 files changed
+337 -6

No files matched your search

@@ -1,11 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 555
INVENTREE_API_VERSION = 556
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v556 -> 2026-10-14 : https://github.com/inventree/InvenTree/pull/13017
- Adds 'is_internal' field (and filter) to the Company API endpoint
- Adds 'INVENTREE_DEFAULT_INTERNAL_COMPANY' global setting
- Exposes 'model_filters' on the global and user settings API endpoints
v555 -> 2026-10-14 : https://github.com/inventree/InvenTree/pull/13016
- Adds "contact" field to the SalesOrderShipment model and API endpoint
- Adds "phone" and "email" fields to the Address model and API endpoint
@@ -179,6 +179,7 @@ class GlobalSettingsSerializer(SettingsSerializer):
'units',
'choices',
'model_name',
'model_filters',
'api_url',
'typ',
'read_only',
@@ -226,6 +227,7 @@ class UserSettingsSerializer(SettingsSerializer):
'units',
'choices',
'model_name',
'model_filters',
'api_url',
'typ',
'confirm',
@@ -21,6 +21,24 @@ import report.helpers
from common.setting.type import InvenTreeSettingsKeyType, SettingFlag
def validate_internal_company(value):
"""Validate that the provided value is empty, or references an internal company."""
from company.models import Company
if value in [None, '']:
return
try:
pk = int(value)
except (TypeError, ValueError):
raise ValidationError({'value': _('Invalid company')})
if not Company.objects.filter(pk=pk, is_internal=True).exists():
raise ValidationError({
'value': _('Selected company must be marked as an internal company')
})
def validate_part_name_format(value):
"""Validate part name format.
@@ -255,6 +273,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Internal company name'),
'default': 'My company name',
},
'INVENTREE_DEFAULT_INTERNAL_COMPANY': {
'name': _('Default internal company'),
'description': _('Internal company which represents this organization'),
'default': '',
'model': 'company.company',
'model_filters': {'is_internal': True},
'validator': validate_internal_company,
},
'INVENTREE_BASE_URL': {
'name': _('Base URL'),
'description': _('Base URL for server instance'),
+24
View File
@@ -756,6 +756,30 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
# There should be no user sessions now
self.assertEqual(self.user.usersession_set.count(), 0)
def test_model_filters(self):
"""Test that model-backed settings expose their model filters."""
for key, model_name, filters in [
(
'INVENTREE_DEFAULT_INTERNAL_COMPANY',
'company.company',
{'is_internal': True},
),
(
'PURCHASEORDER_DEFAULT_RECEIVE_LOCATION',
'stock.stocklocation',
{'structural': False},
),
]:
url = reverse('api-global-setting-detail', kwargs={'key': key})
response = self.get(url, expected_code=200)
self.assertEqual(response.data['model_name'], model_name)
self.assertEqual(response.data['model_filters'], filters)
# Settings without a model have no filters
url = reverse('api-global-setting-detail', kwargs={'key': 'INVENTREE_INSTANCE'})
self.assertIsNone(self.get(url, expected_code=200).data['model_filters'])
def test_api_detail(self):
"""Test that we can access the detail view for a setting based on the <key>."""
# These keys are invalid, and should return 404
+8 -1
View File
@@ -45,7 +45,14 @@ class CompanyFilter(FilterSet):
"""Metaclass options."""
model = Company
fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name', 'active']
fields = [
'is_customer',
'is_manufacturer',
'is_supplier',
'is_internal',
'name',
'active',
]
tag_name = common.filters.TagsFilter()
@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-10-14 02:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("company", "0082_address_phone_email"),
]
operations = [
migrations.AddField(
model_name="company",
name="is_internal",
field=models.BooleanField(
default=False,
help_text="Does this company represent your own organization?",
verbose_name="Is internal",
),
),
]
+32
View File
@@ -27,6 +27,7 @@ import InvenTree.ready
import InvenTree.validators
import report.mixins
from common.currency import currency_code_default
from common.settings import get_global_setting
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from order.status_codes import PurchaseOrderStatusGroups
@@ -91,6 +92,7 @@ class Company(
- A supplier is a company from which parts can be purchased
- A customer is a company to which parts can be sold
- A manufacturer is a company which manufactures a raw good (they may or may not be a "supplier" also)
- An internal company represents your own organization (or one of its entities / sites)
Attributes:
@@ -107,6 +109,7 @@ class Company(
is_customer: boolean value, is this company a customer
is_supplier: boolean value, is this company a supplier
is_manufacturer: boolean value, is this company a manufacturer
is_internal: boolean value, does this company represent your own organization
currency_code: Specifies the default currency for the company
tax_id: Tax ID for the company
"""
@@ -129,6 +132,29 @@ class Company(
"""Return the API URL associated with the Company model."""
return reverse('api-company-list')
@classmethod
def get_default_internal(cls) -> Optional['Company']:
"""Return the default internal company for this InvenTree instance.
- If the INVENTREE_DEFAULT_INTERNAL_COMPANY setting points to an internal company, return it
- Otherwise, if exactly one internal company exists, return it
- Otherwise, return None
"""
internal = cls.objects.filter(is_internal=True)
if pk := get_global_setting(
'INVENTREE_DEFAULT_INTERNAL_COMPANY', backup_value=None
):
try:
if company := internal.filter(pk=int(pk)).first():
return company
except (TypeError, ValueError):
pass
companies = list(internal[:2])
return companies[0] if len(companies) == 1 else None
def report_context(self) -> CompanyReportContext:
"""Generate a dict of context data to provide to the reporting framework."""
return {
@@ -212,6 +238,12 @@ class Company(
help_text=_('Does this company manufacture parts?'),
)
is_internal = models.BooleanField(
default=False,
verbose_name=_('Is internal'),
help_text=_('Does this company represent your own organization?'),
)
currency = models.CharField(
max_length=3,
verbose_name=_('Currency'),
@@ -146,6 +146,7 @@ class CompanySerializer(
'is_customer',
'is_manufacturer',
'is_supplier',
'is_internal',
'parts_supplied',
'parts_manufactured',
'primary_address',
+29
View File
@@ -71,6 +71,35 @@ class CompanyTest(InvenTreeAPITestCase):
response = self.get(url, data)
self.assertEqual(len(response.data), 2)
def test_company_internal(self):
"""Test the 'is_internal' field and filter."""
url = reverse('api-company-list')
self.assertEqual(len(self.get(url, {'is_internal': True}).data), 0)
response = self.post(
url,
{'name': 'Our Company', 'is_internal': True, 'is_supplier': False},
expected_code=201,
)
self.assertTrue(response.data['is_internal'])
pk = response.data['pk']
response = self.get(url, {'is_internal': True})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], pk)
self.assertEqual(len(self.get(url, {'is_internal': False}).data), 3)
# Only an internal company can be selected as the default
setting_url = reverse(
'api-global-setting-detail',
kwargs={'key': 'INVENTREE_DEFAULT_INTERNAL_COMPANY'},
)
self.patch(setting_url, {'value': self.acme.pk}, expected_code=400)
self.patch(setting_url, {'value': pk}, expected_code=200)
def test_company_detail(self):
"""Tests for the Company detail endpoint."""
url = reverse('api-company-detail', kwargs={'pk': self.acme.pk})
+53
View File
@@ -5,6 +5,8 @@ import os
from django.core.exceptions import ValidationError
from django.test import TestCase
from common.models import InvenTreeSetting
from common.settings import set_global_setting
from part.models import Part
from .models import (
@@ -159,6 +161,57 @@ class ContactSimpleTest(TestCase):
self.assertEqual(Contact.objects.count(), 0)
class InternalCompanyTest(TestCase):
"""Unit tests for internal company support."""
SETTING = 'INVENTREE_DEFAULT_INTERNAL_COMPANY'
@classmethod
def setUpTestData(cls):
"""Create some companies to work with."""
super().setUpTestData()
cls.supplier = Company.objects.create(name='Supplier', is_supplier=True)
cls.hq = Company.objects.create(name='HQ', is_supplier=False, is_internal=True)
def test_default(self):
"""Test the Company.get_default_internal() helper."""
set_global_setting(self.SETTING, '')
# A single internal company is used as the default
self.assertEqual(Company.get_default_internal(), self.hq)
# Multiple internal companies, with no setting, is ambiguous
site = Company.objects.create(name='Site', is_supplier=False, is_internal=True)
self.assertIsNone(Company.get_default_internal())
# The setting selects between them
set_global_setting(self.SETTING, site.pk)
self.assertEqual(Company.get_default_internal(), site)
# A setting which no longer points to an internal company is ignored
site.is_internal = False
site.save()
self.assertEqual(Company.get_default_internal(), self.hq)
# No internal companies at all
Company.objects.filter(is_internal=True).update(is_internal=False)
self.assertIsNone(Company.get_default_internal())
def test_setting_validation(self):
"""The setting may only reference an internal company."""
setting = InvenTreeSetting.get_setting_object(self.SETTING, create=True)
for value in [self.supplier.pk, 99999, 'abc']:
setting.value = value
with self.assertRaises(ValidationError):
setting.full_clean()
for value in ['', self.hq.pk]:
setting.value = value
setting.full_clean()
class AddressTest(TestCase):
"""Unit tests for the Address model."""
+1 -1
View File
@@ -45,7 +45,7 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
session.extract_columns()
self.assertEqual(session.column_mappings.count(), 13)
self.assertEqual(session.column_mappings.count(), 14)
# Check some of the field mappings
for field, col in [
+11
View File
@@ -30,6 +30,7 @@ import report.helpers
import report.validators
from common.models import DataOutput, RenderChoices, UpdatedUserMixin
from common.settings import get_global_setting
from company.models import Company
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
from plugin import InvenTreePlugin, PluginMixinEnum
@@ -158,6 +159,7 @@ class BaseContextExtension(TypedDict):
template_name: Name of the report template
template_revision: Revision of the report template
user: User who is creating the report (if available)
internal_company: The default internal company (if defined) which represents this organization
report_vars: Private dict for the {% get_var %} / {% set_var %} template tags
"""
@@ -169,6 +171,7 @@ class BaseContextExtension(TypedDict):
template_name: str
template_revision: int
user: Optional[AbstractUser]
internal_company: Optional[Company]
report_vars: dict
@@ -410,6 +413,13 @@ class ReportTemplateBase(
"""Return a filter dict which can be applied to the target model."""
return report.validators.validate_filters(self.filters, model=self.get_model())
def get_internal_company(self) -> Optional[Company]:
"""Return the default internal company, cached for the lifetime of this template instance."""
if not hasattr(self, '_internal_company'):
self._internal_company = Company.get_default_internal()
return self._internal_company
def base_context(self, **kwargs) -> BaseContextExtension:
"""Return base context data (available to all templates)."""
return {
@@ -421,6 +431,7 @@ class ReportTemplateBase(
'template_name': self.name,
'template_revision': self.revision,
'user': kwargs.get('user'),
'internal_company': self.get_internal_company(),
# A private, per-render dict for the {% get_var %} / {% set_var %} tags.
# This is discarded once rendering of *this* instance completes,
# and is never shared between instances, reports, or requests.
+17
View File
@@ -67,6 +67,23 @@ class ReportTest(InvenTreeAPITestCase):
return super().setUp()
def test_internal_company_context(self):
"""Test that the default internal company is available in the base context."""
from company.models import Company
template = ReportTemplate.objects.first()
self.assertIsNone(template.base_context()['internal_company'])
company = Company.objects.create(
name='Our Company', is_supplier=False, is_internal=True
)
# Value is cached against the template instance
self.assertIsNone(template.base_context()['internal_company'])
template = ReportTemplate.objects.get(pk=template.pk)
self.assertEqual(template.base_context()['internal_company'], company)
def test_list_endpoint(self):
"""Test that the LIST endpoint works for each report."""
url = reverse('api-report-template-list')
+1
View File
@@ -189,6 +189,7 @@ export function companyFields({
is_supplier: {},
is_manufacturer: {},
is_customer: {},
is_internal: {},
active: {},
duplicate: DuplicateField({
originalId: duplicateCompanyId,
+2
View File
@@ -10,6 +10,7 @@ import {
IconBrandTelegram,
IconBuilding,
IconBuildingFactory2,
IconBuildingSkyscraper,
IconBuildingStore,
IconBusinessplan,
IconCalendar,
@@ -158,6 +159,7 @@ const icons: InvenTreeIconType = {
manufacturers: IconBuildingFactory2,
suppliers: IconBuilding,
customers: IconBuildingStore,
internal_company: IconBuildingSkyscraper,
purchase_orders: IconShoppingCart,
return_orders: IconTruckReturn,
transfer_orders: IconTransfer,
@@ -4,6 +4,7 @@ import type { PanelGroupType, PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
import { Stack } from '@mantine/core';
import {
IconBuilding,
IconCoins,
IconCpu,
IconDevicesPc,
@@ -35,6 +36,7 @@ import { PanelGroup } from '../../../../components/panels/PanelGroup';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { Loadable } from '../../../../functions/loading';
import { useUserState } from '../../../../states/UserState';
import { CompanyTable } from '../../../../tables/company/CompanyTable';
import ParameterTemplateTable from '../../../../tables/general/ParameterTemplateTable';
import SelectionListTable from '../../../../tables/settings/SelectionListTable';
@@ -176,6 +178,17 @@ export default function AdminCenter() {
icon: <IconExclamationCircle />,
content: <ErrorReportTable />
},
{
name: 'companies',
label: t`Companies`,
icon: <IconBuilding />,
content: (
<Stack gap='xs'>
<GlobalSettingList keys={['INVENTREE_DEFAULT_INTERNAL_COMPANY']} />
<CompanyTable companyType='all' />
</Stack>
)
},
{
name: 'currencies',
label: t`Currencies`,
@@ -299,6 +312,7 @@ export default function AdminCenter() {
'barcode-history',
'background',
'errors',
'companies',
'currencies',
'email'
]
@@ -47,6 +47,7 @@ export default function SystemSettings() {
keys={[
'INVENTREE_BASE_URL',
'INVENTREE_COMPANY_NAME',
'INVENTREE_DEFAULT_INTERNAL_COMPANY',
'INVENTREE_INSTANCE',
'INVENTREE_INSTANCE_TITLE',
'INVENTREE_INSTANCE_ID',
@@ -83,6 +83,13 @@ export function CompanyDetailsPanel({
name: 'is_customer',
label: t`Customer`,
icon: 'customers'
},
{
type: 'boolean',
name: 'is_internal',
label: t`Internal`,
icon: 'internal_company',
hidden: !instance?.is_internal
}
];
@@ -27,6 +27,13 @@ import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
type CompanyTableType =
| 'all'
| 'internal'
| 'supplier'
| 'manufacturer'
| 'customer';
/**
* A table which displays a list of company records,
* based on the provided filter parameters
@@ -34,10 +41,12 @@ import { useUserState } from '../../states/UserState';
export function CompanyTable({
companyType,
params,
initialData,
path
}: Readonly<{
companyType?: string;
companyType?: CompanyTableType;
params?: any;
initialData?: any;
path?: string;
}>) {
const table = useTable(`company-${companyType ?? 'index'}`, {
@@ -77,18 +86,54 @@ export function CompanyTable({
sortable: true,
switchable: true
}),
BooleanColumn({
accessor: 'is_supplier',
filter: 'is_supplier',
title: t`Supplier`,
sortable: true,
switchable: true,
hidden: companyType === 'supplier',
defaultVisible: false
}),
BooleanColumn({
accessor: 'is_manufacturer',
filter: 'is_manufacturer',
title: t`Manufacturer`,
sortable: true,
switchable: true,
hidden: companyType === 'manufacturer',
defaultVisible: false
}),
BooleanColumn({
accessor: 'is_customer',
filter: 'is_customer',
title: t`Customer`,
sortable: true,
switchable: true,
hidden: companyType === 'customer',
defaultVisible: false
}),
BooleanColumn({
accessor: 'is_internal',
filter: 'is_internal',
title: t`Internal`,
sortable: true,
switchable: true,
hidden: companyType === 'internal',
defaultVisible: false
}),
{
accessor: 'website',
sortable: false
}
];
}, []);
}, [companyType]);
const newCompany = useCreateApiFormModal({
url: ApiEndpoints.company_list,
title: t`Add Company`,
fields: companyFields(),
initialData: params,
initialData: initialData ?? params,
follow: true,
modelType: ModelType.company,
keepOpenOption: true
@@ -126,6 +171,11 @@ export function CompanyTable({
label: t`Customer`,
description: t`Show companies which are customers`
},
{
name: 'is_internal',
label: t`Internal`,
description: t`Show companies which represent your own organization`
},
TagsFilter({ modelType: ModelType.company })
];
}, []);