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