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