mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	[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:
		| @@ -41,7 +41,7 @@ repos: | |||||||
|         args: [requirements.in, -o, requirements.txt] |         args: [requirements.in, -o, requirements.txt] | ||||||
|         files: ^requirements\.(in|txt)$ |         files: ^requirements\.(in|txt)$ | ||||||
| -   repo: https://github.com/Riverside-Healthcare/djLint | -   repo: https://github.com/Riverside-Healthcare/djLint | ||||||
|     rev: v1.29.0 |     rev: v1.30.2 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: djlint-django |       - id: djlint-django | ||||||
| - repo: https://github.com/codespell-project/codespell | - repo: https://github.com/codespell-project/codespell | ||||||
|   | |||||||
| @@ -9,9 +9,9 @@ from import_export.fields import Field | |||||||
| from InvenTree.admin import InvenTreeResource | from InvenTree.admin import InvenTreeResource | ||||||
| from part.models import Part | from part.models import Part | ||||||
|  |  | ||||||
| from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, | from .models import (Address, Company, ManufacturerPart, | ||||||
|                      ManufacturerPartParameter, SupplierPart, |                      ManufacturerPartAttachment, ManufacturerPartParameter, | ||||||
|                      SupplierPriceBreak) |                      SupplierPart, SupplierPriceBreak) | ||||||
|  |  | ||||||
|  |  | ||||||
| class CompanyResource(InvenTreeResource): | class CompanyResource(InvenTreeResource): | ||||||
| @@ -187,6 +187,33 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin): | |||||||
|     autocomplete_fields = ('part',) |     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(Company, CompanyAdmin) | ||||||
| admin.site.register(SupplierPart, SupplierPartAdmin) | admin.site.register(SupplierPart, SupplierPartAdmin) | ||||||
| admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) | admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) | ||||||
| @@ -194,3 +221,5 @@ admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) | |||||||
| admin.site.register(ManufacturerPart, ManufacturerPartAdmin) | admin.site.register(ManufacturerPart, ManufacturerPartAdmin) | ||||||
| admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin) | admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin) | ||||||
| admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin) | admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin) | ||||||
|  |  | ||||||
|  | admin.site.register(Address, AddressAdmin) | ||||||
|   | |||||||
| @@ -14,11 +14,12 @@ from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER, | |||||||
| from InvenTree.helpers import str2bool | from InvenTree.helpers import str2bool | ||||||
| from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI | from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI | ||||||
|  |  | ||||||
| from .models import (Company, CompanyAttachment, Contact, ManufacturerPart, | from .models import (Address, Company, CompanyAttachment, Contact, | ||||||
|                      ManufacturerPartAttachment, ManufacturerPartParameter, |                      ManufacturerPart, ManufacturerPartAttachment, | ||||||
|                      SupplierPart, SupplierPriceBreak) |                      ManufacturerPartParameter, SupplierPart, | ||||||
| from .serializers import (CompanyAttachmentSerializer, CompanySerializer, |                      SupplierPriceBreak) | ||||||
|                           ContactSerializer, | from .serializers import (AddressSerializer, CompanyAttachmentSerializer, | ||||||
|  |                           CompanySerializer, ContactSerializer, | ||||||
|                           ManufacturerPartAttachmentSerializer, |                           ManufacturerPartAttachmentSerializer, | ||||||
|                           ManufacturerPartParameterSerializer, |                           ManufacturerPartParameterSerializer, | ||||||
|                           ManufacturerPartSerializer, SupplierPartSerializer, |                           ManufacturerPartSerializer, SupplierPartSerializer, | ||||||
| @@ -135,6 +136,32 @@ class ContactDetail(RetrieveUpdateDestroyAPI): | |||||||
|     serializer_class = ContactSerializer |     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): | class ManufacturerPartFilter(rest_filters.FilterSet): | ||||||
|     """Custom API filters for the ManufacturerPart list endpoint.""" |     """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'^.*$', 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'), |     re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'), | ||||||
|  |  | ||||||
| ] | ] | ||||||
|   | |||||||
							
								
								
									
										37
									
								
								InvenTree/company/migrations/0063_auto_20230502_1956.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								InvenTree/company/migrations/0063_auto_20230502_1956.py
									
									
									
									
									
										Normal 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'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -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) | ||||||
|  |     ] | ||||||
							
								
								
									
										17
									
								
								InvenTree/company/migrations/0065_remove_company_address.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								InvenTree/company/migrations/0065_remove_company_address.py
									
									
									
									
									
										Normal 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', | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										22
									
								
								InvenTree/company/migrations/0066_auto_20230616_2059.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								InvenTree/company/migrations/0066_auto_20230616_2059.py
									
									
									
									
									
										Normal 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'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError | |||||||
| from django.core.validators import MinValueValidator | from django.core.validators import MinValueValidator | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models import Q, Sum, UniqueConstraint | 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.dispatch import receiver | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| @@ -72,7 +72,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model): | |||||||
|         name: Brief name of the company |         name: Brief name of the company | ||||||
|         description: Longer form description |         description: Longer form description | ||||||
|         website: URL for the company website |         website: URL for the company website | ||||||
|         address: Postal address |         address: One-line string representation of primary address | ||||||
|         phone: contact phone number |         phone: contact phone number | ||||||
|         email: contact email address |         email: contact email address | ||||||
|         link: Secondary URL e.g. for link to internal Wiki page |         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') |         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, |     phone = models.CharField(max_length=50, | ||||||
|                              verbose_name=_('Phone number'), |                              verbose_name=_('Phone number'), | ||||||
|                              blank=True, help_text=_('Contact 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], |         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 |     @property | ||||||
|     def currency_code(self): |     def currency_code(self): | ||||||
|         """Return the currency code associated with this company. |         """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) |     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): | 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. |     """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. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,9 +20,10 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer, | |||||||
|                                    RemoteImageMixin) |                                    RemoteImageMixin) | ||||||
| from part.serializers import PartBriefSerializer | from part.serializers import PartBriefSerializer | ||||||
|  |  | ||||||
| from .models import (Company, CompanyAttachment, Contact, ManufacturerPart, | from .models import (Address, Company, CompanyAttachment, Contact, | ||||||
|                      ManufacturerPartAttachment, ManufacturerPartParameter, |                      ManufacturerPart, ManufacturerPartAttachment, | ||||||
|                      SupplierPart, SupplierPriceBreak) |                      ManufacturerPartParameter, SupplierPart, | ||||||
|  |                      SupplierPriceBreak) | ||||||
|  |  | ||||||
|  |  | ||||||
| class CompanyBriefSerializer(InvenTreeModelSerializer): | class CompanyBriefSerializer(InvenTreeModelSerializer): | ||||||
| @@ -45,6 +46,53 @@ class CompanyBriefSerializer(InvenTreeModelSerializer): | |||||||
|     image = serializers.CharField(source='get_thumbnail_url', read_only=True) |     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): | class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): | ||||||
|     """Serializer for Company object (full detail)""" |     """Serializer for Company object (full detail)""" | ||||||
|  |  | ||||||
| @@ -73,11 +121,13 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): | |||||||
|             'parts_supplied', |             'parts_supplied', | ||||||
|             'parts_manufactured', |             'parts_manufactured', | ||||||
|             'remote_image', |             'remote_image', | ||||||
|  |             'address_count', | ||||||
|  |             'primary_address' | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def annotate_queryset(queryset): |     def annotate_queryset(queryset): | ||||||
|         """Annoate the supplied queryset with aggregated information""" |         """Annotate the supplied queryset with aggregated information""" | ||||||
|         # Add count of parts manufactured |         # Add count of parts manufactured | ||||||
|         queryset = queryset.annotate( |         queryset = queryset.annotate( | ||||||
|             parts_manufactured=SubqueryCount('manufactured_parts') |             parts_manufactured=SubqueryCount('manufactured_parts') | ||||||
| @@ -87,14 +137,21 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): | |||||||
|             parts_supplied=SubqueryCount('supplied_parts') |             parts_supplied=SubqueryCount('supplied_parts') | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         queryset = queryset.annotate( | ||||||
|  |             address_count=SubqueryCount('addresses') | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|  |     primary_address = AddressSerializer(required=False, allow_null=True, read_only=True) | ||||||
|  |  | ||||||
|     url = serializers.CharField(source='get_absolute_url', read_only=True) |     url = serializers.CharField(source='get_absolute_url', read_only=True) | ||||||
|  |  | ||||||
|     image = InvenTreeImageSerializerField(required=False, allow_null=True) |     image = InvenTreeImageSerializerField(required=False, allow_null=True) | ||||||
|  |  | ||||||
|     parts_supplied = serializers.IntegerField(read_only=True) |     parts_supplied = serializers.IntegerField(read_only=True) | ||||||
|     parts_manufactured = 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) |     currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -255,6 +255,31 @@ | |||||||
|     </div> |     </div> | ||||||
| </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 panel-hidden' id='panel-attachments'> | ||||||
|     <div class='panel-heading'> |     <div class='panel-heading'> | ||||||
|         <div class='d-flex flex-wrap'> |         <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 |     // Callback function when the 'notes' panel is loaded | ||||||
|     onPanelLoad('company-notes', function() { |     onPanelLoad('company-notes', function() { | ||||||
|  |  | ||||||
|   | |||||||
| @@ -32,6 +32,8 @@ | |||||||
| {% endif %} | {% endif %} | ||||||
| {% trans "Contacts" as text %} | {% trans "Contacts" as text %} | ||||||
| {% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %} | {% 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 %} | {% trans "Notes" as text %} | ||||||
| {% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %} | {% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %} | ||||||
| {% trans "Attachments" as text %} | {% trans "Attachments" as text %} | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ from rest_framework import status | |||||||
|  |  | ||||||
| from InvenTree.unit_test import InvenTreeAPITestCase | from InvenTree.unit_test import InvenTreeAPITestCase | ||||||
|  |  | ||||||
| from .models import Company, Contact, ManufacturerPart, SupplierPart | from .models import Address, Company, Contact, ManufacturerPart, SupplierPart | ||||||
|  |  | ||||||
|  |  | ||||||
| class CompanyTest(InvenTreeAPITestCase): | class CompanyTest(InvenTreeAPITestCase): | ||||||
| @@ -284,6 +284,138 @@ class ContactTest(InvenTreeAPITestCase): | |||||||
|         self.get(url, expected_code=404) |         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): | class ManufacturerTest(InvenTreeAPITestCase): | ||||||
|     """Series of tests for the Manufacturer DRF API.""" |     """Series of tests for the Manufacturer DRF API.""" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -280,6 +280,47 @@ class TestCurrencyMigration(MigratorTestCase): | |||||||
|             self.assertIsNotNone(pb.price) |             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): | class TestSupplierPartQuantity(MigratorTestCase): | ||||||
|     """Test that the supplier part quantity is correctly migrated.""" |     """Test that the supplier part quantity is correctly migrated.""" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,11 +4,13 @@ import os | |||||||
| from decimal import Decimal | from decimal import Decimal | ||||||
|  |  | ||||||
| from django.core.exceptions import ValidationError | from django.core.exceptions import ValidationError | ||||||
|  | from django.db import transaction | ||||||
|  | from django.db.utils import IntegrityError | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
| from part.models import Part | from part.models import Part | ||||||
|  |  | ||||||
| from .models import (Company, Contact, ManufacturerPart, SupplierPart, | from .models import (Address, Company, Contact, ManufacturerPart, SupplierPart, | ||||||
|                      rename_company_image) |                      rename_company_image) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -35,7 +37,6 @@ class CompanySimpleTest(TestCase): | |||||||
|         Company.objects.create(name='ABC Co.', |         Company.objects.create(name='ABC Co.', | ||||||
|                                description='Seller of ABC products', |                                description='Seller of ABC products', | ||||||
|                                website='www.abc-sales.com', |                                website='www.abc-sales.com', | ||||||
|                                address='123 Sales St.', |  | ||||||
|                                is_customer=False, |                                is_customer=False, | ||||||
|                                is_supplier=True) |                                is_supplier=True) | ||||||
|  |  | ||||||
| @@ -174,6 +175,79 @@ class ContactSimpleTest(TestCase): | |||||||
|         self.assertEqual(Contact.objects.count(), 0) |         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): | class ManufacturerPartSimpleTest(TestCase): | ||||||
|     """Unit tests for the ManufacturerPart model""" |     """Unit tests for the ManufacturerPart model""" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										30
									
								
								InvenTree/order/migrations/0097_auto_20230529_0107.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								InvenTree/order/migrations/0097_auto_20230529_0107.py
									
									
									
									
									
										Normal 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'), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @@ -32,7 +32,7 @@ import stock.models | |||||||
| import users.models as UserModels | import users.models as UserModels | ||||||
| from common.notifications import InvenTreeNotificationBodies | from common.notifications import InvenTreeNotificationBodies | ||||||
| from common.settings import currency_code_default | 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.exceptions import log_error | ||||||
| from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField, | from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField, | ||||||
|                               RoundingDecimalField) |                               RoundingDecimalField) | ||||||
| @@ -272,6 +272,15 @@ class Order(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, Reference | |||||||
|         related_name='+', |         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 |     @classmethod | ||||||
|     def get_status_class(cls): |     def get_status_class(cls): | ||||||
|         """Return the enumeration class which represents the 'status' field for this model""" |         """Return the enumeration class which represents the 'status' field for this model""" | ||||||
|   | |||||||
| @@ -18,7 +18,8 @@ import part.filters | |||||||
| import stock.models | import stock.models | ||||||
| import stock.serializers | import stock.serializers | ||||||
| from common.serializers import ProjectCodeSerializer | from common.serializers import ProjectCodeSerializer | ||||||
| from company.serializers import (CompanyBriefSerializer, ContactSerializer, | from company.serializers import (AddressBriefSerializer, | ||||||
|  |                                  CompanyBriefSerializer, ContactSerializer, | ||||||
|                                  SupplierPartSerializer) |                                  SupplierPartSerializer) | ||||||
| from InvenTree.helpers import (extract_serial_numbers, hash_barcode, normalize, | from InvenTree.helpers import (extract_serial_numbers, hash_barcode, normalize, | ||||||
|                                str2bool) |                                str2bool) | ||||||
| @@ -75,6 +76,9 @@ class AbstractOrderSerializer(serializers.Serializer): | |||||||
|     # Detail for project code field |     # Detail for project code field | ||||||
|     project_code_detail = ProjectCodeSerializer(source='project_code', read_only=True, many=False) |     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) |     # Boolean field indicating if this order is overdue (Note: must be annotated) | ||||||
|     overdue = serializers.BooleanField(required=False, read_only=True) |     overdue = serializers.BooleanField(required=False, read_only=True) | ||||||
|  |  | ||||||
| @@ -114,6 +118,8 @@ class AbstractOrderSerializer(serializers.Serializer): | |||||||
|             'responsible_detail', |             'responsible_detail', | ||||||
|             'contact', |             'contact', | ||||||
|             'contact_detail', |             'contact_detail', | ||||||
|  |             'address', | ||||||
|  |             'address_detail', | ||||||
|             'status', |             'status', | ||||||
|             'status_text', |             'status_text', | ||||||
|             'notes', |             'notes', | ||||||
|   | |||||||
| @@ -208,6 +208,13 @@ src="{% static 'img/blank_image.png' %}" | |||||||
|         <td>{{ order.contact.name }}</td> |         <td>{{ order.contact.name }}</td> | ||||||
|     </tr> |     </tr> | ||||||
|     {% endif %} |     {% 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 %} |     {% if order.responsible %} | ||||||
|     <tr> |     <tr> | ||||||
|         <td><span class='fas fa-users'></span></td> |         <td><span class='fas fa-users'></span></td> | ||||||
|   | |||||||
| @@ -176,6 +176,13 @@ src="{% static 'img/blank_image.png' %}" | |||||||
|         <td>{{ order.contact.name }}</td> |         <td>{{ order.contact.name }}</td> | ||||||
|     </tr> |     </tr> | ||||||
|     {% endif %} |     {% 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 %} |     {% if order.responsible %} | ||||||
|     <tr> |     <tr> | ||||||
|         <td><span class='fas fa-users'></span></td> |         <td><span class='fas fa-users'></span></td> | ||||||
|   | |||||||
| @@ -216,6 +216,13 @@ src="{% static 'img/blank_image.png' %}" | |||||||
|         <td>{{ order.contact.name }}</td> |         <td>{{ order.contact.name }}</td> | ||||||
|     </tr> |     </tr> | ||||||
|     {% endif %} |     {% 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 %} |     {% if order.responsible %} | ||||||
|     <tr> |     <tr> | ||||||
|         <td><span class='fas fa-users'></span></td> |         <td><span class='fas fa-users'></span></td> | ||||||
|   | |||||||
| @@ -1,15 +1,19 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| /* globals | /* globals | ||||||
|  |     clearFormErrors, | ||||||
|     constructLabel, |     constructLabel, | ||||||
|     constructForm, |     constructForm, | ||||||
|  |     enableSubmitButton, | ||||||
|     formatCurrency, |     formatCurrency, | ||||||
|     formatDecimal, |     formatDecimal, | ||||||
|     formatDate, |     formatDate, | ||||||
|  |     handleFormErrors, | ||||||
|     handleFormSuccess, |     handleFormSuccess, | ||||||
|     imageHoverIcon, |     imageHoverIcon, | ||||||
|     inventreeGet, |     inventreeGet, | ||||||
|     inventreePut, |     inventreePut, | ||||||
|  |     hideFormInput, | ||||||
|     loadTableFilters, |     loadTableFilters, | ||||||
|     makeDeleteButton, |     makeDeleteButton, | ||||||
|     makeEditButton, |     makeEditButton, | ||||||
| @@ -19,24 +23,29 @@ | |||||||
|     renderLink, |     renderLink, | ||||||
|     renderPart, |     renderPart, | ||||||
|     setupFilterList, |     setupFilterList, | ||||||
|  |     showFormInput, | ||||||
|     thumbnailImage, |     thumbnailImage, | ||||||
|     wrapButtons, |     wrapButtons, | ||||||
| */ | */ | ||||||
|  |  | ||||||
| /* exported | /* exported | ||||||
|  |     createAddress, | ||||||
|     createCompany, |     createCompany, | ||||||
|     createContact, |     createContact, | ||||||
|     createManufacturerPart, |     createManufacturerPart, | ||||||
|     createSupplierPart, |     createSupplierPart, | ||||||
|     createSupplierPartPriceBreak, |     createSupplierPartPriceBreak, | ||||||
|  |     deleteAddress, | ||||||
|     deleteContacts, |     deleteContacts, | ||||||
|     deleteManufacturerParts, |     deleteManufacturerParts, | ||||||
|     deleteManufacturerPartParameters, |     deleteManufacturerPartParameters, | ||||||
|     deleteSupplierParts, |     deleteSupplierParts, | ||||||
|     duplicateSupplierPart, |     duplicateSupplierPart, | ||||||
|  |     editAddress, | ||||||
|     editCompany, |     editCompany, | ||||||
|     editContact, |     editContact, | ||||||
|     editSupplierPartPriceBreak, |     editSupplierPartPriceBreak, | ||||||
|  |     loadAddressTable, | ||||||
|     loadCompanyTable, |     loadCompanyTable, | ||||||
|     loadContactTable, |     loadContactTable, | ||||||
|     loadManufacturerPartTable, |     loadManufacturerPartTable, | ||||||
| @@ -401,9 +410,6 @@ function companyFormFields() { | |||||||
|         website: { |         website: { | ||||||
|             icon: 'fa-globe', |             icon: 'fa-globe', | ||||||
|         }, |         }, | ||||||
|         address: { |  | ||||||
|             icon: 'fa-envelope', |  | ||||||
|         }, |  | ||||||
|         currency: { |         currency: { | ||||||
|             icon: 'fa-dollar-sign', |             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. | /* Delete one or more ManufacturerPart objects from the database. | ||||||
|  * - User will be provided with a modal form, showing all the parts to be deleted. |  * - User will be provided with a modal form, showing all the parts to be deleted. | ||||||
|   | |||||||
| @@ -2225,7 +2225,16 @@ function constructField(name, parameters, options={}) { | |||||||
|         hover_title = ` title='${parameters.help_text}'`; |         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 |     // Add a label | ||||||
|     if (!options.hideLabels) { |     if (!options.hideLabels) { | ||||||
|   | |||||||
| @@ -14,6 +14,7 @@ | |||||||
|     renderBuild, |     renderBuild, | ||||||
|     renderCompany, |     renderCompany, | ||||||
|     renderContact, |     renderContact, | ||||||
|  |     renderAddress, | ||||||
|     renderGroup, |     renderGroup, | ||||||
|     renderManufacturerPart, |     renderManufacturerPart, | ||||||
|     renderOwner, |     renderOwner, | ||||||
| @@ -52,6 +53,8 @@ function getModelRenderer(model) { | |||||||
|         return renderCompany; |         return renderCompany; | ||||||
|     case 'contact': |     case 'contact': | ||||||
|         return renderContact; |         return renderContact; | ||||||
|  |     case 'address': | ||||||
|  |         return renderAddress; | ||||||
|     case 'stockitem': |     case 'stockitem': | ||||||
|         return renderStockItem; |         return renderStockItem; | ||||||
|     case 'stocklocation': |     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 | // Renderer for "StockItem" model | ||||||
| function renderStockItem(data, parameters={}) { | function renderStockItem(data, parameters={}) { | ||||||
|  |  | ||||||
|   | |||||||
| @@ -126,6 +126,18 @@ function purchaseOrderFields(options={}) { | |||||||
|                 return filters; |                 return filters; | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         address: { | ||||||
|  |             icon: 'fa-map', | ||||||
|  |             adjustFilters: function(filters) { | ||||||
|  |                 let supplier = getFormFieldValue('supplier', {}, {modal: options.modal}); | ||||||
|  |  | ||||||
|  |                 if (supplier) { | ||||||
|  |                     filters.company = supplier; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 return filters; | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         responsible: { |         responsible: { | ||||||
|             icon: 'fa-user', |             icon: 'fa-user', | ||||||
|         }, |         }, | ||||||
|   | |||||||
| @@ -90,6 +90,18 @@ function returnOrderFields(options={}) { | |||||||
|                 return filters; |                 return filters; | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         address: { | ||||||
|  |             icon: 'fa-map', | ||||||
|  |             adjustFilters: function(filters) { | ||||||
|  |                 let customer = getFormFieldValue('customer', {}, {modal: options.modal}); | ||||||
|  |  | ||||||
|  |                 if (customer) { | ||||||
|  |                     filters.company = customer; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 return filters; | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         responsible: { |         responsible: { | ||||||
|             icon: 'fa-user', |             icon: 'fa-user', | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -116,6 +116,18 @@ function salesOrderFields(options={}) { | |||||||
|                 return filters; |                 return filters; | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         address: { | ||||||
|  |             icon: 'fa-map', | ||||||
|  |             adjustFilters: function(filters) { | ||||||
|  |                 let customer = getFormFieldValue('customer', {}, {modal: options.modal}); | ||||||
|  |  | ||||||
|  |                 if (customer) { | ||||||
|  |                     filters.company = customer; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 return filters; | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         responsible: { |         responsible: { | ||||||
|             icon: 'fa-user', |             icon: 'fa-user', | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -142,6 +142,7 @@ class RuleSet(models.Model): | |||||||
|             'company_company', |             'company_company', | ||||||
|             'company_companyattachment', |             'company_companyattachment', | ||||||
|             'company_contact', |             'company_contact', | ||||||
|  |             'company_address', | ||||||
|             'company_manufacturerpart', |             'company_manufacturerpart', | ||||||
|             'company_manufacturerpartparameter', |             'company_manufacturerpartparameter', | ||||||
|             'company_supplierpart', |             'company_supplierpart', | ||||||
| @@ -156,6 +157,7 @@ class RuleSet(models.Model): | |||||||
|             'company_company', |             'company_company', | ||||||
|             'company_companyattachment', |             'company_companyattachment', | ||||||
|             'company_contact', |             'company_contact', | ||||||
|  |             'company_address', | ||||||
|             'order_salesorder', |             'order_salesorder', | ||||||
|             'order_salesorderallocation', |             'order_salesorderallocation', | ||||||
|             'order_salesorderattachment', |             'order_salesorderattachment', | ||||||
| @@ -168,6 +170,7 @@ class RuleSet(models.Model): | |||||||
|             'company_company', |             'company_company', | ||||||
|             'company_companyattachment', |             'company_companyattachment', | ||||||
|             'company_contact', |             'company_contact', | ||||||
|  |             'company_address', | ||||||
|             'order_returnorder', |             'order_returnorder', | ||||||
|             'order_returnorderlineitem', |             'order_returnorderlineitem', | ||||||
|             'order_returnorderextraline', |             'order_returnorderextraline', | ||||||
|   | |||||||
| @@ -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)). | 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 | ## Customers | ||||||
|  |  | ||||||
| A *customer* is an external client to whom parts or services are sold. | A *customer* is an external client to whom parts or services are sold. | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user