mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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]
|
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.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user