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