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