mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
61d2f452b2
commit
bf707766b6
@ -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
|
||||
|
@ -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)
|
||||
|
@ -14,11 +14,12 @@ from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER,
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
|
||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
SupplierPart, SupplierPriceBreak)
|
||||
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
|
||||
ContactSerializer,
|
||||
from .models import (Address, Company, CompanyAttachment, Contact,
|
||||
ManufacturerPart, ManufacturerPartAttachment,
|
||||
ManufacturerPartParameter, SupplierPart,
|
||||
SupplierPriceBreak)
|
||||
from .serializers import (AddressSerializer, CompanyAttachmentSerializer,
|
||||
CompanySerializer, ContactSerializer,
|
||||
ManufacturerPartAttachmentSerializer,
|
||||
ManufacturerPartParameterSerializer,
|
||||
ManufacturerPartSerializer, SupplierPartSerializer,
|
||||
@ -135,6 +136,32 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = ContactSerializer
|
||||
|
||||
|
||||
class AddressList(ListCreateDestroyAPIView):
|
||||
"""API endpoint for list view of Address model"""
|
||||
|
||||
queryset = Address.objects.all()
|
||||
serializer_class = AddressSerializer
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
filterset_fields = [
|
||||
'company',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'title',
|
||||
]
|
||||
|
||||
ordering = 'title'
|
||||
|
||||
|
||||
class AddressDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for a single Address object"""
|
||||
|
||||
queryset = Address.objects.all()
|
||||
serializer_class = AddressSerializer
|
||||
|
||||
|
||||
class ManufacturerPartFilter(rest_filters.FilterSet):
|
||||
"""Custom API filters for the ManufacturerPart list endpoint."""
|
||||
|
||||
@ -568,6 +595,11 @@ company_api_urls = [
|
||||
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^address/', include([
|
||||
path('<int:pk>/', AddressDetail.as_view(), name='api-address-detail'),
|
||||
re_path(r'^.*$', AddressList.as_view(), name='api-address-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
|
||||
|
||||
]
|
||||
|
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.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.
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -255,6 +255,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-company-addresses'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Company addresses" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.purchase_order.add or roles.sales_order.add %}
|
||||
<button class='btn btn-success' type='button' id='new-address' title='{% trans "Add Address" %}'>
|
||||
<div class='fas fa-plus-circle'></div> {% trans "Add Address" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='addresses-button-toolbar'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="addresses" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='addresses-table' data-toolbar='#addresses-button-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-attachments'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
@ -309,6 +334,26 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Callback function for when the 'addresses' panel is loaded
|
||||
onPanelLoad('company-addresses', function(){
|
||||
loadAddressTable('#addresses-table', {
|
||||
params: {
|
||||
company: {{ company.pk }},
|
||||
},
|
||||
allow_edit: {% js_bool roles.purchase_order.change %} || {% js_bool roles.sales_order.change %},
|
||||
allow_delete: {% js_bool roles.purchase_order.delete %} || {% js_bool roles.sales_order.delete %},
|
||||
});
|
||||
|
||||
$('#new-address').click(function() {
|
||||
createAddress({
|
||||
company: {{ company.pk }},
|
||||
onSuccess: function() {
|
||||
$('#addresses-table').bootstrapTable('refresh');
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Callback function when the 'notes' panel is loaded
|
||||
onPanelLoad('company-notes', function() {
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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"""
|
||||
|
||||
|
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
|
||||
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"""
|
||||
|
@ -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',
|
||||
|
@ -208,6 +208,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ order.contact.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.address %}
|
||||
<tr>
|
||||
<td><span class='fas fa-map'></span></td>
|
||||
<td>{% trans "Address" %}</td>
|
||||
<td><b>{{ order.address.title }}</b>: {{ order.address }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
|
@ -176,6 +176,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ order.contact.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.address %}
|
||||
<tr>
|
||||
<td><span class='fas fa-map'></span></td>
|
||||
<td>{% trans "Address" %}</td>
|
||||
<td><b>{{ order.address.title }}</b>: {{ order.address }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
|
@ -216,6 +216,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ order.contact.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.address %}
|
||||
<tr>
|
||||
<td><span class='fas fa-map'></span></td>
|
||||
<td>{% trans "Address" %}</td>
|
||||
<td><b>{{ order.address.title }}</b>: {{ order.address }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
|
@ -1,15 +1,19 @@
|
||||
{% load i18n %}
|
||||
|
||||
/* globals
|
||||
clearFormErrors,
|
||||
constructLabel,
|
||||
constructForm,
|
||||
enableSubmitButton,
|
||||
formatCurrency,
|
||||
formatDecimal,
|
||||
formatDate,
|
||||
handleFormErrors,
|
||||
handleFormSuccess,
|
||||
imageHoverIcon,
|
||||
inventreeGet,
|
||||
inventreePut,
|
||||
hideFormInput,
|
||||
loadTableFilters,
|
||||
makeDeleteButton,
|
||||
makeEditButton,
|
||||
@ -19,24 +23,29 @@
|
||||
renderLink,
|
||||
renderPart,
|
||||
setupFilterList,
|
||||
showFormInput,
|
||||
thumbnailImage,
|
||||
wrapButtons,
|
||||
*/
|
||||
|
||||
/* exported
|
||||
createAddress,
|
||||
createCompany,
|
||||
createContact,
|
||||
createManufacturerPart,
|
||||
createSupplierPart,
|
||||
createSupplierPartPriceBreak,
|
||||
deleteAddress,
|
||||
deleteContacts,
|
||||
deleteManufacturerParts,
|
||||
deleteManufacturerPartParameters,
|
||||
deleteSupplierParts,
|
||||
duplicateSupplierPart,
|
||||
editAddress,
|
||||
editCompany,
|
||||
editContact,
|
||||
editSupplierPartPriceBreak,
|
||||
loadAddressTable,
|
||||
loadCompanyTable,
|
||||
loadContactTable,
|
||||
loadManufacturerPartTable,
|
||||
@ -401,9 +410,6 @@ function companyFormFields() {
|
||||
website: {
|
||||
icon: 'fa-globe',
|
||||
},
|
||||
address: {
|
||||
icon: 'fa-envelope',
|
||||
},
|
||||
currency: {
|
||||
icon: 'fa-dollar-sign',
|
||||
},
|
||||
@ -782,6 +788,324 @@ function loadContactTable(table, options={}) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Construct a set of form fields for the Address model
|
||||
*/
|
||||
function addressFields(options={}) {
|
||||
|
||||
let fields = {
|
||||
company: {
|
||||
icon: 'fa-building',
|
||||
},
|
||||
primary: {
|
||||
onEdit: function(val, name, field, opts) {
|
||||
|
||||
if (val === false) {
|
||||
|
||||
hideFormInput("confirm_primary", opts);
|
||||
$('#id_confirm_primary').prop("checked", false);
|
||||
clearFormErrors(opts);
|
||||
enableSubmitButton(opts, true);
|
||||
|
||||
} else if (val === true) {
|
||||
|
||||
showFormInput("confirm_primary", opts);
|
||||
if($('#id_confirm_primary').prop("checked") === false) {
|
||||
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
|
||||
enableSubmitButton(opts, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirm_primary: {
|
||||
help_text: "Confirm",
|
||||
onEdit: function(val, name, field, opts) {
|
||||
|
||||
if (val === true) {
|
||||
|
||||
clearFormErrors(opts);
|
||||
enableSubmitButton(opts, true);
|
||||
|
||||
} else if (val === false) {
|
||||
|
||||
handleFormErrors({'confirm_primary': 'WARNING: Setting this address as primary will remove primary flag from other addresses'}, field, {});
|
||||
enableSubmitButton(opts, false);
|
||||
}
|
||||
},
|
||||
css: {
|
||||
display: 'none'
|
||||
}
|
||||
},
|
||||
title: {},
|
||||
line1: {
|
||||
icon: 'fa-map'
|
||||
},
|
||||
line2: {
|
||||
icon: 'fa-map',
|
||||
},
|
||||
postal_code: {
|
||||
icon: 'fa-map-pin',
|
||||
},
|
||||
postal_city: {
|
||||
icon: 'fa-city'
|
||||
},
|
||||
province: {
|
||||
icon: 'fa-map'
|
||||
},
|
||||
country: {
|
||||
icon: 'fa-map'
|
||||
},
|
||||
shipping_notes: {
|
||||
icon: 'fa-shuttle-van'
|
||||
},
|
||||
internal_shipping_notes: {
|
||||
icon: 'fa-clipboard'
|
||||
},
|
||||
link: {
|
||||
icon: 'fa-link'
|
||||
}
|
||||
};
|
||||
|
||||
if (options.company) {
|
||||
fields.company.value = options.company;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/*
|
||||
* Launches a form to create a new Address
|
||||
*/
|
||||
function createAddress(options={}) {
|
||||
let fields = options.fields || addressFields(options);
|
||||
|
||||
constructForm('{% url "api-address-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
title: '{% trans "Create New Address" %}',
|
||||
onSuccess: function(response) {
|
||||
handleFormSuccess(response, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Launches a form to edit an existing Address
|
||||
*/
|
||||
function editAddress(pk, options={}) {
|
||||
let fields = options.fields || addressFields(options);
|
||||
|
||||
constructForm(`{% url "api-address-list" %}${pk}/`, {
|
||||
fields: fields,
|
||||
title: '{% trans "Edit Address" %}',
|
||||
onSuccess: function(response) {
|
||||
handleFormSuccess(response, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Launches a form to delete one (or more) addresses
|
||||
*/
|
||||
function deleteAddress(addresses, options={}) {
|
||||
|
||||
if (addresses.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
function renderAddress(address) {
|
||||
return `
|
||||
<tr>
|
||||
<td>${address.title}</td>
|
||||
<td>${address.line1}</td>
|
||||
<td>${address.line2}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
let rows = '';
|
||||
let ids = [];
|
||||
|
||||
addresses.forEach(function(address) {
|
||||
rows += renderAddress(address);
|
||||
ids.push(address.pk);
|
||||
});
|
||||
|
||||
let html = `
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "All selected addresses will be deleted" %}
|
||||
</div>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Line 1" %}</th>
|
||||
<th>{% trans "Line 2" %}</th>
|
||||
</tr>
|
||||
${rows}
|
||||
</table>`;
|
||||
|
||||
constructForm('{% url "api-address-list" %}', {
|
||||
method: 'DELETE',
|
||||
multi_delete: true,
|
||||
title: '{% trans "Delete Addresses" %}',
|
||||
preFormContent: html,
|
||||
form_data: {
|
||||
items: ids,
|
||||
},
|
||||
onSuccess: function(response) {
|
||||
handleFormSuccess(response, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadAddressTable(table, options={}) {
|
||||
var params = options.params || {};
|
||||
|
||||
var filters = loadTableFilters('address', params);
|
||||
|
||||
setupFilterList('address', $(table), '#filter-list-addresses');
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: '{% url "api-address-list" %}',
|
||||
queryParams: filters,
|
||||
original: params,
|
||||
idField: 'pk',
|
||||
uniqueId: 'pk',
|
||||
sidePagination: 'server',
|
||||
sortable: true,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No addresses found" %}';
|
||||
},
|
||||
showColumns: true,
|
||||
name: 'addresses',
|
||||
columns: [
|
||||
{
|
||||
field: 'primary',
|
||||
title: '{% trans "Primary" %}',
|
||||
switchable: false,
|
||||
formatter: function(value) {
|
||||
let checked = '';
|
||||
if (value == true) {
|
||||
checked = 'checked="checked"';
|
||||
}
|
||||
return `<input type="checkbox" ${checked} disabled="disabled" value="${value? 1 : 0}">`;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'title',
|
||||
title: '{% trans "Title" %}',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'line1',
|
||||
title: '{% trans "Line 1" %}',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'line2',
|
||||
title: '{% trans "Line 2" %}',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'postal_code',
|
||||
title: '{% trans "Postal code" %}',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'postal_city',
|
||||
title: '{% trans "Postal city" %}',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'province',
|
||||
title: '{% trans "State/province" %}',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'country',
|
||||
title: '{% trans "Country" %}',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'shipping_notes',
|
||||
title: '{% trans "Courier notes" %}',
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
field: 'internal_shipping_notes',
|
||||
title: '{% trans "Internal notes" %}',
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
field: 'link',
|
||||
title: '{% trans "External Link" %}',
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
title: '',
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
visible: options.allow_edit || options.allow_delete,
|
||||
formatter: function(value, row) {
|
||||
var pk = row.pk;
|
||||
|
||||
let html = '';
|
||||
|
||||
if (options.allow_edit) {
|
||||
html += makeEditButton('btn-address-edit', pk, '{% trans "Edit Address" %}');
|
||||
}
|
||||
|
||||
if (options.allow_delete) {
|
||||
html += makeDeleteButton('btn-address-delete', pk, '{% trans "Delete Address" %}');
|
||||
}
|
||||
|
||||
return wrapButtons(html);
|
||||
}
|
||||
}
|
||||
],
|
||||
onPostBody: function() {
|
||||
// Edit button callback
|
||||
if (options.allow_edit) {
|
||||
$(table).find('.btn-address-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
editAddress(pk, {
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Delete button callback
|
||||
if (options.allow_delete) {
|
||||
$(table).find('.btn-address-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
if (row && row.pk) {
|
||||
|
||||
deleteAddress([row], {
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Delete one or more ManufacturerPart objects from the database.
|
||||
* - User will be provided with a modal form, showing all the parts to be deleted.
|
||||
|
@ -2225,7 +2225,16 @@ function constructField(name, parameters, options={}) {
|
||||
hover_title = ` title='${parameters.help_text}'`;
|
||||
}
|
||||
|
||||
html += `<div id='div_id_${field_name}' class='${form_classes}' ${hover_title}>`;
|
||||
var css = '';
|
||||
|
||||
if (parameters.css) {
|
||||
let str = Object.keys(parameters.css).map(function(key) {
|
||||
return `${key}: ${parameters.css[key]};`;
|
||||
})
|
||||
css = ` style="${str}"`;
|
||||
}
|
||||
|
||||
html += `<div id='div_id_${field_name}' class='${form_classes}' ${hover_title} ${css}>`;
|
||||
|
||||
// Add a label
|
||||
if (!options.hideLabels) {
|
||||
|
@ -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={}) {
|
||||
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -43,6 +43,44 @@ The list of contacts associated with a particular company is available in the <s
|
||||
|
||||
A *contact* can be assigned to orders, (such as [purchase orders](./purchase_order.md) or [sales orders](./sales_order.md)).
|
||||
|
||||
### Addresses
|
||||
|
||||
A company can have multiple registered addresses for use with all types of orders.
|
||||
An address is broken down to internationally recognised elements that are designed to allow for formatting an address according to user needs.
|
||||
Addresses are composed differently across the world, and Inventree reflects this by splitting addresses into components:
|
||||
- Line 1: Main street address
|
||||
- Line 2: Extra street address line
|
||||
- Postal Code: Also known as ZIP code, this is normally a number 3-5 digits in length
|
||||
- City: The city/region tied to the postal code
|
||||
- Province: The larger region the address is located in. Also known as State in the US
|
||||
- Country: Country the address is located in, written in CAPS
|
||||
|
||||
Here are a couple of examples of how the address structure differs by country, but these components can construct a correctly formatted address for any given country.
|
||||
|
||||
UK address format:
|
||||
Recipient
|
||||
Line 1
|
||||
Line 2
|
||||
City
|
||||
Postal Code
|
||||
Country
|
||||
|
||||
US Address Format:
|
||||
Recipient
|
||||
Line 1
|
||||
Line 2
|
||||
City State Postal Code
|
||||
Country
|
||||
|
||||
|
||||
Addresses can be accessed by the <span class='badge inventree nav main'><span class='fas fa-map-marked'></span> Addresses</span> navigation tab.
|
||||
|
||||
#### Primary Address
|
||||
|
||||
Each company can have exactly one (1) primary address.
|
||||
This address is the default shown on the company profile, and the one that is automatically suggested when creating an order.
|
||||
Marking a new address as primary will remove the mark from the old primary address.
|
||||
|
||||
## Customers
|
||||
|
||||
A *customer* is an external client to whom parts or services are sold.
|
||||
|
Loading…
Reference in New Issue
Block a user