django-bom 1.262__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-bom might be problematic. Click here for more details.

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