diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index c2441590f5..6f6953ccb5 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -280,11 +280,25 @@ def MakeBarcode(object_name, object_pk, object_data={}, **kwargs): json string of the supplied data plus some other data """ + url = kwargs.get('url', False) brief = kwargs.get('brief', True) data = {} - if brief: + if url: + request = object_data.get('request', None) + item_url = object_data.get('item_url', None) + absolute_url = None + + if request and item_url: + absolute_url = request.build_absolute_uri(item_url) + # Return URL (No JSON) + return absolute_url + + if item_url: + # Return URL (No JSON) + return item_url + elif brief: data[object_name] = object_pk else: data['tool'] = 'InvenTree' diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 153931f974..9d322f339d 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -185,6 +185,10 @@ color: #c55; } +.icon-orange { + color: #fcba03; +} + .icon-green { color: #43bb43; } diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 88160e76c1..da7799397e 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -11,6 +11,7 @@ from django.contrib import admin from django.contrib.auth import views as auth_views from company.urls import company_urls +from company.urls import manufacturer_part_urls from company.urls import supplier_part_urls from company.urls import price_break_urls @@ -115,6 +116,7 @@ dynamic_javascript_urls = [ urlpatterns = [ url(r'^part/', include(part_urls)), + url(r'^manufacturer-part/', include(manufacturer_part_urls)), url(r'^supplier-part/', include(supplier_part_urls)), url(r'^price-break/', include(price_break_urls)), diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index ebf2924d16..66557b783b 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -15,9 +15,11 @@ from django.db.models import Q from InvenTree.helpers import str2bool from .models import Company +from .models import ManufacturerPart from .models import SupplierPart, SupplierPriceBreak from .serializers import CompanySerializer +from .serializers import ManufacturerPartSerializer from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer @@ -80,8 +82,105 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView): queryset = CompanySerializer.annotate_queryset(queryset) return queryset + + +class ManufacturerPartList(generics.ListCreateAPIView): + """ API endpoint for list view of ManufacturerPart object + + - GET: Return list of ManufacturerPart objects + - POST: Create a new ManufacturerPart object + """ + + queryset = ManufacturerPart.objects.all().prefetch_related( + 'part', + 'manufacturer', + 'supplier_parts', + ) + + serializer_class = ManufacturerPartSerializer + + def get_serializer(self, *args, **kwargs): + + # Do we wish to include extra detail? + try: + kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) + except AttributeError: + pass + + try: + kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None)) + except AttributeError: + pass + + try: + kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def filter_queryset(self, queryset): + """ + Custom filtering for the queryset. + """ + + queryset = super().filter_queryset(queryset) + + params = self.request.query_params + + # Filter by manufacturer + manufacturer = params.get('company', None) + + if manufacturer is not None: + queryset = queryset.filter(manufacturer=manufacturer) + + # Filter by parent part? + part = params.get('part', None) + + if part is not None: + queryset = queryset.filter(part=part) + + # Filter by 'active' status of the part? + active = params.get('active', None) + + if active is not None: + active = str2bool(active) + queryset = queryset.filter(part__active=active) + + return queryset + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filter_fields = [ + ] + + search_fields = [ + 'manufacturer__name', + 'description', + 'MPN', + 'part__name', + 'part__description', + ] +class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView): + """ API endpoint for detail view of ManufacturerPart object + + - GET: Retrieve detail view + - PATCH: Update object + - DELETE: Delete object + """ + + queryset = ManufacturerPart.objects.all() + serializer_class = ManufacturerPartSerializer + + class SupplierPartList(generics.ListCreateAPIView): """ API endpoint for list view of SupplierPart object @@ -92,7 +191,7 @@ class SupplierPartList(generics.ListCreateAPIView): queryset = SupplierPart.objects.all().prefetch_related( 'part', 'supplier', - 'manufacturer' + 'manufacturer_part__manufacturer', ) def get_queryset(self): @@ -114,7 +213,7 @@ class SupplierPartList(generics.ListCreateAPIView): manufacturer = params.get('manufacturer', None) if manufacturer is not None: - queryset = queryset.filter(manufacturer=manufacturer) + queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer) # Filter by supplier supplier = params.get('supplier', None) @@ -126,7 +225,7 @@ class SupplierPartList(generics.ListCreateAPIView): company = params.get('company', None) if company is not None: - queryset = queryset.filter(Q(manufacturer=company) | Q(supplier=company)) + queryset = queryset.filter(Q(manufacturer_part__manufacturer=company) | Q(supplier=company)) # Filter by parent part? part = params.get('part', None) @@ -134,6 +233,12 @@ class SupplierPartList(generics.ListCreateAPIView): if part is not None: queryset = queryset.filter(part=part) + # Filter by manufacturer part? + manufacturer_part = params.get('manufacturer_part', None) + + if manufacturer_part is not None: + queryset = queryset.filter(manufacturer_part=manufacturer_part) + # Filter by 'active' status of the part? active = params.get('active', None) @@ -184,9 +289,9 @@ class SupplierPartList(generics.ListCreateAPIView): search_fields = [ 'SKU', 'supplier__name', - 'manufacturer__name', + 'manufacturer_part__manufacturer__name', 'description', - 'MPN', + 'manufacturer_part__MPN', 'part__name', 'part__description', ] @@ -197,7 +302,7 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView): - GET: Retrieve detail view - PATCH: Update object - - DELETE: Delete objec + - DELETE: Delete object """ queryset = SupplierPart.objects.all() @@ -226,6 +331,15 @@ class SupplierPriceBreakList(generics.ListCreateAPIView): ] +manufacturer_part_api_urls = [ + + url(r'^(?P\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'), + + # Catch anything else + url(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'), +] + + supplier_part_api_urls = [ url(r'^(?P\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), @@ -236,7 +350,8 @@ supplier_part_api_urls = [ company_api_urls = [ - + url(r'^part/manufacturer/', include(manufacturer_part_api_urls)), + url(r'^part/', include(supplier_part_api_urls)), url(r'^price-break/', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'), diff --git a/InvenTree/company/fixtures/company.yaml b/InvenTree/company/fixtures/company.yaml index 8301eb0f5e..c302b6efad 100644 --- a/InvenTree/company/fixtures/company.yaml +++ b/InvenTree/company/fixtures/company.yaml @@ -31,3 +31,17 @@ name: Another customer! description: Yet another company is_customer: True + +- model: company.company + pk: 6 + fields: + name: A manufacturer + description: A company that makes parts! + is_manufacturer: True + +- model: company.company + pk: 7 + fields: + name: Another manufacturer + description: They build things and sell it to us + is_manufacturer: True diff --git a/InvenTree/company/fixtures/manufacturer_part.yaml b/InvenTree/company/fixtures/manufacturer_part.yaml new file mode 100644 index 0000000000..880a0e5862 --- /dev/null +++ b/InvenTree/company/fixtures/manufacturer_part.yaml @@ -0,0 +1,39 @@ +# Manufacturer Parts + +- model: company.manufacturerpart + pk: 1 + fields: + part: 5 + manufacturer: 6 + MPN: 'MPN123' + +- model: company.manufacturerpart + pk: 2 + fields: + part: 3 + manufacturer: 7 + MPN: 'MPN456' + +- model: company.manufacturerpart + pk: 3 + fields: + part: 5 + manufacturer: 7 + MPN: 'MPN789' + +# Supplier parts linked to Manufacturer parts +- model: company.supplierpart + pk: 10 + fields: + part: 3 + manufacturer_part: 2 + supplier: 2 + SKU: 'MPN456-APPEL' + +- model: company.supplierpart + pk: 11 + fields: + part: 3 + manufacturer_part: 2 + supplier: 3 + SKU: 'MPN456-ZERG' diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 2677402334..8ad8c6bfea 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -17,6 +17,7 @@ from djmoney.forms.fields import MoneyField import common.settings from .models import Company +from .models import ManufacturerPart from .models import SupplierPart from .models import SupplierPriceBreak @@ -85,12 +86,30 @@ class CompanyImageDownloadForm(HelperForm): ] +class EditManufacturerPartForm(HelperForm): + """ Form for editing a ManufacturerPart object """ + + field_prefix = { + 'link': 'fa-link', + 'MPN': 'fa-hashtag', + } + + class Meta: + model = ManufacturerPart + fields = [ + 'part', + 'manufacturer', + 'MPN', + 'description', + 'link', + ] + + class EditSupplierPartForm(HelperForm): """ Form for editing a SupplierPart object """ field_prefix = { 'link': 'fa-link', - 'MPN': 'fa-hashtag', 'SKU': 'fa-hashtag', 'note': 'fa-pencil-alt', } @@ -104,15 +123,28 @@ class EditSupplierPartForm(HelperForm): required=False, ) + manufacturer = django.forms.ChoiceField( + required=False, + help_text=_('Select manufacturer'), + choices=[], + ) + + MPN = django.forms.CharField( + required=False, + help_text=_('Manufacturer Part Number'), + max_length=100, + label=_('MPN'), + ) + class Meta: model = SupplierPart fields = [ 'part', 'supplier', 'SKU', - 'description', 'manufacturer', 'MPN', + 'description', 'link', 'note', 'single_pricing', @@ -121,6 +153,19 @@ class EditSupplierPartForm(HelperForm): 'packaging', ] + def get_manufacturer_choices(self): + """ Returns tuples for all manufacturers """ + empty_choice = [('', '----------')] + + manufacturers = [(manufacturer.id, manufacturer.name) for manufacturer in Company.objects.filter(is_manufacturer=True)] + + return empty_choice + manufacturers + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['manufacturer'].choices = self.get_manufacturer_choices() + class EditPriceBreakForm(HelperForm): """ Form for creating / editing a supplier price break """ diff --git a/InvenTree/company/migrations/0034_manufacturerpart.py b/InvenTree/company/migrations/0034_manufacturerpart.py new file mode 100644 index 0000000000..2e8a8bf82f --- /dev/null +++ b/InvenTree/company/migrations/0034_manufacturerpart.py @@ -0,0 +1,27 @@ +import InvenTree.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0033_auto_20210410_1528'), + ] + + operations = [ + migrations.CreateModel( + name='ManufacturerPart', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('MPN', models.CharField(help_text='Manufacturer Part Number', max_length=100, null=True, verbose_name='MPN')), + ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='URL for external manufacturer part link', null=True, verbose_name='Link')), + ('description', models.CharField(blank=True, help_text='Manufacturer part description', max_length=250, null=True, verbose_name='Description')), + ('manufacturer', models.ForeignKey(help_text='Select manufacturer', limit_choices_to={'is_manufacturer': True}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='manufactured_parts', to='company.Company', verbose_name='Manufacturer')), + ('part', models.ForeignKey(help_text='Select part', limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='manufacturer_parts', to='part.Part', verbose_name='Base Part')), + ], + options={ + 'unique_together': {('part', 'manufacturer', 'MPN')}, + }, + ), + ] diff --git a/InvenTree/company/migrations/0035_supplierpart_update_1.py b/InvenTree/company/migrations/0035_supplierpart_update_1.py new file mode 100644 index 0000000000..657ae2464c --- /dev/null +++ b/InvenTree/company/migrations/0035_supplierpart_update_1.py @@ -0,0 +1,18 @@ +import InvenTree.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0034_manufacturerpart'), + ] + + operations = [ + migrations.AddField( + model_name='supplierpart', + name='manufacturer_part', + field=models.ForeignKey(blank=True, help_text='Select manufacturer part', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='company.ManufacturerPart', verbose_name='Manufacturer Part'), + ), + ] diff --git a/InvenTree/company/migrations/0036_supplierpart_update_2.py b/InvenTree/company/migrations/0036_supplierpart_update_2.py new file mode 100644 index 0000000000..52a470be92 --- /dev/null +++ b/InvenTree/company/migrations/0036_supplierpart_update_2.py @@ -0,0 +1,110 @@ +import InvenTree.fields +from django.db import migrations, models, transaction +import django.db.models.deletion +from django.db.utils import IntegrityError + +def supplierpart_make_manufacturer_parts(apps, schema_editor): + Part = apps.get_model('part', 'Part') + ManufacturerPart = apps.get_model('company', 'ManufacturerPart') + SupplierPart = apps.get_model('company', 'SupplierPart') + + supplier_parts = SupplierPart.objects.all() + + if supplier_parts: + print(f'\nCreating ManufacturerPart Objects\n{"-"*10}') + for supplier_part in supplier_parts: + print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='') + + if supplier_part.manufacturer_part: + print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]') + continue + + part = supplier_part.part + if not part: + print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]') + continue + + manufacturer = supplier_part.manufacturer + MPN = supplier_part.MPN + link = supplier_part.link + description = supplier_part.description + + if manufacturer or MPN: + print(f' | {part.name[:15].ljust(15)}', end='') + + try: + print(f' | {manufacturer.name[:15].ljust(15)}', end='') + except AttributeError: + print(f' | {"EMPTY MANUF".ljust(15)}', end='') + + try: + print(f' | {MPN[:15].ljust(15)}', end='') + except TypeError: + print(f' | {"EMPTY MPN".ljust(15)}', end='') + + print('\t', end='') + + # Create ManufacturerPart + manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=MPN, description=description, link=link) + created = False + try: + with transaction.atomic(): + manufacturer_part.save() + created = True + except IntegrityError: + manufacturer_part = ManufacturerPart.objects.get(part=part, manufacturer=manufacturer, MPN=MPN) + + # Link it to SupplierPart + supplier_part.manufacturer_part = manufacturer_part + supplier_part.save() + + if created: + print(f'[SUCCESS: MANUFACTURER PART CREATED]') + else: + print(f'[IGNORED: MANUFACTURER PART ALREADY EXISTS]') + else: + print(f'[IGNORED: MISSING MANUFACTURER DATA]') + + print(f'{"-"*10}\nDone\n') + +def supplierpart_populate_manufacturer_info(apps, schema_editor): + Part = apps.get_model('part', 'Part') + ManufacturerPart = apps.get_model('company', 'ManufacturerPart') + SupplierPart = apps.get_model('company', 'SupplierPart') + + supplier_parts = SupplierPart.objects.all() + + if supplier_parts: + print(f'\nSupplierPart: Populating Manufacturer Information\n{"-"*10}') + for supplier_part in supplier_parts: + print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='') + + manufacturer_part = supplier_part.manufacturer_part + + if manufacturer_part: + if manufacturer_part.manufacturer: + supplier_part.manufacturer = manufacturer_part.manufacturer + + if manufacturer_part.MPN: + supplier_part.MPN = manufacturer_part.MPN + + supplier_part.save() + + print(f'[SUCCESS: UPDATED MANUFACTURER INFO]') + else: + print(f'[IGNORED: NO MANUFACTURER PART]') + + print(f'{"-"*10}\nDone\n') + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0035_supplierpart_update_1'), + ] + + operations = [ + # Make new ManufacturerPart with SupplierPart "manufacturer" and "MPN" + # fields, then link it to the new SupplierPart "manufacturer_part" field + migrations.RunPython(supplierpart_make_manufacturer_parts, reverse_code=supplierpart_populate_manufacturer_info), + ] diff --git a/InvenTree/company/migrations/0037_supplierpart_update_3.py b/InvenTree/company/migrations/0037_supplierpart_update_3.py new file mode 100644 index 0000000000..e3384be513 --- /dev/null +++ b/InvenTree/company/migrations/0037_supplierpart_update_3.py @@ -0,0 +1,21 @@ +import InvenTree.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0036_supplierpart_update_2'), + ] + + operations = [ + migrations.RemoveField( + model_name='supplierpart', + name='MPN', + ), + migrations.RemoveField( + model_name='supplierpart', + name='manufacturer', + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index d83748c930..3ea50b1622 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -11,7 +11,9 @@ import math from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator +from django.core.exceptions import ValidationError from django.db import models +from django.db.utils import IntegrityError from django.db.models import Sum, Q, UniqueConstraint from django.apps import apps @@ -208,7 +210,7 @@ class Company(models.Model): @property def parts(self): """ Return SupplierPart objects which are supplied or manufactured by this company """ - return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer=self.id)) + return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id)) @property def part_count(self): @@ -223,7 +225,7 @@ class Company(models.Model): def stock_items(self): """ Return a list of all stock items supplied or manufactured by this company """ stock = apps.get_model('stock', 'StockItem') - return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer=self.id)).all() + return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all() @property def stock_count(self): @@ -284,19 +286,106 @@ class Contact(models.Model): on_delete=models.CASCADE) -class SupplierPart(models.Model): - """ Represents a unique part as provided by a Supplier - Each SupplierPart is identified by a MPN (Manufacturer Part Number) - Each SupplierPart is also linked to a Part object. - A Part may be available from multiple suppliers +class ManufacturerPart(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 Attributes: part: Link to the master Part + manufacturer: Company that manufactures the ManufacturerPart + MPN: Manufacture part number + link: Link to external website for this manufacturer part + description: Descriptive notes field + """ + + class Meta: + unique_together = ('part', 'manufacturer', 'MPN') + + part = models.ForeignKey('part.Part', on_delete=models.CASCADE, + related_name='manufacturer_parts', + verbose_name=_('Base Part'), + limit_choices_to={ + 'purchaseable': True, + }, + help_text=_('Select part'), + ) + + manufacturer = models.ForeignKey( + Company, + on_delete=models.CASCADE, + null=True, + related_name='manufactured_parts', + limit_choices_to={ + 'is_manufacturer': True + }, + verbose_name=_('Manufacturer'), + help_text=_('Select manufacturer'), + ) + + MPN = models.CharField( + null=True, + max_length=100, + verbose_name=_('MPN'), + help_text=_('Manufacturer Part Number') + ) + + link = InvenTreeURLField( + blank=True, null=True, + verbose_name=_('Link'), + help_text=_('URL for external manufacturer part link') + ) + + description = models.CharField( + max_length=250, blank=True, null=True, + verbose_name=_('Description'), + help_text=_('Manufacturer part description') + ) + + @classmethod + def create(cls, part, manufacturer, mpn, description, link=None): + """ Check if ManufacturerPart instance does not already exist + then create it + """ + + manufacturer_part = None + + try: + manufacturer_part = ManufacturerPart.objects.get(part=part, manufacturer=manufacturer, MPN=mpn) + except ManufacturerPart.DoesNotExist: + pass + + if not manufacturer_part: + manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=mpn, description=description, link=link) + manufacturer_part.save() + + return manufacturer_part + + def __str__(self): + s = '' + + if self.manufacturer: + s += f'{self.manufacturer.name}' + s += ' | ' + + s += f'{self.MPN}' + + return s + + +class SupplierPart(models.Model): + """ Represents a unique part as provided by a Supplier + Each SupplierPart is identified by a SKU (Supplier Part Number) + Each SupplierPart is also linked to a Part or ManufacturerPart object. + A Part may be available from multiple suppliers + + Attributes: + part: Link to the master Part (Obsolete) + source_item: The sourcing item linked to this SupplierPart instance supplier: Company that supplies this SupplierPart object SKU: Stock keeping unit (supplier part number) - manufacturer: Company that manufactures the SupplierPart (leave blank if it is the sample as the Supplier!) - MPN: Manufacture part number - link: Link to external website for this part + link: Link to external website for this supplier part description: Descriptive notes field note: Longer form note field base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" @@ -308,6 +397,57 @@ class SupplierPart(models.Model): def get_absolute_url(self): return reverse('supplier-part-detail', kwargs={'pk': self.id}) + def save(self, *args, **kwargs): + """ Overriding save method to process the linked ManufacturerPart + """ + + if 'manufacturer' in kwargs: + manufacturer_id = kwargs.pop('manufacturer') + + try: + manufacturer = Company.objects.get(pk=int(manufacturer_id)) + except (ValueError, Company.DoesNotExist): + manufacturer = None + else: + manufacturer = None + if 'MPN' in kwargs: + MPN = kwargs.pop('MPN') + else: + MPN = None + + if manufacturer or MPN: + if not self.manufacturer_part: + # Create ManufacturerPart + manufacturer_part = ManufacturerPart.create(part=self.part, + manufacturer=manufacturer, + mpn=MPN, + description=self.description) + self.manufacturer_part = manufacturer_part + else: + # Update ManufacturerPart (if ID exists) + try: + manufacturer_part_id = self.manufacturer_part.id + except AttributeError: + manufacturer_part_id = None + + if manufacturer_part_id: + try: + (manufacturer_part, created) = ManufacturerPart.objects.update_or_create(part=self.part, + manufacturer=manufacturer, + MPN=MPN) + except IntegrityError: + manufacturer_part = None + raise ValidationError(f'ManufacturerPart linked to {self.part} from manufacturer {manufacturer.name}' + f'with part number {MPN} already exists!') + + if manufacturer_part: + self.manufacturer_part = manufacturer_part + + self.clean() + self.validate_unique() + + super().save(*args, **kwargs) + class Meta: unique_together = ('part', 'supplier', 'SKU') @@ -336,23 +476,12 @@ class SupplierPart(models.Model): help_text=_('Supplier stock keeping unit') ) - manufacturer = models.ForeignKey( - Company, - on_delete=models.SET_NULL, - related_name='manufactured_parts', - limit_choices_to={ - 'is_manufacturer': True - }, - verbose_name=_('Manufacturer'), - help_text=_('Select manufacturer'), - null=True, blank=True - ) - - MPN = models.CharField( - max_length=100, blank=True, null=True, - verbose_name=_('MPN'), - help_text=_('Manufacturer part number') - ) + manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE, + blank=True, null=True, + related_name='supplier_parts', + verbose_name=_('Manufacturer Part'), + help_text=_('Select manufacturer part'), + ) link = InvenTreeURLField( blank=True, null=True, @@ -389,10 +518,11 @@ class SupplierPart(models.Model): items = [] - if self.manufacturer: - items.append(self.manufacturer.name) - if self.MPN: - items.append(self.MPN) + if self.manufacturer_part: + if self.manufacturer_part.manufacturer: + items.append(self.manufacturer_part.manufacturer.name) + if self.manufacturer_part.MPN: + items.append(self.manufacturer_part.MPN) return ' | '.join(items) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 4951bd3ad0..35e84aac1e 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -7,6 +7,7 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount from .models import Company +from .models import ManufacturerPart from .models import SupplierPart, SupplierPriceBreak from InvenTree.serializers import InvenTreeModelSerializer @@ -80,6 +81,49 @@ class CompanySerializer(InvenTreeModelSerializer): ] +class ManufacturerPartSerializer(InvenTreeModelSerializer): + """ Serializer for ManufacturerPart object """ + + part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + + manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True) + + pretty_name = serializers.CharField(read_only=True) + + def __init__(self, *args, **kwargs): + + part_detail = kwargs.pop('part_detail', False) + manufacturer_detail = kwargs.pop('manufacturer_detail', False) + prettify = kwargs.pop('pretty', False) + + super(ManufacturerPartSerializer, self).__init__(*args, **kwargs) + + if part_detail is not True: + self.fields.pop('part_detail') + + if manufacturer_detail is not True: + self.fields.pop('manufacturer_detail') + + if prettify is not True: + self.fields.pop('pretty_name') + + manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True)) + + class Meta: + model = ManufacturerPart + fields = [ + 'pk', + 'part', + 'part_detail', + 'pretty_name', + 'manufacturer', + 'manufacturer_detail', + 'description', + 'MPN', + 'link', + ] + + class SupplierPartSerializer(InvenTreeModelSerializer): """ Serializer for SupplierPart object """ @@ -87,7 +131,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer): supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) - manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True) + manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True) pretty_name = serializers.CharField(read_only=True) @@ -113,8 +157,12 @@ class SupplierPartSerializer(InvenTreeModelSerializer): self.fields.pop('pretty_name') supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True)) + + manufacturer = serializers.PrimaryKeyRelatedField(source='manufacturer_part.manufacturer', read_only=True) + + MPN = serializers.StringRelatedField(source='manufacturer_part.MPN') - manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True)) + manufacturer_part = ManufacturerPartSerializer(read_only=True) class Meta: model = SupplierPart @@ -127,12 +175,31 @@ class SupplierPartSerializer(InvenTreeModelSerializer): 'supplier_detail', 'SKU', 'manufacturer', - 'manufacturer_detail', - 'description', 'MPN', + 'manufacturer_detail', + 'manufacturer_part', + 'description', 'link', ] + def create(self, validated_data): + """ Extract manufacturer data and process ManufacturerPart """ + + # Create SupplierPart + supplier_part = super().create(validated_data) + + # Get ManufacturerPart raw data (unvalidated) + manufacturer_id = self.initial_data.get('manufacturer', None) + MPN = self.initial_data.get('MPN', None) + + if manufacturer_id or MPN: + kwargs = {'manufacturer': manufacturer_id, + 'MPN': MPN, + } + supplier_part.save(**kwargs) + + return supplier_part + class SupplierPriceBreakSerializer(InvenTreeModelSerializer): """ Serializer for SupplierPriceBreak object """ diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index b803fc5852..8e41b2d639 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -21,11 +21,13 @@ {% trans "Company Name" %} {{ company.name }} + {% if company.description %} {% trans "Description" %} {{ company.description }} + {% endif %} {% trans "Website" %} diff --git a/InvenTree/company/templates/company/detail_manufacturer_part.html b/InvenTree/company/templates/company/detail_manufacturer_part.html new file mode 100644 index 0000000000..902d456eaf --- /dev/null +++ b/InvenTree/company/templates/company/detail_manufacturer_part.html @@ -0,0 +1,127 @@ +{% extends "company/company_base.html" %} +{% load static %} +{% load i18n %} +{% load inventree_extras %} + +{% block menubar %} +{% include 'company/navbar.html' with tab='manufacturer_parts' %} +{% endblock %} + +{% block heading %} +{% trans "Manufacturer Parts" %} +{% endblock %} + + +{% block details %} + +{% if roles.purchase_order.change %} +
+
+
+ {% if roles.purchase_order.add %} + + {% endif %} +
+ +
+
+
+ +
+
+
+{% endif %} + +
+ +{% endblock %} +{% block js_ready %} +{{ block.super }} + + $("#manufacturer-part-create").click(function () { + launchModalForm( + "{% url 'manufacturer-part-create' %}", + { + data: { + manufacturer: {{ company.id }}, + }, + reload: true, + secondary: [ + { + field: 'part', + label: '{% trans "New Part" %}', + title: '{% trans "Create new Part" %}', + url: "{% url 'part-create' %}" + }, + { + field: 'manufacturer', + label: '{% trans "New Manufacturer" %}', + title: '{% trans "Create new Manufacturer" %}', + url: "{% url 'manufacturer-create' %}", + }, + ] + }); + }); + + loadManufacturerPartTable( + "#part-table", + "{% url 'api-manufacturer-part-list' %}", + { + params: { + part_detail: true, + manufacturer_detail: true, + company: {{ company.id }}, + }, + } + ); + + $("#multi-part-delete").click(function() { + var selections = $("#part-table").bootstrapTable("getSelections"); + + var parts = []; + + selections.forEach(function(item) { + parts.push(item.pk); + }); + + var url = "{% url 'manufacturer-part-delete' %}" + + launchModalForm(url, { + data: { + parts: parts, + }, + reload: true, + }); + }); + + $("#multi-part-order").click(function() { + var selections = $("#part-table").bootstrapTable("getSelections"); + + var parts = []; + + selections.forEach(function(item) { + parts.push(item.part); + }); + + launchModalForm("/order/purchase-order/order-parts/", { + data: { + parts: parts, + }, + }); + }); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/company/templates/company/detail_part.html b/InvenTree/company/templates/company/detail_supplier_part.html similarity index 80% rename from InvenTree/company/templates/company/detail_part.html rename to InvenTree/company/templates/company/detail_supplier_part.html index 05aaf78955..bf92f96843 100644 --- a/InvenTree/company/templates/company/detail_part.html +++ b/InvenTree/company/templates/company/detail_supplier_part.html @@ -1,9 +1,10 @@ {% extends "company/company_base.html" %} {% load static %} {% load i18n %} +{% load inventree_extras %} {% block menubar %} -{% include 'company/navbar.html' with tab='parts' %} +{% include 'company/navbar.html' with tab='supplier_parts' %} {% endblock %} {% block heading %} @@ -17,9 +18,9 @@
{% if roles.purchase_order.add %} - + {% endif %}