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/forms.py ADDED
@@ -0,0 +1,1328 @@
1
+ import codecs
2
+ import csv
3
+ import logging
4
+
5
+ from django import forms
6
+ from django.contrib.auth.forms import UserCreationForm
7
+ from django.core.exceptions import ValidationError
8
+ from django.core.validators import MaxLengthValidator, MinLengthValidator
9
+ from django.db import IntegrityError
10
+ from django.forms.models import model_to_dict
11
+ from django.utils.translation import gettext_lazy as _
12
+ from djmoney.money import Money
13
+
14
+ from .constants import (
15
+ NUMBER_SCHEME_INTELLIGENT,
16
+ NUMBER_SCHEME_SEMI_INTELLIGENT,
17
+ ROLE_TYPE_VIEWER,
18
+ PART_REVISION_PROPERTY_TYPE_DECIMAL,
19
+ PART_REVISION_PROPERTY_TYPE_BOOLEAN,
20
+ )
21
+ from .csv_headers import (
22
+ CSVHeaderError,
23
+ PartClassesCSVHeaders,
24
+ )
25
+ from .form_fields import AutocompleteTextInput
26
+ from .models import (
27
+ Assembly,
28
+ AssemblySubparts,
29
+ Manufacturer,
30
+ ManufacturerPart,
31
+ Part,
32
+ PartClass,
33
+ PartRevision,
34
+ PartRevisionProperty,
35
+ PartRevisionPropertyDefinition,
36
+ QuantityOfMeasure,
37
+ Seller,
38
+ SellerPart,
39
+ Subpart,
40
+ UnitDefinition,
41
+ User,
42
+ get_user_meta_model,
43
+ get_organization_model,
44
+ )
45
+ from .utils import listify_string, stringify_list
46
+ from .validators import alphanumeric
47
+
48
+ logger = logging.getLogger(__name__)
49
+ Organization = get_organization_model()
50
+ UserMeta = get_user_meta_model()
51
+
52
+
53
+ # ==========================================
54
+ # MIXINS & BASE CLASSES
55
+ # ==========================================
56
+
57
+ class OrganizationFormMixin:
58
+ """Mixin to handle organization injection."""
59
+
60
+ def __init__(self, *args, **kwargs):
61
+ self.organization = kwargs.pop('organization', None)
62
+ super().__init__(*args, **kwargs)
63
+
64
+
65
+ class PlaceholderMixin:
66
+ """Mixin to move help_text to widget placeholders automatically."""
67
+
68
+ def __init__(self, *args, **kwargs):
69
+ super().__init__(*args, **kwargs)
70
+ for _, field in self.fields.items():
71
+ if field.help_text:
72
+ field.widget.attrs['placeholder'] = field.help_text
73
+ field.help_text = ''
74
+
75
+
76
+ class BaseCSVForm(OrganizationFormMixin, forms.Form):
77
+ """Abstract Base Class for CSV Import Forms to DRY up file handling."""
78
+ file = forms.FileField(required=False)
79
+
80
+ def get_csv_headers_handler(self):
81
+ """Subclasses must return the CSV headers handler instance."""
82
+ raise NotImplementedError
83
+
84
+ def get_header_assertions(self):
85
+ """Subclasses must return a list of header assertions."""
86
+ raise NotImplementedError
87
+
88
+ def process_row(self, row_data, row_count, headers_handler):
89
+ """Subclasses implement specific row logic here."""
90
+ raise NotImplementedError
91
+
92
+ def clean(self):
93
+ cleaned_data = super().clean()
94
+ file = self.cleaned_data.get('file')
95
+ self.successes = []
96
+ self.warnings = []
97
+
98
+ if not file:
99
+ return cleaned_data
100
+
101
+ try:
102
+ # Decode and Sniff
103
+ csvline_decoded = file.readline().decode('utf-8')
104
+ dialect = csv.Sniffer().sniff(csvline_decoded)
105
+ file.open()
106
+
107
+ # handle BOM
108
+ reader = csv.reader(codecs.iterdecode(file, 'utf-8'), dialect)
109
+ headers = [h.lower() for h in next(reader)]
110
+
111
+ if headers and "\ufeff" in headers[0]:
112
+ file.seek(0)
113
+ reader = csv.reader(codecs.iterdecode(file, 'utf-8-sig'), dialect)
114
+ headers = [h.lower() for h in next(reader)]
115
+
116
+ # Header Validation
117
+ csv_headers = self.get_csv_headers_handler()
118
+
119
+ try:
120
+ csv_headers.validate_header_names(headers)
121
+ except CSVHeaderError as e:
122
+ self.warnings.append(f"{e}. Columns ignored.")
123
+
124
+ try:
125
+ csv_headers.validate_header_assertions(headers, self.get_header_assertions())
126
+ headers = csv_headers.get_defaults_list(headers)
127
+ except CSVHeaderError as e:
128
+ raise ValidationError(f"{e}. Uploading stopped.", code='invalid')
129
+
130
+ # Row Processing
131
+ row_count = 1
132
+ for row in reader:
133
+ row_count += 1
134
+ row_data = {}
135
+ for idx, hdr in enumerate(headers):
136
+ if idx < len(row):
137
+ row_data[hdr] = row[idx]
138
+
139
+ self.process_row(row_data, row_count, csv_headers)
140
+
141
+ except UnicodeDecodeError as e:
142
+ self.add_error(None, forms.ValidationError(
143
+ "CSV File Encoding error. Please encode as utf-8.", code='invalid'
144
+ ))
145
+ logger.warning(f"UnicodeDecodeError: {e}")
146
+ raise ValidationError(f"Specific Error: {e}", code='invalid')
147
+ except Exception as e:
148
+ # Catch-all for unexpected errors during processing to ensure they bubble up cleanly
149
+ if isinstance(e, ValidationError):
150
+ raise e
151
+ logger.error(f"Error processing CSV: {e}")
152
+ self.add_error(None, f"An unexpected error occurred: {str(e)}")
153
+
154
+ return cleaned_data
155
+
156
+
157
+ # ==========================================
158
+ # USER & AUTH FORMS
159
+ # ==========================================
160
+
161
+ class UserModelChoiceField(forms.ModelChoiceField):
162
+ def label_from_instance(self, user):
163
+ parts = [f"[{user.username}]"]
164
+ if user.first_name: parts.append(user.first_name)
165
+ if user.last_name: parts.append(user.last_name)
166
+ if user.email: parts.append(f", {user.email}")
167
+ return " ".join(parts)
168
+
169
+
170
+ class UserCreateForm(UserCreationForm):
171
+ first_name = forms.CharField(required=True)
172
+ last_name = forms.CharField(required=True)
173
+ email = forms.EmailField(required=True)
174
+
175
+ def clean_email(self):
176
+ email = self.cleaned_data['email']
177
+ if User.objects.filter(email__iexact=email).exists():
178
+ raise ValidationError('An account with this email address already exists.')
179
+ return email
180
+
181
+ def save(self, commit=True):
182
+ user = super().save(commit=commit)
183
+ user.email = self.cleaned_data['email']
184
+ user.first_name = self.cleaned_data['first_name']
185
+ user.last_name = self.cleaned_data['last_name']
186
+ if commit:
187
+ user.save()
188
+ return user
189
+
190
+
191
+ class UserForm(forms.ModelForm):
192
+ class Meta:
193
+ model = User
194
+ fields = ['first_name', 'last_name', 'email']
195
+
196
+
197
+ class UserAddForm(OrganizationFormMixin, forms.ModelForm):
198
+ class Meta:
199
+ model = UserMeta
200
+ fields = ['role']
201
+
202
+ field_order = ['username', 'role']
203
+ username = forms.CharField(initial=None, required=False)
204
+
205
+ def __init__(self, *args, **kwargs):
206
+ hide_username = kwargs.pop('exclude_username', False)
207
+ super().__init__(*args, **kwargs)
208
+ self.fields['role'].required = False
209
+ if hide_username and self.instance.pk:
210
+ self.fields['username'].widget = forms.HiddenInput()
211
+ self.fields['username'].initial = self.instance.user.username
212
+
213
+ def clean_username(self):
214
+ username = self.cleaned_data.get('username')
215
+ try:
216
+ user = User.objects.get(username=username)
217
+ user_meta = user.bom_profile()
218
+ if user_meta.organization == self.organization:
219
+ self.add_error('username', f"User '{username}' already belongs to {self.organization}.")
220
+ elif user_meta.organization:
221
+ self.add_error('username', f"User '{username}' belongs to another organization.")
222
+ except User.DoesNotExist:
223
+ self.add_error('username', f"User '{username}' does not exist.")
224
+ return username
225
+
226
+ def clean_role(self):
227
+ return self.cleaned_data.get('role') or ROLE_TYPE_VIEWER
228
+
229
+ def save(self, commit=True):
230
+ username = self.cleaned_data.get('username')
231
+ user = User.objects.get(username=username)
232
+ user_meta = user.bom_profile()
233
+ user_meta.organization = self.organization
234
+ user_meta.role = self.cleaned_data.get('role')
235
+ user_meta.save()
236
+ return user_meta
237
+
238
+
239
+ class UserMetaForm(OrganizationFormMixin, forms.ModelForm):
240
+ class Meta:
241
+ model = UserMeta
242
+ exclude = ['user']
243
+
244
+ def save(self, commit=True):
245
+ self.instance.organization = self.organization
246
+ if commit:
247
+ self.instance.save()
248
+ return self.instance
249
+
250
+
251
+ # ==========================================
252
+ # ORGANIZATION FORMS
253
+ # ==========================================
254
+
255
+ class OrganizationBaseForm(forms.ModelForm):
256
+ class Meta:
257
+ model = Organization
258
+ fields = ['name', 'number_class_code_len', 'number_item_len', 'number_variation_len']
259
+ labels = {
260
+ "name": "Organization Name",
261
+ "number_class_code_len": "Number Class Code Length (C)",
262
+ "number_item_len": "Number Item Length (N)",
263
+ "number_variation_len": "Number Variation Length (V)",
264
+ }
265
+
266
+
267
+ class OrganizationCreateForm(OrganizationBaseForm):
268
+ def __init__(self, *args, **kwargs):
269
+ super().__init__(*args, **kwargs)
270
+ if self.data.get('number_scheme') == NUMBER_SCHEME_INTELLIGENT:
271
+ self.data = self.data.copy()
272
+ self.data.update({
273
+ 'number_class_code_len': 3,
274
+ 'number_item_len': 128,
275
+ 'number_variation_len': 2
276
+ })
277
+
278
+ class Meta(OrganizationBaseForm.Meta):
279
+ fields = OrganizationBaseForm.Meta.fields + ['number_scheme']
280
+
281
+
282
+ class OrganizationForm(OrganizationBaseForm):
283
+ def __init__(self, *args, **kwargs):
284
+ user = kwargs.pop('user', None)
285
+ super().__init__(*args, **kwargs)
286
+ if user and self.instance.owner == user:
287
+ # Only show owner selection if current user is owner
288
+ admin_ids = UserMeta.objects.filter(
289
+ organization=self.instance, role='A'
290
+ ).values_list('user', flat=True)
291
+ user_qs = User.objects.filter(id__in=admin_ids).order_by('first_name', 'last_name')
292
+
293
+ self.fields['owner'] = UserModelChoiceField(
294
+ queryset=user_qs, label='Owner', initial=self.instance.owner, required=True
295
+ )
296
+
297
+
298
+ class OrganizationFormEditSettings(OrganizationForm):
299
+ class Meta(OrganizationBaseForm.Meta):
300
+ fields = ['name', 'owner', 'currency']
301
+
302
+
303
+ class OrganizationNumberLenForm(OrganizationBaseForm):
304
+ class Meta(OrganizationBaseForm.Meta):
305
+ fields = ['number_class_code_len', 'number_item_len', 'number_variation_len']
306
+
307
+ def __init__(self, *args, **kwargs):
308
+ self.organization = kwargs.get('instance')
309
+ super().__init__(*args, **kwargs)
310
+
311
+
312
+ # ==========================================
313
+ # PART & MFG FORMS
314
+ # ==========================================
315
+
316
+ class PartInfoForm(forms.Form):
317
+ quantity = forms.IntegerField(label='Quantity for Est Cost', min_value=1)
318
+
319
+
320
+ class ManufacturerForm(forms.ModelForm):
321
+ class Meta:
322
+ model = Manufacturer
323
+ exclude = ['organization']
324
+
325
+ def __init__(self, *args, **kwargs):
326
+ super().__init__(*args, **kwargs)
327
+ self.fields['name'].required = False
328
+
329
+
330
+ class ManufacturerPartForm(OrganizationFormMixin, forms.ModelForm):
331
+ class Meta:
332
+ model = ManufacturerPart
333
+ exclude = ['part']
334
+
335
+ field_order = ['manufacturer_part_number', 'manufacturer']
336
+
337
+ def __init__(self, *args, **kwargs):
338
+ super().__init__(*args, **kwargs)
339
+ self.fields['manufacturer'].required = False
340
+ self.fields['manufacturer_part_number'].required = False
341
+ self.fields['manufacturer'].queryset = Manufacturer.objects.filter(
342
+ organization=self.organization
343
+ ).order_by('name')
344
+ self.fields['mouser_disable'].initial = True
345
+
346
+
347
+ class SellerForm(forms.ModelForm):
348
+ class Meta:
349
+ model = Seller
350
+ exclude = ['organization']
351
+
352
+
353
+ class SellerPartForm(OrganizationFormMixin, forms.ModelForm):
354
+ new_seller = forms.CharField(max_length=128, label='-or- Create new seller', required=False)
355
+
356
+ class Meta:
357
+ model = SellerPart
358
+ exclude = ['manufacturer_part', 'data_source']
359
+
360
+ field_order = ['seller', 'new_seller', 'unit_cost', 'nre_cost', 'lead_time_days', 'minimum_order_quantity',
361
+ 'minimum_pack_quantity']
362
+
363
+ def __init__(self, *args, **kwargs):
364
+ self.manufacturer_part = kwargs.pop('manufacturer_part', None)
365
+ super().__init__(*args, **kwargs)
366
+
367
+ # Currency formatting
368
+ self.fields['unit_cost'] = forms.DecimalField(required=True, decimal_places=4, max_digits=17)
369
+ self.fields['nre_cost'] = forms.DecimalField(required=True, decimal_places=4, max_digits=17, label='NRE cost')
370
+
371
+ if self.instance.pk:
372
+ self.initial['unit_cost'] = self.instance.unit_cost.amount
373
+ self.initial['nre_cost'] = self.instance.nre_cost.amount
374
+
375
+ if self.manufacturer_part:
376
+ self.instance.manufacturer_part = self.manufacturer_part
377
+
378
+ self.fields['seller'].queryset = Seller.objects.filter(organization=self.organization).order_by('name')
379
+ self.fields['seller'].required = False
380
+
381
+ def clean(self):
382
+ cleaned_data = super().clean()
383
+ seller = cleaned_data.get('seller')
384
+ new_seller = cleaned_data.get('new_seller')
385
+
386
+ # Handle Money fields
387
+ for field in ['unit_cost', 'nre_cost']:
388
+ val = cleaned_data.get(field)
389
+ if val is None:
390
+ self.add_error(field, "Invalid cost.")
391
+ else:
392
+ setattr(self.instance, field, Money(val, self.organization.currency))
393
+
394
+ if seller and new_seller:
395
+ raise forms.ValidationError("Cannot have a seller and a new seller.")
396
+ elif new_seller:
397
+ obj, _ = Seller.objects.get_or_create(
398
+ name__iexact=new_seller, organization=self.organization,
399
+ defaults={'name': new_seller}
400
+ )
401
+ cleaned_data['seller'] = obj
402
+ elif not seller:
403
+ raise forms.ValidationError("Must specify a seller.")
404
+
405
+ return cleaned_data
406
+
407
+
408
+ class QuantityOfMeasureForm(OrganizationFormMixin, forms.ModelForm):
409
+ class Meta:
410
+ model = QuantityOfMeasure
411
+ fields = ['name']
412
+
413
+ def clean(self):
414
+ cleaned_data = super().clean()
415
+ self.instance.organization = self.organization
416
+ return cleaned_data
417
+
418
+
419
+ class UnitDefinitionForm(OrganizationFormMixin, forms.ModelForm):
420
+ class Meta:
421
+ model = UnitDefinition
422
+ fields = ['name', 'symbol', 'base_multiplier', ]
423
+
424
+ def clean(self):
425
+ cleaned_data = super().clean()
426
+ self.instance.organization = self.organization
427
+ return cleaned_data
428
+
429
+
430
+ UnitDefinitionFormSet = forms.modelformset_factory(
431
+ UnitDefinition,
432
+ form=UnitDefinitionForm,
433
+ can_delete=True,
434
+ extra=0
435
+ )
436
+
437
+
438
+ class PartRevisionPropertyDefinitionForm(OrganizationFormMixin, forms.ModelForm):
439
+ class Meta:
440
+ model = PartRevisionPropertyDefinition
441
+ fields = ['name', 'type', 'required', 'quantity_of_measure']
442
+
443
+ def __init__(self, *args, **kwargs):
444
+ super().__init__(*args, **kwargs)
445
+ self.fields['quantity_of_measure'].queryset = QuantityOfMeasure.objects.available_to(
446
+ self.organization).order_by('name')
447
+
448
+
449
+ class PartRevisionPropertyDefinitionSelectForm(OrganizationFormMixin, forms.Form):
450
+ property_definition = forms.ModelChoiceField(queryset=PartRevisionPropertyDefinition.objects.none())
451
+
452
+ def __init__(self, *args, **kwargs):
453
+ super().__init__(*args, **kwargs)
454
+ self.fields['property_definition'].queryset = PartRevisionPropertyDefinition.objects.available_to(
455
+ self.organization).order_by('name')
456
+
457
+
458
+ PartRevisionPropertyDefinitionFormSet = forms.formset_factory(
459
+ PartRevisionPropertyDefinitionSelectForm,
460
+ can_delete=True,
461
+ extra=0
462
+ )
463
+
464
+
465
+ class PartClassForm(OrganizationFormMixin, forms.ModelForm):
466
+ class Meta:
467
+ model = PartClass
468
+ fields = ['code', 'name', 'comment']
469
+
470
+ def __init__(self, *args, **kwargs):
471
+ self.ignore_unique_constraint = kwargs.pop('ignore_unique_constraint', False)
472
+ super().__init__(*args, **kwargs)
473
+ self.fields['code'].required = False
474
+ self.fields['name'].required = False
475
+ self.fields['code'].validators.extend([
476
+ MaxLengthValidator(self.organization.number_class_code_len),
477
+ MinLengthValidator(self.organization.number_class_code_len)
478
+ ])
479
+
480
+ def clean_name(self):
481
+ name = self.cleaned_data.get('name')
482
+ if not self.ignore_unique_constraint:
483
+ if PartClass.objects.filter(name__iexact=name, organization=self.organization).exclude(
484
+ pk=self.instance.pk).exists():
485
+ self.add_error('name', f"Part class with name {name} is already defined.")
486
+ return name
487
+
488
+ def clean_code(self):
489
+ code = self.cleaned_data.get('code')
490
+ if not self.ignore_unique_constraint:
491
+ if PartClass.objects.filter(code=code, organization=self.organization).exclude(
492
+ pk=self.instance.pk).exists():
493
+ self.add_error('code', f"Part class with code {code} is already defined.")
494
+ return code
495
+
496
+ def clean(self):
497
+ cleaned_data = super().clean()
498
+ self.instance.organization = self.organization
499
+ return cleaned_data
500
+
501
+
502
+ PartClassFormSet = forms.formset_factory(PartClassForm, extra=2, can_delete=True)
503
+
504
+
505
+ class PartClassSelectionForm(OrganizationFormMixin, forms.Form):
506
+ def __init__(self, *args, **kwargs):
507
+ super().__init__(*args, **kwargs)
508
+ self.fields['part_class'] = forms.CharField(
509
+ required=False,
510
+ widget=AutocompleteTextInput(
511
+ attrs={'placeholder': 'Select a part class.'},
512
+ autocomplete_submit=True,
513
+ queryset=PartClass.objects.filter(organization=self.organization)
514
+ )
515
+ )
516
+
517
+ def clean_part_class(self):
518
+ pc_input = self.cleaned_data['part_class']
519
+ if not pc_input:
520
+ return None
521
+
522
+ try:
523
+ return PartClass.objects.get(organization=self.organization, code=pc_input.split(':')[0])
524
+ except PartClass.DoesNotExist:
525
+ pc = PartClass.objects.filter(name__icontains=pc_input).order_by('name').first()
526
+ if pc:
527
+ return pc
528
+ self.add_error('part_class', 'Select a valid part class.')
529
+ return None
530
+
531
+
532
+ # ==========================================
533
+ # PART FORMS
534
+ # ==========================================
535
+
536
+ class BasePartForm(OrganizationFormMixin, PlaceholderMixin, forms.ModelForm):
537
+ """Base class for part forms to handle common init and placeholder logic."""
538
+
539
+ def __init__(self, *args, **kwargs):
540
+ self.ignore_part_class = kwargs.pop('ignore_part_class', False)
541
+ self.ignore_unique_constraint = kwargs.pop('ignore_unique_constraint', False)
542
+ super().__init__(*args, **kwargs)
543
+
544
+ # Setup MFG Part Queryset if editing
545
+ if self.instance.pk:
546
+ self.fields['primary_manufacturer_part'].queryset = ManufacturerPart.objects.filter(
547
+ part__id=self.instance.id
548
+ ).order_by('manufacturer_part_number')
549
+ elif 'primary_manufacturer_part' in self.fields:
550
+ del self.fields['primary_manufacturer_part']
551
+
552
+
553
+ class PartFormIntelligent(BasePartForm):
554
+ class Meta:
555
+ model = Part
556
+ exclude = ['number_class', 'number_variation', 'organization', 'google_drive_parent']
557
+ help_texts = {'number_item': _('Enter a part number.')}
558
+
559
+ def __init__(self, *args, **kwargs):
560
+ super().__init__(*args, **kwargs)
561
+ self.fields['number_item'].required = True
562
+
563
+
564
+ class PartFormSemiIntelligent(BasePartForm):
565
+ class Meta:
566
+ model = Part
567
+ exclude = ['organization', 'google_drive_parent', ]
568
+ help_texts = {
569
+ 'number_item': _('Auto generated if blank.'),
570
+ 'number_variation': 'Auto generated if blank.',
571
+ }
572
+
573
+ def __init__(self, *args, **kwargs):
574
+ super().__init__(*args, **kwargs)
575
+ self.fields['number_item'].validators.append(alphanumeric)
576
+ self.fields['number_class'] = forms.CharField(
577
+ label='Part Number Class*', required=True,
578
+ help_text='Select a number class.',
579
+ widget=AutocompleteTextInput(queryset=PartClass.objects.filter(organization=self.organization))
580
+ )
581
+
582
+ # Convert ID to string for Autocomplete
583
+ if self.initial.get('number_class'):
584
+ try:
585
+ self.initial['number_class'] = str(PartClass.objects.get(id=self.initial['number_class']))
586
+ except PartClass.DoesNotExist:
587
+ self.initial['number_class'] = ""
588
+
589
+ if self.ignore_part_class:
590
+ self.fields['number_class'].required = False
591
+
592
+ def clean_number_class(self):
593
+ if self.ignore_part_class: return None
594
+ nc = self.cleaned_data['number_class']
595
+ try:
596
+ return PartClass.objects.get(organization=self.organization, code=nc.split(':')[0])
597
+ except PartClass.DoesNotExist:
598
+ self.add_error('number_class', f'Select an existing part class, or create `{nc}` in Settings.')
599
+ return None
600
+
601
+ def clean(self):
602
+ cleaned_data = super().clean()
603
+ n_item = cleaned_data.get('number_item')
604
+ n_class = cleaned_data.get('number_class')
605
+ n_var = cleaned_data.get('number_variation')
606
+
607
+ # Format Verification
608
+ try:
609
+ if n_class and n_class.code: Part.verify_format_number_class(n_class.code, self.organization)
610
+ except AttributeError as e:
611
+ self.add_error('number_class', str(e))
612
+
613
+ try:
614
+ if n_item: Part.verify_format_number_item(n_item, self.organization)
615
+ except AttributeError as e:
616
+ self.add_error('number_item', str(e))
617
+
618
+ try:
619
+ if n_var: Part.verify_format_number_variation(n_var, self.organization)
620
+ except AttributeError as e:
621
+ self.add_error('number_variation', str(e))
622
+
623
+ # Uniqueness Check
624
+ if not self.ignore_unique_constraint:
625
+ qs = Part.objects.filter(number_class=n_class, number_item=n_item, number_variation=n_var,
626
+ organization=self.organization)
627
+ if self.instance.pk: qs = qs.exclude(pk=self.instance.pk)
628
+
629
+ if qs.exists():
630
+ self.add_error(None, f"Part number {n_class.code}-{n_item}-{n_var} already in use.")
631
+
632
+ return cleaned_data
633
+
634
+
635
+ class PartRevisionForm(OrganizationFormMixin, PlaceholderMixin, forms.ModelForm):
636
+ class Meta:
637
+ model = PartRevision
638
+ exclude = ['timestamp', 'assembly', 'part']
639
+ help_texts = {'description': _('Additional part info, special instructions, etc.')}
640
+
641
+ def __init__(self, *args, **kwargs):
642
+ self.part_class = kwargs.pop('part_class', None)
643
+ super().__init__(*args, **kwargs)
644
+ self.fields['revision'].initial = 1
645
+ self.fields['configuration'].required = False
646
+ if not self.part_class and self.instance.pk:
647
+ self.part_class = self.instance.part.number_class
648
+
649
+ if self.part_class:
650
+ self.property_definitions = self.part_class.property_definitions.all().order_by('name')
651
+ elif self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT:
652
+ self.property_definitions = PartRevisionPropertyDefinition.objects.available_to(self.organization).order_by(
653
+ 'name')
654
+ else:
655
+ self.property_definitions = PartRevisionPropertyDefinition.objects.none()
656
+
657
+ self._init_dynamic_properties()
658
+
659
+ def _init_dynamic_properties(self):
660
+ """Dynamically add fields based on Property Definitions."""
661
+ model_field = PartRevisionProperty._meta.get_field('value_raw')
662
+ for pd in self.property_definitions:
663
+ field_name = pd.form_field_name
664
+ if self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT:
665
+ req = False
666
+ else:
667
+ req = pd.required
668
+
669
+ if pd.type == PART_REVISION_PROPERTY_TYPE_DECIMAL:
670
+ self.fields[field_name] = forms.DecimalField(label=pd.name, required=req)
671
+ elif pd.type == PART_REVISION_PROPERTY_TYPE_BOOLEAN:
672
+ self.fields[field_name] = forms.BooleanField(label=pd.name, required=req)
673
+ else:
674
+ self.fields[field_name] = forms.CharField(label=pd.name, required=req,
675
+ max_length=model_field.max_length,
676
+ widget=forms.TextInput(
677
+ attrs={'maxlength': str(model_field.max_length)}))
678
+
679
+ # Pre-fill
680
+ prop = None
681
+ if self.instance.pk:
682
+ prop = self.instance.properties.filter(property_definition=pd).first()
683
+ if prop: self.fields[field_name].initial = prop.value_raw
684
+
685
+ # Unit Logic
686
+ if pd.quantity_of_measure:
687
+ unit_field = pd.form_unit_field_name
688
+ units = UnitDefinition.objects.filter(quantity_of_measure=pd.quantity_of_measure)
689
+ choices = [('', '---------')] + [(u.id, u.symbol) for u in units]
690
+ self.fields[unit_field] = forms.ChoiceField(choices=choices, required=False, label=f"{pd.name} Unit")
691
+ if self.instance.pk and prop and prop.unit_definition:
692
+ self.fields[unit_field].initial = prop.unit_definition.id
693
+
694
+ def property_fields(self):
695
+ """Yields property fields grouped with their corresponding unit fields."""
696
+ for pd in self.property_definitions:
697
+ yield {
698
+ 'property': self[pd.form_field_name],
699
+ 'unit': self[pd.form_unit_field_name] if pd.quantity_of_measure else None,
700
+ }
701
+
702
+ @property
703
+ def property_field_names(self):
704
+ """Returns a list of all field names associated with dynamic properties."""
705
+ names = []
706
+ for pd in self.property_definitions:
707
+ names.append(pd.form_field_name)
708
+ if pd.quantity_of_measure:
709
+ names.append(pd.form_unit_field_name)
710
+ return names
711
+
712
+ def save_properties(self):
713
+ for defn in self.property_definitions:
714
+ val = self.cleaned_data.get(defn.form_field_name)
715
+ if val not in (None, ''):
716
+ unit_id = self.cleaned_data.get(defn.form_unit_field_name)
717
+ unit = UnitDefinition.objects.get(id=unit_id) if unit_id else None
718
+ PartRevisionProperty.objects.update_or_create(
719
+ part_revision=self.instance, property_definition=defn,
720
+ defaults={'value_raw': str(val), 'unit_definition': unit}
721
+ )
722
+ else:
723
+ PartRevisionProperty.objects.filter(part_revision=self.instance, property_definition=defn).delete()
724
+
725
+ def save(self, commit=True):
726
+ instance = super().save(commit=False)
727
+ if commit:
728
+ instance.save()
729
+ self.save_m2m()
730
+ self.save_properties()
731
+ return instance
732
+
733
+
734
+ class PartRevisionNewForm(PartRevisionForm):
735
+ copy_assembly = forms.BooleanField(label='Copy assembly from latest revision', initial=True, required=False)
736
+
737
+ def __init__(self, *args, **kwargs):
738
+ self.part = kwargs.pop('part', None)
739
+ self.revision = kwargs.pop('revision', None)
740
+ self.assembly = kwargs.pop('assembly', None)
741
+ super().__init__(*args, **kwargs)
742
+
743
+ def save(self, commit=True):
744
+ if not self.instance.pk:
745
+ self.instance.part = self.part
746
+ self.instance.revision = self.revision
747
+ self.instance.assembly = self.assembly
748
+ else:
749
+ # If we are incrementing from an existing instance, we want to create a NEW record
750
+ self.instance.pk = None
751
+ self.instance.part = self.part
752
+ self.instance.revision = self.revision
753
+ self.instance.assembly = self.assembly
754
+ return super().save(commit=commit)
755
+
756
+
757
+ # ==========================================
758
+ # SUBPART / BOM FORMS
759
+ # ==========================================
760
+
761
+ class SubpartForm(OrganizationFormMixin, forms.ModelForm):
762
+ class Meta:
763
+ model = Subpart
764
+ fields = ['part_revision', 'reference', 'count', 'do_not_load']
765
+
766
+ def __init__(self, *args, **kwargs):
767
+ self.part_id = kwargs.pop('part_id', None)
768
+ self.ignore_part_revision = kwargs.pop('ignore_part_revision', False)
769
+ super().__init__(*args, **kwargs)
770
+
771
+ if not self.part_id:
772
+ self.Meta.exclude = ['part_revision']
773
+ else:
774
+ self.fields['part_revision'].queryset = PartRevision.objects.filter(part__id=self.part_id).order_by(
775
+ '-timestamp')
776
+
777
+ if self.ignore_part_revision:
778
+ self.fields['part_revision'].required = False
779
+
780
+ def clean_count(self):
781
+ return self.cleaned_data['count'] or 0
782
+
783
+ def clean_reference(self):
784
+ return stringify_list(listify_string(self.cleaned_data['reference']))
785
+
786
+ def clean(self):
787
+ cleaned_data = super().clean()
788
+ refs = listify_string(cleaned_data.get('reference'))
789
+ count = cleaned_data.get('count')
790
+ if len(refs) > 0 and len(refs) != count:
791
+ raise ValidationError(f"Reference designators count ({len(refs)}) mismatch subpart quantity ({count}).")
792
+ return cleaned_data
793
+
794
+
795
+ class AddSubpartForm(OrganizationFormMixin, forms.Form):
796
+ subpart_part_number = forms.CharField(label="Subpart part number", required=True)
797
+ count = forms.FloatField(required=False, label='Quantity')
798
+ reference = forms.CharField(required=False, label="Reference")
799
+ do_not_load = forms.BooleanField(required=False, label="do_not_load")
800
+
801
+ def __init__(self, *args, **kwargs):
802
+ self.part_id = kwargs.pop('part_id', None)
803
+ super().__init__(*args, **kwargs)
804
+
805
+ self.part = Part.objects.get(id=self.part_id)
806
+ # Filter logic
807
+ self.fields['subpart_part_number'].widget = AutocompleteTextInput(
808
+ attrs={'placeholder': 'Select a part.'},
809
+ queryset=Part.objects.filter(organization=self.organization).exclude(id=self.part_id),
810
+ verbose_string_function=Part.verbose_str
811
+ )
812
+
813
+ def clean_subpart_part_number(self):
814
+ subpart_part_number = self.cleaned_data['subpart_part_number']
815
+ if not subpart_part_number:
816
+ raise ValidationError("Must specify a part number.")
817
+
818
+ try:
819
+ if self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT:
820
+ part = Part.objects.get(number_item=subpart_part_number, organization=self.organization)
821
+ else:
822
+ (number_class, number_item, number_variation) = Part.parse_partial_part_number(subpart_part_number, self.organization, validate=False)
823
+ part_class = PartClass.objects.get(code=number_class, organization=self.organization)
824
+ part = Part.objects.get(number_class=part_class, number_item=number_item, number_variation=number_variation, organization=self.organization)
825
+ self.subpart_part = part.latest()
826
+ if self.subpart_part is None:
827
+ 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.")
828
+ return subpart_part_number
829
+
830
+ unusable_ids = [pr.id for pr in self.part.latest().where_used_full()] + [self.part.latest().id]
831
+ if self.subpart_part.id in unusable_ids:
832
+ raise ValidationError("Infinite recursion! Can't add a part to itself.")
833
+
834
+ except (AttributeError, PartClass.DoesNotExist, Part.DoesNotExist) as e:
835
+ raise ValidationError(f"Invalid part number: {e}")
836
+
837
+ return subpart_part_number
838
+
839
+ def clean_count(self):
840
+ return self.cleaned_data.get('count') or 0
841
+
842
+ def clean_reference(self):
843
+ return stringify_list(listify_string(self.cleaned_data.get('reference')))
844
+
845
+ def clean(self):
846
+ cleaned = super().clean()
847
+ refs = listify_string(cleaned.get('reference'))
848
+ count = cleaned.get('count')
849
+ if len(refs) > 0 and len(refs) != count:
850
+ raise ValidationError(f"Reference count ({len(refs)}) mismatch quantity ({count}).")
851
+ return cleaned
852
+
853
+
854
+ # ==========================================
855
+ # CSV IMPORT FORMS
856
+ # ==========================================
857
+
858
+ class PartClassCSVForm(BaseCSVForm):
859
+ def get_csv_headers_handler(self):
860
+ return PartClassesCSVHeaders()
861
+
862
+ def get_header_assertions(self):
863
+ return [
864
+ ('comment', 'description', 'mex'),
865
+ ('code', 'in'),
866
+ ('name', 'in'),
867
+ ]
868
+
869
+ def process_row(self, row_data, row_count, csv_headers):
870
+ name = csv_headers.get_val_from_row(row_data, 'name')
871
+ code = csv_headers.get_val_from_row(row_data, 'code')
872
+ desc = csv_headers.get_val_from_row(row_data, 'description')
873
+ comment = csv_headers.get_val_from_row(row_data, 'comment')
874
+
875
+ if not code:
876
+ self.add_error(None, f"Row {row_count}: Missing code.")
877
+ return
878
+
879
+ if len(code) != self.organization.number_class_code_len:
880
+ self.add_error(None, f"Row {row_count}: Invalid code length.")
881
+ return
882
+
883
+ description = desc or comment or ''
884
+ try:
885
+ PartClass.objects.create(code=code, name=name, comment=description, organization=self.organization)
886
+ self.successes.append(f"Part class {code} {name} on row {row_count} created.")
887
+ except IntegrityError:
888
+ self.add_error(None, f"Row {row_count}: Part class {code} {name} already defined.")
889
+
890
+
891
+ class PartCSVForm(BaseCSVForm):
892
+ def __init__(self, *args, **kwargs):
893
+ super().__init__(*args, **kwargs)
894
+ # Pre-fetch valid units
895
+ self.valid_units = {u.symbol: u.id for u in UnitDefinition.objects.available_to(self.organization)}
896
+
897
+ def get_csv_headers_handler(self):
898
+ return self.organization.part_list_csv_headers()
899
+
900
+ def get_header_assertions(self):
901
+ return [
902
+ ('part_class', 'part_number', 'or'),
903
+ ('revision', 'in'),
904
+ ('value', 'value_units', 'and', 'description', 'or'),
905
+ ]
906
+
907
+ def process_row(self, row_data, row_count, csv_headers):
908
+ part_number = csv_headers.get_val_from_row(row_data, 'part_number')
909
+ part_class = csv_headers.get_val_from_row(row_data, 'part_class')
910
+ number_item = None
911
+ number_variation = None
912
+ revision = csv_headers.get_val_from_row(row_data, 'revision')
913
+ mpn = csv_headers.get_val_from_row(row_data, 'mpn')
914
+ mfg_name = csv_headers.get_val_from_row(row_data, 'mfg_name')
915
+ description = csv_headers.get_val_from_row(row_data, 'description')
916
+ seller_name = csv_headers.get_val_from_row(row_data, 'seller')
917
+ seller_part_number = csv_headers.get_val_from_row(row_data, 'seller_part_number')
918
+ unit_cost = csv_headers.get_val_from_row(row_data, 'unit_cost')
919
+ nre_cost = csv_headers.get_val_from_row(row_data, 'part_nre_cost')
920
+ moq = csv_headers.get_val_from_row(row_data, 'moq')
921
+ mpq = csv_headers.get_val_from_row(row_data, 'minimum_pack_quantity')
922
+
923
+ # Check part number for uniqueness. If part number not specified
924
+ # then Part.save() will create one.
925
+ if part_number:
926
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
927
+ try:
928
+ (number_class, number_item, number_variation) = Part.parse_part_number(part_number,
929
+ self.organization)
930
+ part_class = PartClass.objects.get(code=number_class, organization=self.organization)
931
+ Part.objects.get(number_class=part_class, number_item=number_item,
932
+ number_variation=number_variation, organization=self.organization)
933
+ self.add_error(None,
934
+ "Part number {0} in row {1} already exists. Uploading of this part skipped.".format(
935
+ part_number, row_count))
936
+ return
937
+ except AttributeError as e:
938
+ self.add_error(None, str(e) + " on row {}. Creation of this part skipped.".format(row_count))
939
+ return
940
+ except PartClass.DoesNotExist:
941
+ self.add_error(None,
942
+ "No part class found for part number {0} in row {1}. Creation of this part skipped.".format(
943
+ part_number, row_count))
944
+ return
945
+ except Part.DoesNotExist:
946
+ pass
947
+ else:
948
+ try:
949
+ number_item = part_number
950
+ Part.objects.get(number_class=None, number_item=number_item, number_variation=None,
951
+ organization=self.organization)
952
+ self.add_error(None,
953
+ f"Part number {part_number} in row {row_count} already exists. Uploading of this part skipped.")
954
+ return
955
+ except Part.DoesNotExist:
956
+ pass
957
+ elif part_class:
958
+ try:
959
+ part_class = PartClass.objects.get(code=row_data[csv_headers.get_default('part_class')],
960
+ organization=self.organization)
961
+ except PartClass.DoesNotExist:
962
+ self.add_error(None,
963
+ "Part class {0} in row {1} doesn't exist. Create part class on Settings > IndaBOM and try again."
964
+ "Uploading of this part skipped.".format(
965
+ row_data[csv_headers.get_default('part_class')], row_count))
966
+ return
967
+ else:
968
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
969
+ self.add_error(None,
970
+ "In row {} need to specify a part_class or part_number. Uploading of this part skipped.".format(
971
+ row_count))
972
+ else:
973
+ self.add_error(None, "In row {} need to specify a part_number. Uploading of this part skipped.".format(
974
+ row_count))
975
+ return
976
+
977
+ if not revision:
978
+ self.add_error(None, f"Missing revision in row {row_count}. Uploading of this part skipped.")
979
+ return
980
+ elif len(revision) > 4:
981
+ self.add_error(None, "Revision {0} in row {1} is more than the maximum 4 characters. "
982
+ "Uploading of this part skipped.".format(
983
+ row_data[csv_headers.get_default('revision')], row_count))
984
+ return
985
+ elif revision.isdigit() and int(revision) < 0:
986
+ self.add_error(None, "Revision {0} in row {1} cannot be a negative number. "
987
+ "Uploading of this part skipped.".format(
988
+ row_data[csv_headers.get_default('revision')], row_count))
989
+ return
990
+
991
+ if mpn and mfg_name:
992
+ manufacturer_part = ManufacturerPart.objects.filter(manufacturer_part_number=mpn,
993
+ manufacturer__name=mfg_name,
994
+ manufacturer__organization=self.organization)
995
+ if manufacturer_part.count() > 0:
996
+ self.add_error(None, "Part already exists for manufacturer part {0} in row {1}. "
997
+ "Uploading of this part skipped.".format(row_count, mpn, row_count))
998
+ return
999
+
1000
+ skip = False
1001
+ row_data['revision'] = revision
1002
+ row_data['description'] = description
1003
+
1004
+ if self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT and number_item is None:
1005
+ self.add_error(None,
1006
+ "Can't upload a part without a number_item header for part in row {}. Uploading of this part skipped.".format(
1007
+ row_count))
1008
+ skip = True
1009
+
1010
+ if skip:
1011
+ return
1012
+
1013
+ PartForm = part_form_from_organization(self.organization)
1014
+ part = Part(number_class=part_class, number_item=number_item, number_variation=number_variation,
1015
+ organization=self.organization)
1016
+ part_dict = model_to_dict(part)
1017
+ part_dict.update({'number_class': str(part.number_class)})
1018
+ pf = PartForm(data=part_dict, organization=self.organization)
1019
+ prf = PartRevisionForm(data=row_data, part_class=part_class, organization=self.organization)
1020
+
1021
+ if pf.is_valid() and prf.is_valid():
1022
+ part = pf.save(commit=False)
1023
+ part.organization = self.organization
1024
+ part.save()
1025
+ part_revision = prf.save(commit=False)
1026
+ part_revision.part = part
1027
+ part_revision.save()
1028
+
1029
+ if mfg_name and mpn:
1030
+ mfg, created = Manufacturer.objects.get_or_create(name__iexact=mfg_name, organization=self.organization,
1031
+ defaults={'name': mfg_name})
1032
+ manufacturer_part, created = ManufacturerPart.objects.get_or_create(part=part,
1033
+ manufacturer_part_number=mpn,
1034
+ manufacturer=mfg)
1035
+ if part.primary_manufacturer_part is None and manufacturer_part is not None:
1036
+ part.primary_manufacturer_part = manufacturer_part
1037
+ part.save()
1038
+
1039
+ if seller_name and unit_cost and nre_cost:
1040
+ seller, created = Seller.objects.get_or_create(name__iexact=seller_name,
1041
+ organization=self.organization,
1042
+ defaults={'name': seller_name})
1043
+ seller_part, created = SellerPart.objects.get_or_create(manufacturer_part=manufacturer_part,
1044
+ seller=seller,
1045
+ seller_part_number=seller_part_number,
1046
+ unit_cost=unit_cost, nre_cost=nre_cost,
1047
+ minimum_order_quantity=moq,
1048
+ minimum_pack_quantity=mpq)
1049
+
1050
+ self.successes.append("Part {0} on row {1} created.".format(part.full_part_number(), row_count))
1051
+ else:
1052
+ for k, error in prf.errors.items():
1053
+ for idx, msg in enumerate(error):
1054
+ error[idx] = f"Error on Row {row_count}, {k}: " + msg
1055
+ self.errors.update({k: error})
1056
+ for k, error in pf.errors.items():
1057
+ for idx, msg in enumerate(error):
1058
+ error[idx] = f"Error on Row {row_count}, {k}: " + msg
1059
+ self.errors.update({k: error})
1060
+
1061
+
1062
+ class BOMCSVForm(BaseCSVForm):
1063
+ def __init__(self, *args, **kwargs):
1064
+ self.parent_part = kwargs.pop('parent_part', None)
1065
+ super().__init__(*args, **kwargs)
1066
+ self.parent_part_revision = self.parent_part.latest() if self.parent_part else None
1067
+ self.part_revision_tree = [self.parent_part_revision] if self.parent_part_revision else []
1068
+ self.last_level = None
1069
+ self.last_part_revision = self.parent_part_revision
1070
+
1071
+ def get_csv_headers_handler(self):
1072
+ return self.organization.bom_indented_csv_headers()
1073
+
1074
+ def get_header_assertions(self):
1075
+ return [
1076
+ ('part_number', 'manufacturer_part_number', 'or'),
1077
+ ('quantity', 'in'),
1078
+ ]
1079
+
1080
+ def process_row(self, part_dict, row_count, csv_headers):
1081
+ dnp = csv_headers.get_val_from_row(part_dict, 'dnp')
1082
+ reference = csv_headers.get_val_from_row(part_dict, 'reference')
1083
+ part_number = csv_headers.get_val_from_row(part_dict, 'part_number')
1084
+ manufacturer_part_number = csv_headers.get_val_from_row(part_dict, 'mpn')
1085
+ manufacturer_name = csv_headers.get_val_from_row(part_dict, 'manufacturer_name')
1086
+
1087
+ try:
1088
+ level = int(float(csv_headers.get_val_from_row(part_dict, 'level')))
1089
+ except ValueError as e:
1090
+ # TODO: May want to validate whole file has acceptable levels first.
1091
+ raise ValidationError(f"Row {row_count} - level: invalid level, can't continue.", code='invalid')
1092
+ except TypeError as e:
1093
+ # 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
1094
+ if self.parent_part_revision is None:
1095
+ raise ValidationError(
1096
+ f"Row {row_count} - level: must provide either level, or a parent part to upload a part.",
1097
+ code='invalid')
1098
+ else:
1099
+ level = 1
1100
+
1101
+ if self.last_level is None:
1102
+ self.last_level = level
1103
+
1104
+ # Extract some values
1105
+ part_dict['reference'] = reference
1106
+ part_dict['do_not_load'] = dnp in ['y', 'x', 'dnp', 'dnl', 'yes', 'true', ]
1107
+ part_dict['revision'] = csv_headers.get_val_from_row(part_dict, 'revision') or 1
1108
+ part_dict['count'] = csv_headers.get_val_from_row(part_dict, 'count')
1109
+ part_dict['number_class'] = None
1110
+ part_dict['number_variation'] = None
1111
+
1112
+ if part_number:
1113
+ # TODO: Should this be in a clean function?
1114
+ try:
1115
+ (part_dict['number_class'], part_dict['number_item'],
1116
+ part_dict['number_variation']) = Part.parse_partial_part_number(part_number, self.organization)
1117
+ except AttributeError as e:
1118
+ self.add_error(None,
1119
+ f"Row {row_count} - part_number: Uploading of this subpart skipped. Couldn't parse part number.")
1120
+ return
1121
+ elif manufacturer_part_number:
1122
+ try:
1123
+ part = Part.from_manufacturer_part_number(manufacturer_part_number, self.organization)
1124
+ if part is None:
1125
+ self.add_error(None,
1126
+ f"Row {row_count} - manufacturer_part_number: Uploading of this subpart skipped. No part found for manufacturer part number.")
1127
+ return
1128
+ part_dict['number_class'] = part.number_class.code
1129
+ part_dict['number_item'] = part.number_item
1130
+ part_dict['number_variation'] = part.number_variation
1131
+ part_number = part.full_part_number()
1132
+ except ValueError:
1133
+ self.add_error(None,
1134
+ f"Row {row_count} - manufacturer_part_number: Uploading of this subpart skipped. Too many parts found for manufacturer part number.")
1135
+ return
1136
+ else:
1137
+ raise ValidationError(
1138
+ "No part_number or manufacturer_part_number found. Uploading stopped. No subparts uploaded.",
1139
+ code='invalid')
1140
+
1141
+ # Handle indented bom level changes
1142
+ level_change = level - self.last_level
1143
+ if level_change == 1: # Level decreases, must only decrease by 1
1144
+ self.part_revision_tree.append(self.last_part_revision)
1145
+ elif level_change <= -1: # Level increases, going up in assembly; intentionally empty tree if level change is very negative
1146
+ self.part_revision_tree = self.part_revision_tree[:level_change]
1147
+ elif level_change == 0:
1148
+ pass
1149
+ elif level - self.last_level > 1:
1150
+ raise ValidationError(
1151
+ f'Row {row_count} - level: Assembly levels must decrease by no more than 1 from sequential rows.',
1152
+ code='invalid')
1153
+ else:
1154
+ raise ValidationError(f'Row {row_count} - level: Invalid assembly level.', code='invalid')
1155
+
1156
+ try:
1157
+ parent_part_revision = self.part_revision_tree[-1]
1158
+ if parent_part_revision.assembly is None:
1159
+ parent_part_revision.assembly = Assembly.objects.create()
1160
+ parent_part_revision.save()
1161
+ except IndexError:
1162
+ parent_part_revision = None
1163
+
1164
+ # Check for existing objects
1165
+ existing_part_class = PartClass.objects.filter(code=part_dict['number_class'],
1166
+ organization=self.organization).first()
1167
+
1168
+ existing_part = None
1169
+ if existing_part_class or self.organization.number_scheme == NUMBER_SCHEME_INTELLIGENT:
1170
+ existing_part = Part.objects.filter(number_class=existing_part_class, number_item=part_dict['number_item'],
1171
+ number_variation=part_dict['number_variation'],
1172
+ organization=self.organization).first()
1173
+
1174
+ existing_part_revision = None
1175
+ if existing_part:
1176
+ existing_part_revision = PartRevision.objects.filter(part=existing_part,
1177
+ revision=part_dict['revision']).first()
1178
+
1179
+ if existing_part_revision and parent_part_revision: # Check for infinite recursion
1180
+ contains_parent = False
1181
+ indented_bom = existing_part_revision.indented()
1182
+ for _, sp in indented_bom.parts.items(): # Make sure the subpart does not contain the parent - infinite recursion!
1183
+ if sp.part_revision == parent_part_revision:
1184
+ contains_parent = True
1185
+ if contains_parent:
1186
+ raise ValidationError(
1187
+ 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.",
1188
+ code='invalid')
1189
+
1190
+ existing_subpart = None
1191
+ existing_subpart_count = 0
1192
+ existing_subpart_references = None
1193
+ if existing_part_revision and parent_part_revision:
1194
+ existing_subpart = parent_part_revision.assembly.subparts.all().filter(part_revision=existing_part_revision,
1195
+ do_not_load=part_dict[
1196
+ 'do_not_load']).first()
1197
+ existing_subpart_count = existing_subpart.count if existing_subpart else 0
1198
+ existing_subpart_references = existing_subpart.reference if existing_subpart else None
1199
+
1200
+ # Now validate & save PartClass, Part, PartRevision, Subpart
1201
+ part_class_dict = {'code': part_dict['number_class'], 'name': part_dict.get('part_class', None)}
1202
+ part_class_form = PartClassForm(part_class_dict, instance=existing_part_class, ignore_unique_constraint=True,
1203
+ organization=self.organization)
1204
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT and not part_class_form.is_valid():
1205
+ add_nonfield_error_from_existing(part_class_form, self, f'Row {row_count} - ')
1206
+ return
1207
+
1208
+ PartForm = part_form_from_organization(self.organization)
1209
+ part_form = PartForm(part_dict, instance=existing_part, ignore_part_class=True, ignore_unique_constraint=True,
1210
+ organization=self.organization)
1211
+ if not part_form.is_valid():
1212
+ add_nonfield_error_from_existing(part_form, self, f'Row {row_count} - ')
1213
+ return
1214
+
1215
+ part_revision_form = PartRevisionForm(part_dict, instance=existing_part_revision,
1216
+ organization=self.organization)
1217
+ if not part_revision_form.is_valid():
1218
+ add_nonfield_error_from_existing(part_revision_form, self, f'Row {row_count} - ')
1219
+ return
1220
+
1221
+ subpart_form = SubpartForm(part_dict, instance=existing_subpart, ignore_part_revision=True,
1222
+ organization=self.organization)
1223
+ if not subpart_form.is_valid():
1224
+ add_nonfield_error_from_existing(subpart_form, self, f'Row {row_count} - ')
1225
+ return
1226
+
1227
+ part_class = part_class_form.save(commit=False)
1228
+ part = part_form.save(commit=False)
1229
+ part_revision = part_revision_form.save(commit=False)
1230
+ subpart = subpart_form.save(commit=False)
1231
+
1232
+ reference_list = listify_string(reference) if reference else []
1233
+ if len(reference_list) != len(set(reference_list)):
1234
+ self.add_warning(None,
1235
+ f"Row {row_count} -Duplicate reference designators '{reference}' for subpart on row {row_count}.")
1236
+ if len(reference_list) != subpart.count and len(reference_list) > 0:
1237
+ self.add_warning(None,
1238
+ 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})")
1239
+
1240
+ if self.organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
1241
+ part_class.save()
1242
+ part.number_class = part_class
1243
+
1244
+ part.organization = self.organization
1245
+ part.save()
1246
+ part_revision.part = part
1247
+ part_revision.save()
1248
+ if parent_part_revision:
1249
+ subpart.count += existing_subpart_count # append or create
1250
+ subpart.reference = existing_subpart_references + ', ' + subpart.reference if existing_subpart_references else subpart.reference
1251
+ subpart.part_revision = part_revision
1252
+ subpart.save()
1253
+ AssemblySubparts.objects.get_or_create(assembly=parent_part_revision.assembly, subpart=subpart)
1254
+
1255
+ info_msg = f"Row {row_count}: Added subpart {part_number}"
1256
+ if reference:
1257
+ info_msg += f" with reference designators {reference}"
1258
+ if parent_part_revision:
1259
+ info_msg += f" to parent part {parent_part_revision.part.full_part_number()}"
1260
+ self.successes.append(info_msg + ".")
1261
+
1262
+ # Now validate & save optional fields - Manufacturer, ManufacturerPart, SellerParts
1263
+ existing_manufacturer = Manufacturer.objects.filter(name=manufacturer_name,
1264
+ organization=self.organization).first()
1265
+ manufacturer_form = ManufacturerForm({'name': manufacturer_name}, instance=existing_manufacturer)
1266
+ if not manufacturer_form.is_valid():
1267
+ add_nonfield_error_from_existing(manufacturer_form, self, f'Row {row_count} - ')
1268
+
1269
+ manufacturer_part_data = {'manufacturer_part_number': manufacturer_part_number}
1270
+ manufacturer_part_form = ManufacturerPartForm(manufacturer_part_data)
1271
+ if not manufacturer_part_form.is_valid():
1272
+ add_nonfield_error_from_existing(manufacturer_part_form, self, f'Row {row_count} - ')
1273
+
1274
+ manufacturer = manufacturer_form.save(commit=False)
1275
+ manufacturer.organization = self.organization
1276
+ manufacturer.save()
1277
+
1278
+ manufacturer_part = manufacturer_part_form.save(commit=False)
1279
+ existing_manufacturer_part = ManufacturerPart.objects.filter(part=part, manufacturer=manufacturer,
1280
+ manufacturer_part_number=manufacturer_part.manufacturer_part_number).first()
1281
+ manufacturer_part.id = existing_manufacturer_part.id if existing_manufacturer_part else None
1282
+ manufacturer_part.manufacturer = manufacturer
1283
+ manufacturer_part.part = part
1284
+ manufacturer_part.save()
1285
+
1286
+ part.primary_manufacturer_part = manufacturer_part
1287
+ part.save()
1288
+
1289
+ self.last_part_revision = part_revision
1290
+ self.last_level = level
1291
+
1292
+
1293
+ class UploadBOMForm(OrganizationFormMixin, forms.Form):
1294
+ parent_part_number = forms.CharField(required=False, label="Parent part number")
1295
+
1296
+ def __init__(self, *args, **kwargs):
1297
+ super().__init__(*args, **kwargs)
1298
+ self.parent_part = None
1299
+
1300
+ def clean_parent_part_number(self):
1301
+ ppn = self.cleaned_data['parent_part_number']
1302
+ if ppn:
1303
+ try:
1304
+ self.parent_part = Part.from_part_number(ppn, self.organization)
1305
+ except (AttributeError, Part.DoesNotExist) as e:
1306
+ raise ValidationError(f"Invalid parent part: {e}")
1307
+ return ppn
1308
+
1309
+
1310
+ class FileForm(forms.Form):
1311
+ file = forms.FileField()
1312
+
1313
+
1314
+ # ==========================================
1315
+ # HELPERS
1316
+ # ==========================================
1317
+
1318
+ def part_form_from_organization(organization):
1319
+ if organization.number_scheme == NUMBER_SCHEME_SEMI_INTELLIGENT:
1320
+ return PartFormSemiIntelligent
1321
+ return PartFormIntelligent
1322
+
1323
+
1324
+ def add_nonfield_error_from_existing(from_form, to_form, prefix=''):
1325
+ for field, errors in from_form.errors.as_data().items():
1326
+ for error in errors:
1327
+ for msg in error.messages:
1328
+ to_form.add_error(None, f'{prefix}{field}: {msg}')