[Feature] Company Addresses (#4732)

* Add initial model structure

* Initial Address model defined

* Add migration and unit tests

* Initial migration for Address model generated

* Unit tests for Address model added

* Move address field to new model

* Added migration to move address field to Address model

* Implement address feature to backend

* API endpoints for list and detail implemented

* Serializer class for Address implemented

* Final migration to delete old address field from company added

* Tests for API and migrations added

* Amend migration file names

* Fix migration names in test

* Add address property to company model

* Iinital view and JS code

* Fix indents

* Fix different things

* Pre-emptive change before merge

* Post-merge fixes

* dotdotdot...

* ...

* iDots

* .

* .

* .

* Add form functionality and model checks

* Forms require a confirmation slider to be checked to submit
  if address is selected as primary

* Backend resets primary address before saving if new address
  is designated as primary

* Fix pre-save logic to enforce primary uniqueness

* Fix typos

* Sort out migrations

* Forgot one

* Add admin entry and small fixes

* Fix migration file name and dependency

* Update InvenTree/company/models.py

Co-authored-by: Matthias Mair <code@mjmair.com>

* Update InvenTree/company/models.py

Co-authored-by: Matthias Mair <code@mjmair.com>

* Correct final issues

* .

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Lavissa 2023-06-17 13:55:25 +02:00 committed by GitHub
parent 61d2f452b2
commit bf707766b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1185 additions and 28 deletions

View File

@ -41,7 +41,7 @@ repos:
args: [requirements.in, -o, requirements.txt]
files: ^requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.29.0
rev: v1.30.2
hooks:
- id: djlint-django
- repo: https://github.com/codespell-project/codespell

View File

@ -9,9 +9,9 @@ from import_export.fields import Field
from InvenTree.admin import InvenTreeResource
from part.models import Part
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
from .models import (Address, Company, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
class CompanyResource(InvenTreeResource):
@ -187,6 +187,33 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part',)
class AddressResource(InvenTreeResource):
"""Class for managing Address data import/export"""
class Meta:
"""Metaclass defining extra options"""
model = Address
skip_unchanged = True
report_skipped = False
clean_model_instances = True
company = Field(attribute='company', widget=widgets.ForeignKeyWidget(Company))
class AddressAdmin(ImportExportModelAdmin):
"""Admin class for the Address model"""
resource_class = AddressResource
list_display = ('company', 'line1', 'postal_code', 'country')
search_fields = [
'company',
'country',
'postal_code',
]
admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
@ -194,3 +221,5 @@ admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
admin.site.register(Address, AddressAdmin)

View File

@ -14,11 +14,12 @@ from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER,
from InvenTree.helpers import str2bool
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
ContactSerializer,
from .models import (Address, Company, CompanyAttachment, Contact,
ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
from .serializers import (AddressSerializer, CompanyAttachmentSerializer,
CompanySerializer, ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, SupplierPartSerializer,
@ -135,6 +136,32 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
serializer_class = ContactSerializer
class AddressList(ListCreateDestroyAPIView):
"""API endpoint for list view of Address model"""
queryset = Address.objects.all()
serializer_class = AddressSerializer
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'company',
]
ordering_fields = [
'title',
]
ordering = 'title'
class AddressDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for a single Address object"""
queryset = Address.objects.all()
serializer_class = AddressSerializer
class ManufacturerPartFilter(rest_filters.FilterSet):
"""Custom API filters for the ManufacturerPart list endpoint."""
@ -568,6 +595,11 @@ company_api_urls = [
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
])),
re_path(r'^address/', include([
path('<int:pk>/', AddressDetail.as_view(), name='api-address-detail'),
re_path(r'^.*$', AddressList.as_view(), name='api-address-list'),
])),
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 3.2.18 on 2023-05-02 19:56
import InvenTree.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0062_contact_metadata'),
]
operations = [
migrations.CreateModel(
name='Address',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Title describing the address entry', max_length=100, verbose_name='Address title')),
('primary', models.BooleanField(default=False, help_text='Set as primary address', verbose_name='Primary address')),
('line1', models.CharField(blank=True, help_text='Address line 1', max_length=50, verbose_name='Line 1')),
('line2', models.CharField(blank=True, help_text='Address line 2', max_length=50, verbose_name='Line 2')),
('postal_code', models.CharField(blank=True, help_text='Postal code', max_length=10, verbose_name='Postal code')),
('postal_city', models.CharField(blank=True, help_text='Postal code city', max_length=50, verbose_name='City')),
('province', models.CharField(blank=True, help_text='State or province', max_length=50, verbose_name='State/Province')),
('country', models.CharField(blank=True, help_text='Address country', max_length=50, verbose_name='Country')),
('shipping_notes', models.CharField(blank=True, help_text='Notes for shipping courier', max_length=100, verbose_name='Courier shipping notes')),
('internal_shipping_notes', models.CharField(blank=True, help_text='Shipping notes for internal use', max_length=100, verbose_name='Internal shipping notes')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to address information (external)', verbose_name='Link')),
('company', models.ForeignKey(help_text='Select company', on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='company.company', verbose_name='Company')),
],
),
migrations.AddConstraint(
model_name='address',
constraint=models.UniqueConstraint(condition=models.Q(('primary', True)), fields=('company',), name='one_primary_per_company'),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 3.2.18 on 2023-05-02 20:41
from django.db import migrations
def move_address_to_new_model(apps, schema_editor):
Company = apps.get_model('company', 'Company')
Address = apps.get_model('company', 'Address')
for company in Company.objects.all():
if company.address != '':
# Address field might exceed length of new model fields
l1 = company.address[:50]
l2 = company.address[50:100]
Address.objects.create(company=company,
title="Primary",
primary=True,
line1=l1,
line2=l2)
company.address = ''
company.save()
def revert_address_move(apps, schema_editor):
Address = apps.get_model('company', 'Address')
for address in Address.objects.all():
address.company.address = f'{address.line1}{address.line2}'
address.company.save()
address.delete()
class Migration(migrations.Migration):
dependencies = [
('company', '0063_auto_20230502_1956'),
]
operations = [
migrations.RunPython(move_address_to_new_model, reverse_code=revert_address_move)
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-05-13 14:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0064_move_address_field_to_address_model'),
]
operations = [
migrations.RemoveField(
model_name='company',
name='address',
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.19 on 2023-06-16 20:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0065_remove_company_address'),
]
operations = [
migrations.AlterModelOptions(
name='address',
options={'verbose_name_plural': 'Addresses'},
),
migrations.AlterField(
model_name='address',
name='postal_city',
field=models.CharField(blank=True, help_text='Postal code city/region', max_length=50, verbose_name='City/Region'),
),
]

View File

@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.signals import post_delete, post_save
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -72,7 +72,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
name: Brief name of the company
description: Longer form description
website: URL for the company website
address: Postal address
address: One-line string representation of primary address
phone: contact phone number
email: contact email address
link: Secondary URL e.g. for link to internal Wiki page
@ -114,10 +114,6 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
help_text=_('Company website URL')
)
address = models.CharField(max_length=200,
verbose_name=_('Address'),
blank=True, help_text=_('Company address'))
phone = models.CharField(max_length=50,
verbose_name=_('Phone number'),
blank=True, help_text=_('Contact phone number'))
@ -158,6 +154,22 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
validators=[InvenTree.validators.validate_currency_code],
)
@property
def address(self):
"""Return the string representation for the primary address
This property exists for backwards compatibility
"""
addr = self.primary_address
return str(addr) if addr is not None else None
@property
def primary_address(self):
"""Returns address object of primary address. Parsed by serializer"""
return Address.objects.filter(company=self.id).filter(primary=True).first()
@property
def currency_code(self):
"""Return the currency code associated with this company.
@ -253,6 +265,136 @@ class Contact(MetadataMixin, models.Model):
role = models.CharField(max_length=100, blank=True)
class Address(models.Model):
"""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
title: Human-readable name for the address
primary: True if this is the company's primary address
line1: First line of address
line2: Optional line two for address
postal_code: Postal code, city and state
country: Location country
shipping_notes: Notes for couriers transporting shipments to this address
internal_shipping_notes: Internal notes regarding shipping to this address
link: External link to additional address information
"""
def __init__(self, *args, **kwargs):
"""Custom init function"""
if 'confirm_primary' in kwargs:
self.confirm_primary = kwargs.pop('confirm_primary', None)
super().__init__(*args, **kwargs)
def __str__(self):
"""Defines string representation of address to supple a one-line to API calls"""
available_lines = [self.line1,
self.line2,
self.postal_code,
self.postal_city,
self.province,
self.country
]
populated_lines = []
for line in available_lines:
if len(line) > 0:
populated_lines.append(line)
return ", ".join(populated_lines)
class Meta:
"""Metaclass defines extra model options"""
constraints = [
UniqueConstraint(fields=['company'], condition=Q(primary=True), name='one_primary_per_company')
]
verbose_name_plural = "Addresses"
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model"""
return reverse('api-address-list')
company = models.ForeignKey(Company, related_name='addresses',
on_delete=models.CASCADE,
verbose_name=_('Company'),
help_text=_('Select company'))
title = models.CharField(max_length=100,
verbose_name=_('Address title'),
help_text=_('Title describing the address entry'),
blank=False)
primary = models.BooleanField(default=False,
verbose_name=_('Primary address'),
help_text=_('Set as primary address'))
line1 = models.CharField(max_length=50,
verbose_name=_('Line 1'),
help_text=_('Address line 1'),
blank=True)
line2 = models.CharField(max_length=50,
verbose_name=_('Line 2'),
help_text=_('Address line 2'),
blank=True)
postal_code = models.CharField(max_length=10,
verbose_name=_('Postal code'),
help_text=_('Postal code'),
blank=True)
postal_city = models.CharField(max_length=50,
verbose_name=_('City/Region'),
help_text=_('Postal code city/region'),
blank=True)
province = models.CharField(max_length=50,
verbose_name=_('State/Province'),
help_text=_('State or province'),
blank=True)
country = models.CharField(max_length=50,
verbose_name=_('Country'),
help_text=_('Address country'),
blank=True)
shipping_notes = models.CharField(max_length=100,
verbose_name=_('Courier shipping notes'),
help_text=_('Notes for shipping courier'),
blank=True)
internal_shipping_notes = models.CharField(max_length=100,
verbose_name=_('Internal shipping notes'),
help_text=_('Shipping notes for internal use'),
blank=True)
link = InvenTreeURLField(blank=True,
verbose_name=_('Link'),
help_text=_('Link to address information (external)'))
@receiver(pre_save, sender=Address)
def check_primary(sender, instance, **kwargs):
"""Removes primary flag from current primary address if the to-be-saved address is marked as primary"""
if instance.company.primary_address is None:
instance.primary = True
# If confirm_primary is not present, this function does not need to do anything
if not hasattr(instance, 'confirm_primary') or \
instance.primary is False or \
instance.company.primary_address is None or \
instance.id == instance.company.primary_address.id:
return
if instance.confirm_primary is True:
adr = Address.objects.get(id=instance.company.primary_address.id)
adr.primary = False
adr.save()
class ManufacturerPart(MetadataMixin, models.Model):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.

View File

@ -20,9 +20,10 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
RemoteImageMixin)
from part.serializers import PartBriefSerializer
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
from .models import (Address, Company, CompanyAttachment, Contact,
ManufacturerPart, ManufacturerPartAttachment,
ManufacturerPartParameter, SupplierPart,
SupplierPriceBreak)
class CompanyBriefSerializer(InvenTreeModelSerializer):
@ -45,6 +46,53 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
class AddressSerializer(InvenTreeModelSerializer):
"""Serializer for the Address Model"""
class Meta:
"""Metaclass options"""
model = Address
fields = [
'pk',
'company',
'title',
'primary',
'line1',
'line2',
'postal_code',
'postal_city',
'province',
'country',
'shipping_notes',
'internal_shipping_notes',
'link',
'confirm_primary'
]
confirm_primary = serializers.BooleanField(default=False)
class AddressBriefSerializer(InvenTreeModelSerializer):
"""Serializer for Address Model (limited)"""
class Meta:
"""Metaclass options"""
model = Address
fields = [
'pk',
'line1',
'line2',
'postal_code',
'postal_city',
'province',
'country',
'shipping_notes',
'internal_shipping_notes'
]
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for Company object (full detail)"""
@ -73,11 +121,13 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
'parts_supplied',
'parts_manufactured',
'remote_image',
'address_count',
'primary_address'
]
@staticmethod
def annotate_queryset(queryset):
"""Annoate the supplied queryset with aggregated information"""
"""Annotate the supplied queryset with aggregated information"""
# Add count of parts manufactured
queryset = queryset.annotate(
parts_manufactured=SubqueryCount('manufactured_parts')
@ -87,14 +137,21 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
parts_supplied=SubqueryCount('supplied_parts')
)
queryset = queryset.annotate(
address_count=SubqueryCount('addresses')
)
return queryset
primary_address = AddressSerializer(required=False, allow_null=True, read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True)
parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True)
address_count = serializers.IntegerField(read_only=True)
currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True)

View File

@ -255,6 +255,31 @@
</div>
</div>
<div class='panel panel-hidden' id='panel-company-addresses'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Company addresses" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.purchase_order.add or roles.sales_order.add %}
<button class='btn btn-success' type='button' id='new-address' title='{% trans "Add Address" %}'>
<div class='fas fa-plus-circle'></div> {% trans "Add Address" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='addresses-button-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="addresses" %}
</div>
</div>
<table class='table table-striped table-condensed' id='addresses-table' data-toolbar='#addresses-button-toolbar'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -309,6 +334,26 @@
});
});
// Callback function for when the 'addresses' panel is loaded
onPanelLoad('company-addresses', function(){
loadAddressTable('#addresses-table', {
params: {
company: {{ company.pk }},
},
allow_edit: {% js_bool roles.purchase_order.change %} || {% js_bool roles.sales_order.change %},
allow_delete: {% js_bool roles.purchase_order.delete %} || {% js_bool roles.sales_order.delete %},
});
$('#new-address').click(function() {
createAddress({
company: {{ company.pk }},
onSuccess: function() {
$('#addresses-table').bootstrapTable('refresh');
}
})
})
})
// Callback function when the 'notes' panel is loaded
onPanelLoad('company-notes', function() {

View File

@ -32,6 +32,8 @@
{% endif %}
{% trans "Contacts" as text %}
{% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %}
{% trans "Addresses" as text %}
{% include "sidebar_item.html" with label='company-addresses' text=text icon="fa-map-marked" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
{% trans "Attachments" as text %}

View File

@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.unit_test import InvenTreeAPITestCase
from .models import Company, Contact, ManufacturerPart, SupplierPart
from .models import Address, Company, Contact, ManufacturerPart, SupplierPart
class CompanyTest(InvenTreeAPITestCase):
@ -284,6 +284,138 @@ class ContactTest(InvenTreeAPITestCase):
self.get(url, expected_code=404)
class AddressTest(InvenTreeAPITestCase):
"""Test cases for Address API endpoints"""
roles = []
@classmethod
def setUpTestData(cls):
"""Perform initialization for this test class"""
super().setUpTestData()
cls.num_companies = 3
cls.num_addr = 3
# Create some companies
companies = [
Company(
name=f"Company {idx}",
description="Some company"
) for idx in range(cls.num_companies)
]
Company.objects.bulk_create(companies)
addresses = []
# Create some contacts
for cmp in Company.objects.all():
addresses += [
Address(
company=cmp,
title=f"Address no. {idx}",
) for idx in range(cls.num_addr)
]
cls.url = reverse('api-address-list')
Address.objects.bulk_create(addresses)
def test_list(self):
"""Test listing all addresses without filtering"""
response = self.get(self.url, expected_code=200)
self.assertEqual(len(response.data), self.num_companies * self.num_addr)
def test_filter_list(self):
"""Test listing addresses filtered on company"""
company = Company.objects.first()
response = self.get(self.url, {'company': company.pk}, expected_code=200)
self.assertEqual(len(response.data), self.num_addr)
def test_create(self):
"""Test creating a new address"""
company = Company.objects.first()
self.post(self.url,
{
'company': company.pk,
'title': 'HQ'
},
expected_code=403)
self.assignRole('purchase_order.add')
self.post(self.url,
{
'company': company.pk,
'title': 'HQ'
},
expected_code=201)
def test_get(self):
"""Test that objects are properly returned from a get"""
addr = Address.objects.first()
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
response = self.get(url, expected_code=200)
self.assertEqual(response.data['pk'], addr.pk)
for key in ['title', 'line1', 'line2', 'postal_code', 'postal_city', 'province', 'country']:
self.assertIn(key, response.data)
def test_edit(self):
"""Test editing an object"""
addr = Address.objects.first()
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
self.patch(
url,
{
'title': 'Hello'
},
expected_code=403
)
self.assignRole('purchase_order.change')
self.patch(
url,
{
'title': 'World'
},
expected_code=200
)
data = self.get(url, expected_code=200).data
self.assertEqual(data['title'], 'World')
def test_delete(self):
"""Test deleting an object"""
addr = Address.objects.first()
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
self.delete(url, expected_code=403)
self.assignRole('purchase_order.delete')
self.delete(url, expected_code=204)
self.get(url, expected_code=404)
class ManufacturerTest(InvenTreeAPITestCase):
"""Series of tests for the Manufacturer DRF API."""

View File

@ -280,6 +280,47 @@ class TestCurrencyMigration(MigratorTestCase):
self.assertIsNotNone(pb.price)
class TestAddressMigration(MigratorTestCase):
"""Test moving address data into Address model"""
migrate_from = ('company', '0063_auto_20230502_1956')
migrate_to = ('company', '0064_move_address_field_to_address_model')
# Setting up string values for re-use
short_l1 = 'Less than 50 characters long address'
long_l1 = 'More than 50 characters long address testing line '
l2 = 'splitting functionality'
def prepare(self):
"""Set up some companies with addresses"""
Company = self.old_state.apps.get_model('company', 'company')
Company.objects.create(name='Company 1', address=self.short_l1)
Company.objects.create(name='Company 2', address=self.long_l1 + self.l2)
def test_address_migration(self):
"""Test database state after applying the migration"""
Address = self.new_state.apps.get_model('company', 'address')
Company = self.new_state.apps.get_model('company', 'company')
c1 = Company.objects.filter(name='Company 1').first()
c2 = Company.objects.filter(name='Company 2').first()
self.assertEqual(len(Address.objects.all()), 2)
a1 = Address.objects.filter(company=c1.pk).first()
a2 = Address.objects.filter(company=c2.pk).first()
self.assertEqual(a1.line1, self.short_l1)
self.assertEqual(a1.line2, "")
self.assertEqual(a2.line1, self.long_l1)
self.assertEqual(a2.line2, self.l2)
self.assertEqual(c1.address, '')
self.assertEqual(c2.address, '')
class TestSupplierPartQuantity(MigratorTestCase):
"""Test that the supplier part quantity is correctly migrated."""

View File

@ -4,11 +4,13 @@ import os
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.utils import IntegrityError
from django.test import TestCase
from part.models import Part
from .models import (Company, Contact, ManufacturerPart, SupplierPart,
from .models import (Address, Company, Contact, ManufacturerPart, SupplierPart,
rename_company_image)
@ -35,7 +37,6 @@ class CompanySimpleTest(TestCase):
Company.objects.create(name='ABC Co.',
description='Seller of ABC products',
website='www.abc-sales.com',
address='123 Sales St.',
is_customer=False,
is_supplier=True)
@ -174,6 +175,79 @@ class ContactSimpleTest(TestCase):
self.assertEqual(Contact.objects.count(), 0)
class AddressTest(TestCase):
"""Unit tests for the Address model"""
def setUp(self):
"""Initialization for the tests in this class"""
# Create a simple company
self.c = Company.objects.create(name='Test Corp.', description='We make stuff good')
def test_create(self):
"""Test that object creation with only company supplied is successful"""
Address.objects.create(company=self.c)
self.assertEqual(Address.objects.count(), 1)
def test_delete(self):
"""Test Address deletion"""
addr = Address.objects.create(company=self.c)
addr.delete()
self.assertEqual(Address.objects.count(), 0)
def test_primary_constraint(self):
"""Test that there can only be one company-'primary=true' pair"""
c2 = Company.objects.create(name='Test Corp2.', description='We make stuff good')
Address.objects.create(company=self.c, primary=True)
Address.objects.create(company=self.c, primary=False)
self.assertEqual(Address.objects.count(), 2)
# Testing the constraint itself
# Intentionally throwing exceptions breaks unit tests unless performed in an atomic block
with transaction.atomic():
self.assertRaises(IntegrityError, Address.objects.create, company=self.c, primary=True, confirm_primary=False)
Address.objects.create(company=c2, primary=True, line1="Hellothere", line2="generalkenobi")
with transaction.atomic():
self.assertRaises(IntegrityError, Address.objects.create, company=c2, primary=True)
self.assertEqual(Address.objects.count(), 3)
def test_first_address_is_primary(self):
"""Test that first address related to company is always set to primary"""
addr = Address.objects.create(company=self.c)
self.assertTrue(addr.primary)
self.assertRaises(IntegrityError, Address.objects.create, company=self.c, primary=True)
def test_model_str(self):
"""Test value of __str__"""
t = "Test address"
l1 = "Busy street 56"
l2 = "Red building"
pcd = "12345"
pct = "City"
pv = "Province"
cn = "COUNTRY"
addr = Address.objects.create(company=self.c,
title=t,
line1=l1,
line2=l2,
postal_code=pcd,
postal_city=pct,
province=pv,
country=cn)
self.assertEqual(str(addr), f'{l1}, {l2}, {pcd}, {pct}, {pv}, {cn}')
addr2 = Address.objects.create(company=self.c,
title=t,
line1=l1,
postal_code=pcd)
self.assertEqual(str(addr2), f'{l1}, {pcd}')
class ManufacturerPartSimpleTest(TestCase):
"""Unit tests for the ManufacturerPart model"""

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.19 on 2023-05-29 01:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0065_remove_company_address'),
('order', '0096_alter_returnorderlineitem_outcome'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='address',
field=models.ForeignKey(blank=True, help_text='Company address for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.address', verbose_name='Address'),
),
migrations.AddField(
model_name='returnorder',
name='address',
field=models.ForeignKey(blank=True, help_text='Company address for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.address', verbose_name='Address'),
),
migrations.AddField(
model_name='salesorder',
name='address',
field=models.ForeignKey(blank=True, help_text='Company address for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.address', verbose_name='Address'),
),
]

View File

@ -32,7 +32,7 @@ import stock.models
import users.models as UserModels
from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default
from company.models import Company, Contact, SupplierPart
from company.models import Address, Company, Contact, SupplierPart
from InvenTree.exceptions import log_error
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField,
RoundingDecimalField)
@ -272,6 +272,15 @@ class Order(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, Reference
related_name='+',
)
address = models.ForeignKey(
Address,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Address'),
help_text=_('Company address for this order'),
related_name='+',
)
@classmethod
def get_status_class(cls):
"""Return the enumeration class which represents the 'status' field for this model"""

View File

@ -18,7 +18,8 @@ import part.filters
import stock.models
import stock.serializers
from common.serializers import ProjectCodeSerializer
from company.serializers import (CompanyBriefSerializer, ContactSerializer,
from company.serializers import (AddressBriefSerializer,
CompanyBriefSerializer, ContactSerializer,
SupplierPartSerializer)
from InvenTree.helpers import (extract_serial_numbers, hash_barcode, normalize,
str2bool)
@ -75,6 +76,9 @@ class AbstractOrderSerializer(serializers.Serializer):
# Detail for project code field
project_code_detail = ProjectCodeSerializer(source='project_code', read_only=True, many=False)
# Detail for address field
address_detail = AddressBriefSerializer(source='address', many=False, read_only=True)
# Boolean field indicating if this order is overdue (Note: must be annotated)
overdue = serializers.BooleanField(required=False, read_only=True)
@ -114,6 +118,8 @@ class AbstractOrderSerializer(serializers.Serializer):
'responsible_detail',
'contact',
'contact_detail',
'address',
'address_detail',
'status',
'status_text',
'notes',

View File

@ -208,6 +208,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.address %}
<tr>
<td><span class='fas fa-map'></span></td>
<td>{% trans "Address" %}</td>
<td><b>{{ order.address.title }}</b>: {{ order.address }}</td>
</tr>
{% endif %}
{% if order.responsible %}
<tr>
<td><span class='fas fa-users'></span></td>

View File

@ -176,6 +176,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.address %}
<tr>
<td><span class='fas fa-map'></span></td>
<td>{% trans "Address" %}</td>
<td><b>{{ order.address.title }}</b>: {{ order.address }}</td>
</tr>
{% endif %}
{% if order.responsible %}
<tr>
<td><span class='fas fa-users'></span></td>

View File

@ -216,6 +216,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.address %}
<tr>
<td><span class='fas fa-map'></span></td>
<td>{% trans "Address" %}</td>
<td><b>{{ order.address.title }}</b>: {{ order.address }}</td>
</tr>
{% endif %}
{% if order.responsible %}
<tr>
<td><span class='fas fa-users'></span></td>

View File

@ -1,15 +1,19 @@
{% load i18n %}
/* globals
clearFormErrors,
constructLabel,
constructForm,
enableSubmitButton,
formatCurrency,
formatDecimal,
formatDate,
handleFormErrors,
handleFormSuccess,
imageHoverIcon,
inventreeGet,
inventreePut,
hideFormInput,
loadTableFilters,
makeDeleteButton,
makeEditButton,
@ -19,24 +23,29 @@
renderLink,
renderPart,
setupFilterList,
showFormInput,
thumbnailImage,
wrapButtons,
*/
/* exported
createAddress,
createCompany,
createContact,
createManufacturerPart,
createSupplierPart,
createSupplierPartPriceBreak,
deleteAddress,
deleteContacts,
deleteManufacturerParts,
deleteManufacturerPartParameters,
deleteSupplierParts,
duplicateSupplierPart,
editAddress,
editCompany,
editContact,
editSupplierPartPriceBreak,
loadAddressTable,
loadCompanyTable,
loadContactTable,
loadManufacturerPartTable,
@ -401,9 +410,6 @@ function companyFormFields() {
website: {
icon: 'fa-globe',
},
address: {
icon: 'fa-envelope',
},
currency: {
icon: 'fa-dollar-sign',
},
@ -782,6 +788,324 @@ function loadContactTable(table, options={}) {
});
}
/*
* Construct a set of form fields for the Address model
*/
function addressFields(options={}) {
let fields = {
company: {
icon: 'fa-building',
},
primary: {
onEdit: function(val, name, field, opts) {
if (val === false) {
hideFormInput("confirm_primary", opts);
$('#id_confirm_primary').prop("checked", false);
clearFormErrors(opts);
enableSubmitButton(opts, true);
} else if (val === true) {
showFormInput("confirm_primary", opts);
if($('#id_confirm_primary').prop("checked") === false) {
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
enableSubmitButton(opts, false);
}
}
}
},
confirm_primary: {
help_text: "Confirm",
onEdit: function(val, name, field, opts) {
if (val === true) {
clearFormErrors(opts);
enableSubmitButton(opts, true);
} else if (val === false) {
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
enableSubmitButton(opts, false);
}
},
css: {
display: 'none'
}
},
title: {},
line1: {
icon: 'fa-map'
},
line2: {
icon: 'fa-map',
},
postal_code: {
icon: 'fa-map-pin',
},
postal_city: {
icon: 'fa-city'
},
province: {
icon: 'fa-map'
},
country: {
icon: 'fa-map'
},
shipping_notes: {
icon: 'fa-shuttle-van'
},
internal_shipping_notes: {
icon: 'fa-clipboard'
},
link: {
icon: 'fa-link'
}
};
if (options.company) {
fields.company.value = options.company;
}
return fields;
}
/*
* Launches a form to create a new Address
*/
function createAddress(options={}) {
let fields = options.fields || addressFields(options);
constructForm('{% url "api-address-list" %}', {
method: 'POST',
fields: fields,
title: '{% trans "Create New Address" %}',
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Launches a form to edit an existing Address
*/
function editAddress(pk, options={}) {
let fields = options.fields || addressFields(options);
constructForm(`{% url "api-address-list" %}${pk}/`, {
fields: fields,
title: '{% trans "Edit Address" %}',
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Launches a form to delete one (or more) addresses
*/
function deleteAddress(addresses, options={}) {
if (addresses.length == 0) {
return;
}
function renderAddress(address) {
return `
<tr>
<td>${address.title}</td>
<td>${address.line1}</td>
<td>${address.line2}</td>
</tr>`;
}
let rows = '';
let ids = [];
addresses.forEach(function(address) {
rows += renderAddress(address);
ids.push(address.pk);
});
let html = `
<div class='alert alert-block alert-danger'>
{% trans "All selected addresses will be deleted" %}
</div>
<table class='table table-striped table-condensed'>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Line 1" %}</th>
<th>{% trans "Line 2" %}</th>
</tr>
${rows}
</table>`;
constructForm('{% url "api-address-list" %}', {
method: 'DELETE',
multi_delete: true,
title: '{% trans "Delete Addresses" %}',
preFormContent: html,
form_data: {
items: ids,
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
function loadAddressTable(table, options={}) {
var params = options.params || {};
var filters = loadTableFilters('address', params);
setupFilterList('address', $(table), '#filter-list-addresses');
$(table).inventreeTable({
url: '{% url "api-address-list" %}',
queryParams: filters,
original: params,
idField: 'pk',
uniqueId: 'pk',
sidePagination: 'server',
sortable: true,
formatNoMatches: function() {
return '{% trans "No addresses found" %}';
},
showColumns: true,
name: 'addresses',
columns: [
{
field: 'primary',
title: '{% trans "Primary" %}',
switchable: false,
formatter: function(value) {
let checked = '';
if (value == true) {
checked = 'checked="checked"';
}
return `<input type="checkbox" ${checked} disabled="disabled" value="${value? 1 : 0}">`;
}
},
{
field: 'title',
title: '{% trans "Title" %}',
sortable: true,
switchable: false,
},
{
field: 'line1',
title: '{% trans "Line 1" %}',
sortable: false,
switchable: false,
},
{
field: 'line2',
title: '{% trans "Line 2" %}',
sortable: false,
switchable: false,
},
{
field: 'postal_code',
title: '{% trans "Postal code" %}',
sortable: false,
switchable: false,
},
{
field: 'postal_city',
title: '{% trans "Postal city" %}',
sortable: false,
switchable: false,
},
{
field: 'province',
title: '{% trans "State/province" %}',
sortable: false,
switchable: false,
},
{
field: 'country',
title: '{% trans "Country" %}',
sortable: false,
switchable: false,
},
{
field: 'shipping_notes',
title: '{% trans "Courier notes" %}',
sortable: false,
switchable: true,
},
{
field: 'internal_shipping_notes',
title: '{% trans "Internal notes" %}',
sortable: false,
switchable: true,
},
{
field: 'link',
title: '{% trans "External Link" %}',
sortable: false,
switchable: true,
},
{
field: 'actions',
title: '',
sortable: false,
switchable: false,
visible: options.allow_edit || options.allow_delete,
formatter: function(value, row) {
var pk = row.pk;
let html = '';
if (options.allow_edit) {
html += makeEditButton('btn-address-edit', pk, '{% trans "Edit Address" %}');
}
if (options.allow_delete) {
html += makeDeleteButton('btn-address-delete', pk, '{% trans "Delete Address" %}');
}
return wrapButtons(html);
}
}
],
onPostBody: function() {
// Edit button callback
if (options.allow_edit) {
$(table).find('.btn-address-edit').click(function() {
var pk = $(this).attr('pk');
editAddress(pk, {
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
}
// Delete button callback
if (options.allow_delete) {
$(table).find('.btn-address-delete').click(function() {
var pk = $(this).attr('pk');
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
if (row && row.pk) {
deleteAddress([row], {
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
}
});
}
}
});
}
/* Delete one or more ManufacturerPart objects from the database.
* - User will be provided with a modal form, showing all the parts to be deleted.

View File

@ -2225,7 +2225,16 @@ function constructField(name, parameters, options={}) {
hover_title = ` title='${parameters.help_text}'`;
}
html += `<div id='div_id_${field_name}' class='${form_classes}' ${hover_title}>`;
var css = '';
if (parameters.css) {
let str = Object.keys(parameters.css).map(function(key) {
return `${key}: ${parameters.css[key]};`;
})
css = ` style="${str}"`;
}
html += `<div id='div_id_${field_name}' class='${form_classes}' ${hover_title} ${css}>`;
// Add a label
if (!options.hideLabels) {

View File

@ -14,6 +14,7 @@
renderBuild,
renderCompany,
renderContact,
renderAddress,
renderGroup,
renderManufacturerPart,
renderOwner,
@ -52,6 +53,8 @@ function getModelRenderer(model) {
return renderCompany;
case 'contact':
return renderContact;
case 'address':
return renderAddress;
case 'stockitem':
return renderStockItem;
case 'stocklocation':
@ -173,6 +176,17 @@ function renderContact(data, parameters={}) {
}
// Renderer for "Address" model
function renderAddress(data, parameters={}) {
return renderModel(
{
text: [data.title, data.country, data.postal_code, data.postal_city, data.province, data.line1, data.line2].filter(Boolean).join(', '),
},
parameters
);
}
// Renderer for "StockItem" model
function renderStockItem(data, parameters={}) {

View File

@ -126,6 +126,18 @@ function purchaseOrderFields(options={}) {
return filters;
}
},
address: {
icon: 'fa-map',
adjustFilters: function(filters) {
let supplier = getFormFieldValue('supplier', {}, {modal: options.modal});
if (supplier) {
filters.company = supplier;
}
return filters;
}
},
responsible: {
icon: 'fa-user',
},

View File

@ -90,6 +90,18 @@ function returnOrderFields(options={}) {
return filters;
}
},
address: {
icon: 'fa-map',
adjustFilters: function(filters) {
let customer = getFormFieldValue('customer', {}, {modal: options.modal});
if (customer) {
filters.company = customer;
}
return filters;
}
},
responsible: {
icon: 'fa-user',
}

View File

@ -116,6 +116,18 @@ function salesOrderFields(options={}) {
return filters;
}
},
address: {
icon: 'fa-map',
adjustFilters: function(filters) {
let customer = getFormFieldValue('customer', {}, {modal: options.modal});
if (customer) {
filters.company = customer;
}
return filters;
}
},
responsible: {
icon: 'fa-user',
}

View File

@ -142,6 +142,7 @@ class RuleSet(models.Model):
'company_company',
'company_companyattachment',
'company_contact',
'company_address',
'company_manufacturerpart',
'company_manufacturerpartparameter',
'company_supplierpart',
@ -156,6 +157,7 @@ class RuleSet(models.Model):
'company_company',
'company_companyattachment',
'company_contact',
'company_address',
'order_salesorder',
'order_salesorderallocation',
'order_salesorderattachment',
@ -168,6 +170,7 @@ class RuleSet(models.Model):
'company_company',
'company_companyattachment',
'company_contact',
'company_address',
'order_returnorder',
'order_returnorderlineitem',
'order_returnorderextraline',

View File

@ -43,6 +43,44 @@ The list of contacts associated with a particular company is available in the <s
A *contact* can be assigned to orders, (such as [purchase orders](./purchase_order.md) or [sales orders](./sales_order.md)).
### 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.
Addresses are composed differently across the world, and Inventree reflects this by splitting addresses into components:
- Line 1: Main street address
- Line 2: Extra street address line
- Postal Code: Also known as ZIP code, this is normally a number 3-5 digits in length
- City: The city/region tied to the postal code
- Province: The larger region the address is located in. Also known as State in the US
- Country: Country the address is located in, written in CAPS
Here are a couple of examples of how the address structure differs by country, but these components can construct a correctly formatted address for any given country.
UK address format:
Recipient
Line 1
Line 2
City
Postal Code
Country
US Address Format:
Recipient
Line 1
Line 2
City State Postal Code
Country
Addresses can be accessed by the <span class='badge inventree nav main'><span class='fas fa-map-marked'></span> Addresses</span> navigation tab.
#### Primary Address
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.
## Customers
A *customer* is an external client to whom parts or services are sold.