Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ba32138
Allow 'company' field of Address model to be null
SchrodingersGat May 31, 2026
2384a99
Add API filtering
SchrodingersGat May 31, 2026
bb39b19
Migrate address fields for order models
SchrodingersGat May 31, 2026
2b917d0
Adjust UI forms
SchrodingersGat May 31, 2026
cb4113b
Refactor address table
SchrodingersGat May 31, 2026
3b77c41
Admin table for internal addresses
SchrodingersGat May 31, 2026
97caa20
New unit tests for order addresses
SchrodingersGat May 31, 2026
a92e2f7
Adjust admin interface
SchrodingersGat May 31, 2026
28ef786
Documentation updates
SchrodingersGat May 31, 2026
6f4312d
Tweak text
SchrodingersGat May 31, 2026
8afae9d
Add fallback for primary address
SchrodingersGat May 31, 2026
801d244
Update CHANGELOG
SchrodingersGat May 31, 2026
22f94d3
lazy load address table
SchrodingersGat May 31, 2026
10b93d2
Display address on PurchaseOrderDetail page
SchrodingersGat May 31, 2026
296ee10
Bump API version
SchrodingersGat May 31, 2026
e1acec9
only staff users can manipulate internal addresses
SchrodingersGat May 31, 2026
755a724
Fix CHANGELOG
SchrodingersGat May 31, 2026
3b1ba84
Test for new API filter
SchrodingersGat May 31, 2026
4a7ba6a
docs fix
SchrodingersGat May 31, 2026
2730775
Adjust address fallback
SchrodingersGat May 31, 2026
27767bb
Merge branch 'master' into delivery-address
SchrodingersGat May 31, 2026
90fba96
Update migration
SchrodingersGat Jun 1, 2026
95b8202
Merge commit 'd38af61ba2fb3c87143da57ddd92bc4845f85a7c' into delivery…
SchrodingersGat Jun 1, 2026
2c8d511
Merge branch 'delivery-address' of github.com:SchrodingersGat/InvenTr…
SchrodingersGat Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself
- [#12056](https://github.com/inventree/InvenTree/pull/12056) - Purchase Orders, Return Orders, and Transfer Orders now require an *internal* address (one not linked to any company) for their `address` field. Previously these orders accepted any company-linked address. Any existing orders with a company-linked address set will fail validation on next save and must have their address field cleared or updated to an internal address.

### Added

- [#12056](https://github.com/inventree/InvenTree/pull/12056) - adds support for *internal addresses* — addresses that belong to your own organisation rather than to an external company. Internal addresses are managed from the Admin Center and are used as delivery addresses on Purchase Orders, Return Orders, and Transfer Orders. A new `?internal=true/false` filter has been added to the address list API endpoint to distinguish internal from company-linked addresses. If no explicit address is set on an order, the primary internal address is used as a fallback.

### Changed

- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
Expand Down
33 changes: 32 additions & 1 deletion docs/docs/concepts/company.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ A *contact* can be assigned to orders, (such as [purchase orders](../purchasing/
### Addresses

A company can have multiple registered addresses for use with all types of orders.
An address is broken down to internationally recognised elements that are designed to allow for formatting an address according to user needs.
An address is broken down to internationally recognized elements that are designed to allow for formatting an address according to user needs.
Addresses are composed differently across the world, and InvenTree reflects this by splitting addresses into components:

| Field | Description |
Expand Down Expand Up @@ -97,10 +97,41 @@ Each company can have exactly one (1) primary address.
This address is the default shown on the company profile, and the one that is automatically suggested when creating an order.
Marking a new address as primary will remove the mark from the old primary address.

!!! info "Effective order address"
When an order ([Sales Order](../sales/sales_order.md)) has no explicit address assigned, InvenTree uses the customer's primary address as the effective delivery address. This fallback ensures that reports and shipping labels always have an address to render, even if none was explicitly chosen on the order.

#### Managing Addresses

Addresses can be accessed by the <span class='badge inventree nav main'>{{ icon("map-2") }} Addresses</span> navigation tab, from the company detail page.

Here, the addresses associated with the company are listed, and can be added, edited, or deleted.

{{ image("concepts/edit_address.png", "Edit Address") }}

### Internal Addresses

In addition to addresses linked to external companies, InvenTree supports **internal addresses** — addresses that belong to your own organization rather than to any external company.

Internal addresses are used when the delivery destination is one of your own sites rather than a customer or supplier location. Orders that use internal addresses include:

| Order Type | Address Meaning |
| --- | --- |
| [Purchase Order](../purchasing/purchase_order.md) | Optional delivery destination for incoming goods |
| [Return Order](../sales/return_order.md) | Optional location to which returned items are sent |
| [Transfer Order](../stock/transfer_order.md) | Optional destination for an internal stock transfer |

!!! info "Sales Orders use external addresses"
[Sales Orders](../sales/sales_order.md) are addressed to the *customer*, so they use addresses linked to the customer company, not internal addresses.

#### Managing Internal Addresses

Internal addresses are managed from the **Admin Center**, under the *Addresses* panel. Only staff users can create, edit, or delete internal addresses.

Each internal address uses the same fields as a company address (Title, Line 1, Line 2, Postal Code, City, Province, Country).

#### Primary Internal Address

One internal address can be marked as the **primary** address. This address is the default suggestion when assigning a delivery address to a new order. Marking a different address as primary will automatically remove the mark from the previous primary address.

!!! info "Effective order address"
When a Purchase Order, Return Order, or Transfer Order has no explicit address assigned, InvenTree falls back to the primary internal address as the effective delivery address. This ensures that reports and shipping documents always have an address to render. If no internal address exists at all, the effective address is blank.
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 498
INVENTREE_API_VERSION = 499
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """

v499 -> 2026-05-31 : https://github.com/inventree/InvenTree/pull/12056
- Allow null values for the 'company' field on the Address model

v498 -> 2026-05-31 : https://github.com/inventree/InvenTree/pull/12055
- Updates the "status_text" field for models which support custom status values

Expand Down
45 changes: 43 additions & 2 deletions src/backend/InvenTree/company/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework.filterset import FilterSet
from rest_framework.exceptions import PermissionDenied

import part.models
from data_exporter.mixins import DataExportViewMixin
Expand Down Expand Up @@ -105,27 +106,67 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
serializer_class = ContactSerializer


class AddressFilter(FilterSet):
"""Custom API filters for the Address list endpoint."""

class Meta:
"""Metaclass options."""

model = Address
fields = ['company']

internal = rest_filters.BooleanFilter(
label=_('Internal Address'), field_name='company', lookup_expr='isnull'
)
Comment thread
SchrodingersGat marked this conversation as resolved.
Comment thread
SchrodingersGat marked this conversation as resolved.


def _require_staff_for_internal_address(request, address_company) -> None:
"""Raise PermissionDenied if the address is internal and the user is not staff."""
if address_company is None and not request.user.is_staff:
raise PermissionDenied(_('Only staff users can modify internal addresses'))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this forces ppl to use staff accounts - which are well-known sources for security issues; imo this should be a seperate permissions



class AddressList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Address model."""

queryset = Address.objects.all()
serializer_class = AddressSerializer
filterset_class = AddressFilter
Comment thread
SchrodingersGat marked this conversation as resolved.

filter_backends = SEARCH_ORDER_FILTER

filterset_fields = ['company']

ordering_fields = ['title']

ordering = 'title'

def perform_create(self, serializer):
"""Enforce staff-only creation of internal addresses."""
company = serializer.validated_data.get('company', None)
_require_staff_for_internal_address(self.request, company)
super().perform_create(serializer)

def validate_delete(self, queryset, request) -> None:
"""Prevent non-staff users from bulk-deleting internal addresses."""
if not request.user.is_staff and queryset.filter(company__isnull=True).exists():
raise PermissionDenied(_('Only staff users can delete internal addresses'))


class AddressDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for a single Address object."""

queryset = Address.objects.all()
serializer_class = AddressSerializer

def perform_update(self, serializer):
"""Enforce staff-only updates to internal addresses."""
_require_staff_for_internal_address(self.request, serializer.instance.company)
super().perform_update(serializer)

def perform_destroy(self, instance):
"""Enforce staff-only deletion of internal addresses."""
_require_staff_for_internal_address(self.request, instance.company)
super().perform_destroy(instance)


class ManufacturerPartFilter(FilterSet):
"""Custom API filters for the ManufacturerPart list endpoint."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.14 on 2026-05-31 03:39

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("company", "0079_auto_20260212_1054"),
]

operations = [
migrations.AlterField(
model_name="address",
name="company",
field=models.ForeignKey(
blank=True,
help_text="Select company",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="addresses",
to="company.company",
verbose_name="Company",
),
),
]
4 changes: 3 additions & 1 deletion src/backend/InvenTree/company/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ class Address(InvenTree.models.InvenTreeModel):
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations.

Attributes:
company: Company link for this address
company: Company link for this address (null if this is an *internal* address, not associated with a company)
title: Human-readable name for the address
primary: True if this is the company's primary address
line1: First line of address
Expand Down Expand Up @@ -403,6 +403,8 @@ def get_api_url():
on_delete=models.CASCADE,
verbose_name=_('Company'),
help_text=_('Select company'),
null=True,
blank=True,
)

title = models.CharField(
Expand Down
156 changes: 156 additions & 0 deletions src/backend/InvenTree/company/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,28 @@ def test_filter_list(self):

self.assertEqual(len(response.data), self.num_addr)

def test_filter_internal(self):
"""Test the ?internal= filter returns only internal / only company addresses."""
n_internal = 2
Address.objects.bulk_create([
Address(company=None, title=f'Internal {idx}') for idx in range(n_internal)
])

total = Address.objects.count()
n_company = total - n_internal

# internal=true → only addresses with company=None
response = self.get(self.url, {'internal': True}, expected_code=200)
self.assertEqual(len(response.data), n_internal)
for item in response.data:
self.assertIsNone(item['company'])

# internal=false → only addresses with a company set
response = self.get(self.url, {'internal': False}, expected_code=200)
self.assertEqual(len(response.data), n_company)
for item in response.data:
self.assertIsNotNone(item['company'])

def test_create(self):
"""Test creating a new address."""
company = Company.objects.first()
Expand Down Expand Up @@ -466,6 +488,140 @@ def test_delete(self):
self.get(url, expected_code=404)


class InternalAddressPermissionTest(InvenTreeAPITestCase):
"""Tests that internal addresses (company=None) require staff for write operations."""

# Start as non-staff so most tests can verify the 403 path first
is_staff = False
roles = ['purchase_order.add', 'purchase_order.change', 'purchase_order.delete']

@classmethod
def setUpTestData(cls):
"""Create one company address and one internal address for use across tests."""
super().setUpTestData()

cls.company = Company.objects.create(
name='Test Co', description='A test company'
)
cls.company_address = Address.objects.create(
company=cls.company, title='Company HQ'
)
cls.internal_address = Address.objects.create(
company=None, title='Our Warehouse'
)

cls.list_url = reverse('api-address-list')
cls.internal_url = reverse(
'api-address-detail', kwargs={'pk': cls.internal_address.pk}
)
cls.company_url = reverse(
'api-address-detail', kwargs={'pk': cls.company_address.pk}
)

# --- Create ---

def test_create_internal_address_non_staff_forbidden(self):
"""Non-staff cannot create an internal address (no company field)."""
self.post(self.list_url, {'title': 'New Depot'}, expected_code=403)

def test_create_internal_address_staff_allowed(self):
"""Staff users can create an internal address."""
self.user.is_staff = True
self.user.save()
try:
response = self.post(
self.list_url, {'title': 'Staff Depot'}, expected_code=201
)
self.assertIsNone(response.data['company'])
finally:
self.user.is_staff = False
self.user.save()

def test_create_company_address_non_staff_allowed(self):
"""Non-staff can still create a regular company-linked address."""
self.post(
self.list_url,
{'company': self.company.pk, 'title': 'Branch Office'},
expected_code=201,
)

# --- Update ---

def test_update_internal_address_non_staff_forbidden(self):
"""Non-staff cannot PATCH an internal address."""
self.patch(self.internal_url, {'title': 'Renamed'}, expected_code=403)

def test_update_internal_address_staff_allowed(self):
"""Staff users can PATCH an internal address."""
self.user.is_staff = True
self.user.save()
try:
self.patch(
self.internal_url, {'title': 'Updated Warehouse'}, expected_code=200
)
finally:
self.user.is_staff = False
self.user.save()

def test_update_company_address_non_staff_allowed(self):
"""Non-staff can PATCH a regular company-linked address."""
self.patch(self.company_url, {'title': 'Updated HQ'}, expected_code=200)

# --- Delete (detail) ---

def test_delete_internal_address_non_staff_forbidden(self):
"""Non-staff cannot DELETE an internal address."""
self.delete(self.internal_url, expected_code=403)
# Confirm the object still exists
self.assertTrue(Address.objects.filter(pk=self.internal_address.pk).exists())

def test_delete_internal_address_staff_allowed(self):
"""Staff users can DELETE an internal address."""
addr = Address.objects.create(company=None, title='Temporary')
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
self.user.is_staff = True
self.user.save()
try:
self.delete(url, expected_code=204)
self.assertFalse(Address.objects.filter(pk=addr.pk).exists())
finally:
self.user.is_staff = False
self.user.save()

def test_delete_company_address_non_staff_allowed(self):
"""Non-staff can DELETE a regular company-linked address."""
addr = Address.objects.create(company=self.company, title='Temporary Branch')
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
self.delete(url, expected_code=204)

# --- Bulk delete ---

def test_bulk_delete_internal_address_non_staff_forbidden(self):
"""Non-staff cannot bulk-delete a set that contains internal addresses."""
addr = Address.objects.create(company=None, title='Bulk Target')
self.delete(self.list_url, data={'items': [addr.pk]}, expected_code=403)
self.assertTrue(Address.objects.filter(pk=addr.pk).exists())

def test_bulk_delete_internal_address_staff_allowed(self):
"""Staff users can bulk-delete internal addresses."""
addr = Address.objects.create(company=None, title='Bulk Internal')
self.user.is_staff = True
self.user.save()
try:
self.delete(self.list_url, data={'items': [addr.pk]}, expected_code=200)
self.assertFalse(Address.objects.filter(pk=addr.pk).exists())
finally:
self.user.is_staff = False
self.user.save()

def test_bulk_delete_company_addresses_non_staff_allowed(self):
"""Non-staff can bulk-delete a set that contains only company-linked addresses."""
a1 = Address.objects.create(company=self.company, title='Bulk A')
a2 = Address.objects.create(company=self.company, title='Bulk B')
self.delete(self.list_url, data={'items': [a1.pk, a2.pk]}, expected_code=200)
self.assertFalse(Address.objects.filter(pk__in=[a1.pk, a2.pk]).exists())


class ManufacturerTest(InvenTreeAPITestCase):
"""Series of tests for the Manufacturer DRF API."""

Expand Down
Loading
Loading