From bf707766b648c8ece79dcd94ab1d5c41ac2a3661 Mon Sep 17 00:00:00 2001 From: Lavissa Date: Sat, 17 Jun 2023 13:55:25 +0200 Subject: [PATCH] [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 * Update InvenTree/company/models.py Co-authored-by: Matthias Mair * Correct final issues * . --------- Co-authored-by: Matthias Mair --- .pre-commit-config.yaml | 2 +- InvenTree/company/admin.py | 35 +- InvenTree/company/api.py | 42 ++- .../migrations/0063_auto_20230502_1956.py | 37 ++ ...064_move_address_field_to_address_model.py | 37 ++ .../migrations/0065_remove_company_address.py | 17 + .../migrations/0066_auto_20230616_2059.py | 22 ++ InvenTree/company/models.py | 154 +++++++- InvenTree/company/serializers.py | 65 +++- .../company/templates/company/detail.html | 45 +++ .../company/templates/company/sidebar.html | 2 + InvenTree/company/test_api.py | 134 ++++++- InvenTree/company/test_migrations.py | 41 +++ InvenTree/company/tests.py | 78 ++++- .../migrations/0097_auto_20230529_0107.py | 30 ++ InvenTree/order/models.py | 11 +- InvenTree/order/serializers.py | 8 +- .../order/templates/order/order_base.html | 7 + .../templates/order/return_order_base.html | 7 + .../templates/order/sales_order_base.html | 7 + InvenTree/templates/js/translated/company.js | 330 +++++++++++++++++- InvenTree/templates/js/translated/forms.js | 11 +- .../js/translated/model_renderers.js | 14 + .../templates/js/translated/purchase_order.js | 12 + .../templates/js/translated/return_order.js | 12 + .../templates/js/translated/sales_order.js | 12 + InvenTree/users/models.py | 3 + docs/docs/order/company.md | 38 ++ 28 files changed, 1185 insertions(+), 28 deletions(-) create mode 100644 InvenTree/company/migrations/0063_auto_20230502_1956.py create mode 100644 InvenTree/company/migrations/0064_move_address_field_to_address_model.py create mode 100644 InvenTree/company/migrations/0065_remove_company_address.py create mode 100644 InvenTree/company/migrations/0066_auto_20230616_2059.py create mode 100644 InvenTree/order/migrations/0097_auto_20230529_0107.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43fb414092..d52cdc69a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index 9bc5562aaa..415b95d11c 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -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) diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 8137f7a6a8..05638e1398 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -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('/', 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'), ] diff --git a/InvenTree/company/migrations/0063_auto_20230502_1956.py b/InvenTree/company/migrations/0063_auto_20230502_1956.py new file mode 100644 index 0000000000..5daf2418e7 --- /dev/null +++ b/InvenTree/company/migrations/0063_auto_20230502_1956.py @@ -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'), + ), + ] diff --git a/InvenTree/company/migrations/0064_move_address_field_to_address_model.py b/InvenTree/company/migrations/0064_move_address_field_to_address_model.py new file mode 100644 index 0000000000..76d7d3b478 --- /dev/null +++ b/InvenTree/company/migrations/0064_move_address_field_to_address_model.py @@ -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) + ] diff --git a/InvenTree/company/migrations/0065_remove_company_address.py b/InvenTree/company/migrations/0065_remove_company_address.py new file mode 100644 index 0000000000..4beaeca792 --- /dev/null +++ b/InvenTree/company/migrations/0065_remove_company_address.py @@ -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', + ), + ] diff --git a/InvenTree/company/migrations/0066_auto_20230616_2059.py b/InvenTree/company/migrations/0066_auto_20230616_2059.py new file mode 100644 index 0000000000..19ce798301 --- /dev/null +++ b/InvenTree/company/migrations/0066_auto_20230616_2059.py @@ -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'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index c41fa39a6f..9a44ecf3dd 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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. diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index f4c04fde22..b4019ea0f7 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -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) diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 6018005548..9adfa255a0 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -255,6 +255,31 @@ +
+
+
+

{% trans "Company addresses" %}

+ {% include "spacer.html" %} +
+ {% if roles.purchase_order.add or roles.sales_order.add %} + + {% endif %} +
+
+
+
+
+
+ {% include "filter_list.html" with id="addresses" %} +
+
+ +
+
+
+
@@ -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() { diff --git a/InvenTree/company/templates/company/sidebar.html b/InvenTree/company/templates/company/sidebar.html index 7fe616b7d1..8e3078f342 100644 --- a/InvenTree/company/templates/company/sidebar.html +++ b/InvenTree/company/templates/company/sidebar.html @@ -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 %} diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index 4fa3ba595a..05fbfe8eb2 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -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.""" diff --git a/InvenTree/company/test_migrations.py b/InvenTree/company/test_migrations.py index 1d9a9f88cf..5a84fc4b7d 100644 --- a/InvenTree/company/test_migrations.py +++ b/InvenTree/company/test_migrations.py @@ -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.""" diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index bc9c51fa81..807b78e866 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -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""" diff --git a/InvenTree/order/migrations/0097_auto_20230529_0107.py b/InvenTree/order/migrations/0097_auto_20230529_0107.py new file mode 100644 index 0000000000..cacf50ba4c --- /dev/null +++ b/InvenTree/order/migrations/0097_auto_20230529_0107.py @@ -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'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 93f431d43f..6aa3fa98e0 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -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""" diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 116f0fb7b0..fe27cd447e 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -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', diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 436636b8f8..dfa2d4f871 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -208,6 +208,13 @@ src="{% static 'img/blank_image.png' %}" {{ order.contact.name }} {% endif %} + {% if order.address %} + + + {% trans "Address" %} + {{ order.address.title }}: {{ order.address }} + + {% endif %} {% if order.responsible %} diff --git a/InvenTree/order/templates/order/return_order_base.html b/InvenTree/order/templates/order/return_order_base.html index d701112b0b..7c3a609c99 100644 --- a/InvenTree/order/templates/order/return_order_base.html +++ b/InvenTree/order/templates/order/return_order_base.html @@ -176,6 +176,13 @@ src="{% static 'img/blank_image.png' %}" {{ order.contact.name }} {% endif %} + {% if order.address %} + + + {% trans "Address" %} + {{ order.address.title }}: {{ order.address }} + + {% endif %} {% if order.responsible %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index b149a440eb..2c5c28e05e 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -216,6 +216,13 @@ src="{% static 'img/blank_image.png' %}" {{ order.contact.name }} {% endif %} + {% if order.address %} + + + {% trans "Address" %} + {{ order.address.title }}: {{ order.address }} + + {% endif %} {% if order.responsible %} diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index 44fad80b8d..833a822c50 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -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 ` + + ${address.title} + ${address.line1} + ${address.line2} + `; + } + + let rows = ''; + let ids = []; + + addresses.forEach(function(address) { + rows += renderAddress(address); + ids.push(address.pk); + }); + + let html = ` +
+ {% trans "All selected addresses will be deleted" %} +
+ + + + + + + ${rows} +
{% trans "Name" %}{% trans "Line 1" %}{% trans "Line 2" %}
`; + + 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 ``; + } + }, + { + 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. diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 4efde8f1bd..950fec6a5d 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -2225,7 +2225,16 @@ function constructField(name, parameters, options={}) { hover_title = ` title='${parameters.help_text}'`; } - html += `
`; + var css = ''; + + if (parameters.css) { + let str = Object.keys(parameters.css).map(function(key) { + return `${key}: ${parameters.css[key]};`; + }) + css = ` style="${str}"`; + } + + html += `
`; // Add a label if (!options.hideLabels) { diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index e610b5531c..87c08de53e 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -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={}) { diff --git a/InvenTree/templates/js/translated/purchase_order.js b/InvenTree/templates/js/translated/purchase_order.js index 78e0419289..25b46dbd9a 100644 --- a/InvenTree/templates/js/translated/purchase_order.js +++ b/InvenTree/templates/js/translated/purchase_order.js @@ -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', }, diff --git a/InvenTree/templates/js/translated/return_order.js b/InvenTree/templates/js/translated/return_order.js index 49e3b9d7ad..b326a97da0 100644 --- a/InvenTree/templates/js/translated/return_order.js +++ b/InvenTree/templates/js/translated/return_order.js @@ -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', } diff --git a/InvenTree/templates/js/translated/sales_order.js b/InvenTree/templates/js/translated/sales_order.js index 678169a82f..4052769baa 100644 --- a/InvenTree/templates/js/translated/sales_order.js +++ b/InvenTree/templates/js/translated/sales_order.js @@ -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', } diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 1cfca8a596..d6f5c05214 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -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', diff --git a/docs/docs/order/company.md b/docs/docs/order/company.md index 4670c23119..c99994b867 100644 --- a/docs/docs/order/company.md +++ b/docs/docs/order/company.md @@ -43,6 +43,44 @@ The list of contacts associated with a particular company is available in the Addresses 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.