django-bom 1.237__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.
Files changed (183) hide show
  1. bom/__init__.py +1 -0
  2. bom/admin.py +161 -0
  3. bom/apps.py +8 -0
  4. bom/base_classes.py +31 -0
  5. bom/constants.py +210 -0
  6. bom/context_processors.py +9 -0
  7. bom/csv_headers.py +274 -0
  8. bom/decorators.py +32 -0
  9. bom/form_fields.py +59 -0
  10. bom/forms.py +1400 -0
  11. bom/helpers.py +292 -0
  12. bom/local_settings.py +35 -0
  13. bom/migrations/0001_initial.py +135 -0
  14. bom/migrations/0002_auto_20180908_2151.py +24 -0
  15. bom/migrations/0003_sellerpart_data_source.py +18 -0
  16. bom/migrations/0004_auto_20180911_0011.py +18 -0
  17. bom/migrations/0005_auto_20181007_1934.py +56 -0
  18. bom/migrations/0006_auto_20181007_1949.py +41 -0
  19. bom/migrations/0007_auto_20181009_0256.py +19 -0
  20. bom/migrations/0008_auto_20181030_0427.py +19 -0
  21. bom/migrations/0009_subpart_reference.py +18 -0
  22. bom/migrations/0010_auto_20181202_0733.py +23 -0
  23. bom/migrations/0011_auto_20181202_2113.py +22 -0
  24. bom/migrations/0012_partchangehistory.py +30 -0
  25. bom/migrations/0013_auto_20190222_1631.py +19 -0
  26. bom/migrations/0014_auto_20190223_2353.py +18 -0
  27. bom/migrations/0015_auto_20190303_1915.py +136 -0
  28. bom/migrations/0016_auto_20190405_2308.py +58 -0
  29. bom/migrations/0017_auto_20190616_1912.py +19 -0
  30. bom/migrations/0018_auto_20190616_2143.py +24 -0
  31. bom/migrations/0019_auto_20190624_1246.py +45 -0
  32. bom/migrations/0020_auto_20190627_0207.py +38 -0
  33. bom/migrations/0021_auto_20190627_0428.py +23 -0
  34. bom/migrations/0022_auto_20190811_2140.py +35 -0
  35. bom/migrations/0023_auto_20191205_2351.py +255 -0
  36. bom/migrations/0024_auto_20191214_1342.py +89 -0
  37. bom/migrations/0025_auto_20191221_1907.py +38 -0
  38. bom/migrations/0026_auto_20191222_2258.py +22 -0
  39. bom/migrations/0027_auto_20191222_2347.py +17 -0
  40. bom/migrations/0028_partrevision_displayable_synopsis.py +74 -0
  41. bom/migrations/0029_auto_20191231_1630.py +23 -0
  42. bom/migrations/0030_auto_20200101_2253.py +22 -0
  43. bom/migrations/0031_auto_20200104_1352.py +38 -0
  44. bom/migrations/0032_auto_20200126_1806.py +27 -0
  45. bom/migrations/0033_auto_20200203_0618.py +29 -0
  46. bom/migrations/0034_auto_20200222_0359.py +30 -0
  47. bom/migrations/0035_auto_20200303_0111.py +34 -0
  48. bom/migrations/0036_auto_20200303_0538.py +17 -0
  49. bom/migrations/0037_auto_20200405_1642.py +44 -0
  50. bom/migrations/0038_auto_20200422_0504.py +19 -0
  51. bom/migrations/0039_auto_20200929_2315.py +41 -0
  52. bom/migrations/0040_alter_organization_currency.py +19 -0
  53. bom/migrations/0041_organization_subscription_quantity.py +18 -0
  54. bom/migrations/0042_auto_20210720_2137.py +23 -0
  55. bom/migrations/0043_auto_20211123_0157.py +24 -0
  56. bom/migrations/0044_auto_20220831_1241.py +23 -0
  57. bom/migrations/0045_sellerpart_link.py +18 -0
  58. bom/migrations/0046_alter_sellerpart_unique_together.py +17 -0
  59. bom/migrations/0047_sellerpart_seller_part_number.py +18 -0
  60. bom/migrations/0048_rename_part_organization_number_class_bom_part_organiz_b333d6_idx_and_more.py +1017 -0
  61. bom/migrations/0049_alter_assembly_id_alter_assemblysubparts_id_and_more.py +99 -0
  62. bom/migrations/__init__.py +0 -0
  63. bom/models.py +757 -0
  64. bom/part_bom.py +192 -0
  65. bom/settings.py +264 -0
  66. bom/static/bom/css/dashboard.css +17 -0
  67. bom/static/bom/css/jquery.treetable.css +28 -0
  68. bom/static/bom/css/materialize.min.css +13 -0
  69. bom/static/bom/css/part-info.css +15 -0
  70. bom/static/bom/css/style.css +319 -0
  71. bom/static/bom/css/tablesorter-theme.materialize.css +176 -0
  72. bom/static/bom/css/treetable-theme.css +42 -0
  73. bom/static/bom/doc/sample_part_classes.csv +38 -0
  74. bom/static/bom/doc/test_bom.csv +6 -0
  75. bom/static/bom/doc/test_bom_5_intelligent.csv +4 -0
  76. bom/static/bom/doc/test_full_bom.csv +37 -0
  77. bom/static/bom/doc/test_new_parts.csv +5 -0
  78. bom/static/bom/doc/test_new_parts_5_intelligent.csv +5 -0
  79. bom/static/bom/img/_ionicons_svg_md-arrow-dropdown.svg +1 -0
  80. bom/static/bom/img/_ionicons_svg_md-arrow-dropright.svg +1 -0
  81. bom/static/bom/img/favicon.ico +0 -0
  82. bom/static/bom/img/google/web/1x/btn_google_signin_dark_disabled_web.png +0 -0
  83. bom/static/bom/img/google/web/1x/btn_google_signin_dark_focus_web.png +0 -0
  84. bom/static/bom/img/google/web/1x/btn_google_signin_dark_normal_web.png +0 -0
  85. bom/static/bom/img/google/web/1x/btn_google_signin_dark_pressed_web.png +0 -0
  86. bom/static/bom/img/google/web/1x/btn_google_signin_light_disabled_web.png +0 -0
  87. bom/static/bom/img/google/web/1x/btn_google_signin_light_focus_web.png +0 -0
  88. bom/static/bom/img/google/web/1x/btn_google_signin_light_normal_web.png +0 -0
  89. bom/static/bom/img/google/web/1x/btn_google_signin_light_pressed_web.png +0 -0
  90. bom/static/bom/img/google/web/2x/btn_google_signin_dark_disabled_web@2x.png +0 -0
  91. bom/static/bom/img/google/web/2x/btn_google_signin_dark_focus_web@2x.png +0 -0
  92. bom/static/bom/img/google/web/2x/btn_google_signin_dark_normal_web@2x.png +0 -0
  93. bom/static/bom/img/google/web/2x/btn_google_signin_dark_pressed_web@2x.png +0 -0
  94. bom/static/bom/img/google/web/2x/btn_google_signin_light_disabled_web@2x.png +0 -0
  95. bom/static/bom/img/google/web/2x/btn_google_signin_light_focus_web@2x.png +0 -0
  96. bom/static/bom/img/google/web/2x/btn_google_signin_light_normal_web@2x.png +0 -0
  97. bom/static/bom/img/google/web/2x/btn_google_signin_light_pressed_web@2x.png +0 -0
  98. bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.eps +814 -0
  99. bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.svg +24 -0
  100. bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.eps +1866 -0
  101. bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.svg +51 -0
  102. bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.eps +1031 -0
  103. bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.svg +50 -0
  104. bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.eps +1031 -0
  105. bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.svg +50 -0
  106. bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.eps +814 -0
  107. bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.svg +24 -0
  108. bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.eps +1837 -0
  109. bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.svg +44 -0
  110. bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.eps +1002 -0
  111. bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.svg +43 -0
  112. bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.eps +1002 -0
  113. bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.svg +43 -0
  114. bom/static/bom/img/google_drive_logo.svg +1 -0
  115. bom/static/bom/img/indabom.png +0 -0
  116. bom/static/bom/img/mouser.png +0 -0
  117. bom/static/bom/img/octopart_blue.svg +19 -0
  118. bom/static/bom/js/jquery-3.4.1.min.js +2 -0
  119. bom/static/bom/js/jquery.ba-floatingscrollbar.min.js +10 -0
  120. bom/static/bom/js/jquery.treetable.js +629 -0
  121. bom/static/bom/js/materialize.min.js +6 -0
  122. bom/templates/bom/account-delete.html +23 -0
  123. bom/templates/bom/add-manufacturer-part.html +66 -0
  124. bom/templates/bom/add-sellerpart.html +93 -0
  125. bom/templates/bom/base-menu.html +16 -0
  126. bom/templates/bom/base.html +129 -0
  127. bom/templates/bom/bom-action-btn.html +23 -0
  128. bom/templates/bom/bom-action-table.html +57 -0
  129. bom/templates/bom/bom-base-menu.html +6 -0
  130. bom/templates/bom/bom-base.html +24 -0
  131. bom/templates/bom/bom-form-modal.html +36 -0
  132. bom/templates/bom/bom-form.html +30 -0
  133. bom/templates/bom/bom-signup.html +12 -0
  134. bom/templates/bom/components/bom-flat.html +131 -0
  135. bom/templates/bom/components/bom-indented.html +237 -0
  136. bom/templates/bom/components/manufacturer-part-list.html +270 -0
  137. bom/templates/bom/components/seller-part-list.html +62 -0
  138. bom/templates/bom/create-part.html +65 -0
  139. bom/templates/bom/dashboard-menu.html +15 -0
  140. bom/templates/bom/dashboard.html +303 -0
  141. bom/templates/bom/edit-manufacturer-part.html +72 -0
  142. bom/templates/bom/edit-part-class.html +33 -0
  143. bom/templates/bom/edit-part.html +67 -0
  144. bom/templates/bom/edit-user-meta.html +38 -0
  145. bom/templates/bom/help.html +1356 -0
  146. bom/templates/bom/manufacturer-info.html +82 -0
  147. bom/templates/bom/manufacturers.html +97 -0
  148. bom/templates/bom/nothing-to-see.html +15 -0
  149. bom/templates/bom/organization-create.html +132 -0
  150. bom/templates/bom/part-info.html +439 -0
  151. bom/templates/bom/part-revision-display.html +184 -0
  152. bom/templates/bom/part-revision-edit.html +39 -0
  153. bom/templates/bom/part-revision-manage-bom.html +115 -0
  154. bom/templates/bom/part-revision-new.html +57 -0
  155. bom/templates/bom/part-revision-release.html +41 -0
  156. bom/templates/bom/search-help.html +101 -0
  157. bom/templates/bom/seller-info.html +82 -0
  158. bom/templates/bom/sellers.html +97 -0
  159. bom/templates/bom/settings.html +408 -0
  160. bom/templates/bom/signup.html +28 -0
  161. bom/templates/bom/table_of_contents.html +47 -0
  162. bom/templates/bom/upload-bom.html +111 -0
  163. bom/templates/bom/upload-parts-help.html +103 -0
  164. bom/templates/bom/upload-parts.html +50 -0
  165. bom/templates/registration/login.html +39 -0
  166. bom/tests.py +1506 -0
  167. bom/third_party_apis/__init__.py +0 -0
  168. bom/third_party_apis/base_api.py +51 -0
  169. bom/third_party_apis/google_drive.py +166 -0
  170. bom/third_party_apis/mouser.py +132 -0
  171. bom/third_party_apis/test_apis.py +24 -0
  172. bom/urls.py +94 -0
  173. bom/utils.py +228 -0
  174. bom/validators.py +23 -0
  175. bom/views/__init__.py +0 -0
  176. bom/views/json_views.py +55 -0
  177. bom/views/views.py +1620 -0
  178. bom/wsgi.py +16 -0
  179. django_bom-1.237.dist-info/METADATA +206 -0
  180. django_bom-1.237.dist-info/RECORD +183 -0
  181. django_bom-1.237.dist-info/WHEEL +5 -0
  182. django_bom-1.237.dist-info/licenses/LICENSE +674 -0
  183. django_bom-1.237.dist-info/top_level.txt +1 -0
bom/models.py ADDED
@@ -0,0 +1,757 @@
1
+ from __future__ import unicode_literals
2
+
3
+ import logging
4
+ from math import ceil
5
+
6
+ from django.conf import settings
7
+ from django.contrib.auth import get_user_model
8
+ from django.core.cache import cache
9
+ from django.core.validators import MaxValueValidator, MinValueValidator
10
+ from django.db import models
11
+ from django.utils import timezone
12
+
13
+ from djmoney.models.fields import CURRENCY_CHOICES, CurrencyField, MoneyField
14
+ from social_django.models import UserSocialAuth
15
+
16
+ from .base_classes import AsDictModel
17
+ from .constants import (
18
+ CONFIGURATION_TYPES,
19
+ CURRENT_UNITS,
20
+ DISTANCE_UNITS,
21
+ FREQUENCY_UNITS,
22
+ INTERFACE_TYPES,
23
+ MEMORY_UNITS,
24
+ NUMBER_CLASS_CODE_LEN_DEFAULT,
25
+ NUMBER_CLASS_CODE_LEN_MAX,
26
+ NUMBER_CLASS_CODE_LEN_MIN,
27
+ NUMBER_ITEM_LEN_DEFAULT,
28
+ NUMBER_ITEM_LEN_MAX,
29
+ NUMBER_ITEM_LEN_MIN,
30
+ NUMBER_SCHEME_INTELLIGENT,
31
+ NUMBER_SCHEME_SEMI_INTELLIGENT,
32
+ NUMBER_SCHEMES,
33
+ NUMBER_VARIATION_LEN_DEFAULT,
34
+ NUMBER_VARIATION_LEN_MAX,
35
+ NUMBER_VARIATION_LEN_MIN,
36
+ PACKAGE_TYPES,
37
+ POWER_UNITS,
38
+ ROLE_TYPES,
39
+ SUBSCRIPTION_TYPES,
40
+ TEMPERATURE_UNITS,
41
+ VALUE_UNITS,
42
+ VOLTAGE_UNITS,
43
+ WAVELENGTH_UNITS,
44
+ WEIGHT_UNITS,
45
+ )
46
+ from .csv_headers import PartsListCSVHeaders, PartsListCSVHeadersSemiIntelligent
47
+ from .part_bom import PartBom, PartBomItem, PartIndentedBomItem
48
+ from .utils import increment_str, listify_string, prep_for_sorting_nicely, stringify_list, strip_trailing_zeros
49
+ from .validators import alphanumeric, numeric, validate_pct
50
+
51
+
52
+ logger = logging.getLogger(__name__)
53
+ User = get_user_model()
54
+
55
+
56
+ class OrganizationScopedModel(models.Model):
57
+ organization = models.ForeignKey('Organization', on_delete=models.CASCADE, db_index=True)
58
+
59
+ class Meta:
60
+ abstract = True
61
+
62
+
63
+ class Organization(models.Model):
64
+ name = models.CharField(max_length=255, default=None)
65
+ subscription = models.CharField(max_length=1, choices=SUBSCRIPTION_TYPES)
66
+ subscription_quantity = models.IntegerField(default=0)
67
+ owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
68
+ number_scheme = models.CharField(max_length=1, choices=NUMBER_SCHEMES, default=NUMBER_SCHEME_SEMI_INTELLIGENT)
69
+ number_class_code_len = models.PositiveIntegerField(default=NUMBER_CLASS_CODE_LEN_DEFAULT,
70
+ validators=[MinValueValidator(NUMBER_CLASS_CODE_LEN_MIN), MaxValueValidator(NUMBER_CLASS_CODE_LEN_MAX)])
71
+ number_item_len = models.PositiveIntegerField(default=NUMBER_ITEM_LEN_DEFAULT,
72
+ validators=[MinValueValidator(NUMBER_ITEM_LEN_MIN), MaxValueValidator(NUMBER_ITEM_LEN_MAX)])
73
+ number_variation_len = models.PositiveIntegerField(default=NUMBER_VARIATION_LEN_DEFAULT,
74
+ validators=[MinValueValidator(NUMBER_VARIATION_LEN_MIN), MaxValueValidator(NUMBER_VARIATION_LEN_MAX)])
75
+ google_drive_parent = models.CharField(max_length=128, blank=True, default=None, null=True)
76
+ currency = CurrencyField(max_length=3, choices=CURRENCY_CHOICES, default='USD')
77
+
78
+ def number_cs(self):
79
+ return "C" * self.number_class_code_len
80
+
81
+ def number_ns(self):
82
+ return "N" * self.number_item_len
83
+
84
+ def number_vs(self):
85
+ return "V" * self.number_variation_len
86
+
87
+ def __str__(self):
88
+ return u'%s' % self.name
89
+
90
+ def seller_parts(self):
91
+ return SellerPart.objects.filter(seller__organization=self)
92
+
93
+ def part_list_csv_headers(self):
94
+ if self.number_scheme == NUMBER_SCHEME_INTELLIGENT:
95
+ return PartsListCSVHeaders()
96
+ else:
97
+ return PartsListCSVHeadersSemiIntelligent()
98
+
99
+ @property
100
+ def email(self):
101
+ return self.owner.email
102
+
103
+ def save(self, *args, **kwargs):
104
+ super(Organization, self).save()
105
+ SellerPart.objects.filter(seller__organization=self).update(unit_cost_currency=self.currency, nre_cost_currency=self.currency)
106
+
107
+
108
+ class UserMeta(models.Model):
109
+ user = models.OneToOneField(settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE)
110
+ organization = models.ForeignKey(Organization, blank=True, null=True, on_delete=models.CASCADE)
111
+ role = models.CharField(max_length=1, choices=ROLE_TYPES)
112
+
113
+ def get_or_create_organization(self):
114
+ if self.organization is None:
115
+ if self.user.first_name == '' and self.user.last_name == '':
116
+ org_name = self.user.username
117
+ else:
118
+ org_name = self.user.first_name + ' ' + self.user.last_name
119
+
120
+ organization, created = Organization.objects.get_or_create(owner=self.user, defaults={'name': org_name, 'subscription': 'F'})
121
+
122
+ self.organization = organization
123
+ self.role = 'A'
124
+ self.save()
125
+ return self.organization
126
+
127
+ def google_authenticated(self) -> bool:
128
+ try:
129
+ self.user.social_auth.get(provider='google-oauth2')
130
+ return True
131
+ except UserSocialAuth.DoesNotExist:
132
+ return False
133
+
134
+ def is_organization_owner(self) -> bool:
135
+ return self.organization.owner == self.user if self.organization else False
136
+
137
+ def _user_meta(self, organization=None):
138
+ return UserMeta.objects.get_or_create(user=self, defaults={'organization': organization})[0]
139
+
140
+ User.add_to_class('bom_profile', _user_meta)
141
+
142
+
143
+ class PartClass(OrganizationScopedModel):
144
+ code = models.CharField(max_length=NUMBER_CLASS_CODE_LEN_MAX, validators=[alphanumeric])
145
+ name = models.CharField(max_length=255, default=None)
146
+ comment = models.CharField(max_length=255, default='', blank=True)
147
+ mouser_enabled = models.BooleanField(default=False)
148
+
149
+ class Meta(OrganizationScopedModel.Meta):
150
+ unique_together = [['code', 'organization', ], ]
151
+ ordering = ['code']
152
+ indexes = [
153
+ models.Index(fields=['organization', 'code']),
154
+ ]
155
+
156
+ def __str__(self):
157
+ return f'{self.code}: {self.name}'
158
+
159
+
160
+ class Manufacturer(OrganizationScopedModel, AsDictModel):
161
+ name = models.CharField(max_length=128, default=None)
162
+
163
+ class Meta(OrganizationScopedModel.Meta):
164
+ ordering = ['name']
165
+
166
+ def __str__(self):
167
+ return u'%s' % self.name
168
+
169
+
170
+ # Part contains the root information for a component. Parts have attributes that can be changed over time
171
+ # (see PartRevision). Part numbers can be changed over time, but these cannot be tracked, as it is not a practice
172
+ # that should be done often.
173
+ class Part(OrganizationScopedModel):
174
+ number_class = models.ForeignKey(PartClass, default=None, blank=True, null=True, related_name='number_class', on_delete=models.CASCADE, db_index=True)
175
+ number_item = models.CharField(max_length=NUMBER_ITEM_LEN_MAX, default=None, blank=True)
176
+ number_variation = models.CharField(max_length=NUMBER_VARIATION_LEN_MAX, default=None, blank=True, null=True, validators=[alphanumeric])
177
+ primary_manufacturer_part = models.ForeignKey('ManufacturerPart', default=None, null=True, blank=True,
178
+ on_delete=models.SET_NULL, related_name='primary_manufacturer_part')
179
+ google_drive_parent = models.CharField(max_length=128, blank=True, default=None, null=True)
180
+
181
+ class Meta(OrganizationScopedModel.Meta):
182
+ unique_together = ['number_class', 'number_item', 'number_variation', 'organization', ]
183
+ indexes = [
184
+ models.Index(fields=['organization', 'number_class']),
185
+ ]
186
+
187
+ def full_part_number(self):
188
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
189
+ if self.organization.number_variation_len > 0:
190
+ return f"{self.number_class.code}-{self.number_item}-{self.number_variation}"
191
+ else:
192
+ return f"{self.number_class.code}-{self.number_item}"
193
+ else:
194
+ return self.number_item
195
+
196
+ @staticmethod
197
+ def _expect_length(value, expected_len, units_word, label):
198
+ if len(value) != expected_len:
199
+ raise AttributeError(f"Expect {expected_len} {units_word} for {label}")
200
+
201
+ @staticmethod
202
+ def verify_format_number_class(number_class, organization):
203
+ Part._expect_length(number_class, organization.number_class_code_len, 'digits', 'number class')
204
+ if number_class is not None:
205
+ for c in number_class:
206
+ if not (c.isdigit() or c.isalpha()):
207
+ raise AttributeError(f"{c} is not a proper character for a number class")
208
+ return number_class
209
+
210
+ @staticmethod
211
+ def verify_format_number_item(number_item, organization):
212
+ Part._expect_length(number_item, organization.number_item_len, 'digits', 'number item')
213
+ if number_item is not None:
214
+ for c in number_item:
215
+ if not c.isdigit():
216
+ raise AttributeError(f"{c} is not a proper character for a number item")
217
+ return number_item
218
+
219
+ @staticmethod
220
+ def verify_format_number_variation(number_variation, organization):
221
+ Part._expect_length(number_variation, organization.number_variation_len, 'characters', 'number variation')
222
+ if number_variation is not None:
223
+ for c in number_variation:
224
+ if not c.isalnum():
225
+ raise AttributeError(f"{c} is not a proper character for a number variation. Must be alphanumeric.")
226
+ return number_variation
227
+
228
+ @staticmethod
229
+ def parse_part_number(part_number, organization):
230
+ if part_number is None:
231
+ raise AttributeError("Cannot parse empty part number")
232
+ if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
233
+ try:
234
+ (number_class, number_item, number_variation) = Part.parse_partial_part_number(part_number, organization)
235
+ except IndexError:
236
+ raise AttributeError("Invalid part number. Does not match organization preferences.")
237
+
238
+ if number_class is None:
239
+ raise AttributeError("Missing part number part class")
240
+ if number_item is None:
241
+ raise AttributeError("Missing part number item number")
242
+ if number_variation is None and organization.number_class_code_len != 0 and organization.number_variation_len > 0:
243
+ raise AttributeError("Missing part number part item variation")
244
+
245
+ return number_class, number_item, number_variation
246
+ else:
247
+ return None, part_number, None
248
+
249
+ @staticmethod
250
+ def parse_partial_part_number(part_number, organization, validate=True):
251
+ if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
252
+ elements = part_number.split('-')
253
+
254
+ number_class = elements[0] if len(elements) >= 1 else None
255
+ number_item = elements[1] if len(elements) >= 2 else None
256
+ number_variation = elements[2] if len(elements) >= 3 else None
257
+
258
+ if validate:
259
+ if len(elements) >= 2:
260
+ number_class = Part.verify_format_number_class(elements[0], organization)
261
+ number_item = Part.verify_format_number_item(elements[1], organization)
262
+ if len(elements) >= 3:
263
+ number_variation = Part.verify_format_number_variation(elements[2], organization)
264
+
265
+ return number_class, number_item, number_variation
266
+ else:
267
+ return None, part_number, None
268
+
269
+ @classmethod
270
+ def from_part_number(cls, part_number, organization):
271
+ (number_class, number_item, number_variation) = Part.parse_part_number(part_number, organization)
272
+ if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
273
+ return Part.objects.get(
274
+ number_class__code=number_class,
275
+ number_class__organization=organization,
276
+ number_item=number_item,
277
+ number_variation=number_variation,
278
+ organization=organization
279
+ )
280
+ return Part.objects.get(
281
+ number_item=number_item,
282
+ organization=organization
283
+ )
284
+
285
+ @classmethod
286
+ def from_manufacturer_part_number(cls, manufacturer_part_number, organization):
287
+ part = Part.objects.filter(
288
+ primary_manufacturer_part__manufacturer_part_number=manufacturer_part_number,
289
+ organization=organization
290
+ )
291
+ if len(part) == 1:
292
+ return part[0]
293
+ elif len(part) == 0:
294
+ return None
295
+ else:
296
+ raise ValueError('Too many objects found')
297
+
298
+ def description(self):
299
+ return self.latest().description if self.latest() is not None else ''
300
+
301
+ def latest(self):
302
+ return self.revisions().order_by('-id').first()
303
+
304
+ def revisions(self):
305
+ return PartRevision.objects.filter(part=self)
306
+
307
+ def seller_parts(self, exclude_primary=False):
308
+ manufacturer_parts = ManufacturerPart.objects.filter(part=self)
309
+ q = SellerPart.objects.filter(manufacturer_part__in=manufacturer_parts).order_by('seller', 'minimum_order_quantity')\
310
+ .select_related('manufacturer_part').select_related('manufacturer_part__manufacturer').select_related('seller')
311
+ if exclude_primary and self.primary_manufacturer_part is not None and self.primary_manufacturer_part.optimal_seller():
312
+ return q.exclude(id=self.primary_manufacturer_part.optimal_seller().id)
313
+ return q
314
+
315
+ def manufacturer_parts(self, exclude_primary=False):
316
+ q = ManufacturerPart.objects.filter(part=self).select_related('manufacturer')
317
+ if exclude_primary and self.primary_manufacturer_part is not None and self.primary_manufacturer_part.optimal_seller():
318
+ return q.exclude(id=self.primary_manufacturer_part.id)
319
+ return q
320
+
321
+ def where_used(self):
322
+ revisions = PartRevision.objects.filter(part=self)
323
+ used_in_subparts = Subpart.objects.filter(part_revision__in=revisions)
324
+ used_in_assembly_ids = AssemblySubparts.objects.filter(subpart__in=used_in_subparts).values_list('assembly', flat=True)
325
+ used_in_prs = PartRevision.objects.filter(assembly__in=used_in_assembly_ids)
326
+ return used_in_prs
327
+
328
+ def where_used_full(self):
329
+ def where_used_given_part(used_in_parts, part):
330
+ where_used = part.where_used()
331
+ used_in_parts.update(where_used)
332
+ for p in where_used:
333
+ where_used_given_part(used_in_parts, p)
334
+ return used_in_parts
335
+
336
+ used_in_parts = set()
337
+ where_used_given_part(used_in_parts, self)
338
+ return list(used_in_parts)
339
+
340
+ def indented(self, part_revision=None):
341
+ if part_revision is None:
342
+ return self.latest().indented() if self.latest() is not None else None
343
+ else:
344
+ return part_revision.indented()
345
+
346
+ def optimal_seller(self, quantity=None):
347
+ if not quantity:
348
+ qty_cache_key = str(self.id) + '_qty'
349
+ quantity = int(cache.get(qty_cache_key, 100))
350
+
351
+ manufacturer_parts = ManufacturerPart.objects.filter(part=self)
352
+ sellerparts = SellerPart.objects.filter(manufacturer_part__in=manufacturer_parts)
353
+ # sellerparts = SellerPart.objects.filter(manufacturer_part__part=self)
354
+ return SellerPart.optimal(sellerparts, int(quantity))
355
+
356
+ def assign_part_number(self):
357
+ if self.number_item is None or self.number_item == '':
358
+ last_number_item = Part.objects.filter(
359
+ number_class=self.number_class,
360
+ organization=self.organization
361
+ ).order_by('number_item').last()
362
+ if not last_number_item:
363
+ self.number_item = str(1).zfill(self.organization.number_item_len)
364
+ else:
365
+ next_num = int(last_number_item.number_item) + 1
366
+ self.number_item = str(next_num).zfill(self.organization.number_item_len)
367
+ if (self.number_variation is None or self.number_variation == '') and self.organization.number_variation_len > 0:
368
+ last_number_variation = Part.objects.filter(
369
+ number_class=self.number_class,
370
+ number_item=self.number_item
371
+ ).order_by('number_variation').last()
372
+
373
+ if not last_number_variation:
374
+ self.number_variation = '0'.zfill(self.organization.number_variation_len)
375
+ else:
376
+ try:
377
+ next_var = int(last_number_variation.number_variation) + 1
378
+ self.number_variation = str(next_var).zfill(self.organization.number_variation_len)
379
+ except ValueError:
380
+ self.number_variation = f"{increment_str(last_number_variation.number_variation)}"
381
+
382
+ def save(self, *args, **kwargs):
383
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
384
+ self.assign_part_number()
385
+ super(Part, self).save()
386
+
387
+ def verbose_str(self):
388
+ return f'{self.full_part_number()} ┆ {self.description()}'
389
+
390
+ def __str__(self):
391
+ return u'%s' % (self.full_part_number())
392
+
393
+
394
+ # Below are attributes of a part that can be changed, but it's important to trace the change over time
395
+ class PartRevision(models.Model):
396
+ part = models.ForeignKey(Part, on_delete=models.CASCADE, db_index=True)
397
+ timestamp = models.DateTimeField(default=timezone.now)
398
+ configuration = models.CharField(max_length=1, choices=CONFIGURATION_TYPES, default='W')
399
+ revision = models.CharField(max_length=4, db_index=True, default='1')
400
+ assembly = models.ForeignKey('Assembly', default=None, null=True, on_delete=models.CASCADE, db_index=True)
401
+ displayable_synopsis = models.CharField(editable=False, default="", null=True, blank=True, max_length=255, db_index=True)
402
+ searchable_synopsis = models.CharField(editable=False, default="", null=True, blank=True, max_length=255, db_index=True)
403
+
404
+ class Meta:
405
+ unique_together = (('part', 'revision'),)
406
+ ordering = ['part']
407
+
408
+ # Part Revision Specification Properties:
409
+
410
+ description = models.CharField(max_length=255, default="", null=True, blank=True)
411
+
412
+ # By convention for IndaBOM, for part revision properties below, if a property value has
413
+ # an associated units of measure, and if the property value field name is 'vvv' then the
414
+ # associated units of measure field name must be 'vvv_units'.
415
+ value_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VALUE_UNITS)
416
+ value = models.CharField(max_length=255, default=None, null=True, blank=True)
417
+ attribute = models.CharField(max_length=255, default=None, null=True, blank=True)
418
+ pin_count = models.DecimalField(max_digits=3, decimal_places=0, default=None, null=True, blank=True)
419
+ tolerance = models.CharField(max_length=6, validators=[validate_pct], default=None, null=True, blank=True)
420
+ package = models.CharField(max_length=16, default=None, null=True, blank=True, choices=PACKAGE_TYPES)
421
+ material = models.CharField(max_length=32, default=None, null=True, blank=True)
422
+ finish = models.CharField(max_length=32, default=None, null=True, blank=True)
423
+ color = models.CharField(max_length=32, default=None, null=True, blank=True)
424
+ length_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
425
+ length = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
426
+ width_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
427
+ width = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
428
+ height_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
429
+ height = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
430
+ weight_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=WEIGHT_UNITS)
431
+ weight = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
432
+ temperature_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=TEMPERATURE_UNITS)
433
+ temperature_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
434
+ temperature_rating_range_max = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
435
+ temperature_rating_range_min = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
436
+ wavelength_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=WAVELENGTH_UNITS)
437
+ wavelength = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
438
+ frequency_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=FREQUENCY_UNITS)
439
+ frequency = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
440
+ memory_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=MEMORY_UNITS)
441
+ memory = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
442
+ interface = models.CharField(max_length=12, default=None, null=True, blank=True, choices=INTERFACE_TYPES)
443
+ power_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=POWER_UNITS)
444
+ power_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
445
+ supply_voltage_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VOLTAGE_UNITS)
446
+ supply_voltage = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
447
+ voltage_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VOLTAGE_UNITS)
448
+ voltage_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
449
+ current_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=CURRENT_UNITS)
450
+ current_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
451
+
452
+ def generate_synopsis(self, make_searchable=False):
453
+ def verbosify(val, units=None, pre=None, pre_whitespace=True, post=None, post_whitespace=True):
454
+ elaborated = ""
455
+ if val is not None and val != '':
456
+ try:
457
+ elaborated = strip_trailing_zeros(str(val))
458
+ if units is not None and units != '': elaborated += units
459
+ if pre is not None and pre != '':
460
+ elaborated = pre + (' ' if pre_whitespace else '') + elaborated
461
+ if post is not None and post != '': elaborated += (' ' if post_whitespace else '') + post
462
+ elaborated = elaborated + ' '
463
+ except ValueError:
464
+ pass
465
+ return elaborated
466
+
467
+ s = ""
468
+ s += verbosify(self.value, units=self.value_units if make_searchable else self.get_value_units_display())
469
+ s += verbosify(self.description)
470
+ tolerance = self.tolerance.replace('%', '') if self.tolerance else ''
471
+ s += verbosify(tolerance, post='%', post_whitespace=False)
472
+ s += verbosify(self.attribute)
473
+ s += verbosify(self.package if make_searchable else self.get_package_display())
474
+ s += verbosify(self.pin_count, post='pins')
475
+ s += verbosify(self.frequency, units=self.frequency_units if make_searchable else self.get_frequency_units_display())
476
+ s += verbosify(self.wavelength, units=self.wavelength_units if make_searchable else self.get_wavelength_units_display())
477
+ s += verbosify(self.memory, units=self.memory_units if make_searchable else self.get_memory_units_display())
478
+ s += verbosify(self.interface if make_searchable else self.get_interface_display())
479
+ s += verbosify(self.supply_voltage, units=self.supply_voltage_units if make_searchable else self.get_supply_voltage_units_display(), post='supply')
480
+ s += verbosify(self.temperature_rating, units=self.temperature_rating_units if make_searchable else self.get_temperature_rating_units_display(), post='rating')
481
+ s += verbosify(self.power_rating, units=self.power_rating_units if make_searchable else self.get_power_rating_units_display(), post='rating')
482
+ s += verbosify(self.voltage_rating, units=self.voltage_rating_units if make_searchable else self.get_voltage_rating_units_display(), post='rating')
483
+ s += verbosify(self.current_rating, units=self.current_rating_units if make_searchable else self.get_current_rating_units_display(), post='rating')
484
+ s += verbosify(self.material)
485
+ s += verbosify(self.color)
486
+ s += verbosify(self.finish)
487
+ s += verbosify(self.length, units=self.length_units if make_searchable else self.get_length_units_display(), pre='L')
488
+ s += verbosify(self.width, units=self.width_units if make_searchable else self.get_width_units_display(), pre='W')
489
+ s += verbosify(self.height, units=self.height_units if make_searchable else self.get_height_units_display(), pre='H')
490
+ s += verbosify(self.weight, units=self.weight_units if make_searchable else self.get_weight_units_display())
491
+ return s[:255]
492
+
493
+ def synopsis(self, return_displayable=True):
494
+ return self.displayable_synopsis if return_displayable else self.searchable_synopsis
495
+
496
+ def save(self, *args, **kwargs):
497
+ if self.tolerance:
498
+ self.tolerance = self.tolerance.replace('%', '')
499
+ if self.assembly is None:
500
+ assy = Assembly.objects.create()
501
+ self.assembly = assy
502
+ # if self.id:
503
+ # previous_configuration = PartRevision.objects.get(id=self.id).configuration
504
+ # if self.configuration != previous_configuration:
505
+ # self.timestamp = timezone.now()
506
+ self.searchable_synopsis = self.generate_synopsis(True)
507
+ self.displayable_synopsis = self.generate_synopsis(False)
508
+ super(PartRevision, self).save(*args, **kwargs)
509
+
510
+ def indented(self, top_level_quantity=100):
511
+ 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):
512
+ bom_item_id = (parent_id or '') + (str(part_revision.id) + '-dnl' if do_not_load else str(part_revision.id))
513
+ extended_quantity = parent_qty * qty
514
+ total_extended_quantity = top_level_quantity * extended_quantity
515
+
516
+ try:
517
+ seller_part = part_revision.part.optimal_seller(quantity=total_extended_quantity)
518
+ except AttributeError:
519
+ seller_part = None
520
+
521
+ bom.append_item_and_update(PartIndentedBomItem(
522
+ bom_id=bom_item_id,
523
+ part=part_revision.part,
524
+ part_revision=part_revision,
525
+ do_not_load=do_not_load,
526
+ references=reference,
527
+ quantity=qty,
528
+ extended_quantity=extended_quantity,
529
+ parent_quantity=parent_qty, # Do we need this?
530
+ indent_level=indent_level,
531
+ parent_id=parent_id,
532
+ subpart=subpart,
533
+ seller_part=seller_part,
534
+ ))
535
+
536
+ indent_level = indent_level + 1
537
+ if part_revision is None or part_revision.assembly is None or part_revision.assembly.subparts.count() == 0:
538
+ return
539
+ else:
540
+ parent_qty *= qty
541
+ # TODO: Cache Me!
542
+ for sp in part_revision.assembly.subparts.all():
543
+ qty = sp.count
544
+ reference = sp.reference
545
+ indented_given_bom(bom, sp.part_revision, parent_id=bom_item_id, parent=part_revision, qty=qty, parent_qty=parent_qty,
546
+ indent_level=indent_level, subpart=sp, reference=reference, do_not_load=sp.do_not_load)
547
+
548
+ bom = PartBom(part_revision=self, quantity=top_level_quantity)
549
+ indented_given_bom(bom, self)
550
+
551
+ return bom
552
+
553
+ def flat(self, top_level_quantity=100, sort=False):
554
+ def flat_given_bom(bom, part_revision, parent=None, qty=1, parent_qty=1, subpart=None, reference=''):
555
+ extended_quantity = parent_qty * qty
556
+ total_extended_quantity = top_level_quantity * extended_quantity
557
+
558
+ try:
559
+ seller_part = part_revision.part.optimal_seller(quantity=total_extended_quantity)
560
+ except AttributeError:
561
+ seller_part = None
562
+
563
+ try:
564
+ do_not_load = subpart.do_not_load
565
+ except AttributeError:
566
+ do_not_load = False
567
+
568
+ bom_item_id = str(part_revision.id) + '-dnl' if do_not_load else str(part_revision.id)
569
+ bom.append_item_and_update(PartBomItem(
570
+ bom_id=bom_item_id,
571
+ part=part_revision.part,
572
+ part_revision=part_revision,
573
+ do_not_load=do_not_load,
574
+ references=reference,
575
+ quantity=qty,
576
+ extended_quantity=extended_quantity,
577
+ seller_part=seller_part,
578
+ ))
579
+
580
+ if part_revision is None or part_revision.assembly is None or part_revision.assembly.subparts.count() == 0:
581
+ return
582
+ else:
583
+ parent_qty *= qty
584
+ for sp in part_revision.assembly.subparts.all():
585
+ qty = sp.count
586
+ reference = sp.reference
587
+ flat_given_bom(bom, sp.part_revision, parent=part_revision, qty=qty, parent_qty=parent_qty, subpart=sp, reference=reference)
588
+
589
+ flat_bom = PartBom(part_revision=self, quantity=top_level_quantity)
590
+ flat_given_bom(flat_bom, self)
591
+
592
+ # Sort by references, if no references then use part number.
593
+ # Note that need to convert part number to a list so can be compared with the
594
+ # list-ified string returned by prep_for_sorting_nicely.
595
+ def sort_by_references(p):
596
+ return prep_for_sorting_nicely(p.references) if p.references else p.__str__().split()
597
+ if sort:
598
+ flat_bom.parts = sorted(flat_bom.parts.values(), key=sort_by_references)
599
+ return flat_bom
600
+
601
+ def where_used(self):
602
+ # Where is a part_revision used???
603
+ # it gets used by being a subpart to an assembly of a part_revision
604
+ # so we can look up subparts, then their assemblys, then their partrevisions
605
+ used_in_subparts = Subpart.objects.filter(part_revision=self)
606
+ used_in_assembly_ids = AssemblySubparts.objects.filter(subpart__in=used_in_subparts).values_list('assembly', flat=True)
607
+ used_in_pr = PartRevision.objects.filter(assembly__in=used_in_assembly_ids).order_by('-revision')
608
+ return used_in_pr
609
+
610
+ def where_used_full(self):
611
+ def where_used_given_part(used_in_parts, part):
612
+ where_used = part.where_used()
613
+ used_in_parts.update(where_used)
614
+ for p in where_used:
615
+ where_used_given_part(used_in_parts, p)
616
+ return used_in_parts
617
+
618
+ used_in_parts = set()
619
+ where_used_given_part(used_in_parts, self)
620
+ return list(used_in_parts)
621
+
622
+ def next_revision(self):
623
+ try:
624
+ return int(self.revision) + 1
625
+ except ValueError:
626
+ return increment_str(self.revision)
627
+
628
+ def __str__(self):
629
+ return u'{}, Rev {}'.format(self.part.full_part_number(), self.revision)
630
+
631
+
632
+ class AssemblySubparts(models.Model):
633
+ assembly = models.ForeignKey('Assembly', models.CASCADE)
634
+ subpart = models.ForeignKey('Subpart', models.CASCADE)
635
+
636
+ class Meta:
637
+ db_table = 'bom_assembly_subparts'
638
+ unique_together = (('assembly', 'subpart'),)
639
+
640
+
641
+ class Subpart(models.Model):
642
+ part_revision = models.ForeignKey('PartRevision', related_name='assembly_subpart', null=True, on_delete=models.CASCADE)
643
+ count = models.FloatField(default=1, validators=[MinValueValidator(0)])
644
+ reference = models.TextField(default='', blank=True, null=True)
645
+ do_not_load = models.BooleanField(default=False, verbose_name='Do Not Load')
646
+
647
+ def save(self, *args, **kwargs):
648
+ # Make sure reference designators are formated as a string with comma-separated fields.
649
+ try:
650
+ reference = stringify_list(listify_string(self.reference))
651
+ self.reference = reference
652
+ except TypeError:
653
+ pass
654
+ super(Subpart, self).save(*args, **kwargs)
655
+
656
+ def __str__(self):
657
+ return u'{} {}'.format(self.part_revision, self.count)
658
+
659
+
660
+ class Assembly(models.Model):
661
+ subparts = models.ManyToManyField(Subpart, related_name='assemblies', through='AssemblySubparts')
662
+
663
+
664
+ class ManufacturerPart(models.Model, AsDictModel):
665
+ part = models.ForeignKey(Part, on_delete=models.CASCADE, db_index=True)
666
+ manufacturer_part_number = models.CharField(max_length=128, default='', blank=True)
667
+ manufacturer = models.ForeignKey(Manufacturer, default=None, blank=True, null=True, on_delete=models.CASCADE)
668
+ mouser_disable = models.BooleanField(default=False)
669
+ link = models.URLField(null=True, blank=True)
670
+
671
+ class Meta:
672
+ unique_together = [
673
+ 'part',
674
+ 'manufacturer_part_number',
675
+ 'manufacturer']
676
+
677
+ def seller_parts(self):
678
+ return SellerPart.objects.filter(manufacturer_part=self).order_by('seller', 'minimum_order_quantity')
679
+
680
+ def optimal_seller(self, quantity=None):
681
+ if quantity is None:
682
+ qty_cache_key = str(self.part.id) + '_qty'
683
+ quantity = int(cache.get(qty_cache_key, 100))
684
+ sellerparts = SellerPart.objects.filter(manufacturer_part=self)
685
+ return SellerPart.optimal(sellerparts, quantity)
686
+
687
+ def as_dict_for_export(self):
688
+ return {
689
+ 'manufacturer_name': self.manufacturer.name if self.manufacturer is not None else '',
690
+ 'manufacturer_part_number': self.manufacturer_part_number
691
+ }
692
+
693
+ def __str__(self):
694
+ return u'%s' % (self.manufacturer_part_number)
695
+
696
+
697
+ class Seller(OrganizationScopedModel, AsDictModel):
698
+ name = models.CharField(max_length=128, default=None)
699
+
700
+ def __str__(self):
701
+ return u'%s' % (self.name)
702
+
703
+
704
+ class SellerPart(models.Model, AsDictModel):
705
+ seller = models.ForeignKey(Seller, on_delete=models.CASCADE)
706
+ seller_part_number = models.CharField(max_length=64, default='', blank=True, null=True)
707
+ manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE)
708
+ minimum_order_quantity = models.PositiveIntegerField(default=1)
709
+ minimum_pack_quantity = models.PositiveIntegerField(default=1)
710
+ data_source = models.CharField(max_length=32, default=None, null=True, blank=True)
711
+ # "To comply with certain strict accounting or financial regulations, you may consider using max_digits=19 and decimal_places=4"
712
+ unit_cost = MoneyField(max_digits=19, decimal_places=4, default_currency='USD')
713
+ lead_time_days = models.PositiveIntegerField(null=True, blank=True)
714
+ nre_cost = MoneyField(max_digits=19, decimal_places=4, default_currency='USD')
715
+ link = models.URLField(null=True, blank=True)
716
+ ncnr = models.BooleanField(default=False)
717
+
718
+ def as_dict(self):
719
+ d = super().as_dict()
720
+ d['unit_cost'] = self.unit_cost.amount
721
+ d['nre_cost'] = self.nre_cost.amount
722
+ return d
723
+
724
+ def as_dict_for_export(self):
725
+ return {
726
+ 'manufacturer_name': self.manufacturer_part.manufacturer.name if self.manufacturer_part.manufacturer is not None else '',
727
+ 'manufacturer_part_number': self.manufacturer_part.manufacturer_part_number,
728
+ 'seller': self.seller.name,
729
+ 'seller_part_number': self.seller_part_number,
730
+ 'unit_cost': self.unit_cost,
731
+ 'minimum_order_quantity': self.minimum_order_quantity,
732
+ 'nre_cost': self.nre_cost
733
+ }
734
+
735
+ @staticmethod
736
+ def optimal(sellerparts, quantity):
737
+ seller = None
738
+ for sellerpart in sellerparts:
739
+ if seller is None:
740
+ seller = sellerpart
741
+ else:
742
+ new_quantity = quantity if sellerpart.minimum_order_quantity < quantity else sellerpart.minimum_order_quantity
743
+ new_total_cost = new_quantity * sellerpart.unit_cost
744
+ old_quantity = quantity if seller.minimum_order_quantity < quantity else seller.minimum_order_quantity
745
+ old_total_cost = old_quantity * seller.unit_cost
746
+ if new_total_cost < old_total_cost:
747
+ seller = sellerpart
748
+ return seller
749
+
750
+ def order_quantity(self, extended_quantity):
751
+ order_qty = extended_quantity
752
+ if self.minimum_order_quantity and extended_quantity > self.minimum_order_quantity:
753
+ order_qty = ceil(extended_quantity / float(self.minimum_order_quantity)) * self.minimum_order_quantity
754
+ return order_qty
755
+
756
+ def __str__(self):
757
+ return u'%s' % (self.manufacturer_part.part.full_part_number() + ' ' + self.seller.name)