django-bom 1.262__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-bom might be problematic. Click here for more details.
- bom/__init__.py +1 -0
- bom/admin.py +207 -0
- bom/apps.py +8 -0
- bom/auth_backends.py +47 -0
- bom/base_classes.py +31 -0
- bom/constants.py +217 -0
- bom/context_processors.py +9 -0
- bom/csv_headers.py +252 -0
- bom/decorators.py +32 -0
- bom/form_fields.py +59 -0
- bom/forms.py +1328 -0
- bom/helpers.py +367 -0
- bom/local_settings.py +35 -0
- bom/migrations/0001_initial.py +135 -0
- bom/migrations/0002_auto_20180908_2151.py +24 -0
- bom/migrations/0003_sellerpart_data_source.py +18 -0
- bom/migrations/0004_auto_20180911_0011.py +18 -0
- bom/migrations/0005_auto_20181007_1934.py +56 -0
- bom/migrations/0006_auto_20181007_1949.py +41 -0
- bom/migrations/0007_auto_20181009_0256.py +19 -0
- bom/migrations/0008_auto_20181030_0427.py +19 -0
- bom/migrations/0009_subpart_reference.py +18 -0
- bom/migrations/0010_auto_20181202_0733.py +23 -0
- bom/migrations/0011_auto_20181202_2113.py +22 -0
- bom/migrations/0012_partchangehistory.py +30 -0
- bom/migrations/0013_auto_20190222_1631.py +19 -0
- bom/migrations/0014_auto_20190223_2353.py +18 -0
- bom/migrations/0015_auto_20190303_1915.py +136 -0
- bom/migrations/0016_auto_20190405_2308.py +58 -0
- bom/migrations/0017_auto_20190616_1912.py +19 -0
- bom/migrations/0018_auto_20190616_2143.py +24 -0
- bom/migrations/0019_auto_20190624_1246.py +45 -0
- bom/migrations/0020_auto_20190627_0207.py +38 -0
- bom/migrations/0021_auto_20190627_0428.py +23 -0
- bom/migrations/0022_auto_20190811_2140.py +35 -0
- bom/migrations/0023_auto_20191205_2351.py +255 -0
- bom/migrations/0024_auto_20191214_1342.py +89 -0
- bom/migrations/0025_auto_20191221_1907.py +38 -0
- bom/migrations/0026_auto_20191222_2258.py +22 -0
- bom/migrations/0027_auto_20191222_2347.py +17 -0
- bom/migrations/0028_partrevision_displayable_synopsis.py +74 -0
- bom/migrations/0029_auto_20191231_1630.py +23 -0
- bom/migrations/0030_auto_20200101_2253.py +22 -0
- bom/migrations/0031_auto_20200104_1352.py +38 -0
- bom/migrations/0032_auto_20200126_1806.py +27 -0
- bom/migrations/0033_auto_20200203_0618.py +29 -0
- bom/migrations/0034_auto_20200222_0359.py +30 -0
- bom/migrations/0035_auto_20200303_0111.py +34 -0
- bom/migrations/0036_auto_20200303_0538.py +17 -0
- bom/migrations/0037_auto_20200405_1642.py +44 -0
- bom/migrations/0038_auto_20200422_0504.py +19 -0
- bom/migrations/0039_auto_20200929_2315.py +41 -0
- bom/migrations/0040_alter_organization_currency.py +19 -0
- bom/migrations/0041_organization_subscription_quantity.py +18 -0
- bom/migrations/0042_auto_20210720_2137.py +23 -0
- bom/migrations/0043_auto_20211123_0157.py +24 -0
- bom/migrations/0044_auto_20220831_1241.py +23 -0
- bom/migrations/0045_sellerpart_link.py +18 -0
- bom/migrations/0046_alter_sellerpart_unique_together.py +17 -0
- bom/migrations/0047_sellerpart_seller_part_number.py +18 -0
- bom/migrations/0048_rename_part_organization_number_class_bom_part_organiz_b333d6_idx_and_more.py +1017 -0
- bom/migrations/0049_alter_assembly_id_alter_assemblysubparts_id_and_more.py +99 -0
- bom/migrations/0050_alter_organization_options.py +17 -0
- bom/migrations/0051_alter_manufacturer_organization_and_more.py +41 -0
- bom/migrations/0052_remove_partrevision_attribute_and_more.py +584 -0
- bom/migrations/__init__.py +0 -0
- bom/models.py +886 -0
- bom/part_bom.py +192 -0
- bom/settings.py +262 -0
- bom/static/bom/css/dashboard.css +17 -0
- bom/static/bom/css/jquery.treetable.css +28 -0
- bom/static/bom/css/materialize.min.css +13 -0
- bom/static/bom/css/part-info.css +15 -0
- bom/static/bom/css/style.css +482 -0
- bom/static/bom/css/tablesorter-theme.materialize.css +176 -0
- bom/static/bom/css/treetable-theme.css +42 -0
- bom/static/bom/doc/sample_part_classes.csv +38 -0
- bom/static/bom/doc/test_bom.csv +6 -0
- bom/static/bom/doc/test_bom_5_intelligent.csv +4 -0
- bom/static/bom/doc/test_full_bom.csv +37 -0
- bom/static/bom/doc/test_new_parts.csv +5 -0
- bom/static/bom/doc/test_new_parts_5_intelligent.csv +5 -0
- bom/static/bom/img/_ionicons_svg_md-arrow-dropdown.svg +1 -0
- bom/static/bom/img/_ionicons_svg_md-arrow-dropright.svg +1 -0
- bom/static/bom/img/favicon.ico +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_dark_disabled_web.png +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_dark_focus_web.png +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_dark_normal_web.png +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_dark_pressed_web.png +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_light_disabled_web.png +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_light_focus_web.png +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_light_normal_web.png +0 -0
- bom/static/bom/img/google/web/1x/btn_google_signin_light_pressed_web.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_dark_disabled_web@2x.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_dark_focus_web@2x.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_dark_normal_web@2x.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_dark_pressed_web@2x.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_light_disabled_web@2x.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_light_focus_web@2x.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_light_normal_web@2x.png +0 -0
- bom/static/bom/img/google/web/2x/btn_google_signin_light_pressed_web@2x.png +0 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.eps +814 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.svg +24 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.eps +1866 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.svg +51 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.eps +1031 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.svg +50 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.eps +1031 -0
- bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.svg +50 -0
- bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.eps +814 -0
- bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.svg +24 -0
- bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.eps +1837 -0
- bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.svg +44 -0
- bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.eps +1002 -0
- bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.svg +43 -0
- bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.eps +1002 -0
- bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.svg +43 -0
- bom/static/bom/img/google_drive_logo.svg +1 -0
- bom/static/bom/img/indabom.png +0 -0
- bom/static/bom/img/mouser.png +0 -0
- bom/static/bom/img/octopart_blue.svg +19 -0
- bom/static/bom/js/formset-handler.js +65 -0
- bom/static/bom/js/jquery-3.4.1.min.js +2 -0
- bom/static/bom/js/jquery.ba-floatingscrollbar.min.js +10 -0
- bom/static/bom/js/jquery.treetable.js +629 -0
- bom/static/bom/js/materialize.min.js +6 -0
- bom/templates/bom/account-delete.html +23 -0
- bom/templates/bom/add-manufacturer-part.html +66 -0
- bom/templates/bom/add-sellerpart.html +93 -0
- bom/templates/bom/base-menu.html +16 -0
- bom/templates/bom/base.html +129 -0
- bom/templates/bom/bom-action-btn.html +23 -0
- bom/templates/bom/bom-action-table.html +57 -0
- bom/templates/bom/bom-base-menu.html +6 -0
- bom/templates/bom/bom-base.html +24 -0
- bom/templates/bom/bom-form-modal.html +36 -0
- bom/templates/bom/bom-form.html +30 -0
- bom/templates/bom/bom-modal-add-users.html +49 -0
- bom/templates/bom/bom-signup.html +12 -0
- bom/templates/bom/components/bom-flat.html +131 -0
- bom/templates/bom/components/bom-indented.html +237 -0
- bom/templates/bom/components/manufacturer-part-list.html +270 -0
- bom/templates/bom/components/seller-part-list.html +62 -0
- bom/templates/bom/create-part.html +65 -0
- bom/templates/bom/dashboard-menu.html +15 -0
- bom/templates/bom/dashboard.html +303 -0
- bom/templates/bom/edit-manufacturer-part.html +72 -0
- bom/templates/bom/edit-part-class.html +120 -0
- bom/templates/bom/edit-part.html +67 -0
- bom/templates/bom/edit-quantity-of-measure.html +119 -0
- bom/templates/bom/edit-user-meta.html +70 -0
- bom/templates/bom/help.html +1356 -0
- bom/templates/bom/manufacturer-info.html +82 -0
- bom/templates/bom/manufacturers.html +97 -0
- bom/templates/bom/nothing-to-see.html +15 -0
- bom/templates/bom/organization-create.html +135 -0
- bom/templates/bom/part-info.html +448 -0
- bom/templates/bom/part-revision-display.html +50 -0
- bom/templates/bom/part-revision-edit.html +39 -0
- bom/templates/bom/part-revision-manage-bom.html +115 -0
- bom/templates/bom/part-revision-new.html +57 -0
- bom/templates/bom/part-revision-release.html +41 -0
- bom/templates/bom/search-help.html +101 -0
- bom/templates/bom/seller-info.html +82 -0
- bom/templates/bom/sellers.html +97 -0
- bom/templates/bom/settings.html +734 -0
- bom/templates/bom/signup.html +28 -0
- bom/templates/bom/subscription_panel.html +16 -0
- bom/templates/bom/table_of_contents.html +47 -0
- bom/templates/bom/upload-bom.html +111 -0
- bom/templates/bom/upload-parts-help.html +103 -0
- bom/templates/bom/upload-parts.html +50 -0
- bom/templates/registration/login.html +39 -0
- bom/tests.py +1592 -0
- bom/third_party_apis/__init__.py +0 -0
- bom/third_party_apis/base_api.py +51 -0
- bom/third_party_apis/google_drive.py +166 -0
- bom/third_party_apis/mouser.py +132 -0
- bom/third_party_apis/test_apis.py +24 -0
- bom/urls.py +100 -0
- bom/utils.py +228 -0
- bom/validators.py +23 -0
- bom/views/__init__.py +0 -0
- bom/views/json_views.py +55 -0
- bom/views/views.py +1773 -0
- bom/wsgi.py +16 -0
- django_bom-1.262.dist-info/METADATA +206 -0
- django_bom-1.262.dist-info/RECORD +191 -0
- django_bom-1.262.dist-info/WHEEL +5 -0
- django_bom-1.262.dist-info/licenses/LICENSE +674 -0
- django_bom-1.262.dist-info/top_level.txt +1 -0
bom/models.py
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
from __future__ import unicode_literals
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
5
|
+
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.contrib.auth import get_user_model
|
|
9
|
+
from django.core.cache import cache
|
|
10
|
+
from django.core.exceptions import ValidationError
|
|
11
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
12
|
+
from django.db import models
|
|
13
|
+
from django.utils import timezone
|
|
14
|
+
from django.utils.text import slugify
|
|
15
|
+
from djmoney.models.fields import CURRENCY_CHOICES, CurrencyField, MoneyField
|
|
16
|
+
from math import ceil
|
|
17
|
+
from social_django.models import UserSocialAuth
|
|
18
|
+
|
|
19
|
+
from .base_classes import AsDictModel
|
|
20
|
+
from .constants import *
|
|
21
|
+
from .csv_headers import PartsListCSVHeaders, PartsListCSVHeadersSemiIntelligent, BOMIndentedCSVHeaders, \
|
|
22
|
+
BOMFlatCSVHeaders
|
|
23
|
+
from .part_bom import PartBom, PartBomItem, PartIndentedBomItem
|
|
24
|
+
from .utils import increment_str, listify_string, prep_for_sorting_nicely, stringify_list, strip_trailing_zeros
|
|
25
|
+
from .validators import alphanumeric
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
User = get_user_model()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_user_meta_model():
|
|
32
|
+
from django.apps import apps
|
|
33
|
+
from django.conf import settings
|
|
34
|
+
return apps.get_model(settings.BOM_USER_META_MODEL)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_organization_model():
|
|
38
|
+
from django.apps import apps
|
|
39
|
+
from django.conf import settings
|
|
40
|
+
return apps.get_model(settings.BOM_ORGANIZATION_MODEL)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _user_meta(self, organization=None):
|
|
44
|
+
from django.apps import apps
|
|
45
|
+
from django.conf import settings
|
|
46
|
+
UserMetaModel = apps.get_model(settings.BOM_USER_META_MODEL)
|
|
47
|
+
meta, created = UserMetaModel.objects.get_or_create(
|
|
48
|
+
user=self,
|
|
49
|
+
defaults={'organization': organization}
|
|
50
|
+
)
|
|
51
|
+
return meta
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OrganizationManager(models.Manager):
|
|
55
|
+
def available_to(self, organization):
|
|
56
|
+
return self.get_queryset().filter(
|
|
57
|
+
models.Q(organization=organization) |
|
|
58
|
+
models.Q(organization__isnull=True)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OrganizationScopedModel(models.Model):
|
|
63
|
+
organization = models.ForeignKey(settings.BOM_ORGANIZATION_MODEL, on_delete=models.CASCADE, db_index=True)
|
|
64
|
+
|
|
65
|
+
objects = OrganizationManager()
|
|
66
|
+
|
|
67
|
+
class Meta:
|
|
68
|
+
abstract = True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class OrganizationOptionalModel(models.Model):
|
|
72
|
+
organization = models.ForeignKey(settings.BOM_ORGANIZATION_MODEL, on_delete=models.CASCADE, db_index=True,
|
|
73
|
+
null=True, blank=True, help_text="Leave empty for a Global/System record.")
|
|
74
|
+
|
|
75
|
+
objects = OrganizationManager()
|
|
76
|
+
|
|
77
|
+
class Meta:
|
|
78
|
+
abstract = True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AbstractOrganization(models.Model):
|
|
82
|
+
name = models.CharField(max_length=255, default=None)
|
|
83
|
+
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
|
84
|
+
number_scheme = models.CharField(max_length=1, choices=NUMBER_SCHEMES, default=NUMBER_SCHEME_SEMI_INTELLIGENT)
|
|
85
|
+
number_class_code_len = models.PositiveIntegerField(default=NUMBER_CLASS_CODE_LEN_DEFAULT,
|
|
86
|
+
validators=[MinValueValidator(NUMBER_CLASS_CODE_LEN_MIN),
|
|
87
|
+
MaxValueValidator(NUMBER_CLASS_CODE_LEN_MAX)])
|
|
88
|
+
number_item_len = models.PositiveIntegerField(default=NUMBER_ITEM_LEN_DEFAULT,
|
|
89
|
+
validators=[MinValueValidator(NUMBER_ITEM_LEN_MIN),
|
|
90
|
+
MaxValueValidator(NUMBER_ITEM_LEN_MAX)])
|
|
91
|
+
number_variation_len = models.PositiveIntegerField(default=NUMBER_VARIATION_LEN_DEFAULT,
|
|
92
|
+
validators=[MinValueValidator(NUMBER_VARIATION_LEN_MIN),
|
|
93
|
+
MaxValueValidator(NUMBER_VARIATION_LEN_MAX)])
|
|
94
|
+
google_drive_parent = models.CharField(max_length=128, blank=True, default=None, null=True)
|
|
95
|
+
currency = CurrencyField(max_length=3, choices=CURRENCY_CHOICES, default='USD')
|
|
96
|
+
|
|
97
|
+
subscription = models.CharField(max_length=1, choices=SUBSCRIPTION_TYPES)
|
|
98
|
+
subscription_quantity = models.IntegerField(default=0)
|
|
99
|
+
|
|
100
|
+
def number_cs(self):
|
|
101
|
+
return "C" * self.number_class_code_len
|
|
102
|
+
|
|
103
|
+
def number_ns(self):
|
|
104
|
+
return "N" * self.number_item_len
|
|
105
|
+
|
|
106
|
+
def number_vs(self):
|
|
107
|
+
return "V" * self.number_variation_len
|
|
108
|
+
|
|
109
|
+
def __str__(self):
|
|
110
|
+
return u'%s' % self.name
|
|
111
|
+
|
|
112
|
+
def seller_parts(self):
|
|
113
|
+
return SellerPart.objects.filter(seller__organization=self)
|
|
114
|
+
|
|
115
|
+
def part_list_csv_headers(self):
|
|
116
|
+
if self.number_scheme == NUMBER_SCHEME_INTELLIGENT:
|
|
117
|
+
headers = PartsListCSVHeaders()
|
|
118
|
+
else:
|
|
119
|
+
headers = PartsListCSVHeadersSemiIntelligent()
|
|
120
|
+
|
|
121
|
+
# Add dynamic headers
|
|
122
|
+
definitions = PartRevisionPropertyDefinition.objects.available_to(self).all()
|
|
123
|
+
headers.add_dynamic_headers(definitions)
|
|
124
|
+
return headers
|
|
125
|
+
|
|
126
|
+
def bom_indented_csv_headers(self):
|
|
127
|
+
headers = BOMIndentedCSVHeaders()
|
|
128
|
+
definitions = PartRevisionPropertyDefinition.objects.available_to(self).all()
|
|
129
|
+
headers.add_dynamic_headers(definitions)
|
|
130
|
+
return headers
|
|
131
|
+
|
|
132
|
+
def bom_flat_csv_headers(self):
|
|
133
|
+
headers = BOMFlatCSVHeaders()
|
|
134
|
+
definitions = PartRevisionPropertyDefinition.objects.available_to(self).all()
|
|
135
|
+
headers.add_dynamic_headers(definitions)
|
|
136
|
+
return headers
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def email(self):
|
|
140
|
+
return self.owner.email
|
|
141
|
+
|
|
142
|
+
def save(self, *args, **kwargs):
|
|
143
|
+
super(AbstractOrganization, self).save()
|
|
144
|
+
SellerPart.objects.filter(seller__organization=self).update(unit_cost_currency=self.currency,
|
|
145
|
+
nre_cost_currency=self.currency)
|
|
146
|
+
|
|
147
|
+
class Meta:
|
|
148
|
+
abstract = True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Organization(AbstractOrganization):
|
|
152
|
+
class Meta:
|
|
153
|
+
swappable = 'BOM_ORGANIZATION_MODEL'
|
|
154
|
+
permissions = (
|
|
155
|
+
("manage_members", "Can manage organization members"),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AbstractUserMeta(models.Model):
|
|
160
|
+
user = models.OneToOneField(settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE)
|
|
161
|
+
organization = models.ForeignKey(settings.BOM_ORGANIZATION_MODEL, blank=True, null=True, on_delete=models.CASCADE)
|
|
162
|
+
role = models.CharField(max_length=1, choices=ROLE_TYPES)
|
|
163
|
+
|
|
164
|
+
def get_or_create_organization(self):
|
|
165
|
+
if self.organization is None:
|
|
166
|
+
if self.user.first_name == '' and self.user.last_name == '':
|
|
167
|
+
org_name = self.user.username
|
|
168
|
+
else:
|
|
169
|
+
org_name = self.user.first_name + ' ' + self.user.last_name
|
|
170
|
+
|
|
171
|
+
OrganizationModel = apps.get_model(settings.BOM_ORGANIZATION_MODEL)
|
|
172
|
+
organization, created = OrganizationModel.objects.get_or_create(owner=self.user, defaults={'name': org_name,
|
|
173
|
+
'subscription': 'F'})
|
|
174
|
+
|
|
175
|
+
self.organization = organization
|
|
176
|
+
self.role = 'A'
|
|
177
|
+
self.save()
|
|
178
|
+
return self.organization
|
|
179
|
+
|
|
180
|
+
def google_authenticated(self) -> bool:
|
|
181
|
+
try:
|
|
182
|
+
self.user.social_auth.get(provider='google-oauth2')
|
|
183
|
+
return True
|
|
184
|
+
except UserSocialAuth.DoesNotExist:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def is_organization_owner(self) -> bool:
|
|
188
|
+
return self.organization.owner == self.user if self.organization else False
|
|
189
|
+
|
|
190
|
+
class Meta:
|
|
191
|
+
abstract = True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class UserMeta(AbstractUserMeta):
|
|
195
|
+
class Meta:
|
|
196
|
+
swappable = 'BOM_USER_META_MODEL'
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class PartClass(OrganizationScopedModel):
|
|
200
|
+
code = models.CharField(max_length=NUMBER_CLASS_CODE_LEN_MAX, validators=[alphanumeric])
|
|
201
|
+
name = models.CharField(max_length=255, default=None)
|
|
202
|
+
comment = models.CharField(max_length=255, default='', blank=True)
|
|
203
|
+
mouser_enabled = models.BooleanField(default=False)
|
|
204
|
+
property_definitions = models.ManyToManyField('PartRevisionPropertyDefinition', blank=True,
|
|
205
|
+
related_name='part_classes')
|
|
206
|
+
|
|
207
|
+
class Meta(OrganizationScopedModel.Meta):
|
|
208
|
+
unique_together = [['code', 'organization', ], ]
|
|
209
|
+
ordering = ['code']
|
|
210
|
+
indexes = [
|
|
211
|
+
models.Index(fields=['organization', 'code']),
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def __str__(self):
|
|
215
|
+
return f'{self.code}: {self.name}'
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Manufacturer(OrganizationScopedModel, AsDictModel):
|
|
219
|
+
name = models.CharField(max_length=128, default=None)
|
|
220
|
+
|
|
221
|
+
class Meta(OrganizationScopedModel.Meta):
|
|
222
|
+
ordering = ['name']
|
|
223
|
+
|
|
224
|
+
def __str__(self):
|
|
225
|
+
return u'%s' % self.name
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Part contains the root information for a component. Parts have attributes that can be changed over time
|
|
229
|
+
# (see PartRevision). Part numbers can be changed over time, but these cannot be tracked, as it is not a practice
|
|
230
|
+
# that should be done often.
|
|
231
|
+
class Part(OrganizationScopedModel):
|
|
232
|
+
number_class = models.ForeignKey(PartClass, default=None, blank=True, null=True, related_name='number_class', on_delete=models.CASCADE, db_index=True)
|
|
233
|
+
number_item = models.CharField(max_length=NUMBER_ITEM_LEN_MAX, default=None, blank=True)
|
|
234
|
+
number_variation = models.CharField(max_length=NUMBER_VARIATION_LEN_MAX, default=None, blank=True, null=True, validators=[alphanumeric])
|
|
235
|
+
primary_manufacturer_part = models.ForeignKey('ManufacturerPart', default=None, null=True, blank=True,
|
|
236
|
+
on_delete=models.SET_NULL, related_name='primary_manufacturer_part')
|
|
237
|
+
google_drive_parent = models.CharField(max_length=128, blank=True, default=None, null=True)
|
|
238
|
+
|
|
239
|
+
class Meta(OrganizationScopedModel.Meta):
|
|
240
|
+
unique_together = ['number_class', 'number_item', 'number_variation', 'organization', ]
|
|
241
|
+
indexes = [
|
|
242
|
+
models.Index(fields=['organization', 'number_class']),
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
def full_part_number(self):
|
|
246
|
+
if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
|
|
247
|
+
if self.organization.number_variation_len > 0:
|
|
248
|
+
return f"{self.number_class.code}-{self.number_item}-{self.number_variation}"
|
|
249
|
+
else:
|
|
250
|
+
return f"{self.number_class.code}-{self.number_item}"
|
|
251
|
+
else:
|
|
252
|
+
return self.number_item
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _expect_length(value, expected_len, units_word, label):
|
|
256
|
+
if len(value) != expected_len:
|
|
257
|
+
raise AttributeError(f"Expect {expected_len} {units_word} for {label}")
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def verify_format_number_class(number_class, organization):
|
|
261
|
+
Part._expect_length(number_class, organization.number_class_code_len, 'digits', 'number class')
|
|
262
|
+
if number_class is not None:
|
|
263
|
+
for c in number_class:
|
|
264
|
+
if not (c.isdigit() or c.isalpha()):
|
|
265
|
+
raise AttributeError(f"{c} is not a proper character for a number class")
|
|
266
|
+
return number_class
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def verify_format_number_item(number_item, organization):
|
|
270
|
+
Part._expect_length(number_item, organization.number_item_len, 'digits', 'number item')
|
|
271
|
+
if number_item is not None:
|
|
272
|
+
for c in number_item:
|
|
273
|
+
if not c.isdigit():
|
|
274
|
+
raise AttributeError(f"{c} is not a proper character for a number item")
|
|
275
|
+
return number_item
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def verify_format_number_variation(number_variation, organization):
|
|
279
|
+
Part._expect_length(number_variation, organization.number_variation_len, 'characters', 'number variation')
|
|
280
|
+
if number_variation is not None:
|
|
281
|
+
for c in number_variation:
|
|
282
|
+
if not c.isalnum():
|
|
283
|
+
raise AttributeError(f"{c} is not a proper character for a number variation. Must be alphanumeric.")
|
|
284
|
+
return number_variation
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def parse_part_number(part_number, organization):
|
|
288
|
+
if part_number is None:
|
|
289
|
+
raise AttributeError("Cannot parse empty part number")
|
|
290
|
+
if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
|
|
291
|
+
try:
|
|
292
|
+
(number_class, number_item, number_variation) = Part.parse_partial_part_number(part_number, organization)
|
|
293
|
+
except IndexError:
|
|
294
|
+
raise AttributeError("Invalid part number. Does not match organization preferences.")
|
|
295
|
+
|
|
296
|
+
if number_class is None:
|
|
297
|
+
raise AttributeError("Missing part number part class")
|
|
298
|
+
if number_item is None:
|
|
299
|
+
raise AttributeError("Missing part number item number")
|
|
300
|
+
if number_variation is None and organization.number_class_code_len != 0 and organization.number_variation_len > 0:
|
|
301
|
+
raise AttributeError("Missing part number part item variation")
|
|
302
|
+
|
|
303
|
+
return number_class, number_item, number_variation
|
|
304
|
+
else:
|
|
305
|
+
return None, part_number, None
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def parse_partial_part_number(part_number, organization, validate=True):
|
|
309
|
+
if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
|
|
310
|
+
elements = part_number.split('-')
|
|
311
|
+
|
|
312
|
+
number_class = elements[0] if len(elements) >= 1 else None
|
|
313
|
+
number_item = elements[1] if len(elements) >= 2 else None
|
|
314
|
+
number_variation = elements[2] if len(elements) >= 3 else None
|
|
315
|
+
|
|
316
|
+
if validate:
|
|
317
|
+
if len(elements) >= 2:
|
|
318
|
+
number_class = Part.verify_format_number_class(elements[0], organization)
|
|
319
|
+
number_item = Part.verify_format_number_item(elements[1], organization)
|
|
320
|
+
if len(elements) >= 3:
|
|
321
|
+
number_variation = Part.verify_format_number_variation(elements[2], organization)
|
|
322
|
+
|
|
323
|
+
return number_class, number_item, number_variation
|
|
324
|
+
else:
|
|
325
|
+
return None, part_number, None
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def from_part_number(cls, part_number, organization):
|
|
329
|
+
(number_class, number_item, number_variation) = Part.parse_part_number(part_number, organization)
|
|
330
|
+
if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
|
|
331
|
+
return Part.objects.get(
|
|
332
|
+
number_class__code=number_class,
|
|
333
|
+
number_class__organization=organization,
|
|
334
|
+
number_item=number_item,
|
|
335
|
+
number_variation=number_variation,
|
|
336
|
+
organization=organization
|
|
337
|
+
)
|
|
338
|
+
return Part.objects.get(
|
|
339
|
+
number_item=number_item,
|
|
340
|
+
organization=organization
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def from_manufacturer_part_number(cls, manufacturer_part_number, organization):
|
|
345
|
+
part = Part.objects.filter(
|
|
346
|
+
primary_manufacturer_part__manufacturer_part_number=manufacturer_part_number,
|
|
347
|
+
organization=organization
|
|
348
|
+
)
|
|
349
|
+
if len(part) == 1:
|
|
350
|
+
return part[0]
|
|
351
|
+
elif len(part) == 0:
|
|
352
|
+
return None
|
|
353
|
+
else:
|
|
354
|
+
raise ValueError('Too many objects found')
|
|
355
|
+
|
|
356
|
+
def description(self):
|
|
357
|
+
return self.latest().description if self.latest() is not None else ''
|
|
358
|
+
|
|
359
|
+
def latest(self):
|
|
360
|
+
return self.revisions().order_by('-id').first()
|
|
361
|
+
|
|
362
|
+
def revisions(self):
|
|
363
|
+
return PartRevision.objects.filter(part=self)
|
|
364
|
+
|
|
365
|
+
def seller_parts(self, exclude_primary=False):
|
|
366
|
+
manufacturer_parts = ManufacturerPart.objects.filter(part=self)
|
|
367
|
+
q = SellerPart.objects.filter(manufacturer_part__in=manufacturer_parts).order_by('seller', 'minimum_order_quantity')\
|
|
368
|
+
.select_related('manufacturer_part').select_related('manufacturer_part__manufacturer').select_related('seller')
|
|
369
|
+
if exclude_primary and self.primary_manufacturer_part is not None and self.primary_manufacturer_part.optimal_seller():
|
|
370
|
+
return q.exclude(id=self.primary_manufacturer_part.optimal_seller().id)
|
|
371
|
+
return q
|
|
372
|
+
|
|
373
|
+
def manufacturer_parts(self, exclude_primary=False):
|
|
374
|
+
q = ManufacturerPart.objects.filter(part=self).select_related('manufacturer')
|
|
375
|
+
if exclude_primary and self.primary_manufacturer_part is not None and self.primary_manufacturer_part.id is not None:
|
|
376
|
+
return q.exclude(id=self.primary_manufacturer_part.id)
|
|
377
|
+
return q
|
|
378
|
+
|
|
379
|
+
def where_used(self):
|
|
380
|
+
revisions = PartRevision.objects.filter(part=self)
|
|
381
|
+
used_in_subparts = Subpart.objects.filter(part_revision__in=revisions)
|
|
382
|
+
used_in_assembly_ids = AssemblySubparts.objects.filter(subpart__in=used_in_subparts).values_list('assembly', flat=True)
|
|
383
|
+
used_in_prs = PartRevision.objects.filter(assembly__in=used_in_assembly_ids)
|
|
384
|
+
return used_in_prs
|
|
385
|
+
|
|
386
|
+
def where_used_full(self):
|
|
387
|
+
def where_used_given_part(used_in_parts, part):
|
|
388
|
+
where_used = part.where_used()
|
|
389
|
+
used_in_parts.update(where_used)
|
|
390
|
+
for p in where_used:
|
|
391
|
+
where_used_given_part(used_in_parts, p)
|
|
392
|
+
return used_in_parts
|
|
393
|
+
|
|
394
|
+
used_in_parts = set()
|
|
395
|
+
where_used_given_part(used_in_parts, self)
|
|
396
|
+
return list(used_in_parts)
|
|
397
|
+
|
|
398
|
+
def indented(self, part_revision=None):
|
|
399
|
+
if part_revision is None:
|
|
400
|
+
return self.latest().indented() if self.latest() is not None else None
|
|
401
|
+
else:
|
|
402
|
+
return part_revision.indented()
|
|
403
|
+
|
|
404
|
+
def optimal_seller(self, quantity=None):
|
|
405
|
+
if not quantity:
|
|
406
|
+
qty_cache_key = str(self.id) + '_qty'
|
|
407
|
+
quantity = int(cache.get(qty_cache_key, 100))
|
|
408
|
+
|
|
409
|
+
manufacturer_parts = ManufacturerPart.objects.filter(part=self)
|
|
410
|
+
sellerparts = SellerPart.objects.filter(manufacturer_part__in=manufacturer_parts)
|
|
411
|
+
# sellerparts = SellerPart.objects.filter(manufacturer_part__part=self)
|
|
412
|
+
return SellerPart.optimal(sellerparts, int(quantity))
|
|
413
|
+
|
|
414
|
+
def assign_part_number(self):
|
|
415
|
+
if self.number_item is None or self.number_item == '':
|
|
416
|
+
last_number_item = Part.objects.filter(
|
|
417
|
+
number_class=self.number_class,
|
|
418
|
+
organization=self.organization
|
|
419
|
+
).order_by('number_item').last()
|
|
420
|
+
if not last_number_item:
|
|
421
|
+
self.number_item = str(1).zfill(self.organization.number_item_len)
|
|
422
|
+
else:
|
|
423
|
+
next_num = int(last_number_item.number_item) + 1
|
|
424
|
+
self.number_item = str(next_num).zfill(self.organization.number_item_len)
|
|
425
|
+
if (self.number_variation is None or self.number_variation == '') and self.organization.number_variation_len > 0:
|
|
426
|
+
last_number_variation = Part.objects.filter(
|
|
427
|
+
number_class=self.number_class,
|
|
428
|
+
number_item=self.number_item
|
|
429
|
+
).order_by('number_variation').last()
|
|
430
|
+
|
|
431
|
+
if not last_number_variation:
|
|
432
|
+
self.number_variation = '0'.zfill(self.organization.number_variation_len)
|
|
433
|
+
else:
|
|
434
|
+
try:
|
|
435
|
+
next_var = int(last_number_variation.number_variation) + 1
|
|
436
|
+
self.number_variation = str(next_var).zfill(self.organization.number_variation_len)
|
|
437
|
+
except ValueError:
|
|
438
|
+
self.number_variation = f"{increment_str(last_number_variation.number_variation)}"
|
|
439
|
+
|
|
440
|
+
def save(self, *args, **kwargs):
|
|
441
|
+
if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
|
|
442
|
+
self.assign_part_number()
|
|
443
|
+
super(Part, self).save()
|
|
444
|
+
|
|
445
|
+
def verbose_str(self):
|
|
446
|
+
return f'{self.full_part_number()} ┆ {self.description()}'
|
|
447
|
+
|
|
448
|
+
def __str__(self):
|
|
449
|
+
return u'%s' % (self.full_part_number())
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class QuantityOfMeasure(OrganizationOptionalModel):
|
|
453
|
+
"""
|
|
454
|
+
Defines the physical dimension (e.g., Length, Voltage, Mass).
|
|
455
|
+
Acts as the 'bucket' for compatible units.
|
|
456
|
+
"""
|
|
457
|
+
name = models.CharField(max_length=64, help_text="e.g. Voltage")
|
|
458
|
+
|
|
459
|
+
def get_base_unit(self):
|
|
460
|
+
return self.units.filter(base_multiplier=1.0).first()
|
|
461
|
+
|
|
462
|
+
class Meta:
|
|
463
|
+
unique_together = (('organization', 'name',),)
|
|
464
|
+
|
|
465
|
+
def __str__(self):
|
|
466
|
+
return self.name
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class UnitDefinition(OrganizationOptionalModel):
|
|
470
|
+
"""
|
|
471
|
+
Defines valid units.
|
|
472
|
+
"""
|
|
473
|
+
name = models.CharField(max_length=64) # e.g. Millivolt
|
|
474
|
+
symbol = models.CharField(max_length=16) # e.g. mV
|
|
475
|
+
quantity_of_measure = models.ForeignKey(QuantityOfMeasure, on_delete=models.CASCADE, related_name='units')
|
|
476
|
+
base_multiplier = models.DecimalField(default=Decimal('1.0'), max_digits=40, decimal_places=20)
|
|
477
|
+
|
|
478
|
+
class Meta:
|
|
479
|
+
unique_together = (('organization', 'quantity_of_measure', 'symbol'),)
|
|
480
|
+
ordering = ['base_multiplier']
|
|
481
|
+
|
|
482
|
+
def __str__(self):
|
|
483
|
+
return f"{self.symbol}"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class PartRevisionPropertyDefinition(OrganizationOptionalModel):
|
|
487
|
+
code = models.CharField(max_length=64, blank=True) # The internal slug (e.g., max_operating_temp).
|
|
488
|
+
name = models.CharField(max_length=64) # The user-friendly text displayed in the UI
|
|
489
|
+
type = models.CharField(max_length=1, choices=PART_REVISION_PROPERTY_TYPES)
|
|
490
|
+
required = models.BooleanField(default=False)
|
|
491
|
+
quantity_of_measure = models.ForeignKey(QuantityOfMeasure, on_delete=models.SET_NULL, null=True, blank=True)
|
|
492
|
+
|
|
493
|
+
class Meta:
|
|
494
|
+
unique_together = ('organization', 'code',)
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def form_field_name(self):
|
|
498
|
+
return f'property_{self.code}'
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def form_unit_field_name(self):
|
|
502
|
+
return f'{self.form_field_name}_unit'
|
|
503
|
+
|
|
504
|
+
def __str__(self):
|
|
505
|
+
return self.name
|
|
506
|
+
|
|
507
|
+
def save(self, *args, **kwargs):
|
|
508
|
+
if not self.code:
|
|
509
|
+
self.code = slugify(self.name)
|
|
510
|
+
|
|
511
|
+
super(PartRevisionPropertyDefinition, self).save(*args, **kwargs)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# Below are attributes of a part that can be changed, but it's important to trace the change over time
|
|
515
|
+
class PartRevision(models.Model):
|
|
516
|
+
part = models.ForeignKey(Part, on_delete=models.CASCADE, db_index=True)
|
|
517
|
+
timestamp = models.DateTimeField(default=timezone.now)
|
|
518
|
+
configuration = models.CharField(max_length=1, choices=CONFIGURATION_TYPES, default='W')
|
|
519
|
+
revision = models.CharField(max_length=4, db_index=True, default='1')
|
|
520
|
+
assembly = models.ForeignKey('Assembly', default=None, null=True, on_delete=models.CASCADE, db_index=True)
|
|
521
|
+
displayable_synopsis = models.CharField(editable=False, default="", null=True, blank=True, max_length=255, db_index=True)
|
|
522
|
+
searchable_synopsis = models.CharField(editable=False, default="", null=True, blank=True, max_length=255, db_index=True)
|
|
523
|
+
description = models.CharField(max_length=255, default="", null=True, blank=True)
|
|
524
|
+
|
|
525
|
+
class Meta:
|
|
526
|
+
unique_together = (('part', 'revision'),)
|
|
527
|
+
ordering = ['part']
|
|
528
|
+
|
|
529
|
+
def generate_synopsis(self, make_searchable=False):
|
|
530
|
+
def verbosify(val, units=None, pre=None, pre_whitespace=True, post=None, post_whitespace=True):
|
|
531
|
+
elaborated = ""
|
|
532
|
+
if val is not None and val != '':
|
|
533
|
+
try:
|
|
534
|
+
elaborated = strip_trailing_zeros(str(val))
|
|
535
|
+
if units is not None and units != '': elaborated += units
|
|
536
|
+
if pre is not None and pre != '':
|
|
537
|
+
elaborated = pre + (' ' if pre_whitespace else '') + elaborated
|
|
538
|
+
if post is not None and post != '': elaborated += (' ' if post_whitespace else '') + post
|
|
539
|
+
elaborated = elaborated + ' '
|
|
540
|
+
except ValueError:
|
|
541
|
+
pass
|
|
542
|
+
return elaborated
|
|
543
|
+
|
|
544
|
+
s = ""
|
|
545
|
+
s += verbosify(self.description)
|
|
546
|
+
|
|
547
|
+
# TODO: We no longer order these, in the future we will add a template / inheritance type pattern like
|
|
548
|
+
# Capacitor: {Capacitance} {Units}, {Voltage}, {Package}
|
|
549
|
+
for prop in self.properties.all().select_related('property_definition', 'unit_definition'):
|
|
550
|
+
val = prop.value_raw
|
|
551
|
+
units = prop.unit_definition.symbol if prop.unit_definition else None
|
|
552
|
+
s += verbosify(val, units=units)
|
|
553
|
+
|
|
554
|
+
return s[:255]
|
|
555
|
+
|
|
556
|
+
def synopsis(self, return_displayable=True):
|
|
557
|
+
return self.displayable_synopsis if return_displayable else self.searchable_synopsis
|
|
558
|
+
|
|
559
|
+
def update_synopsis(self):
|
|
560
|
+
self.searchable_synopsis = self.generate_synopsis(True)
|
|
561
|
+
self.displayable_synopsis = self.generate_synopsis(False)
|
|
562
|
+
# Use update() to avoid triggering save() again and potentially recursion
|
|
563
|
+
PartRevision.objects.filter(id=self.id).update(
|
|
564
|
+
searchable_synopsis=self.searchable_synopsis,
|
|
565
|
+
displayable_synopsis=self.displayable_synopsis
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def save(self, *args, **kwargs):
|
|
569
|
+
if self.assembly is None:
|
|
570
|
+
self.assembly = Assembly.objects.create()
|
|
571
|
+
|
|
572
|
+
super(PartRevision, self).save(*args, **kwargs)
|
|
573
|
+
|
|
574
|
+
self.searchable_synopsis = self.generate_synopsis(True)
|
|
575
|
+
self.displayable_synopsis = self.generate_synopsis(False)
|
|
576
|
+
|
|
577
|
+
def get_field_value(self, field_name):
|
|
578
|
+
if hasattr(self, field_name):
|
|
579
|
+
return getattr(self, field_name)
|
|
580
|
+
|
|
581
|
+
is_unit = field_name.endswith('_units')
|
|
582
|
+
prop_name = field_name[:-6] if is_unit else field_name
|
|
583
|
+
|
|
584
|
+
for prop in self.properties.all():
|
|
585
|
+
if prop.property_definition.name == prop_name:
|
|
586
|
+
if is_unit:
|
|
587
|
+
return prop.unit_definition.symbol if prop.unit_definition else ''
|
|
588
|
+
else:
|
|
589
|
+
return prop.value_raw
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
def indented(self, top_level_quantity=100):
|
|
593
|
+
def indented_given_bom(bom, part_revision, parent_id=None, parent=None, qty=1, parent_qty=1, indent_level=0, subpart=None, reference='', do_not_load=False):
|
|
594
|
+
bom_item_id = (parent_id or '') + (str(part_revision.id) + '-dnl' if do_not_load else str(part_revision.id))
|
|
595
|
+
extended_quantity = parent_qty * qty
|
|
596
|
+
total_extended_quantity = top_level_quantity * extended_quantity
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
seller_part = part_revision.part.optimal_seller(quantity=total_extended_quantity)
|
|
600
|
+
except AttributeError:
|
|
601
|
+
seller_part = None
|
|
602
|
+
|
|
603
|
+
bom.append_item_and_update(PartIndentedBomItem(
|
|
604
|
+
bom_id=bom_item_id,
|
|
605
|
+
part=part_revision.part,
|
|
606
|
+
part_revision=part_revision,
|
|
607
|
+
do_not_load=do_not_load,
|
|
608
|
+
references=reference,
|
|
609
|
+
quantity=qty,
|
|
610
|
+
extended_quantity=extended_quantity,
|
|
611
|
+
parent_quantity=parent_qty, # Do we need this?
|
|
612
|
+
indent_level=indent_level,
|
|
613
|
+
parent_id=parent_id,
|
|
614
|
+
subpart=subpart,
|
|
615
|
+
seller_part=seller_part,
|
|
616
|
+
))
|
|
617
|
+
|
|
618
|
+
indent_level = indent_level + 1
|
|
619
|
+
if part_revision is None or part_revision.assembly is None or part_revision.assembly.subparts.count() == 0:
|
|
620
|
+
return
|
|
621
|
+
else:
|
|
622
|
+
parent_qty *= qty
|
|
623
|
+
# TODO: Cache Me!
|
|
624
|
+
for sp in part_revision.assembly.subparts.all():
|
|
625
|
+
qty = sp.count
|
|
626
|
+
reference = sp.reference
|
|
627
|
+
indented_given_bom(bom, sp.part_revision, parent_id=bom_item_id, parent=part_revision, qty=qty, parent_qty=parent_qty,
|
|
628
|
+
indent_level=indent_level, subpart=sp, reference=reference, do_not_load=sp.do_not_load)
|
|
629
|
+
|
|
630
|
+
bom = PartBom(part_revision=self, quantity=top_level_quantity)
|
|
631
|
+
indented_given_bom(bom, self)
|
|
632
|
+
|
|
633
|
+
return bom
|
|
634
|
+
|
|
635
|
+
def flat(self, top_level_quantity=100, sort=False):
|
|
636
|
+
def flat_given_bom(bom, part_revision, parent=None, qty=1, parent_qty=1, subpart=None, reference=''):
|
|
637
|
+
extended_quantity = parent_qty * qty
|
|
638
|
+
total_extended_quantity = top_level_quantity * extended_quantity
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
seller_part = part_revision.part.optimal_seller(quantity=total_extended_quantity)
|
|
642
|
+
except AttributeError:
|
|
643
|
+
seller_part = None
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
do_not_load = subpart.do_not_load
|
|
647
|
+
except AttributeError:
|
|
648
|
+
do_not_load = False
|
|
649
|
+
|
|
650
|
+
bom_item_id = str(part_revision.id) + '-dnl' if do_not_load else str(part_revision.id)
|
|
651
|
+
bom.append_item_and_update(PartBomItem(
|
|
652
|
+
bom_id=bom_item_id,
|
|
653
|
+
part=part_revision.part,
|
|
654
|
+
part_revision=part_revision,
|
|
655
|
+
do_not_load=do_not_load,
|
|
656
|
+
references=reference,
|
|
657
|
+
quantity=qty,
|
|
658
|
+
extended_quantity=extended_quantity,
|
|
659
|
+
seller_part=seller_part,
|
|
660
|
+
))
|
|
661
|
+
|
|
662
|
+
if part_revision is None or part_revision.assembly is None or part_revision.assembly.subparts.count() == 0:
|
|
663
|
+
return
|
|
664
|
+
else:
|
|
665
|
+
parent_qty *= qty
|
|
666
|
+
for sp in part_revision.assembly.subparts.all():
|
|
667
|
+
qty = sp.count
|
|
668
|
+
reference = sp.reference
|
|
669
|
+
flat_given_bom(bom, sp.part_revision, parent=part_revision, qty=qty, parent_qty=parent_qty, subpart=sp, reference=reference)
|
|
670
|
+
|
|
671
|
+
flat_bom = PartBom(part_revision=self, quantity=top_level_quantity)
|
|
672
|
+
flat_given_bom(flat_bom, self)
|
|
673
|
+
|
|
674
|
+
# Sort by references, if no references then use part number.
|
|
675
|
+
# Note that need to convert part number to a list so can be compared with the
|
|
676
|
+
# list-ified string returned by prep_for_sorting_nicely.
|
|
677
|
+
def sort_by_references(p):
|
|
678
|
+
return prep_for_sorting_nicely(p.references) if p.references else p.__str__().split()
|
|
679
|
+
if sort:
|
|
680
|
+
flat_bom.parts = sorted(flat_bom.parts.values(), key=sort_by_references)
|
|
681
|
+
return flat_bom
|
|
682
|
+
|
|
683
|
+
def where_used(self):
|
|
684
|
+
# Where is a part_revision used???
|
|
685
|
+
# it gets used by being a subpart to an assembly of a part_revision
|
|
686
|
+
# so we can look up subparts, then their assemblys, then their partrevisions
|
|
687
|
+
used_in_subparts = Subpart.objects.filter(part_revision=self)
|
|
688
|
+
used_in_assembly_ids = AssemblySubparts.objects.filter(subpart__in=used_in_subparts).values_list('assembly', flat=True)
|
|
689
|
+
used_in_pr = PartRevision.objects.filter(assembly__in=used_in_assembly_ids).order_by('-revision')
|
|
690
|
+
return used_in_pr
|
|
691
|
+
|
|
692
|
+
def where_used_full(self):
|
|
693
|
+
def where_used_given_part(used_in_parts, part):
|
|
694
|
+
where_used = part.where_used()
|
|
695
|
+
used_in_parts.update(where_used)
|
|
696
|
+
for p in where_used:
|
|
697
|
+
where_used_given_part(used_in_parts, p)
|
|
698
|
+
return used_in_parts
|
|
699
|
+
|
|
700
|
+
used_in_parts = set()
|
|
701
|
+
where_used_given_part(used_in_parts, self)
|
|
702
|
+
return list(used_in_parts)
|
|
703
|
+
|
|
704
|
+
def next_revision(self):
|
|
705
|
+
try:
|
|
706
|
+
return int(self.revision) + 1
|
|
707
|
+
except ValueError:
|
|
708
|
+
return increment_str(self.revision)
|
|
709
|
+
|
|
710
|
+
def __str__(self):
|
|
711
|
+
return u'{}, Rev {}'.format(self.part.full_part_number(), self.revision)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class PartRevisionProperty(models.Model):
|
|
715
|
+
part_revision = models.ForeignKey(PartRevision, on_delete=models.CASCADE, related_name='properties')
|
|
716
|
+
property_definition = models.ForeignKey(PartRevisionPropertyDefinition, on_delete=models.CASCADE)
|
|
717
|
+
value_raw = models.CharField(max_length=255) # Base unit value, e.g. 0.01 (to describe 10mV)
|
|
718
|
+
unit_definition = models.ForeignKey(UnitDefinition, null=True, blank=True, on_delete=models.SET_NULL)
|
|
719
|
+
value_normalized = models.DecimalField(null=True, blank=True, max_digits=40, decimal_places=20)
|
|
720
|
+
|
|
721
|
+
def clean(self):
|
|
722
|
+
super().clean()
|
|
723
|
+
|
|
724
|
+
if self.unit_definition and self.property_definition.quantity_of_measure:
|
|
725
|
+
if self.unit_definition.quantity_of_measure != self.property_definition.quantity_of_measure:
|
|
726
|
+
raise ValidationError(
|
|
727
|
+
f"Unit '{self.unit_definition}' matches {self.unit_definition.quantity_of_measure}, but property requires {self.property_definition.quantity_of_measure}")
|
|
728
|
+
|
|
729
|
+
# Validate property is allowed for this part class
|
|
730
|
+
part = self.part_revision.part
|
|
731
|
+
if part.number_class:
|
|
732
|
+
allowed_definitions = part.number_class.property_definitions.all()
|
|
733
|
+
if not allowed_definitions.filter(id=self.property_definition.id).exists():
|
|
734
|
+
raise ValidationError(
|
|
735
|
+
f"The property '{self.property_definition.name}' is not valid for the Part Class '{part.number_class.name}'."
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
def save(self, *args, **kwargs):
|
|
739
|
+
if self.unit_definition and self.value_raw:
|
|
740
|
+
try:
|
|
741
|
+
val = Decimal(str(self.value_raw))
|
|
742
|
+
multiplier = Decimal(str(self.unit_definition.base_multiplier))
|
|
743
|
+
self.value_normalized = val * multiplier
|
|
744
|
+
except (ValueError, InvalidOperation):
|
|
745
|
+
self.value_normalized = None
|
|
746
|
+
else:
|
|
747
|
+
self.value_normalized = None
|
|
748
|
+
super(PartRevisionProperty, self).save(*args, **kwargs)
|
|
749
|
+
|
|
750
|
+
if self.part_revision:
|
|
751
|
+
self.part_revision.update_synopsis()
|
|
752
|
+
|
|
753
|
+
def __str__(self):
|
|
754
|
+
unit_sym = self.unit_definition.symbol if self.unit_definition else ""
|
|
755
|
+
return f"{self.property_definition.name}: {self.value_raw} {unit_sym}"
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
class AssemblySubparts(models.Model):
|
|
759
|
+
assembly = models.ForeignKey('Assembly', models.CASCADE)
|
|
760
|
+
subpart = models.ForeignKey('Subpart', models.CASCADE)
|
|
761
|
+
|
|
762
|
+
class Meta:
|
|
763
|
+
db_table = 'bom_assembly_subparts'
|
|
764
|
+
unique_together = (('assembly', 'subpart'),)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
class Subpart(models.Model):
|
|
768
|
+
part_revision = models.ForeignKey('PartRevision', related_name='assembly_subpart', null=True, on_delete=models.CASCADE)
|
|
769
|
+
count = models.FloatField(default=1, validators=[MinValueValidator(0)])
|
|
770
|
+
reference = models.TextField(default='', blank=True, null=True)
|
|
771
|
+
do_not_load = models.BooleanField(default=False, verbose_name='Do Not Load')
|
|
772
|
+
|
|
773
|
+
def save(self, *args, **kwargs):
|
|
774
|
+
# Make sure reference designators are formated as a string with comma-separated fields.
|
|
775
|
+
try:
|
|
776
|
+
reference = stringify_list(listify_string(self.reference))
|
|
777
|
+
self.reference = reference
|
|
778
|
+
except TypeError:
|
|
779
|
+
pass
|
|
780
|
+
super(Subpart, self).save(*args, **kwargs)
|
|
781
|
+
|
|
782
|
+
def __str__(self):
|
|
783
|
+
return u'{} {}'.format(self.part_revision, self.count)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class Assembly(models.Model):
|
|
787
|
+
subparts = models.ManyToManyField(Subpart, related_name='assemblies', through='AssemblySubparts')
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
class ManufacturerPart(models.Model, AsDictModel):
|
|
791
|
+
part = models.ForeignKey(Part, on_delete=models.CASCADE, db_index=True)
|
|
792
|
+
manufacturer_part_number = models.CharField(max_length=128, default='', blank=True)
|
|
793
|
+
manufacturer = models.ForeignKey(Manufacturer, default=None, blank=True, null=True, on_delete=models.CASCADE)
|
|
794
|
+
mouser_disable = models.BooleanField(default=False)
|
|
795
|
+
link = models.URLField(null=True, blank=True)
|
|
796
|
+
|
|
797
|
+
class Meta:
|
|
798
|
+
unique_together = [
|
|
799
|
+
'part',
|
|
800
|
+
'manufacturer_part_number',
|
|
801
|
+
'manufacturer']
|
|
802
|
+
|
|
803
|
+
def seller_parts(self):
|
|
804
|
+
return SellerPart.objects.filter(manufacturer_part=self).order_by('seller', 'minimum_order_quantity')
|
|
805
|
+
|
|
806
|
+
def optimal_seller(self, quantity=None):
|
|
807
|
+
if quantity is None:
|
|
808
|
+
qty_cache_key = str(self.part.id) + '_qty'
|
|
809
|
+
quantity = int(cache.get(qty_cache_key, 100))
|
|
810
|
+
sellerparts = SellerPart.objects.filter(manufacturer_part=self)
|
|
811
|
+
return SellerPart.optimal(sellerparts, quantity)
|
|
812
|
+
|
|
813
|
+
def as_dict_for_export(self):
|
|
814
|
+
return {
|
|
815
|
+
'manufacturer_name': self.manufacturer.name if self.manufacturer is not None else '',
|
|
816
|
+
'manufacturer_part_number': self.manufacturer_part_number
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
def __str__(self):
|
|
820
|
+
return u'%s' % (self.manufacturer_part_number)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class Seller(OrganizationScopedModel, AsDictModel):
|
|
824
|
+
name = models.CharField(max_length=128, default=None)
|
|
825
|
+
|
|
826
|
+
def __str__(self):
|
|
827
|
+
return u'%s' % (self.name)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
class SellerPart(models.Model, AsDictModel):
|
|
831
|
+
seller = models.ForeignKey(Seller, on_delete=models.CASCADE)
|
|
832
|
+
seller_part_number = models.CharField(max_length=64, default='', blank=True, null=True)
|
|
833
|
+
manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE)
|
|
834
|
+
minimum_order_quantity = models.PositiveIntegerField(default=1)
|
|
835
|
+
minimum_pack_quantity = models.PositiveIntegerField(default=1)
|
|
836
|
+
data_source = models.CharField(max_length=32, default=None, null=True, blank=True)
|
|
837
|
+
# "To comply with certain strict accounting or financial regulations, you may consider using max_digits=19 and decimal_places=4"
|
|
838
|
+
unit_cost = MoneyField(max_digits=19, decimal_places=4, default_currency='USD')
|
|
839
|
+
lead_time_days = models.PositiveIntegerField(null=True, blank=True)
|
|
840
|
+
nre_cost = MoneyField(max_digits=19, decimal_places=4, default_currency='USD')
|
|
841
|
+
link = models.URLField(null=True, blank=True)
|
|
842
|
+
ncnr = models.BooleanField(default=False)
|
|
843
|
+
|
|
844
|
+
def as_dict(self):
|
|
845
|
+
d = super().as_dict()
|
|
846
|
+
d['unit_cost'] = self.unit_cost.amount
|
|
847
|
+
d['nre_cost'] = self.nre_cost.amount
|
|
848
|
+
return d
|
|
849
|
+
|
|
850
|
+
def as_dict_for_export(self):
|
|
851
|
+
return {
|
|
852
|
+
'manufacturer_name': self.manufacturer_part.manufacturer.name if self.manufacturer_part.manufacturer is not None else '',
|
|
853
|
+
'manufacturer_part_number': self.manufacturer_part.manufacturer_part_number,
|
|
854
|
+
'seller': self.seller.name,
|
|
855
|
+
'seller_part_number': self.seller_part_number,
|
|
856
|
+
'unit_cost': self.unit_cost,
|
|
857
|
+
'minimum_order_quantity': self.minimum_order_quantity,
|
|
858
|
+
'nre_cost': self.nre_cost
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def optimal(sellerparts, quantity):
|
|
863
|
+
seller = None
|
|
864
|
+
for sellerpart in sellerparts:
|
|
865
|
+
if seller is None:
|
|
866
|
+
seller = sellerpart
|
|
867
|
+
else:
|
|
868
|
+
new_quantity = quantity if sellerpart.minimum_order_quantity < quantity else sellerpart.minimum_order_quantity
|
|
869
|
+
new_total_cost = new_quantity * sellerpart.unit_cost
|
|
870
|
+
old_quantity = quantity if seller.minimum_order_quantity < quantity else seller.minimum_order_quantity
|
|
871
|
+
old_total_cost = old_quantity * seller.unit_cost
|
|
872
|
+
if new_total_cost < old_total_cost:
|
|
873
|
+
seller = sellerpart
|
|
874
|
+
return seller
|
|
875
|
+
|
|
876
|
+
def order_quantity(self, extended_quantity):
|
|
877
|
+
order_qty = extended_quantity
|
|
878
|
+
if self.minimum_order_quantity and extended_quantity > self.minimum_order_quantity:
|
|
879
|
+
order_qty = ceil(extended_quantity / float(self.minimum_order_quantity)) * self.minimum_order_quantity
|
|
880
|
+
return order_qty
|
|
881
|
+
|
|
882
|
+
def __str__(self):
|
|
883
|
+
return u'%s' % (self.manufacturer_part.part.full_part_number() + ' ' + self.seller.name)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
User.add_to_class('bom_profile', _user_meta)
|