django-bom 1.237__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. bom/__init__.py +1 -0
  2. bom/admin.py +161 -0
  3. bom/apps.py +8 -0
  4. bom/base_classes.py +31 -0
  5. bom/constants.py +210 -0
  6. bom/context_processors.py +9 -0
  7. bom/csv_headers.py +274 -0
  8. bom/decorators.py +32 -0
  9. bom/form_fields.py +59 -0
  10. bom/forms.py +1400 -0
  11. bom/helpers.py +292 -0
  12. bom/local_settings.py +35 -0
  13. bom/migrations/0001_initial.py +135 -0
  14. bom/migrations/0002_auto_20180908_2151.py +24 -0
  15. bom/migrations/0003_sellerpart_data_source.py +18 -0
  16. bom/migrations/0004_auto_20180911_0011.py +18 -0
  17. bom/migrations/0005_auto_20181007_1934.py +56 -0
  18. bom/migrations/0006_auto_20181007_1949.py +41 -0
  19. bom/migrations/0007_auto_20181009_0256.py +19 -0
  20. bom/migrations/0008_auto_20181030_0427.py +19 -0
  21. bom/migrations/0009_subpart_reference.py +18 -0
  22. bom/migrations/0010_auto_20181202_0733.py +23 -0
  23. bom/migrations/0011_auto_20181202_2113.py +22 -0
  24. bom/migrations/0012_partchangehistory.py +30 -0
  25. bom/migrations/0013_auto_20190222_1631.py +19 -0
  26. bom/migrations/0014_auto_20190223_2353.py +18 -0
  27. bom/migrations/0015_auto_20190303_1915.py +136 -0
  28. bom/migrations/0016_auto_20190405_2308.py +58 -0
  29. bom/migrations/0017_auto_20190616_1912.py +19 -0
  30. bom/migrations/0018_auto_20190616_2143.py +24 -0
  31. bom/migrations/0019_auto_20190624_1246.py +45 -0
  32. bom/migrations/0020_auto_20190627_0207.py +38 -0
  33. bom/migrations/0021_auto_20190627_0428.py +23 -0
  34. bom/migrations/0022_auto_20190811_2140.py +35 -0
  35. bom/migrations/0023_auto_20191205_2351.py +255 -0
  36. bom/migrations/0024_auto_20191214_1342.py +89 -0
  37. bom/migrations/0025_auto_20191221_1907.py +38 -0
  38. bom/migrations/0026_auto_20191222_2258.py +22 -0
  39. bom/migrations/0027_auto_20191222_2347.py +17 -0
  40. bom/migrations/0028_partrevision_displayable_synopsis.py +74 -0
  41. bom/migrations/0029_auto_20191231_1630.py +23 -0
  42. bom/migrations/0030_auto_20200101_2253.py +22 -0
  43. bom/migrations/0031_auto_20200104_1352.py +38 -0
  44. bom/migrations/0032_auto_20200126_1806.py +27 -0
  45. bom/migrations/0033_auto_20200203_0618.py +29 -0
  46. bom/migrations/0034_auto_20200222_0359.py +30 -0
  47. bom/migrations/0035_auto_20200303_0111.py +34 -0
  48. bom/migrations/0036_auto_20200303_0538.py +17 -0
  49. bom/migrations/0037_auto_20200405_1642.py +44 -0
  50. bom/migrations/0038_auto_20200422_0504.py +19 -0
  51. bom/migrations/0039_auto_20200929_2315.py +41 -0
  52. bom/migrations/0040_alter_organization_currency.py +19 -0
  53. bom/migrations/0041_organization_subscription_quantity.py +18 -0
  54. bom/migrations/0042_auto_20210720_2137.py +23 -0
  55. bom/migrations/0043_auto_20211123_0157.py +24 -0
  56. bom/migrations/0044_auto_20220831_1241.py +23 -0
  57. bom/migrations/0045_sellerpart_link.py +18 -0
  58. bom/migrations/0046_alter_sellerpart_unique_together.py +17 -0
  59. bom/migrations/0047_sellerpart_seller_part_number.py +18 -0
  60. bom/migrations/0048_rename_part_organization_number_class_bom_part_organiz_b333d6_idx_and_more.py +1017 -0
  61. bom/migrations/0049_alter_assembly_id_alter_assemblysubparts_id_and_more.py +99 -0
  62. bom/migrations/__init__.py +0 -0
  63. bom/models.py +757 -0
  64. bom/part_bom.py +192 -0
  65. bom/settings.py +264 -0
  66. bom/static/bom/css/dashboard.css +17 -0
  67. bom/static/bom/css/jquery.treetable.css +28 -0
  68. bom/static/bom/css/materialize.min.css +13 -0
  69. bom/static/bom/css/part-info.css +15 -0
  70. bom/static/bom/css/style.css +319 -0
  71. bom/static/bom/css/tablesorter-theme.materialize.css +176 -0
  72. bom/static/bom/css/treetable-theme.css +42 -0
  73. bom/static/bom/doc/sample_part_classes.csv +38 -0
  74. bom/static/bom/doc/test_bom.csv +6 -0
  75. bom/static/bom/doc/test_bom_5_intelligent.csv +4 -0
  76. bom/static/bom/doc/test_full_bom.csv +37 -0
  77. bom/static/bom/doc/test_new_parts.csv +5 -0
  78. bom/static/bom/doc/test_new_parts_5_intelligent.csv +5 -0
  79. bom/static/bom/img/_ionicons_svg_md-arrow-dropdown.svg +1 -0
  80. bom/static/bom/img/_ionicons_svg_md-arrow-dropright.svg +1 -0
  81. bom/static/bom/img/favicon.ico +0 -0
  82. bom/static/bom/img/google/web/1x/btn_google_signin_dark_disabled_web.png +0 -0
  83. bom/static/bom/img/google/web/1x/btn_google_signin_dark_focus_web.png +0 -0
  84. bom/static/bom/img/google/web/1x/btn_google_signin_dark_normal_web.png +0 -0
  85. bom/static/bom/img/google/web/1x/btn_google_signin_dark_pressed_web.png +0 -0
  86. bom/static/bom/img/google/web/1x/btn_google_signin_light_disabled_web.png +0 -0
  87. bom/static/bom/img/google/web/1x/btn_google_signin_light_focus_web.png +0 -0
  88. bom/static/bom/img/google/web/1x/btn_google_signin_light_normal_web.png +0 -0
  89. bom/static/bom/img/google/web/1x/btn_google_signin_light_pressed_web.png +0 -0
  90. bom/static/bom/img/google/web/2x/btn_google_signin_dark_disabled_web@2x.png +0 -0
  91. bom/static/bom/img/google/web/2x/btn_google_signin_dark_focus_web@2x.png +0 -0
  92. bom/static/bom/img/google/web/2x/btn_google_signin_dark_normal_web@2x.png +0 -0
  93. bom/static/bom/img/google/web/2x/btn_google_signin_dark_pressed_web@2x.png +0 -0
  94. bom/static/bom/img/google/web/2x/btn_google_signin_light_disabled_web@2x.png +0 -0
  95. bom/static/bom/img/google/web/2x/btn_google_signin_light_focus_web@2x.png +0 -0
  96. bom/static/bom/img/google/web/2x/btn_google_signin_light_normal_web@2x.png +0 -0
  97. bom/static/bom/img/google/web/2x/btn_google_signin_light_pressed_web@2x.png +0 -0
  98. bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.eps +814 -0
  99. bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.svg +24 -0
  100. bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.eps +1866 -0
  101. bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.svg +51 -0
  102. bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.eps +1031 -0
  103. bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.svg +50 -0
  104. bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.eps +1031 -0
  105. bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.svg +50 -0
  106. bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.eps +814 -0
  107. bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.svg +24 -0
  108. bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.eps +1837 -0
  109. bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.svg +44 -0
  110. bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.eps +1002 -0
  111. bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.svg +43 -0
  112. bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.eps +1002 -0
  113. bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.svg +43 -0
  114. bom/static/bom/img/google_drive_logo.svg +1 -0
  115. bom/static/bom/img/indabom.png +0 -0
  116. bom/static/bom/img/mouser.png +0 -0
  117. bom/static/bom/img/octopart_blue.svg +19 -0
  118. bom/static/bom/js/jquery-3.4.1.min.js +2 -0
  119. bom/static/bom/js/jquery.ba-floatingscrollbar.min.js +10 -0
  120. bom/static/bom/js/jquery.treetable.js +629 -0
  121. bom/static/bom/js/materialize.min.js +6 -0
  122. bom/templates/bom/account-delete.html +23 -0
  123. bom/templates/bom/add-manufacturer-part.html +66 -0
  124. bom/templates/bom/add-sellerpart.html +93 -0
  125. bom/templates/bom/base-menu.html +16 -0
  126. bom/templates/bom/base.html +129 -0
  127. bom/templates/bom/bom-action-btn.html +23 -0
  128. bom/templates/bom/bom-action-table.html +57 -0
  129. bom/templates/bom/bom-base-menu.html +6 -0
  130. bom/templates/bom/bom-base.html +24 -0
  131. bom/templates/bom/bom-form-modal.html +36 -0
  132. bom/templates/bom/bom-form.html +30 -0
  133. bom/templates/bom/bom-signup.html +12 -0
  134. bom/templates/bom/components/bom-flat.html +131 -0
  135. bom/templates/bom/components/bom-indented.html +237 -0
  136. bom/templates/bom/components/manufacturer-part-list.html +270 -0
  137. bom/templates/bom/components/seller-part-list.html +62 -0
  138. bom/templates/bom/create-part.html +65 -0
  139. bom/templates/bom/dashboard-menu.html +15 -0
  140. bom/templates/bom/dashboard.html +303 -0
  141. bom/templates/bom/edit-manufacturer-part.html +72 -0
  142. bom/templates/bom/edit-part-class.html +33 -0
  143. bom/templates/bom/edit-part.html +67 -0
  144. bom/templates/bom/edit-user-meta.html +38 -0
  145. bom/templates/bom/help.html +1356 -0
  146. bom/templates/bom/manufacturer-info.html +82 -0
  147. bom/templates/bom/manufacturers.html +97 -0
  148. bom/templates/bom/nothing-to-see.html +15 -0
  149. bom/templates/bom/organization-create.html +132 -0
  150. bom/templates/bom/part-info.html +439 -0
  151. bom/templates/bom/part-revision-display.html +184 -0
  152. bom/templates/bom/part-revision-edit.html +39 -0
  153. bom/templates/bom/part-revision-manage-bom.html +115 -0
  154. bom/templates/bom/part-revision-new.html +57 -0
  155. bom/templates/bom/part-revision-release.html +41 -0
  156. bom/templates/bom/search-help.html +101 -0
  157. bom/templates/bom/seller-info.html +82 -0
  158. bom/templates/bom/sellers.html +97 -0
  159. bom/templates/bom/settings.html +408 -0
  160. bom/templates/bom/signup.html +28 -0
  161. bom/templates/bom/table_of_contents.html +47 -0
  162. bom/templates/bom/upload-bom.html +111 -0
  163. bom/templates/bom/upload-parts-help.html +103 -0
  164. bom/templates/bom/upload-parts.html +50 -0
  165. bom/templates/registration/login.html +39 -0
  166. bom/tests.py +1506 -0
  167. bom/third_party_apis/__init__.py +0 -0
  168. bom/third_party_apis/base_api.py +51 -0
  169. bom/third_party_apis/google_drive.py +166 -0
  170. bom/third_party_apis/mouser.py +132 -0
  171. bom/third_party_apis/test_apis.py +24 -0
  172. bom/urls.py +94 -0
  173. bom/utils.py +228 -0
  174. bom/validators.py +23 -0
  175. bom/views/__init__.py +0 -0
  176. bom/views/json_views.py +55 -0
  177. bom/views/views.py +1620 -0
  178. bom/wsgi.py +16 -0
  179. django_bom-1.237.dist-info/METADATA +206 -0
  180. django_bom-1.237.dist-info/RECORD +183 -0
  181. django_bom-1.237.dist-info/WHEEL +5 -0
  182. django_bom-1.237.dist-info/licenses/LICENSE +674 -0
  183. django_bom-1.237.dist-info/top_level.txt +1 -0
bom/forms.py ADDED
@@ -0,0 +1,1400 @@
1
+ import codecs
2
+ import csv
3
+ import logging
4
+ from typing import Type, TypeVar
5
+
6
+ from django import forms
7
+ from django.contrib.auth.forms import UserCreationForm
8
+ from django.core.exceptions import ValidationError
9
+ from django.core.validators import MaxLengthValidator, MaxValueValidator, MinLengthValidator, MinValueValidator
10
+ from django.db import IntegrityError
11
+ from django.forms.models import model_to_dict
12
+ from django.utils.translation import gettext_lazy as _
13
+
14
+ from djmoney.money import Money
15
+
16
+ from .constants import (
17
+ CONFIGURATION_TYPES,
18
+ CURRENT_UNITS,
19
+ DISTANCE_UNITS,
20
+ FREQUENCY_UNITS,
21
+ INTERFACE_TYPES,
22
+ MEMORY_UNITS,
23
+ NUMBER_CLASS_CODE_LEN_DEFAULT,
24
+ NUMBER_CLASS_CODE_LEN_MAX,
25
+ NUMBER_CLASS_CODE_LEN_MIN,
26
+ NUMBER_ITEM_LEN_DEFAULT,
27
+ NUMBER_ITEM_LEN_MAX,
28
+ NUMBER_ITEM_LEN_MIN,
29
+ NUMBER_SCHEME_INTELLIGENT,
30
+ NUMBER_SCHEME_SEMI_INTELLIGENT,
31
+ NUMBER_VARIATION_LEN_DEFAULT,
32
+ NUMBER_VARIATION_LEN_MAX,
33
+ NUMBER_VARIATION_LEN_MIN,
34
+ PACKAGE_TYPES,
35
+ POWER_UNITS,
36
+ ROLE_TYPE_VIEWER,
37
+ ROLE_TYPES,
38
+ SUBSCRIPTION_TYPES,
39
+ TEMPERATURE_UNITS,
40
+ VALUE_UNITS,
41
+ VOLTAGE_UNITS,
42
+ WAVELENGTH_UNITS,
43
+ WEIGHT_UNITS,
44
+ )
45
+ from .csv_headers import (
46
+ BOMFlatCSVHeaders,
47
+ BOMIndentedCSVHeaders,
48
+ CSVHeaderError,
49
+ PartClassesCSVHeaders,
50
+ PartsListCSVHeaders,
51
+ )
52
+ from .form_fields import AutocompleteTextInput
53
+ from .models import (
54
+ Assembly,
55
+ AssemblySubparts,
56
+ Manufacturer,
57
+ ManufacturerPart,
58
+ Organization,
59
+ Part,
60
+ PartClass,
61
+ PartRevision,
62
+ Seller,
63
+ SellerPart,
64
+ Subpart,
65
+ User,
66
+ UserMeta,
67
+ )
68
+ from .utils import (
69
+ check_references_for_duplicates,
70
+ get_from_dict,
71
+ listify_string,
72
+ prep_for_sorting_nicely,
73
+ stringify_list,
74
+ )
75
+ from .validators import alphanumeric, decimal, numeric
76
+
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+
81
+ class UserModelChoiceField(forms.ModelChoiceField):
82
+ def label_from_instance(self, user):
83
+ l = "[" + user.username + "]"
84
+ if user.first_name:
85
+ l += " " + user.first_name
86
+ if user.last_name:
87
+ l += " " + user.last_name
88
+ if user.email:
89
+ l += ", " + user.email
90
+ return l
91
+
92
+
93
+ class UserCreateForm(UserCreationForm):
94
+ first_name = forms.CharField(required=True)
95
+ last_name = forms.CharField(required=True)
96
+ email = forms.EmailField(required=True)
97
+
98
+ def clean_email(self):
99
+ email = self.cleaned_data['email']
100
+ exists = User.objects.filter(email__iexact=email).count() > 0
101
+ if exists:
102
+ raise ValidationError('An account with this email address already exists.')
103
+ return email
104
+
105
+ def save(self, commit=True):
106
+ user = super(UserCreateForm, self).save(commit=commit)
107
+ user.email = self.cleaned_data['email']
108
+ user.first_name = self.cleaned_data['first_name']
109
+ user.last_name = self.cleaned_data['last_name']
110
+ user.save()
111
+ return user
112
+
113
+
114
+ class UserForm(forms.ModelForm):
115
+ class Meta:
116
+ model = User
117
+ fields = ['first_name', 'last_name', 'email', ]
118
+
119
+
120
+ class UserAddForm(forms.ModelForm):
121
+ class Meta:
122
+ model = UserMeta
123
+ fields = ['role']
124
+
125
+ field_order = ['username', 'role', ]
126
+ username = forms.CharField(initial=None, required=False)
127
+
128
+ def __init__(self, *args, **kwargs):
129
+ self.organization = kwargs.pop('organization', None)
130
+ super(UserAddForm, self).__init__(*args, **kwargs)
131
+ self.fields['role'].required = False
132
+
133
+ def clean_username(self):
134
+ cleaned_data = super(UserAddForm, self).clean()
135
+ username = cleaned_data.get('username')
136
+ try:
137
+ user = User.objects.get(username=username)
138
+ user_meta = UserMeta.objects.get(user=user)
139
+ if user_meta.organization == self.organization:
140
+ validation_error = forms.ValidationError("User '{0}' already belongs to {1}.".format(username, self.organization), code='invalid')
141
+ self.add_error('username', validation_error)
142
+ elif user_meta.organization:
143
+ validation_error = forms.ValidationError("User '{}' belongs to another organization.".format(username), code='invalid')
144
+ self.add_error('username', validation_error)
145
+ except User.DoesNotExist:
146
+ validation_error = forms.ValidationError("User '{}' does not exist.".format(username), code='invalid')
147
+ self.add_error('username', validation_error)
148
+
149
+ return username
150
+
151
+ def clean_role(self):
152
+ cleaned_data = super(UserAddForm, self).clean()
153
+ role = cleaned_data.get('role', None)
154
+ if not role:
155
+ role = ROLE_TYPE_VIEWER
156
+ return role
157
+
158
+ def save(self, *args, **kwargs):
159
+ username = self.cleaned_data.get('username')
160
+ role = self.cleaned_data.get('role', ROLE_TYPE_VIEWER)
161
+ user = User.objects.get(username=username)
162
+ user_meta = user.bom_profile()
163
+ user_meta.organization = self.organization
164
+ user_meta.role = role
165
+ user_meta.save()
166
+ return user_meta
167
+
168
+
169
+ class UserMetaForm(forms.ModelForm):
170
+ class Meta:
171
+ model = UserMeta
172
+ exclude = ['user', ]
173
+
174
+ def __init__(self, *args, **kwargs):
175
+ self.organization = kwargs.pop('organization', None)
176
+ super(UserMetaForm, self).__init__(*args, **kwargs)
177
+
178
+ def save(self):
179
+ self.instance.organization = self.organization
180
+ self.instance.save()
181
+ return self.instance
182
+
183
+
184
+ class OrganizationCreateForm(forms.ModelForm):
185
+ def __init__(self, *args, **kwargs):
186
+ super(OrganizationCreateForm, self).__init__(*args, **kwargs)
187
+ if self.data.get('number_scheme') == NUMBER_SCHEME_INTELLIGENT:
188
+ # make the QueryDict mutable
189
+ self.data = self.data.copy()
190
+ self.data['number_class_code_len'] = 3
191
+ self.data['number_item_len'] = 128
192
+ self.data['number_variation_len'] = 2
193
+
194
+ class Meta:
195
+ model = Organization
196
+ fields = ['name', 'number_scheme', 'number_class_code_len', 'number_item_len', 'number_variation_len', ]
197
+ labels = {
198
+ "name": "Organization Name",
199
+ "number_class_code_len": "Number Class Code Length (C)",
200
+ "number_item_len": "Number Item Length (N)",
201
+ "number_variation_len": "Number Variation Length (V)",
202
+ }
203
+
204
+
205
+ class OrganizationForm(forms.ModelForm):
206
+ class Meta:
207
+ model = Organization
208
+ exclude = ['owner', 'subscription', 'subscription_quantity', 'google_drive_parent', 'number_scheme', ]
209
+ labels = {
210
+ "name": "Organization Name",
211
+ "number_class_code_len": "Number Class Code Length (C)",
212
+ "number_item_len": "Number Item Length (N)",
213
+ "number_variation_len": "Number Variation Length (V)",
214
+ }
215
+
216
+ def __init__(self, *args, **kwargs):
217
+ user = kwargs.pop('user', None)
218
+ super(OrganizationForm, self).__init__(*args, **kwargs)
219
+ if user and self.instance.owner == user:
220
+ user_queryset = User.objects.filter(
221
+ id__in=UserMeta.objects.filter(organization=self.instance, role='A').values_list('user', flat=True)).order_by(
222
+ 'first_name', 'last_name', 'email')
223
+ self.fields['owner'] = UserModelChoiceField(queryset=user_queryset, label='Owner', initial=self.instance.owner, required=True)
224
+
225
+
226
+ class OrganizationFormEditSettings(OrganizationForm):
227
+ def __init__(self, *args, **kwargs):
228
+ super(OrganizationFormEditSettings, self).__init__(*args, **kwargs)
229
+ user = kwargs.get('user', None)
230
+
231
+ class Meta:
232
+ model = Organization
233
+ exclude = ['subscription', 'subscription_quantity', 'google_drive_parent', 'number_scheme', 'number_item_len', 'number_class_code_len', 'number_variation_len', 'owner', ]
234
+ labels = {
235
+ "name": "Organization Name",
236
+ }
237
+
238
+
239
+ class OrganizationNumberLenForm(forms.ModelForm):
240
+ class Meta:
241
+ model = Organization
242
+ fields = ['number_class_code_len', 'number_item_len', 'number_variation_len', ]
243
+ labels = {
244
+ "number_class_code_len": "Number Class Code Length (C)",
245
+ "number_item_len": "Number Item Length (N)",
246
+ "number_variation_len": "Number Variation Length (V)",
247
+ }
248
+
249
+ def __init__(self, *args, **kwargs):
250
+ self.organization = kwargs.get('instance', None)
251
+ super(OrganizationNumberLenForm, self).__init__(*args, **kwargs)
252
+ # self.fields['number_class_code_len'].validators.append(MinValueValidator(self.organization.number_class_code_len))
253
+ # self.fields['number_item_len'].validators.append(MinValueValidator(self.organization.number_item_len))
254
+ # self.fields['number_variation_len'].validators.append(MinValueValidator(self.organization.number_variation_len))
255
+
256
+
257
+ class PartInfoForm(forms.Form):
258
+ quantity = forms.IntegerField(label='Quantity for Est Cost', min_value=1)
259
+
260
+
261
+ class ManufacturerForm(forms.ModelForm):
262
+ class Meta:
263
+ model = Manufacturer
264
+ exclude = ['organization', ]
265
+
266
+ def __init__(self, *args, **kwargs):
267
+ super().__init__(*args, **kwargs)
268
+ self.fields['name'].required = False
269
+
270
+
271
+ class ManufacturerPartForm(forms.ModelForm):
272
+ class Meta:
273
+ model = ManufacturerPart
274
+ exclude = ['part', ]
275
+
276
+ field_order = ['manufacturer_part_number', 'manufacturer']
277
+
278
+ def __init__(self, *args, **kwargs):
279
+ self.organization = kwargs.pop('organization', None)
280
+ super(ManufacturerPartForm, self).__init__(*args, **kwargs)
281
+ self.fields['manufacturer'].required = False
282
+ self.fields['manufacturer_part_number'].required = False
283
+ self.fields['manufacturer'].queryset = Manufacturer.objects.filter(organization=self.organization).order_by('name')
284
+ self.fields['mouser_disable'].initial = True
285
+
286
+
287
+ class SellerForm(forms.ModelForm):
288
+ class Meta:
289
+ model = Seller
290
+ exclude = ['organization', ]
291
+
292
+
293
+ class SellerPartForm(forms.ModelForm):
294
+ class Meta:
295
+ model = SellerPart
296
+ exclude = ['manufacturer_part', 'data_source', ]
297
+
298
+ new_seller = forms.CharField(max_length=128, label='-or- Create new seller (leave blank if selecting)', required=False)
299
+ field_order = ['seller', 'new_seller', 'unit_cost', 'nre_cost', 'lead_time_days', 'minimum_order_quantity', 'minimum_pack_quantity', ]
300
+
301
+ def __init__(self, *args, **kwargs):
302
+ self.organization = kwargs.pop('organization', None)
303
+ self.manufacturer_part = kwargs.pop('manufacturer_part', None)
304
+ self.base_fields['unit_cost'] = forms.DecimalField(required=True, decimal_places=4, max_digits=17)
305
+ self.base_fields['nre_cost'] = forms.DecimalField(required=True, decimal_places=4, max_digits=17, label='NRE cost')
306
+
307
+ instance = kwargs.get('instance')
308
+ if instance:
309
+ initial = kwargs.get('initial', {})
310
+ initial['unit_cost'] = instance.unit_cost.amount
311
+ initial['nre_cost'] = instance.nre_cost.amount
312
+ kwargs['initial'] = initial
313
+ super(SellerPartForm, self).__init__(*args, **kwargs)
314
+ if self.manufacturer_part is not None:
315
+ self.instance.manufacturer_part = self.manufacturer_part
316
+ self.fields['seller'].queryset = Seller.objects.filter(organization=self.organization).order_by('name')
317
+ self.fields['seller'].required = False
318
+
319
+ def clean(self):
320
+ cleaned_data = super(SellerPartForm, self).clean()
321
+ seller = cleaned_data.get('seller')
322
+ new_seller = cleaned_data.get('new_seller')
323
+ unit_cost = cleaned_data.get('unit_cost')
324
+ nre_cost = cleaned_data.get('nre_cost')
325
+ if unit_cost is None:
326
+ raise forms.ValidationError("Invalid unit cost.", code='invalid')
327
+ self.instance.unit_cost = Money(unit_cost, self.organization.currency)
328
+
329
+ if nre_cost is None:
330
+ raise forms.ValidationError("Invalid NRE cost.", code='invalid')
331
+ self.instance.nre_cost = Money(nre_cost, self.organization.currency)
332
+
333
+ if seller and new_seller:
334
+ raise forms.ValidationError("Cannot have a seller and a new seller.", code='invalid')
335
+ elif new_seller:
336
+ obj, created = Seller.objects.get_or_create(name__iexact=new_seller, organization=self.organization, defaults={'name': new_seller})
337
+ self.cleaned_data['seller'] = obj
338
+ elif not seller:
339
+ raise forms.ValidationError("Must specify a seller.", code='invalid')
340
+
341
+
342
+ class PartClassForm(forms.ModelForm):
343
+ class Meta:
344
+ model = PartClass
345
+ fields = ['code', 'name', 'comment']
346
+
347
+ def __init__(self, *args, **kwargs):
348
+ self.organization = kwargs.pop('organization', None)
349
+ self.ignore_unique_constraint = kwargs.pop('ignore_unique_constraint', False)
350
+ super(PartClassForm, self).__init__(*args, **kwargs)
351
+ self.fields['code'].required = False
352
+ self.fields['name'].required = False
353
+ self.fields['code'].validators.extend([MaxLengthValidator(self.organization.number_class_code_len), MinLengthValidator(self.organization.number_class_code_len)])
354
+
355
+ def clean_name(self):
356
+ cleaned_data = super(PartClassForm, self).clean()
357
+ name = cleaned_data.get('name')
358
+ if self.ignore_unique_constraint:
359
+ return name
360
+ try:
361
+ part_class_with_name = PartClass.objects.get(name__iexact=name, organization=self.organization)
362
+ if part_class_with_name and self.instance and self.instance.id != part_class_with_name.id:
363
+ validation_error = forms.ValidationError("Part class with name {} is already defined.".format(name), code='invalid')
364
+ self.add_error('name', validation_error)
365
+ except PartClass.DoesNotExist:
366
+ pass
367
+ return name
368
+
369
+ def clean_code(self):
370
+ cleaned_data = super(PartClassForm, self).clean()
371
+ code = cleaned_data.get('code')
372
+ if self.ignore_unique_constraint:
373
+ return code
374
+ if PartClass.objects.filter(code=code, organization=self.organization).exclude(pk=self.instance.pk).count() > 0:
375
+ validation_error = forms.ValidationError(f"Part class with code {code} is already defined.", code='invalid')
376
+ self.add_error('code', validation_error)
377
+ return code
378
+
379
+ def clean(self):
380
+ cleaned_data = super(PartClassForm, self).clean()
381
+ cleaned_data['organization_id'] = self.organization
382
+ self.instance.organization = self.organization
383
+ return cleaned_data
384
+
385
+
386
+ PartClassFormSet = forms.formset_factory(PartClassForm, extra=2, can_delete=True)
387
+
388
+
389
+ class PartClassSelectionForm(forms.Form):
390
+ def __init__(self, *args, **kwargs):
391
+ self.organization = kwargs.pop('organization', None)
392
+ super(PartClassSelectionForm, self).__init__(*args, **kwargs)
393
+ self.fields['part_class'] = forms.CharField(required=False,
394
+ widget=AutocompleteTextInput(attrs={'placeholder': 'Select a part class.'},
395
+ autocomplete_submit=True,
396
+ queryset=PartClass.objects.filter(organization=self.organization)))
397
+
398
+ def clean_part_class(self):
399
+ part_class = self.cleaned_data['part_class']
400
+ if part_class == '':
401
+ return None
402
+
403
+ try:
404
+ return PartClass.objects.get(organization=self.organization, code=part_class.split(':')[0])
405
+ except PartClass.DoesNotExist:
406
+ pc = PartClass.objects.filter(name__icontains=part_class).order_by('name').first()
407
+ if pc is not None:
408
+ return pc
409
+ else:
410
+ self.add_error('part_class', 'Select a valid part class.')
411
+ return None
412
+
413
+
414
+ class PartClassCSVForm(forms.Form):
415
+ file = forms.FileField(required=False)
416
+
417
+ def __init__(self, *args, **kwargs):
418
+ self.organization = kwargs.pop('organization', None)
419
+ super(PartClassCSVForm, self).__init__(*args, **kwargs)
420
+
421
+ def clean(self):
422
+ cleaned_data = super(PartClassCSVForm, self).clean()
423
+ file = self.cleaned_data.get('file')
424
+ self.successes = list()
425
+ self.warnings = list()
426
+
427
+ try:
428
+ csvline_decoded = file.readline().decode('utf-8')
429
+ dialect = csv.Sniffer().sniff(csvline_decoded)
430
+ file.open()
431
+ reader = csv.reader(codecs.iterdecode(file, 'utf-8'), dialect)
432
+ headers = [h.lower().replace('\ufeff', '') for h in next(reader)]
433
+
434
+ csv_headers = PartClassesCSVHeaders()
435
+
436
+ try:
437
+ # Issue warning if unrecognized column header names appear in file.
438
+ csv_headers.validate_header_names(headers)
439
+ except CSVHeaderError as e:
440
+ self.warnings.append(e.__str__() + ". Column(s) ignored.")
441
+
442
+ try:
443
+ # Make sure that required columns appear in the file, then convert whatever
444
+ # header synonym names were used to default header names.
445
+ hdr_assertions = [
446
+ ('comment', 'description', 'mex'), # MUTUALLY EXCLUSIVE part_class or part_number but not both
447
+ ('code', 'in'), # CONTAINS revision
448
+ ('name', 'in'), # CONTAINS name
449
+ ]
450
+ csv_headers.validate_header_assertions(headers, hdr_assertions)
451
+ headers = csv_headers.get_defaults_list(headers)
452
+ except CSVHeaderError as e:
453
+ raise ValidationError(e.__str__() + ". Uploading stopped. No part classes uploaded.", code='invalid')
454
+
455
+ row_count = 1 # Skip over header row
456
+ for row in reader:
457
+ row_count += 1
458
+ part_class_data = {}
459
+
460
+ for idx, hdr in enumerate(headers):
461
+ if idx == len(row): break
462
+ part_class_data[hdr] = row[idx]
463
+
464
+ name = csv_headers.get_val_from_row(part_class_data, 'name')
465
+ code = csv_headers.get_val_from_row(part_class_data, 'code')
466
+ description = csv_headers.get_val_from_row(part_class_data, 'description')
467
+ comment = csv_headers.get_val_from_row(part_class_data, 'comment')
468
+
469
+ try:
470
+ if code is None:
471
+ validation_error = forms.ValidationError(
472
+ "Part class 'code' in row {} does not have a value. Uploading of this part class skipped.".format(row_count),
473
+ code='invalid')
474
+ self.add_error(None, validation_error)
475
+ continue
476
+ elif len(code) != self.organization.number_class_code_len:
477
+ validation_error = forms.ValidationError(
478
+ "Length of part class 'code' in row {} is different than the organization class length {}. Uploading of this part class skipped.".format(row_count, self.organization.number_class_code_len),
479
+ code='invalid')
480
+ self.add_error(None, validation_error)
481
+ continue
482
+
483
+ description_or_comment = ''
484
+ if description is not None:
485
+ description_or_comment = description
486
+ elif comment is not None:
487
+ description_or_comment = comment
488
+ PartClass.objects.create(code=code, name=name, comment=description_or_comment, organization=self.organization)
489
+ self.successes.append("Part class {0} {1} on row {2} created.".format(code, name, row_count))
490
+
491
+ except IntegrityError:
492
+ validation_error = forms.ValidationError(
493
+ "Part class {0} {1} on row {2} is already defined. Uploading of this part class skipped.".format(code, name, row_count),
494
+ code='invalid')
495
+ self.add_error(None, validation_error)
496
+
497
+ except UnicodeDecodeError as e:
498
+ self.add_error(None, forms.ValidationError("CSV File Encoding error, try encoding your file as utf-8, and upload again. \
499
+ If this keeps happening, reach out to info@indabom.com with your csv file and we'll do our best to \
500
+ fix your issue!", code='invalid'))
501
+ logger.warning("UnicodeDecodeError: {}".format(e))
502
+ raise ValidationError("Specific Error: {}".format(e),
503
+ code='invalid')
504
+
505
+ return cleaned_data
506
+
507
+
508
+ class PartCSVForm(forms.Form):
509
+ file = forms.FileField(required=False)
510
+
511
+ def __init__(self, *args, **kwargs):
512
+ self.organization = kwargs.pop('organization', None)
513
+ super(PartCSVForm, self).__init__(*args, **kwargs)
514
+
515
+ def clean(self):
516
+ cleaned_data = super(PartCSVForm, self).clean()
517
+ file = self.cleaned_data.get('file')
518
+ self.successes = list()
519
+ self.warnings = list()
520
+
521
+ try:
522
+ csvline_decoded = file.readline().decode('utf-8')
523
+ dialect = csv.Sniffer().sniff(csvline_decoded)
524
+ file.open()
525
+ reader = csv.reader(codecs.iterdecode(file, 'utf-8'), dialect)
526
+ headers = [h.lower() for h in next(reader)]
527
+
528
+ # Handle utf-8-sig encoding
529
+ if "\ufeff" in headers[0]:
530
+ reader = csv.reader(codecs.iterdecode(file, 'utf-8-sig'), dialect)
531
+ headers = [h.lower() for h in next(reader)]
532
+
533
+ csv_headers = self.organization.part_list_csv_headers()
534
+
535
+ try:
536
+ # Issue warning if unrecognized column header names appear in file.
537
+ csv_headers.validate_header_names(headers)
538
+ except CSVHeaderError as e:
539
+ self.warnings.append(e.__str__() + ". Columns ignored.")
540
+
541
+ try:
542
+ # Make sure that required columns appear in the file, then convert whatever
543
+ # header synonym names were used to default header names.
544
+ hdr_assertions = [
545
+ ('part_class', 'part_number', 'or'), # part_class OR part_number
546
+ ('revision', 'in'), # CONTAINS revision
547
+ ('value', 'value_units', 'and', 'description', 'or'), # (value AND value units) OR description
548
+ ]
549
+ csv_headers.validate_header_assertions(headers, hdr_assertions)
550
+ headers = csv_headers.get_defaults_list(headers)
551
+ except CSVHeaderError as e:
552
+ raise ValidationError(e.__str__() + ". Uploading stopped. No parts uploaded.", code='invalid')
553
+
554
+ row_count = 1 # Skip over header row
555
+ for row in reader:
556
+ row_count += 1
557
+ part_data = {}
558
+
559
+ for idx, hdr in enumerate(headers):
560
+ if idx == len(row): break
561
+ part_data[hdr] = row[idx]
562
+
563
+ part_number = csv_headers.get_val_from_row(part_data, 'part_number')
564
+ part_class = csv_headers.get_val_from_row(part_data, 'part_class')
565
+ number_item = None
566
+ number_variation = None
567
+ revision = csv_headers.get_val_from_row(part_data, 'revision')
568
+ mpn = csv_headers.get_val_from_row(part_data, 'mpn')
569
+ mfg_name = csv_headers.get_val_from_row(part_data, 'mfg_name')
570
+ description = csv_headers.get_val_from_row(part_data, 'description')
571
+ value = csv_headers.get_val_from_row(part_data, 'value')
572
+ value_units = csv_headers.get_val_from_row(part_data, 'value_units')
573
+ seller_name = csv_headers.get_val_from_row(part_data, 'seller')
574
+ seller_part_number = csv_headers.get_val_from_row(part_data, 'seller_part_number')
575
+ unit_cost = csv_headers.get_val_from_row(part_data, 'unit_cost')
576
+ nre_cost = csv_headers.get_val_from_row(part_data, 'part_nre_cost')
577
+ moq = csv_headers.get_val_from_row(part_data, 'moq')
578
+ mpq = csv_headers.get_val_from_row(part_data, 'minimum_pack_quantity')
579
+
580
+ # Check part number for uniqueness. If part number not specified
581
+ # then Part.save() will create one.
582
+ if part_number:
583
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
584
+ try:
585
+ (number_class, number_item, number_variation) = Part.parse_part_number(part_number, self.organization)
586
+ part_class = PartClass.objects.get(code=number_class, organization=self.organization)
587
+ Part.objects.get(number_class=part_class, number_item=number_item, number_variation=number_variation, organization=self.organization)
588
+ self.add_error(None, "Part number {0} in row {1} already exists. Uploading of this part skipped.".format(part_number, row_count))
589
+ continue
590
+ except AttributeError as e:
591
+ self.add_error(None, str(e) + " on row {}. Creation of this part skipped.".format(row_count))
592
+ continue
593
+ except PartClass.DoesNotExist:
594
+ self.add_error(None, "No part class found for part number {0} in row {1}. Creation of this part skipped.".format(part_number, row_count))
595
+ continue
596
+ except Part.DoesNotExist:
597
+ pass
598
+ else:
599
+ try:
600
+ number_item = part_number
601
+ Part.objects.get(number_class=None, number_item=number_item, number_variation=None, organization=self.organization)
602
+ self.add_error(None, f"Part number {part_number} in row {row_count} already exists. Uploading of this part skipped.")
603
+ continue
604
+ except Part.DoesNotExist:
605
+ pass
606
+ elif part_class:
607
+ try:
608
+ part_class = PartClass.objects.get(code=part_data[csv_headers.get_default('part_class')], organization=self.organization)
609
+ except PartClass.DoesNotExist:
610
+ self.add_error(None, "Part class {0} in row {1} doesn't exist. Create part class on Settings > IndaBOM and try again."
611
+ "Uploading of this part skipped.".format(part_data[csv_headers.get_default('part_class')], row_count))
612
+ continue
613
+ else:
614
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
615
+ self.add_error(None, "In row {} need to specify a part_class or part_number. Uploading of this part skipped.".format(row_count))
616
+ else:
617
+ self.add_error(None, "In row {} need to specify a part_number. Uploading of this part skipped.".format(row_count))
618
+ continue
619
+
620
+ if not revision:
621
+ self.add_error(None, f"Missing revision in row {row_count}. Uploading of this part skipped.")
622
+ continue
623
+ elif len(revision) > 4:
624
+ self.add_error(None, "Revision {0} in row {1} is more than the maximum 4 characters. "
625
+ "Uploading of this part skipped.".format(part_data[csv_headers.get_default('revision')], row_count))
626
+ continue
627
+ elif revision.isdigit() and int(revision) < 0:
628
+ self.add_error(None, "Revision {0} in row {1} cannot be a negative number. "
629
+ "Uploading of this part skipped.".format(part_data[csv_headers.get_default('revision')], row_count))
630
+ continue
631
+
632
+ if mpn and mfg_name:
633
+ manufacturer_part = ManufacturerPart.objects.filter(manufacturer_part_number=mpn,
634
+ manufacturer__name=mfg_name,
635
+ manufacturer__organization=self.organization)
636
+ if manufacturer_part.count() > 0:
637
+ self.add_error(None, "Part already exists for manufacturer part {0} in row {1}. "
638
+ "Uploading of this part skipped.".format(row_count, mpn, row_count))
639
+ continue
640
+
641
+ skip = False
642
+ part_revision = PartRevision()
643
+ part_revision.revision = revision
644
+
645
+ # Required properties:
646
+ if description is None:
647
+ if value is None and value_units is None:
648
+ self.add_error(None, "Missing 'description' or 'value' plus 'value_units' for part in row {}. Uploading of this part skipped.".format(row_count))
649
+ skip = True
650
+ break
651
+ elif value is None and value_units is not None:
652
+ self.add_error(None, "Missing 'value' for part in row {}. Uploading of this part skipped.".format(row_count))
653
+ skip = True
654
+ break
655
+ elif value is not None and value_units is None:
656
+ self.add_error(None, "Missing 'value_units' for part in row {}. Uploading of this part skipped.".format(row_count))
657
+ skip = True
658
+ break
659
+
660
+ part_revision.description = description
661
+
662
+ # Part revision's value and value_units set below, after have had a chance to validate unit choice.
663
+
664
+ def is_valid_choice(choice, choices):
665
+ for c in choices:
666
+ if choice == c[0]:
667
+ return True
668
+ return False
669
+
670
+ # Optional properties with free-form values:
671
+ props_free_form = ['tolerance', 'pin_count', 'color', 'material', 'finish', 'attribute']
672
+ for prop_free_form in props_free_form:
673
+ prop_free_form = csv_headers.get_default(prop_free_form)
674
+ if prop_free_form in part_data:
675
+ setattr(part_revision, prop_free_form, part_data[prop_free_form])
676
+
677
+ # Optional properties with choices for values:
678
+ props_with_value_choices = {'package': PACKAGE_TYPES, 'interface': INTERFACE_TYPES}
679
+ for k, v in props_with_value_choices.items():
680
+ k = csv_headers.get_default(k)
681
+ if k in part_data:
682
+ if is_valid_choice(part_data[k], v):
683
+ setattr(part_revision, k, part_data[k])
684
+ else:
685
+ self.warnings.append("'{0}' is an invalid choice of value for '{1}' for part in row {2} . Uploading of this property skipped. "
686
+ "Part will still be uploaded".format(part_data[k], k, row_count))
687
+
688
+ # Optional properties with units:
689
+ props_with_unit_choices = {
690
+ 'value': VALUE_UNITS,
691
+ 'supply_voltage': VOLTAGE_UNITS, 'power_rating': POWER_UNITS,
692
+ 'voltage_rating': VOLTAGE_UNITS, 'current_rating': CURRENT_UNITS,
693
+ 'temperature_rating': TEMPERATURE_UNITS, 'memory': MEMORY_UNITS,
694
+ 'frequency': FREQUENCY_UNITS, 'wavelength': WAVELENGTH_UNITS,
695
+ 'length': DISTANCE_UNITS, 'width': DISTANCE_UNITS,
696
+ 'height': DISTANCE_UNITS, 'weight': WEIGHT_UNITS,
697
+ }
698
+ for k, v in props_with_unit_choices.items():
699
+ k = csv_headers.get_default(k)
700
+ if k in part_data and k + '_units' in part_data:
701
+ if part_data[k] and not part_data[k + '_units']:
702
+ self.add_error(None, "Missing '{0}' for part in row {1}. Uploading of this part skipped.".format(k, row_count))
703
+ skip = True
704
+ break
705
+ elif not part_data[k] and part_data[k + '_units']:
706
+ self.add_error(None, "Missing '{0}' for part in row {1}. Uploading of this part skipped.".format(k + '_units', row_count))
707
+ skip = True
708
+ break
709
+ elif part_data[k + '_units']:
710
+ if is_valid_choice(part_data[k + '_units'], v):
711
+ setattr(part_revision, k, part_data[k])
712
+ setattr(part_revision, k + '_units', part_data[k + '_units'])
713
+ else:
714
+ self.add_error(None, "'{0}' is an invalid choice of units for '{1}' for part in row {2}. Uploading of this part skipped."
715
+ .format(part_data[k + '_units'], k + '_units', row_count))
716
+ skip = True
717
+ break
718
+
719
+ if self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT and number_item is None:
720
+ self.add_error(None, "Can't upload a part without a number_item header for part in row {}. Uploading of this part skipped.".format(row_count))
721
+ skip = True
722
+
723
+ if skip:
724
+ continue
725
+
726
+ PartForm = part_form_from_organization(self.organization)
727
+ part = Part(number_class=part_class, number_item=number_item, number_variation=number_variation, organization=self.organization)
728
+ part_dict = model_to_dict(part)
729
+ part_dict.update({'number_class': str(part.number_class)})
730
+ pf = PartForm(data=part_dict, organization=self.organization)
731
+ prf = PartRevisionForm(data=model_to_dict(part_revision))
732
+
733
+ if pf.is_valid() and prf.is_valid():
734
+ part = pf.save(commit=False)
735
+ part.organization = self.organization
736
+ part.save()
737
+ part_revision = prf.save(commit=False)
738
+ part_revision.part = part
739
+ part_revision.save()
740
+
741
+ if mfg_name and mpn:
742
+ mfg, created = Manufacturer.objects.get_or_create(name__iexact=mfg_name, organization=self.organization, defaults={'name': mfg_name})
743
+ manufacturer_part, created = ManufacturerPart.objects.get_or_create(part=part, manufacturer_part_number=mpn, manufacturer=mfg)
744
+ if part.primary_manufacturer_part is None and manufacturer_part is not None:
745
+ part.primary_manufacturer_part = manufacturer_part
746
+ part.save()
747
+
748
+ if seller_name and unit_cost and nre_cost:
749
+ seller, created = Seller.objects.get_or_create(name__iexact=seller_name, organization=self.organization, defaults={'name': seller_name})
750
+ seller_part, created = SellerPart.objects.get_or_create(manufacturer_part=manufacturer_part, seller=seller, seller_part_number=seller_part_number, unit_cost=unit_cost, nre_cost=nre_cost, minimum_order_quantity=moq, minimum_pack_quantity=mpq)
751
+
752
+ self.successes.append("Part {0} on row {1} created.".format(part.full_part_number(), row_count))
753
+ else:
754
+ for k, error in prf.errors.items():
755
+ for idx, msg in enumerate(error):
756
+ error[idx] = f"Error on Row {row_count}, {k}: " + msg
757
+ self.errors.update({k: error})
758
+ for k, error in pf.errors.items():
759
+ for idx, msg in enumerate(error):
760
+ error[idx] = f"Error on Row {row_count}, {k}: " + msg
761
+ self.errors.update({k: error})
762
+
763
+ # part = Part.objects.create(number_class=part_class, number_item=number_item, number_variation=number_variation, organization=self.organization)
764
+
765
+ except UnicodeDecodeError as e:
766
+ self.add_error(None, forms.ValidationError("CSV File Encoding error, try encoding your file as utf-8, and upload again. \
767
+ If this keeps happening, reach out to info@indabom.com with your csv file and we'll do our best to \
768
+ fix your issue!", code='invalid'))
769
+ logger.warning("UnicodeDecodeError: {}".format(e))
770
+ raise ValidationError("Specific Error: {}".format(e), code='invalid')
771
+
772
+ return cleaned_data
773
+
774
+
775
+ class PartFormIntelligent(forms.ModelForm):
776
+ class Meta:
777
+ model = Part
778
+ exclude = ['number_class', 'number_variation', 'organization', 'google_drive_parent', ]
779
+ help_texts = {
780
+ 'number_item': _('Enter a part number.'),
781
+ }
782
+
783
+ def __init__(self, *args, **kwargs):
784
+ self.organization = kwargs.pop('organization', None)
785
+ self.ignore_number_class = kwargs.pop('ignore_part_class', False)
786
+ self.ignore_unique_constraint = kwargs.pop('ignore_unique_constraint', False)
787
+ super(PartFormIntelligent, self).__init__(*args, **kwargs)
788
+ self.fields['number_item'].required = True
789
+ if self.instance and self.instance.id:
790
+ self.fields['primary_manufacturer_part'].queryset = ManufacturerPart.objects.filter(part__id=self.instance.id).order_by('manufacturer_part_number')
791
+ else:
792
+ del self.fields['primary_manufacturer_part']
793
+ for _, value in self.fields.items():
794
+ value.widget.attrs['placeholder'] = value.help_text
795
+ value.help_text = ''
796
+
797
+ class PartFormSemiIntelligent(forms.ModelForm):
798
+ class Meta:
799
+ model = Part
800
+ exclude = ['organization', 'google_drive_parent', ]
801
+ help_texts = {
802
+ 'number_item': _('Auto generated if blank.'),
803
+ 'number_variation': 'Auto generated if blank.',
804
+ }
805
+
806
+ def __init__(self, *args, **kwargs):
807
+ self.organization = kwargs.pop('organization', None)
808
+ self.ignore_number_class = kwargs.pop('ignore_part_class', False)
809
+ self.ignore_unique_constraint = kwargs.pop('ignore_unique_constraint', False)
810
+ super(PartFormSemiIntelligent, self).__init__(*args, **kwargs)
811
+ self.fields['number_item'].validators.append(alphanumeric)
812
+ self.fields['number_class'] = forms.CharField(label='Part Number Class*', required=True, help_text='Select a number class.',
813
+ widget=AutocompleteTextInput(queryset=PartClass.objects.filter(organization=self.organization)))
814
+ if kwargs.get('instance', None): # To check uniqueness
815
+ self.id = kwargs['instance'].id
816
+
817
+ if self.instance and self.instance.id:
818
+ self.fields['primary_manufacturer_part'].queryset = ManufacturerPart.objects.filter(part__id=self.instance.id).order_by('manufacturer_part_number')
819
+ else:
820
+ del self.fields['primary_manufacturer_part']
821
+ for _, value in self.fields.items():
822
+ value.widget.attrs['placeholder'] = value.help_text
823
+ value.help_text = ''
824
+
825
+ if self.initial.get('number_class'):
826
+ try:
827
+ part_class = PartClass.objects.get(id=self.initial['number_class'])
828
+ self.initial['number_class'] = str(part_class)
829
+ except PartClass.DoesNotExist:
830
+ self.initial['number_class'] = ""
831
+
832
+ if self.ignore_number_class:
833
+ self.fields.get('number_class').required = False
834
+
835
+ def clean_number_class(self):
836
+ if self.ignore_number_class:
837
+ return None
838
+ number_class = self.cleaned_data['number_class']
839
+ try:
840
+ return PartClass.objects.get(organization=self.organization, code=number_class.split(':')[0])
841
+ except PartClass.DoesNotExist:
842
+ self.add_error('number_class', f'Select an existing part class, or create `{number_class}` in Settings.')
843
+ return None
844
+
845
+ def clean(self):
846
+ cleaned_data = super(PartFormSemiIntelligent, self).clean()
847
+ number_item = cleaned_data.get('number_item')
848
+ number_class = cleaned_data.get('number_class')
849
+ number_variation = cleaned_data.get('number_variation')
850
+
851
+ try:
852
+ if number_class is not None and number_class.code != '':
853
+ Part.verify_format_number_class(number_class.code, self.organization)
854
+ except AttributeError as e:
855
+ validation_error = forms.ValidationError(str(e), code='invalid')
856
+ self.add_error('number_class', validation_error)
857
+
858
+ try:
859
+ if number_item is not None and number_item != '':
860
+ Part.verify_format_number_item(number_item, self.organization)
861
+ except AttributeError as e:
862
+ validation_error = forms.ValidationError(str(e), code='invalid')
863
+ self.add_error('number_item', validation_error)
864
+
865
+ try:
866
+ if number_variation:
867
+ Part.verify_format_number_variation(number_variation, self.organization)
868
+ except AttributeError as e:
869
+ validation_error = forms.ValidationError(str(e), code='invalid')
870
+ self.add_error('number_variation', validation_error)
871
+
872
+ if self.ignore_unique_constraint:
873
+ return cleaned_data
874
+
875
+ part = Part.objects.filter(
876
+ number_class=number_class,
877
+ number_item=number_item,
878
+ number_variation=number_variation,
879
+ organization=self.organization
880
+ )
881
+
882
+ try:
883
+ part = part.exclude(pk=self.id)
884
+ except AttributeError:
885
+ pass
886
+
887
+ if part.count() > 0:
888
+ validation_error = forms.ValidationError(
889
+ ("Part number {0}-{1}-{2} already in use.".format(number_class.code, number_item, number_variation)),
890
+ code='invalid')
891
+ self.add_error(None, validation_error)
892
+ return cleaned_data
893
+
894
+
895
+ class PartRevisionForm(forms.ModelForm):
896
+ class Meta:
897
+ model = PartRevision
898
+ exclude = ['timestamp', 'assembly', 'part']
899
+ help_texts = {
900
+ 'description': _('Additional part info, special instructions, etc.'),
901
+ 'attribute': _('Additional part attributes (free form)'),
902
+ 'value': _('Number or text'),
903
+ }
904
+
905
+ def __init__(self, *args, **kwargs):
906
+ super(PartRevisionForm, self).__init__(*args, **kwargs)
907
+
908
+ self.fields['revision'].initial = 1
909
+ self.fields['configuration'].required = False
910
+
911
+ self.fields['tolerance'].initial = '%'
912
+
913
+ # Fix up field labels to be succinct for use in rendered form:
914
+ for f in self.fields.values():
915
+ if 'units' in f.label: f.label = 'Units'
916
+ f.label.replace('rating', '')
917
+ # f.value = strip_trailing_zeros(f.value) # Harmless if field is not a number
918
+ self.fields['supply_voltage'].label = 'Vsupply'
919
+ self.fields['attribute'].label = ''
920
+
921
+ for _, value in self.fields.items():
922
+ value.widget.attrs['placeholder'] = value.help_text
923
+ value.help_text = ''
924
+
925
+ if self.instance and not self.data.get('description') and self.instance.description:
926
+ self.data['description'] = self.instance.description
927
+
928
+ def clean(self):
929
+ cleaned_data = super(PartRevisionForm, self).clean()
930
+
931
+ if not cleaned_data.get('description') and not cleaned_data.get('value'):
932
+ validation_error = forms.ValidationError("Must specify either a description or both value and value units.", code='invalid')
933
+ self.add_error('description', validation_error)
934
+ self.add_error('value', validation_error)
935
+
936
+ for key in self.fields.keys():
937
+ if '_units' in key:
938
+ value_name = str.replace(key, '_units', '')
939
+ value = cleaned_data.get(value_name)
940
+ units = cleaned_data.get(key)
941
+ if not value and units:
942
+ self.add_error(key, forms.ValidationError(f"Cannot specify {units} without an accompanying value", code='invalid'))
943
+ elif units and not units:
944
+ self.add_error(key, forms.ValidationError(f"Cannot specify value {units} without an accompanying units", code='invalid'))
945
+
946
+ return cleaned_data
947
+
948
+
949
+ class PartRevisionNewForm(PartRevisionForm):
950
+ copy_assembly = forms.BooleanField(label='Copy assembly from latest revision', initial=True, required=False)
951
+
952
+ def __init__(self, *args, **kwargs):
953
+ self.part = kwargs.pop('part', None)
954
+ self.revision = kwargs.pop('revision', None)
955
+ self.assembly = kwargs.pop('assembly', None)
956
+ super(PartRevisionNewForm, self).__init__(*args, **kwargs)
957
+ for _, value in self.fields.items():
958
+ value.widget.attrs['placeholder'] = value.help_text
959
+ value.help_text = ''
960
+
961
+ def save(self):
962
+ cleaned_data = super(PartRevisionNewForm, self).clean()
963
+ self.instance.part = self.part
964
+ self.instance.revision = self.revision
965
+ self.instance.assembly = self.assembly
966
+ self.instance.save()
967
+ return self.instance
968
+
969
+
970
+ class SubpartForm(forms.ModelForm):
971
+ class Meta:
972
+ model = Subpart
973
+ fields = ['part_revision', 'reference', 'count', 'do_not_load']
974
+
975
+ def __init__(self, *args, **kwargs):
976
+ self.organization = kwargs.pop('organization', None)
977
+ self.part_id = kwargs.pop('part_id', None)
978
+ self.ignore_part_revision = kwargs.pop('ignore_part_revision', False)
979
+ super(SubpartForm, self).__init__(*args, **kwargs)
980
+ if self.part_id is None:
981
+ self.Meta.exclude = ['part_revision']
982
+ else:
983
+ self.fields['part_revision'].queryset = PartRevision.objects.filter(
984
+ part__id=self.part_id).order_by('-timestamp')
985
+ if self.ignore_part_revision:
986
+ self.fields.get('part_revision').required = False
987
+
988
+ if self.part_id:
989
+ part = Part.objects.get(id=self.part_id)
990
+ unusable_part_ids = [p.id for p in part.where_used_full()]
991
+ unusable_part_ids.append(part.id)
992
+
993
+ def clean_count(self):
994
+ count = self.cleaned_data['count']
995
+ if not count:
996
+ count = 0
997
+ return count
998
+
999
+ def clean_reference(self):
1000
+ reference = self.cleaned_data['reference']
1001
+ reference = stringify_list(listify_string(reference))
1002
+ return reference
1003
+
1004
+ def clean_part_revision(self):
1005
+ if self.ignore_part_revision:
1006
+ return None
1007
+ part_revision = self.cleaned_data['part_revision']
1008
+ return part_revision
1009
+
1010
+ def clean(self):
1011
+ cleaned_data = super(SubpartForm, self).clean()
1012
+ reference_list = listify_string(cleaned_data.get('reference'))
1013
+ count = cleaned_data.get('count')
1014
+
1015
+ if len(reference_list) > 0 and len(reference_list) != count:
1016
+ raise forms.ValidationError(
1017
+ ("The number of reference designators ({0}) did not match the subpart quantity ({1}).".format(len(reference_list), count)),
1018
+ code='invalid')
1019
+
1020
+ return cleaned_data
1021
+
1022
+
1023
+ class AddSubpartForm(forms.Form):
1024
+ subpart_part_number = forms.CharField(required=True, label="Subpart part number")
1025
+ count = forms.FloatField(required=False, label='Quantity')
1026
+ reference = forms.CharField(required=False, label="Reference")
1027
+ do_not_load = forms.BooleanField(required=False, label="do_not_load")
1028
+
1029
+ def __init__(self, *args, **kwargs):
1030
+ self.organization = kwargs.pop('organization', None)
1031
+ self.part_id = kwargs.pop('part_id', None)
1032
+ self.part = Part.objects.get(id=self.part_id)
1033
+ self.part_revision = self.part.latest()
1034
+ self.unusable_part_rev_ids = [pr.id for pr in self.part_revision.where_used_full()]
1035
+ self.unusable_part_rev_ids.append(self.part_revision.id)
1036
+ super(AddSubpartForm, self).__init__(*args, **kwargs)
1037
+ self.fields['subpart_part_number'] = forms.CharField(required=True, label="Subpart part number",
1038
+ widget=AutocompleteTextInput(attrs={'placeholder': 'Select a part.'},
1039
+ queryset=Part.objects.filter(organization=self.organization).exclude(id=self.part_id),
1040
+ verbose_string_function=Part.verbose_str))
1041
+
1042
+ def clean_count(self):
1043
+ count = self.cleaned_data['count']
1044
+ if not count:
1045
+ count = 0
1046
+ return count
1047
+
1048
+ def clean_reference(self):
1049
+ reference = self.cleaned_data['reference']
1050
+ reference = stringify_list(listify_string(reference))
1051
+ return reference
1052
+
1053
+ def clean_subpart_part_number(self):
1054
+ subpart_part_number = self.cleaned_data['subpart_part_number']
1055
+
1056
+ if not subpart_part_number:
1057
+ validation_error = forms.ValidationError("Must specify a part number.", code='invalid')
1058
+ self.add_error('subpart_part_number', validation_error)
1059
+
1060
+ try:
1061
+ if self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT:
1062
+ part = Part.objects.get(number_item=subpart_part_number, organization=self.organization)
1063
+ else:
1064
+ (number_class, number_item, number_variation) = Part.parse_partial_part_number(subpart_part_number, self.organization, validate=False)
1065
+ part_class = PartClass.objects.get(code=number_class, organization=self.organization)
1066
+ part = Part.objects.get(number_class=part_class, number_item=number_item, number_variation=number_variation, organization=self.organization)
1067
+ self.subpart_part = part.latest()
1068
+ if self.subpart_part is None:
1069
+ self.add_error('subpart_part_number', f"No part revision exists for part {part.full_part_number()}. Create a revision before adding to an assembly.")
1070
+ return subpart_part_number
1071
+ if self.subpart_part.id in self.unusable_part_rev_ids:
1072
+ validation_error = forms.ValidationError("Infinite recursion! Can't add a part to its self.", code='invalid')
1073
+ self.add_error('subpart_part_number', validation_error)
1074
+ except AttributeError as e:
1075
+ validation_error = forms.ValidationError("Ill-formed subpart part number... " + str(e) + ".", code='invalid')
1076
+ self.add_error('subpart_part_number', validation_error)
1077
+ except PartClass.DoesNotExist:
1078
+ validation_error = forms.ValidationError(f"No part class for in given part number {subpart_part_number}.", code='invalid')
1079
+ self.add_error('subpart_part_number', validation_error)
1080
+ except Part.DoesNotExist:
1081
+ validation_error = forms.ValidationError(f"No part found with given part number {subpart_part_number}.", code='invalid')
1082
+ self.add_error('subpart_part_number', validation_error)
1083
+
1084
+ return subpart_part_number
1085
+
1086
+ def clean(self):
1087
+ cleaned_data = super(AddSubpartForm, self).clean()
1088
+ reference_list = listify_string(cleaned_data.get('reference'))
1089
+ count = cleaned_data.get('count')
1090
+
1091
+ if len(reference_list) > 0 and len(reference_list) != count:
1092
+ raise forms.ValidationError(
1093
+ ("The number of reference designators ({0}) did not match the subpart quantity ({1}).".format(len(reference_list), count)),
1094
+ code='invalid')
1095
+
1096
+ return cleaned_data
1097
+
1098
+
1099
+ class UploadBOMForm(forms.Form):
1100
+ parent_part_number = forms.CharField(required=False, label="Parent part number")
1101
+
1102
+ def __init__(self, *args, **kwargs):
1103
+ self.organization = kwargs.pop('organization', None)
1104
+ self.parent_part = kwargs.pop('parent_part', None)
1105
+ super(UploadBOMForm, self).__init__(*args, **kwargs)
1106
+
1107
+ def clean_parent_part_number(self):
1108
+ parent_part_number = self.cleaned_data['parent_part_number']
1109
+
1110
+ if parent_part_number: # not required, so only validate if its provided
1111
+ try:
1112
+ self.parent_part = Part.from_part_number(parent_part_number, self.organization)
1113
+ except AttributeError as e:
1114
+ validation_error = forms.ValidationError("Ill-formed parent part number... " + str(e) + ".", code='invalid')
1115
+ self.add_error('parent_part_number', validation_error)
1116
+ except Part.DoesNotExist:
1117
+ validation_error = forms.ValidationError(("Parent part not found with given part number {}.".format(parent_part_number)), code='invalid')
1118
+ self.add_error('parent_part_number', validation_error)
1119
+
1120
+ return parent_part_number
1121
+
1122
+
1123
+ class BOMCSVForm(forms.Form):
1124
+ file = forms.FileField(required=False)
1125
+
1126
+ def __init__(self, *args, **kwargs):
1127
+ self.organization = kwargs.pop('organization', None)
1128
+ self.parent_part = kwargs.pop('parent_part', None)
1129
+ super(BOMCSVForm, self).__init__(*args, **kwargs)
1130
+
1131
+ def clean(self):
1132
+ cleaned_data = super(BOMCSVForm, self).clean()
1133
+ file = self.cleaned_data.get('file')
1134
+ self.successes = list()
1135
+ self.warnings = list()
1136
+
1137
+ try:
1138
+ csv_row_decoded = file.readline().decode('utf-8')
1139
+ dialect = csv.Sniffer().sniff(csv_row_decoded)
1140
+ file.open()
1141
+
1142
+ reader = csv.reader(codecs.iterdecode(file, 'utf-8'), dialect, quotechar='"', escapechar='\\')
1143
+ headers = [h.lower() for h in next(reader)]
1144
+
1145
+ # Handle utf-8-sig encoding
1146
+ if len(headers) > 0 and "\ufeff" in headers[0]:
1147
+ reader = csv.reader(codecs.iterdecode(file, 'utf-8-sig'), dialect, quotechar='"', escapechar='\\')
1148
+ headers = [h.lower() for h in next(reader)]
1149
+ elif len(headers) == 0:
1150
+ self.warnings.append("No headers found in CSV file.")
1151
+
1152
+ csv_headers = BOMIndentedCSVHeaders()
1153
+
1154
+ try:
1155
+ # Issue warning if unrecognized column header names appear in file.
1156
+ csv_headers.validate_header_names(headers)
1157
+ except CSVHeaderError as e:
1158
+ self.warnings.append(e.__str__() + ". Columns ignored.")
1159
+
1160
+ try:
1161
+ # Make sure that required columns appear in the file, then convert whatever
1162
+ # header synonym names were used to default header names.
1163
+ hdr_assertions = [
1164
+ ('part_number', 'manufacturer_part_number', 'or'), # part_class OR part_number
1165
+ ('quantity', 'in'), # CONTAINS quantity
1166
+ ]
1167
+ csv_headers.validate_header_assertions(headers, hdr_assertions)
1168
+ headers = csv_headers.get_defaults_list(headers)
1169
+ except CSVHeaderError as e:
1170
+ raise ValidationError(e.__str__() + ". Uploading stopped. No subparts uploaded.", code='invalid')
1171
+
1172
+ parent_part_revision = self.parent_part.latest() if self.parent_part else None
1173
+
1174
+ last_level = None
1175
+ last_part_revision = parent_part_revision
1176
+ part_revision_tree = [] if parent_part_revision is None else [parent_part_revision]
1177
+
1178
+ row_count = 1 # Skip over header row
1179
+ for row in reader:
1180
+ row_count += 1
1181
+ part_dict = {}
1182
+
1183
+ # First prepare data
1184
+ for idx, hdr in enumerate(headers):
1185
+ if idx == len(row): break
1186
+ part_dict[hdr] = row[idx]
1187
+
1188
+ dnp = csv_headers.get_val_from_row(part_dict, 'dnp')
1189
+ reference = csv_headers.get_val_from_row(part_dict, 'reference')
1190
+ part_number = csv_headers.get_val_from_row(part_dict, 'part_number')
1191
+ manufacturer_part_number = csv_headers.get_val_from_row(part_dict, 'mpn')
1192
+ manufacturer_name = csv_headers.get_val_from_row(part_dict, 'manufacturer_name')
1193
+ try:
1194
+ level = int(float(csv_headers.get_val_from_row(part_dict, 'level')))
1195
+ except ValueError as e:
1196
+ # TODO: May want to validate whole file has acceptable levels first.
1197
+ raise ValidationError(f"Row {row_count} - level: invalid level, can't continue.", code='invalid')
1198
+ except TypeError as e:
1199
+ # no level field was provided, we MUST have a parent part number to upload this way, and in this case all levels are the same
1200
+ if parent_part_revision is None:
1201
+ raise ValidationError(f"Row {row_count} - level: must provide either level, or a parent part to upload a part.", code='invalid')
1202
+ else:
1203
+ level = 1
1204
+
1205
+ if last_level is None:
1206
+ last_level = level
1207
+
1208
+ # Extract some values
1209
+ part_dict['reference'] = reference
1210
+ part_dict['do_not_load'] = dnp in ['y', 'x', 'dnp', 'dnl', 'yes', 'true', ]
1211
+ part_dict['revision'] = csv_headers.get_val_from_row(part_dict, 'revision') or 1
1212
+ part_dict['count'] = csv_headers.get_val_from_row(part_dict, 'count')
1213
+ part_dict['number_class'] = None
1214
+ part_dict['number_variation'] = None
1215
+
1216
+ if part_number:
1217
+ try:
1218
+ (part_dict['number_class'], part_dict['number_item'], part_dict['number_variation']) = Part.parse_partial_part_number(part_number, self.organization)
1219
+ except AttributeError as e:
1220
+ self.add_error(None, f"Row {row_count} - part_number: Uploading of this subpart skipped. Couldn't parse part number.")
1221
+ continue
1222
+ elif manufacturer_part_number:
1223
+ try:
1224
+ part = Part.from_manufacturer_part_number(manufacturer_part_number, self.organization)
1225
+ if part is None:
1226
+ self.add_error(None, f"Row {row_count} - manufacturer_part_number: Uploading of this subpart skipped. No part found for manufacturer part number.")
1227
+ continue
1228
+ part_dict['number_class'] = part.number_class.code
1229
+ part_dict['number_item'] = part.number_item
1230
+ part_dict['number_variation'] = part.number_variation
1231
+ part_number = part.full_part_number()
1232
+ except ValueError:
1233
+ self.add_error(None, f"Row {row_count} - manufacturer_part_number: Uploading of this subpart skipped. Too many parts found for manufacturer part number.")
1234
+ continue
1235
+ else:
1236
+ raise ValidationError("No part_number or manufacturer_part_number found. Uploading stopped. No subparts uploaded.", code='invalid')
1237
+
1238
+ # Handle indented bom level changes
1239
+ level_change = level - last_level
1240
+ if level_change == 1: # Level decreases, must only decrease by 1
1241
+ part_revision_tree.append(last_part_revision)
1242
+ elif level_change <= -1: # Level increases, going up in assembly; intentionally empty tree if level change is very negative
1243
+ part_revision_tree = part_revision_tree[:level_change]
1244
+ elif level_change == 0:
1245
+ pass
1246
+ elif level - last_level > 1:
1247
+ raise ValidationError(f'Row {row_count} - level: Assembly levels must decrease by no more than 1 from sequential rows.', code='invalid')
1248
+ else:
1249
+ raise ValidationError(f'Row {row_count} - level: Invalid assembly level.', code='invalid')
1250
+
1251
+ try:
1252
+ parent_part_revision = part_revision_tree[-1]
1253
+ if parent_part_revision.assembly is None:
1254
+ parent_part_revision.assembly = Assembly.objects.create()
1255
+ parent_part_revision.save()
1256
+ except IndexError:
1257
+ parent_part_revision = None
1258
+
1259
+ # Check for existing objects
1260
+ existing_part_class = PartClass.objects.filter(code=part_dict['number_class'], organization=self.organization).first()
1261
+
1262
+ existing_part = None
1263
+ if existing_part_class or self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT:
1264
+ existing_part = Part.objects.filter(number_class=existing_part_class, number_item=part_dict['number_item'], number_variation=part_dict['number_variation'], organization=self.organization).first()
1265
+
1266
+ existing_part_revision = None
1267
+ if existing_part:
1268
+ existing_part_revision = PartRevision.objects.filter(part=existing_part, revision=part_dict['revision']).first()
1269
+
1270
+ if existing_part_revision and parent_part_revision: # Check for infinite recursion
1271
+ contains_parent = False
1272
+ indented_bom = existing_part_revision.indented()
1273
+ for _, sp in indented_bom.parts.items(): # Make sure the subpart does not contain the parent - infinite recursion!
1274
+ if sp.part_revision == parent_part_revision:
1275
+ contains_parent = True
1276
+ if contains_parent:
1277
+ raise ValidationError(
1278
+ f"Row {row_count} - Uploaded part {part_number} contains parent part in its assembly. Cannot add {part_number} as it would cause infinite recursion. Uploading of this subpart skipped.",
1279
+ code='invalid')
1280
+
1281
+ existing_subpart = None
1282
+ existing_subpart_count = 0
1283
+ existing_subpart_references = None
1284
+ if existing_part_revision and parent_part_revision:
1285
+ existing_subpart = parent_part_revision.assembly.subparts.all().filter(part_revision=existing_part_revision, do_not_load=part_dict['do_not_load']).first()
1286
+ existing_subpart_count = existing_subpart.count if existing_subpart else 0
1287
+ existing_subpart_references = existing_subpart.reference if existing_subpart else None
1288
+
1289
+ # Now validate & save PartClass, Part, PartRevision, Subpart
1290
+ part_class_dict = {'code': part_dict['number_class'], 'name': part_dict.get('part_class', None)}
1291
+ part_class_form = PartClassForm(part_class_dict, instance=existing_part_class, ignore_unique_constraint=True, organization=self.organization)
1292
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT and not part_class_form.is_valid():
1293
+ add_nonfield_error_from_existing(part_class_form, self, f'Row {row_count} - ')
1294
+ continue
1295
+
1296
+ PartForm = part_form_from_organization(self.organization)
1297
+ part_form = PartForm(part_dict, instance=existing_part, ignore_part_class=True, ignore_unique_constraint=True, organization=self.organization)
1298
+ if not part_form.is_valid():
1299
+ add_nonfield_error_from_existing(part_form, self, f'Row {row_count} - ')
1300
+ continue
1301
+
1302
+ part_revision_form = PartRevisionForm(part_dict, instance=existing_part_revision)
1303
+ if not part_revision_form.is_valid():
1304
+ add_nonfield_error_from_existing(part_revision_form, self, f'Row {row_count} - ')
1305
+ continue
1306
+
1307
+ subpart_form = SubpartForm(part_dict, instance=existing_subpart, ignore_part_revision=True, organization=self.organization)
1308
+ if not subpart_form.is_valid():
1309
+ add_nonfield_error_from_existing(subpart_form, self, f'Row {row_count} - ')
1310
+ continue
1311
+
1312
+ part_class = part_class_form.save(commit=False)
1313
+ part = part_form.save(commit=False)
1314
+ part_revision = part_revision_form.save(commit=False)
1315
+ subpart = subpart_form.save(commit=False)
1316
+
1317
+ reference_list = listify_string(reference) if reference else []
1318
+ if len(reference_list) != len(set(reference_list)):
1319
+ self.add_warning(None, f"Row {row_count} -Duplicate reference designators '{reference}' for subpart on row {row_count}.")
1320
+ if len(reference_list) != subpart.count and len(reference_list) > 0:
1321
+ self.add_warning(None, f"Row {row_count} -The quantity of reference designators for {part_number} on row {row_count} does not match the subpart quantity ({len(reference_list)} != {subpart.count})")
1322
+
1323
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
1324
+ part_class.save()
1325
+ part.number_class = part_class
1326
+
1327
+ part.organization = self.organization
1328
+ part.save()
1329
+ part_revision.part = part
1330
+ part_revision.save()
1331
+ if parent_part_revision:
1332
+ subpart.count += existing_subpart_count # append or create
1333
+ subpart.reference = existing_subpart_references + ', ' + subpart.reference if existing_subpart_references else subpart.reference
1334
+ subpart.part_revision = part_revision
1335
+ subpart.save()
1336
+ AssemblySubparts.objects.get_or_create(assembly=parent_part_revision.assembly, subpart=subpart)
1337
+
1338
+ info_msg = f"Row {row_count}: Added subpart {part_number}"
1339
+ if reference:
1340
+ info_msg += f" with reference designators {reference}"
1341
+ if parent_part_revision:
1342
+ info_msg += f" to parent part {parent_part_revision.part.full_part_number()}"
1343
+ self.successes.append(info_msg + ".")
1344
+
1345
+ # Now validate & save optional fields - Manufacturer, ManufacturerPart, SellerParts
1346
+ existing_manufacturer = Manufacturer.objects.filter(name=manufacturer_name, organization=self.organization).first()
1347
+ manufacturer_form = ManufacturerForm({'name': manufacturer_name}, instance=existing_manufacturer)
1348
+ if not manufacturer_form.is_valid():
1349
+ add_nonfield_error_from_existing(manufacturer_form, self, f'Row {row_count} - ')
1350
+
1351
+ manufacturer_part_data = {'manufacturer_part_number': manufacturer_part_number}
1352
+ manufacturer_part_form = ManufacturerPartForm(manufacturer_part_data)
1353
+ if not manufacturer_part_form.is_valid():
1354
+ add_nonfield_error_from_existing(manufacturer_part_form, self, f'Row {row_count} - ')
1355
+
1356
+ manufacturer = manufacturer_form.save(commit=False)
1357
+ manufacturer.organization = self.organization
1358
+ manufacturer.save()
1359
+
1360
+ manufacturer_part = manufacturer_part_form.save(commit=False)
1361
+ existing_manufacturer_part = ManufacturerPart.objects.filter(part=part, manufacturer=manufacturer, manufacturer_part_number=manufacturer_part.manufacturer_part_number).first()
1362
+ manufacturer_part.id = existing_manufacturer_part.id if existing_manufacturer_part else None
1363
+ manufacturer_part.manufacturer = manufacturer
1364
+ manufacturer_part.part = part
1365
+ manufacturer_part.save()
1366
+
1367
+ part.primary_manufacturer_part = manufacturer_part
1368
+ part.save()
1369
+
1370
+ last_part_revision = part_revision
1371
+ last_level = level
1372
+
1373
+ # TODO: Add SellerParts
1374
+ except UnicodeDecodeError as e:
1375
+ self.add_error(None, forms.ValidationError("CSV File Encoding error, try encoding your file as utf-8, and upload again. \
1376
+ If this keeps happening, reach out to info@indabom.com with your csv file and we'll do our best to \
1377
+ fix your issue!", code='invalid'))
1378
+ logger.warning("UnicodeDecodeError: {}".format(e))
1379
+ raise ValidationError("Specific Error: {}".format(e), code='invalid')
1380
+
1381
+ return cleaned_data
1382
+
1383
+
1384
+ class FileForm(forms.Form):
1385
+ file = forms.FileField()
1386
+
1387
+ def part_form_from_organization(organization):
1388
+ return PartFormSemiIntelligent if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT else PartFormIntelligent
1389
+
1390
+ def add_nonfield_error_from_existing(from_form, to_form, prefix=''):
1391
+ for field, errors in from_form.errors.as_data().items():
1392
+ for error in errors:
1393
+ for msg in error.messages:
1394
+ to_form.add_error(None, f'{prefix}{field}: {msg}')
1395
+
1396
+ def add_nonfield_warning_from_existing(from_form, to_form, prefix=''):
1397
+ for field, errors in from_form.errors.as_data().items():
1398
+ for error in errors:
1399
+ for msg in error.messages:
1400
+ to_form.add_warning(None, f'{prefix}{field}: {msg}')