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