django-bom 1.235__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.

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