django-bom 1.237__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.
Files changed (183) hide show
  1. bom/__init__.py +1 -0
  2. bom/admin.py +161 -0
  3. bom/apps.py +8 -0
  4. bom/base_classes.py +31 -0
  5. bom/constants.py +210 -0
  6. bom/context_processors.py +9 -0
  7. bom/csv_headers.py +274 -0
  8. bom/decorators.py +32 -0
  9. bom/form_fields.py +59 -0
  10. bom/forms.py +1400 -0
  11. bom/helpers.py +292 -0
  12. bom/local_settings.py +35 -0
  13. bom/migrations/0001_initial.py +135 -0
  14. bom/migrations/0002_auto_20180908_2151.py +24 -0
  15. bom/migrations/0003_sellerpart_data_source.py +18 -0
  16. bom/migrations/0004_auto_20180911_0011.py +18 -0
  17. bom/migrations/0005_auto_20181007_1934.py +56 -0
  18. bom/migrations/0006_auto_20181007_1949.py +41 -0
  19. bom/migrations/0007_auto_20181009_0256.py +19 -0
  20. bom/migrations/0008_auto_20181030_0427.py +19 -0
  21. bom/migrations/0009_subpart_reference.py +18 -0
  22. bom/migrations/0010_auto_20181202_0733.py +23 -0
  23. bom/migrations/0011_auto_20181202_2113.py +22 -0
  24. bom/migrations/0012_partchangehistory.py +30 -0
  25. bom/migrations/0013_auto_20190222_1631.py +19 -0
  26. bom/migrations/0014_auto_20190223_2353.py +18 -0
  27. bom/migrations/0015_auto_20190303_1915.py +136 -0
  28. bom/migrations/0016_auto_20190405_2308.py +58 -0
  29. bom/migrations/0017_auto_20190616_1912.py +19 -0
  30. bom/migrations/0018_auto_20190616_2143.py +24 -0
  31. bom/migrations/0019_auto_20190624_1246.py +45 -0
  32. bom/migrations/0020_auto_20190627_0207.py +38 -0
  33. bom/migrations/0021_auto_20190627_0428.py +23 -0
  34. bom/migrations/0022_auto_20190811_2140.py +35 -0
  35. bom/migrations/0023_auto_20191205_2351.py +255 -0
  36. bom/migrations/0024_auto_20191214_1342.py +89 -0
  37. bom/migrations/0025_auto_20191221_1907.py +38 -0
  38. bom/migrations/0026_auto_20191222_2258.py +22 -0
  39. bom/migrations/0027_auto_20191222_2347.py +17 -0
  40. bom/migrations/0028_partrevision_displayable_synopsis.py +74 -0
  41. bom/migrations/0029_auto_20191231_1630.py +23 -0
  42. bom/migrations/0030_auto_20200101_2253.py +22 -0
  43. bom/migrations/0031_auto_20200104_1352.py +38 -0
  44. bom/migrations/0032_auto_20200126_1806.py +27 -0
  45. bom/migrations/0033_auto_20200203_0618.py +29 -0
  46. bom/migrations/0034_auto_20200222_0359.py +30 -0
  47. bom/migrations/0035_auto_20200303_0111.py +34 -0
  48. bom/migrations/0036_auto_20200303_0538.py +17 -0
  49. bom/migrations/0037_auto_20200405_1642.py +44 -0
  50. bom/migrations/0038_auto_20200422_0504.py +19 -0
  51. bom/migrations/0039_auto_20200929_2315.py +41 -0
  52. bom/migrations/0040_alter_organization_currency.py +19 -0
  53. bom/migrations/0041_organization_subscription_quantity.py +18 -0
  54. bom/migrations/0042_auto_20210720_2137.py +23 -0
  55. bom/migrations/0043_auto_20211123_0157.py +24 -0
  56. bom/migrations/0044_auto_20220831_1241.py +23 -0
  57. bom/migrations/0045_sellerpart_link.py +18 -0
  58. bom/migrations/0046_alter_sellerpart_unique_together.py +17 -0
  59. bom/migrations/0047_sellerpart_seller_part_number.py +18 -0
  60. bom/migrations/0048_rename_part_organization_number_class_bom_part_organiz_b333d6_idx_and_more.py +1017 -0
  61. bom/migrations/0049_alter_assembly_id_alter_assemblysubparts_id_and_more.py +99 -0
  62. bom/migrations/__init__.py +0 -0
  63. bom/models.py +757 -0
  64. bom/part_bom.py +192 -0
  65. bom/settings.py +264 -0
  66. bom/static/bom/css/dashboard.css +17 -0
  67. bom/static/bom/css/jquery.treetable.css +28 -0
  68. bom/static/bom/css/materialize.min.css +13 -0
  69. bom/static/bom/css/part-info.css +15 -0
  70. bom/static/bom/css/style.css +319 -0
  71. bom/static/bom/css/tablesorter-theme.materialize.css +176 -0
  72. bom/static/bom/css/treetable-theme.css +42 -0
  73. bom/static/bom/doc/sample_part_classes.csv +38 -0
  74. bom/static/bom/doc/test_bom.csv +6 -0
  75. bom/static/bom/doc/test_bom_5_intelligent.csv +4 -0
  76. bom/static/bom/doc/test_full_bom.csv +37 -0
  77. bom/static/bom/doc/test_new_parts.csv +5 -0
  78. bom/static/bom/doc/test_new_parts_5_intelligent.csv +5 -0
  79. bom/static/bom/img/_ionicons_svg_md-arrow-dropdown.svg +1 -0
  80. bom/static/bom/img/_ionicons_svg_md-arrow-dropright.svg +1 -0
  81. bom/static/bom/img/favicon.ico +0 -0
  82. bom/static/bom/img/google/web/1x/btn_google_signin_dark_disabled_web.png +0 -0
  83. bom/static/bom/img/google/web/1x/btn_google_signin_dark_focus_web.png +0 -0
  84. bom/static/bom/img/google/web/1x/btn_google_signin_dark_normal_web.png +0 -0
  85. bom/static/bom/img/google/web/1x/btn_google_signin_dark_pressed_web.png +0 -0
  86. bom/static/bom/img/google/web/1x/btn_google_signin_light_disabled_web.png +0 -0
  87. bom/static/bom/img/google/web/1x/btn_google_signin_light_focus_web.png +0 -0
  88. bom/static/bom/img/google/web/1x/btn_google_signin_light_normal_web.png +0 -0
  89. bom/static/bom/img/google/web/1x/btn_google_signin_light_pressed_web.png +0 -0
  90. bom/static/bom/img/google/web/2x/btn_google_signin_dark_disabled_web@2x.png +0 -0
  91. bom/static/bom/img/google/web/2x/btn_google_signin_dark_focus_web@2x.png +0 -0
  92. bom/static/bom/img/google/web/2x/btn_google_signin_dark_normal_web@2x.png +0 -0
  93. bom/static/bom/img/google/web/2x/btn_google_signin_dark_pressed_web@2x.png +0 -0
  94. bom/static/bom/img/google/web/2x/btn_google_signin_light_disabled_web@2x.png +0 -0
  95. bom/static/bom/img/google/web/2x/btn_google_signin_light_focus_web@2x.png +0 -0
  96. bom/static/bom/img/google/web/2x/btn_google_signin_light_normal_web@2x.png +0 -0
  97. bom/static/bom/img/google/web/2x/btn_google_signin_light_pressed_web@2x.png +0 -0
  98. bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.eps +814 -0
  99. bom/static/bom/img/google/web/vector/btn_google_dark_disabled_ios.svg +24 -0
  100. bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.eps +1866 -0
  101. bom/static/bom/img/google/web/vector/btn_google_dark_focus_ios.svg +51 -0
  102. bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.eps +1031 -0
  103. bom/static/bom/img/google/web/vector/btn_google_dark_normal_ios.svg +50 -0
  104. bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.eps +1031 -0
  105. bom/static/bom/img/google/web/vector/btn_google_dark_pressed_ios.svg +50 -0
  106. bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.eps +814 -0
  107. bom/static/bom/img/google/web/vector/btn_google_light_disabled_ios.svg +24 -0
  108. bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.eps +1837 -0
  109. bom/static/bom/img/google/web/vector/btn_google_light_focus_ios.svg +44 -0
  110. bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.eps +1002 -0
  111. bom/static/bom/img/google/web/vector/btn_google_light_normal_ios.svg +43 -0
  112. bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.eps +1002 -0
  113. bom/static/bom/img/google/web/vector/btn_google_light_pressed_ios.svg +43 -0
  114. bom/static/bom/img/google_drive_logo.svg +1 -0
  115. bom/static/bom/img/indabom.png +0 -0
  116. bom/static/bom/img/mouser.png +0 -0
  117. bom/static/bom/img/octopart_blue.svg +19 -0
  118. bom/static/bom/js/jquery-3.4.1.min.js +2 -0
  119. bom/static/bom/js/jquery.ba-floatingscrollbar.min.js +10 -0
  120. bom/static/bom/js/jquery.treetable.js +629 -0
  121. bom/static/bom/js/materialize.min.js +6 -0
  122. bom/templates/bom/account-delete.html +23 -0
  123. bom/templates/bom/add-manufacturer-part.html +66 -0
  124. bom/templates/bom/add-sellerpart.html +93 -0
  125. bom/templates/bom/base-menu.html +16 -0
  126. bom/templates/bom/base.html +129 -0
  127. bom/templates/bom/bom-action-btn.html +23 -0
  128. bom/templates/bom/bom-action-table.html +57 -0
  129. bom/templates/bom/bom-base-menu.html +6 -0
  130. bom/templates/bom/bom-base.html +24 -0
  131. bom/templates/bom/bom-form-modal.html +36 -0
  132. bom/templates/bom/bom-form.html +30 -0
  133. bom/templates/bom/bom-signup.html +12 -0
  134. bom/templates/bom/components/bom-flat.html +131 -0
  135. bom/templates/bom/components/bom-indented.html +237 -0
  136. bom/templates/bom/components/manufacturer-part-list.html +270 -0
  137. bom/templates/bom/components/seller-part-list.html +62 -0
  138. bom/templates/bom/create-part.html +65 -0
  139. bom/templates/bom/dashboard-menu.html +15 -0
  140. bom/templates/bom/dashboard.html +303 -0
  141. bom/templates/bom/edit-manufacturer-part.html +72 -0
  142. bom/templates/bom/edit-part-class.html +33 -0
  143. bom/templates/bom/edit-part.html +67 -0
  144. bom/templates/bom/edit-user-meta.html +38 -0
  145. bom/templates/bom/help.html +1356 -0
  146. bom/templates/bom/manufacturer-info.html +82 -0
  147. bom/templates/bom/manufacturers.html +97 -0
  148. bom/templates/bom/nothing-to-see.html +15 -0
  149. bom/templates/bom/organization-create.html +132 -0
  150. bom/templates/bom/part-info.html +439 -0
  151. bom/templates/bom/part-revision-display.html +184 -0
  152. bom/templates/bom/part-revision-edit.html +39 -0
  153. bom/templates/bom/part-revision-manage-bom.html +115 -0
  154. bom/templates/bom/part-revision-new.html +57 -0
  155. bom/templates/bom/part-revision-release.html +41 -0
  156. bom/templates/bom/search-help.html +101 -0
  157. bom/templates/bom/seller-info.html +82 -0
  158. bom/templates/bom/sellers.html +97 -0
  159. bom/templates/bom/settings.html +408 -0
  160. bom/templates/bom/signup.html +28 -0
  161. bom/templates/bom/table_of_contents.html +47 -0
  162. bom/templates/bom/upload-bom.html +111 -0
  163. bom/templates/bom/upload-parts-help.html +103 -0
  164. bom/templates/bom/upload-parts.html +50 -0
  165. bom/templates/registration/login.html +39 -0
  166. bom/tests.py +1506 -0
  167. bom/third_party_apis/__init__.py +0 -0
  168. bom/third_party_apis/base_api.py +51 -0
  169. bom/third_party_apis/google_drive.py +166 -0
  170. bom/third_party_apis/mouser.py +132 -0
  171. bom/third_party_apis/test_apis.py +24 -0
  172. bom/urls.py +94 -0
  173. bom/utils.py +228 -0
  174. bom/validators.py +23 -0
  175. bom/views/__init__.py +0 -0
  176. bom/views/json_views.py +55 -0
  177. bom/views/views.py +1620 -0
  178. bom/wsgi.py +16 -0
  179. django_bom-1.237.dist-info/METADATA +206 -0
  180. django_bom-1.237.dist-info/RECORD +183 -0
  181. django_bom-1.237.dist-info/WHEEL +5 -0
  182. django_bom-1.237.dist-info/licenses/LICENSE +674 -0
  183. django_bom-1.237.dist-info/top_level.txt +1 -0
bom/views/views.py ADDED
@@ -0,0 +1,1620 @@
1
+ import csv
2
+ import logging
3
+ import operator
4
+ from functools import reduce
5
+ from json import dumps
6
+
7
+ from django.conf import settings
8
+ from django.contrib import messages
9
+ from django.contrib.auth import login
10
+ from django.contrib.auth.decorators import login_required
11
+ from django.core.cache import cache
12
+ from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
13
+ from django.db import IntegrityError
14
+ from django.db.models import Count, ProtectedError, Q, Subquery, prefetch_related_objects
15
+ from django.db.models.aggregates import Max
16
+ from django.http import HttpResponse, HttpResponseRedirect
17
+ from django.shortcuts import get_object_or_404
18
+ from django.template.response import TemplateResponse
19
+ from django.urls import reverse
20
+ from django.utils.encoding import smart_str
21
+ from django.utils.text import smart_split
22
+ from django.views.generic.base import TemplateView
23
+
24
+ from social_django.models import UserSocialAuth
25
+
26
+ import bom.constants as constants
27
+ from bom.csv_headers import (
28
+ BOMFlatCSVHeaders,
29
+ BOMIndentedCSVHeaders,
30
+ ManufacturerPartCSVHeaders,
31
+ PartClassesCSVHeaders,
32
+ SellerPartCSVHeaders,
33
+ )
34
+ from bom.decorators import organization_admin
35
+ from bom.forms import (
36
+ AddSubpartForm,
37
+ BOMCSVForm,
38
+ FileForm,
39
+ ManufacturerForm,
40
+ ManufacturerPartForm,
41
+ OrganizationCreateForm,
42
+ OrganizationFormEditSettings,
43
+ OrganizationNumberLenForm,
44
+ PartClassCSVForm,
45
+ PartClassForm,
46
+ PartClassSelectionForm,
47
+ PartCSVForm,
48
+ PartInfoForm,
49
+ PartRevisionForm,
50
+ PartRevisionNewForm,
51
+ Seller,
52
+ SellerForm,
53
+ SellerPartForm,
54
+ SubpartForm,
55
+ UploadBOMForm,
56
+ UserAddForm,
57
+ UserCreateForm,
58
+ UserForm,
59
+ UserMetaForm,
60
+ part_form_from_organization,
61
+ )
62
+ from bom.models import (
63
+ Assembly,
64
+ AssemblySubparts,
65
+ Manufacturer,
66
+ ManufacturerPart,
67
+ Part,
68
+ PartClass,
69
+ PartRevision,
70
+ SellerPart,
71
+ Subpart,
72
+ User,
73
+ UserMeta,
74
+ )
75
+ from bom.utils import check_references_for_duplicates, listify_string, prep_for_sorting_nicely
76
+
77
+
78
+ logger = logging.getLogger(__name__)
79
+ BOM_LOGIN_URL = getattr(settings, "BOM_LOGIN_URL", None) or settings.LOGIN_URL
80
+
81
+ def form_error_messages(form_errors) -> [str]:
82
+ error_messages = []
83
+ for k, errors in form_errors.as_data().items():
84
+ for error_message in errors:
85
+ error_messages.append(str(error_message.message))
86
+
87
+ @login_required(login_url=BOM_LOGIN_URL)
88
+ def home(request):
89
+ profile = request.user.bom_profile()
90
+ organization = profile.organization
91
+ if not organization:
92
+ return HttpResponseRedirect(reverse('bom:organization-create'))
93
+
94
+ query = request.GET.get('q', '')
95
+ title = f'{organization.name}\'s'
96
+
97
+ # Note that posting a PartClass selection does not include a named parameter in
98
+ # the POST, so this case is the de facto "else" clause.
99
+ part_class_selection_form = PartClassSelectionForm(organization=organization)
100
+
101
+ if request.method == 'GET':
102
+ part_class_selection_form = PartClassSelectionForm(request.GET, organization=organization)
103
+ elif request.method == 'POST':
104
+ if 'actions' in request.POST and 'part-action' in request.POST:
105
+ action = request.POST.get('part-action')
106
+ if action == 'Delete':
107
+ part_ids = [part_id for part_id in request.POST.getlist('actions') if part_id.isdigit()]
108
+ for part_id in part_ids:
109
+ try:
110
+ part = Part.objects.get(id=part_id, organization=organization)
111
+ part_number = part.full_part_number()
112
+ part.delete()
113
+ messages.success(request, f"Deleted part {part_number}")
114
+ except Part.DoesNotExist:
115
+ messages.error(request, "Can't delete part. No part found with given id {}.".format(part_id))
116
+
117
+ if part_class_selection_form.is_valid():
118
+ part_class = part_class_selection_form.cleaned_data['part_class']
119
+ else:
120
+ part_class = None
121
+
122
+ if part_class or query:
123
+ title += f' - Search Results'
124
+ else:
125
+ title += f' Part List'
126
+
127
+ if part_class:
128
+ parts = Part.objects.filter(Q(organization=organization) & Q(number_class__code=part_class.code))
129
+ else:
130
+ parts = Part.objects.filter(organization=organization)
131
+
132
+ part_ids = list(parts.values_list('id', flat=True))
133
+
134
+ part_revs = PartRevision.objects\
135
+ .filter(id__in=Subquery(
136
+ PartRevision.objects.filter(
137
+ part_id__in=part_ids
138
+ ).annotate(max_id=Max('id')).values("id")
139
+ )).order_by(
140
+ "part__number_class__code",
141
+ "part__number_item",
142
+ "part__number_variation",
143
+ )
144
+
145
+ autocomplete_dict = {}
146
+ enable_autocomplete = settings.BOM_CONFIG.get('admin_dashboard', {}).get('enable_autocomplete', False)
147
+ if enable_autocomplete:
148
+ prefetch_related_objects(part_revs, 'part')
149
+ manufacturer_parts = ManufacturerPart.objects.filter(part__in=parts)
150
+
151
+ for pr in part_revs:
152
+ autocomplete_dict.update({pr.searchable_synopsis.replace('"', ''): None})
153
+ autocomplete_dict.update({pr.part.full_part_number(): None})
154
+
155
+ for mpn in manufacturer_parts:
156
+ if mpn.manufacturer_part_number:
157
+ autocomplete_dict.update({mpn.manufacturer_part_number.replace('"', ''): None})
158
+ if mpn.manufacturer is not None and mpn.manufacturer.name:
159
+ autocomplete_dict.update({mpn.manufacturer.name.replace('"', ''): None})
160
+
161
+ autocomplete = dumps(autocomplete_dict)
162
+
163
+ if query:
164
+ query_stripped = query.strip()
165
+
166
+ # Parse terms separated by white space but keep together words inside of double quotes,
167
+ # for example
168
+ # "Big Company Inc."
169
+ # is parsed as 'Big Company Inc.' while
170
+ # Big Company Inc.
171
+ # is parsed as 'Big' 'Company' 'Inc.'
172
+ search_terms = query_stripped
173
+ search_terms = list(smart_split(search_terms))
174
+ search_terms = [search_term.replace('"', '') for search_term in search_terms]
175
+ noqoutes_query = query_stripped.replace('"', '')
176
+
177
+ number_class = None
178
+ number_item = None
179
+ number_variation = None
180
+
181
+ # Scan for search terms that might represent a complete or partial part number
182
+ if organization.number_scheme == constants.NUMBER_SCHEME_SEMI_INTELLIGENT:
183
+ for search_term in search_terms:
184
+ try:
185
+ (number_class, number_item, number_variation) = Part.parse_partial_part_number(search_term, organization, validate=False)
186
+ except AttributeError:
187
+ pass
188
+
189
+ # Query searchable_synopsis by OR'ing search terms
190
+ part_synopsis_ids = PartRevision.objects.filter(reduce(operator.or_, (Q(searchable_synopsis__icontains=term) for term in search_terms))).values_list("part", flat=True)
191
+ # Prepare Part.primary_manufacturer_part.manufacturer_part_number query by OR'ing search terms
192
+ q_primary_mpn = reduce(operator.or_, (Q(primary_manufacturer_part__manufacturer_part_number__icontains=term) for term in search_terms))
193
+
194
+ # Prepare Part.primary_manufacturer.part__manufacturer.name query by OR'ing search terms
195
+ q_primary_mfg = reduce(operator.or_, (Q(primary_manufacturer_part__manufacturer__name__icontains=term) for term in search_terms))
196
+
197
+ if number_class and number_item and number_variation:
198
+ parts = parts.filter(
199
+ Q(number_class__code=number_class, number_item=number_item, number_variation=number_variation) |
200
+ Q(id__in=part_synopsis_ids) |
201
+ q_primary_mpn |
202
+ q_primary_mfg)
203
+ elif number_class and number_item:
204
+ parts = parts.filter(
205
+ Q(number_class__code=number_class, number_item=number_item) |
206
+ Q(id__in=part_synopsis_ids) |
207
+ q_primary_mpn |
208
+ q_primary_mfg)
209
+ else:
210
+ parts = parts.filter(
211
+ Q(number_item__in=search_terms) |
212
+ Q(id__in=part_synopsis_ids) |
213
+ q_primary_mpn |
214
+ q_primary_mfg)
215
+
216
+ part_ids = list(parts.values_list('id', flat=True))
217
+
218
+ part_revs = PartRevision.objects \
219
+ .filter(id__in=Subquery(
220
+ PartRevision.objects.filter(
221
+ part_id__in=part_ids
222
+ ).annotate(max_id=Max('id')).values("id")
223
+ )).order_by(
224
+ "part__number_class__code",
225
+ "part__number_item",
226
+ "part__number_variation"
227
+ )
228
+
229
+ if 'download' in request.GET:
230
+ response = HttpResponse(content_type='text/csv')
231
+ response['Content-Disposition'] = 'attachment; filename="indabom_parts_search.csv"'
232
+ csv_headers = organization.part_list_csv_headers()
233
+ seller_csv_headers = SellerPartCSVHeaders()
234
+ writer = csv.DictWriter(response, fieldnames=csv_headers.get_default_all())
235
+ writer.writeheader()
236
+ for part_rev in part_revs:
237
+ if organization.number_scheme == constants.NUMBER_SCHEME_SEMI_INTELLIGENT:
238
+ row = {
239
+ csv_headers.get_default('part_number'): part_rev.part.full_part_number(),
240
+ csv_headers.get_default('part_category'): part_rev.part.number_class.name,
241
+ csv_headers.get_default('part_revision'): part_rev.revision,
242
+ csv_headers.get_default('part_manufacturer'): part_rev.part.primary_manufacturer_part.manufacturer.name if part_rev.part.primary_manufacturer_part is not None and
243
+ part_rev.part.primary_manufacturer_part.manufacturer is not None else '',
244
+ csv_headers.get_default('part_manufacturer_part_number'): part_rev.part.primary_manufacturer_part.manufacturer_part_number if part_rev.part.primary_manufacturer_part is not None else '',
245
+ }
246
+ for field_name in csv_headers.get_default_all():
247
+ if field_name not in csv_headers.get_defaults_list(['part_number', 'part_category', 'part_synopsis', 'part_revision', 'part_manufacturer', 'part_manufacturer_part_number', ]
248
+ + seller_csv_headers.get_default_all()):
249
+ attr = getattr(part_rev, field_name)
250
+ row.update({csv_headers.get_default(field_name): attr if attr is not None else ''})
251
+ else:
252
+ row = {
253
+ csv_headers.get_default('part_number'): part_rev.part.full_part_number(),
254
+ csv_headers.get_default('part_revision'): part_rev.revision,
255
+ csv_headers.get_default('part_manufacturer'): part_rev.part.primary_manufacturer_part.manufacturer.name if part_rev.part.primary_manufacturer_part is not None and
256
+ part_rev.part.primary_manufacturer_part.manufacturer is not None else '',
257
+ csv_headers.get_default('part_manufacturer_part_number'): part_rev.part.primary_manufacturer_part.manufacturer_part_number if part_rev.part.primary_manufacturer_part is not None else '',
258
+ }
259
+ for field_name in csv_headers.get_default_all():
260
+ if field_name not in csv_headers.get_defaults_list(['part_number', 'part_synopsis', 'part_revision', 'part_manufacturer', 'part_manufacturer_part_number', ]
261
+ + seller_csv_headers.get_default_all()):
262
+ attr = getattr(part_rev, field_name)
263
+ row.update({csv_headers.get_default(field_name): attr if attr is not None else ''})
264
+
265
+ sellerparts = part_rev.part.seller_parts()
266
+ if len(sellerparts) > 0:
267
+ for sellerpart in part_rev.part.seller_parts():
268
+ for field_name in seller_csv_headers.get_default_all():
269
+ attr = getattr(sellerpart, field_name)
270
+ row.update({csv_headers.get_default(field_name): attr if attr is not None else ''})
271
+ writer.writerow({k: smart_str(v) for k, v in row.items()})
272
+ else:
273
+ writer.writerow({k: smart_str(v) for k, v in row.items()})
274
+ return response
275
+
276
+ page_size = settings.BOM_CONFIG.get('admin_dashboard', {}).get('page_size', 25)
277
+ paginator = Paginator(part_revs, page_size)
278
+
279
+ page = request.GET.get('page')
280
+ try:
281
+ part_revs = paginator.page(page)
282
+ except PageNotAnInteger:
283
+ part_revs = paginator.page(1)
284
+ except EmptyPage:
285
+ part_revs = paginator.page(paginator.num_pages)
286
+
287
+ return TemplateResponse(request, 'bom/dashboard.html', locals())
288
+
289
+
290
+ @login_required(login_url=BOM_LOGIN_URL)
291
+ def organization_create(request):
292
+ user = request.user
293
+ profile = user.bom_profile()
294
+
295
+ if user.first_name == '' and user.last_name == '':
296
+ org_name = user.username
297
+ else:
298
+ org_name = user.first_name + ' ' + user.last_name
299
+
300
+ form = OrganizationCreateForm(initial={'name': org_name, 'number_item_len': 4})
301
+ if request.method == 'POST':
302
+ form = OrganizationCreateForm(request.POST)
303
+ if form.is_valid():
304
+ organization = form.save(commit=False)
305
+ organization.owner = user
306
+ organization.subscription = constants.SUBSCRIPTION_TYPE_FREE
307
+ organization.save()
308
+ profile.organization = organization
309
+ profile.role = constants.ROLE_TYPE_ADMIN
310
+ profile.save()
311
+ return HttpResponseRedirect(reverse('bom:home'))
312
+ return TemplateResponse(request, 'bom/organization-create.html', locals())
313
+
314
+
315
+ @login_required(login_url=BOM_LOGIN_URL)
316
+ def search_help(request):
317
+ return TemplateResponse(request, 'bom/search-help.html', locals())
318
+
319
+
320
+ def signup(request):
321
+ name = 'signup'
322
+
323
+ if request.method == 'POST':
324
+ form = UserCreateForm(request.POST)
325
+ if form.is_valid():
326
+ new_user = form.save()
327
+ login(request, new_user, backend='django.contrib.auth.backends.ModelBackend')
328
+ return HttpResponseRedirect(reverse('bom:home'))
329
+ else:
330
+ form = UserCreateForm()
331
+
332
+ return TemplateResponse(request, 'bom/signup.html', locals())
333
+
334
+
335
+ @login_required
336
+ def bom_signup(request):
337
+ user = request.user
338
+ organization = user.bom_profile().organization
339
+ title = 'Set Up Your BOM Organization'
340
+
341
+ if organization is not None:
342
+ return HttpResponseRedirect(reverse('bom:home'))
343
+
344
+ return TemplateResponse(request, 'bom/bom-signup.html', locals())
345
+
346
+
347
+ @login_required
348
+ def bom_settings(request, tab_anchor=None):
349
+ user = request.user
350
+ profile = user.bom_profile()
351
+ organization = profile.organization
352
+ if organization is None:
353
+ return HttpResponseRedirect(reverse('bom:home'))
354
+
355
+ title = 'Settings'
356
+ owner = organization.owner
357
+ name = 'settings'
358
+
359
+ part_classes = PartClass.objects.all().filter(organization=organization)
360
+
361
+ users_in_organization = User.objects.filter(
362
+ id__in=UserMeta.objects.filter(organization=organization).values_list('user', flat=True)).exclude(id__in=[organization.owner.id]).order_by(
363
+ 'first_name', 'last_name', 'email')
364
+ google_authentication = UserSocialAuth.objects.filter(user=user).first()
365
+
366
+ organization_parts_count = Part.objects.filter(organization=organization).count()
367
+
368
+ USER_TAB = 'user'
369
+ ORGANIZATION_TAB = 'organization'
370
+ INDABOM_TAB = 'indabom'
371
+
372
+ if request.method == 'POST':
373
+ part_class_action_ids = request.POST.getlist('actions')
374
+ part_class_action = request.POST.get('part-class-action')
375
+ if 'submit-edit-user' in request.POST:
376
+ tab_anchor = USER_TAB
377
+ user_form = UserForm(request.POST, instance=user)
378
+ if user_form.is_valid():
379
+ user = user_form.save()
380
+ else:
381
+ messages.error(request, user_form.errors)
382
+
383
+ elif 'refresh-edit-user' in request.POST:
384
+ tab_anchor = USER_TAB
385
+ user_form = UserForm(instance=user)
386
+
387
+ elif 'submit-add-user' in request.POST:
388
+ tab_anchor = ORGANIZATION_TAB
389
+ if organization.subscription == 'F':
390
+ messages.error(request, "Error: You must have a paid account to add users.")
391
+ else:
392
+ user_add_form = UserAddForm(request.POST, organization=organization)
393
+ if user_add_form.is_valid():
394
+ added_user_profile = user_add_form.save()
395
+ messages.info(request, f"Added {added_user_profile.user.first_name} {added_user_profile.user.last_name} to your organization.")
396
+ else:
397
+ messages.error(request, user_add_form.errors)
398
+
399
+ elif 'clear-add-user' in request.POST:
400
+ tab_anchor = ORGANIZATION_TAB
401
+ user_add_form = UserAddForm()
402
+
403
+ elif 'submit-remove-user' in request.POST:
404
+ tab_anchor = ORGANIZATION_TAB
405
+ for item in request.POST:
406
+ if 'remove_user_meta_id_' in item:
407
+ user_meta_id = item.partition('remove_user_meta_id_')[2]
408
+ try:
409
+ user_meta = UserMeta.objects.get(id=user_meta_id, organization=organization)
410
+ if user_meta.user == organization.owner:
411
+ messages.error(request, "Can't remove organization owner.")
412
+ else:
413
+ user_meta.organization = None
414
+ user_meta.role = ''
415
+ user_meta.save()
416
+ except UserMeta.DoesNotExist:
417
+ messages.error(request, "No user found with given id {}.".format(user_meta_id))
418
+
419
+ elif 'submit-edit-organization' in request.POST:
420
+ tab_anchor = ORGANIZATION_TAB
421
+ organization_form = OrganizationFormEditSettings(request.POST, instance=organization, user=user)
422
+ if organization_form.is_valid():
423
+ organization_form.save()
424
+ else:
425
+ messages.error(request, organization_form.errors)
426
+
427
+ elif 'refresh-edit-organization' in request.POST:
428
+ tab_anchor = ORGANIZATION_TAB
429
+ organization_form = OrganizationFormEditSettings(instance=organization, user=user)
430
+
431
+ elif 'submit-number-item-len' in request.POST:
432
+ tab_anchor = INDABOM_TAB
433
+ organization_number_len_form = OrganizationNumberLenForm(request.POST, instance=organization)
434
+ if organization_number_len_form.is_valid():
435
+ organization_number_len_form.save()
436
+ else:
437
+ messages.error(request, organization_number_len_form.errors)
438
+
439
+ elif 'refresh-number-item-len' in request.POST:
440
+ tab_anchor = INDABOM_TAB
441
+ organization_number_len_form = OrganizationNumberLenForm(organization)
442
+
443
+ elif 'submit-part-class-create' in request.POST:
444
+ tab_anchor = INDABOM_TAB
445
+ part_class_form = PartClassForm(request.POST, request.FILES, organization=organization)
446
+ if part_class_form.is_valid():
447
+ part_class_form.save()
448
+ else:
449
+ messages.error(request, part_class_form.errors)
450
+
451
+ elif 'cancel-part-class-create' in request.POST:
452
+ tab_anchor = INDABOM_TAB
453
+ part_class_form = PartClassForm(organization=organization)
454
+
455
+ elif 'submit-part-class-upload' in request.POST and request.FILES.get('file') is not None:
456
+ tab_anchor = INDABOM_TAB
457
+ part_class_csv_form = PartClassCSVForm(request.POST, request.FILES, organization=organization)
458
+ if part_class_csv_form.is_valid():
459
+ for success in part_class_csv_form.successes:
460
+ messages.info(request, success)
461
+ for warning in part_class_csv_form.warnings:
462
+ messages.warning(request, warning)
463
+ else:
464
+ messages.error(request, part_class_csv_form.errors)
465
+
466
+ elif 'submit-part-class-export' in request.POST:
467
+ response = HttpResponse(content_type='text/csv')
468
+ response['Content-Disposition'] = 'attachment; filename="indabom_parts_search.csv"'
469
+ csv_headers = PartClassesCSVHeaders()
470
+ writer = csv.DictWriter(response, fieldnames=csv_headers.get_default_all())
471
+ writer.writeheader()
472
+ part_classes = PartClass.objects.filter(organization=organization)
473
+ for part_class in part_classes:
474
+ row = {}
475
+ for field_name in csv_headers.get_default_all():
476
+ attr = getattr(part_class, field_name)
477
+ row.update({csv_headers.get_default(field_name): attr if attr is not None else ''})
478
+ writer.writerow({k: smart_str(v) for k, v in row.items()})
479
+ return response
480
+
481
+ elif 'part-class-action' in request.POST and part_class_action is not None:
482
+ if len(part_class_action_ids) <= 0:
483
+ messages.warning(request, "No action was taken because no part classes were selected. Select part classes by checking the checkboxes below.")
484
+ elif part_class_action == 'submit-part-class-enable-mouser':
485
+ tab_anchor = INDABOM_TAB
486
+ PartClass.objects.filter(id__in=part_class_action_ids).update(mouser_enabled=True)
487
+ elif part_class_action == 'submit-part-class-disable-mouser':
488
+ tab_anchor = INDABOM_TAB
489
+ PartClass.objects.filter(id__in=part_class_action_ids).update(mouser_enabled=False)
490
+ elif part_class_action == 'submit-part-class-delete':
491
+ tab_anchor = INDABOM_TAB
492
+ try:
493
+ PartClass.objects.filter(id__in=part_class_action_ids).delete()
494
+ except PartClass.DoesNotExist as err:
495
+ messages.error(request, f"No part class found: {err}")
496
+ except ProtectedError as err:
497
+ messages.error(request, f"Cannot delete a part class because it has parts. You must delete those parts first. {err}")
498
+ elif 'change-number-scheme' in request.POST:
499
+ tab_anchor = INDABOM_TAB
500
+ if organization_parts_count > 0:
501
+ messages.error(request, f"Please export, then delete all of your {organization_parts_count} parts before changing your organization's number scheme.")
502
+ else:
503
+ if organization.number_scheme == constants.NUMBER_SCHEME_SEMI_INTELLIGENT:
504
+ organization.number_scheme = constants.NUMBER_SCHEME_INTELLIGENT
505
+ organization.number_item_len = 128
506
+ else:
507
+ organization.number_scheme = constants.NUMBER_SCHEME_SEMI_INTELLIGENT
508
+ organization.number_item_len = 3
509
+ organization.save()
510
+ elif 'submit-leave-organization' in request.POST:
511
+ if organization.owner == user:
512
+ messages.error(request, "You are the owner of the organization. For now we're not letting owners leave their organization. This will change in the future. Contact info@indabom.com "
513
+ "if you want us to manually remove you from your organization.")
514
+ else:
515
+ profile.organization = None
516
+ profile.save()
517
+ if users_in_organization == 0:
518
+ organization.delete()
519
+
520
+ user_form = UserForm(instance=user)
521
+ user_add_form = UserAddForm()
522
+ user_add_form_action = reverse('bom:settings', kwargs={'tab_anchor': ORGANIZATION_TAB})
523
+ user_meta_form = UserMetaForm()
524
+
525
+ organization_form = OrganizationFormEditSettings(instance=organization, user=user)
526
+ organization_number_len_form = OrganizationNumberLenForm(instance=organization)
527
+ part_class_form = PartClassForm(organization=organization)
528
+ part_class_form_action = reverse('bom:settings', kwargs={'tab_anchor': INDABOM_TAB})
529
+ part_class_csv_form = PartClassCSVForm(organization=organization)
530
+
531
+ return TemplateResponse(request, 'bom/settings.html', locals())
532
+
533
+
534
+ @login_required(login_url=BOM_LOGIN_URL)
535
+ def manufacturers(request):
536
+ profile = request.user.bom_profile()
537
+ organization = profile.organization
538
+ name = 'manufacturers'
539
+ if not organization:
540
+ return HttpResponseRedirect(reverse('bom:organization-create'))
541
+
542
+ query = request.GET.get('q', '')
543
+ title = f'{organization.name}\'s Manufacturers'
544
+
545
+ if query:
546
+ title += ' - Search Results'
547
+
548
+ manufacturers = Manufacturer.objects.filter(organization=organization, name__icontains=query).annotate(manufacturerpart_count=Count('manufacturerpart')).order_by('name')
549
+
550
+ autocomplete_dict = {}
551
+ for manufacturer in manufacturers:
552
+ autocomplete_dict.update({manufacturer.name: None})
553
+ autocomplete = dumps(autocomplete_dict)
554
+
555
+ paginator = Paginator(manufacturers, 50)
556
+
557
+ page = request.GET.get('page')
558
+ try:
559
+ manufacturers = paginator.page(page)
560
+ except PageNotAnInteger:
561
+ manufacturers = paginator.page(1)
562
+ except EmptyPage:
563
+ manufacturers = paginator.page(paginator.num_pages)
564
+
565
+ return TemplateResponse(request, 'bom/manufacturers.html', locals())
566
+
567
+ @login_required(login_url=BOM_LOGIN_URL)
568
+ def manufacturer_info(request, manufacturer_id):
569
+ user = request.user
570
+ profile = user.bom_profile()
571
+ organization = profile.organization
572
+
573
+ manufacturer = get_object_or_404(Manufacturer, pk=manufacturer_id)
574
+
575
+ if manufacturer.organization != organization:
576
+ messages.error(request, "Can't access a manufacturer that is not yours!")
577
+ return HttpResponseRedirect(reverse('bom:home'))
578
+
579
+ manufacturer_parts = ManufacturerPart.objects.filter(manufacturer=manufacturer).order_by('manufacturer_part_number')
580
+
581
+ return TemplateResponse(request, 'bom/manufacturer-info.html', locals())
582
+
583
+
584
+ @login_required(login_url=BOM_LOGIN_URL)
585
+ def manufacturer_edit(request, manufacturer_id):
586
+ user = request.user
587
+ profile = user.bom_profile()
588
+ organization = profile.organization
589
+
590
+ manufacturer = get_object_or_404(Manufacturer, pk=manufacturer_id)
591
+ title = 'Edit Manufacturer'
592
+ action = reverse('bom:manufacturer-edit', kwargs={'manufacturer_id': manufacturer_id})
593
+
594
+ if request.method == 'POST':
595
+ form = ManufacturerForm(request.POST, instance=manufacturer)
596
+ if form.is_valid():
597
+ form.save()
598
+ return HttpResponseRedirect(reverse('bom:manufacturer-info', kwargs={'manufacturer_id': manufacturer_id}))
599
+ else:
600
+ form = ManufacturerForm(instance=manufacturer)
601
+
602
+ return TemplateResponse(request, 'bom/bom-form.html', locals())
603
+
604
+
605
+ @login_required(login_url=BOM_LOGIN_URL)
606
+ @organization_admin
607
+ def manufacturer_delete(request, manufacturer_id):
608
+ manufacturer = get_object_or_404(Manufacturer, pk=manufacturer_id)
609
+ manufacturer.delete()
610
+ return HttpResponseRedirect(reverse('bom:manufacturers'))
611
+
612
+
613
+ @login_required(login_url=BOM_LOGIN_URL)
614
+ def sellers(request):
615
+ profile = request.user.bom_profile()
616
+ organization = profile.organization
617
+ name = 'sellers'
618
+ query = request.GET.get('q', '')
619
+ title = f'{organization.name}\'s Sellers'
620
+
621
+ if query:
622
+ title += ' - Search Results'
623
+
624
+ sellers = Seller.objects.filter(organization=organization, name__icontains=query).annotate(sellerpart_count=Count('sellerpart')).order_by('name')
625
+
626
+ autocomplete_dict = {}
627
+ for seller in sellers:
628
+ autocomplete_dict.update({seller.name: None})
629
+
630
+ autocomplete = dumps(autocomplete_dict)
631
+
632
+ paginator = Paginator(sellers, 50)
633
+ page = request.GET.get('page')
634
+ try:
635
+ sellers = paginator.page(page)
636
+ except PageNotAnInteger:
637
+ sellers = paginator.page(1)
638
+ except EmptyPage:
639
+ sellers = paginator.page(paginator.num_pages)
640
+
641
+ return TemplateResponse(request, 'bom/sellers.html', locals())
642
+
643
+ @login_required(login_url=BOM_LOGIN_URL)
644
+ def seller_info(request, seller_id):
645
+ user = request.user
646
+ profile = user.bom_profile()
647
+ organization = profile.organization
648
+
649
+ seller = get_object_or_404(Seller, pk=seller_id)
650
+
651
+ if seller.organization != organization:
652
+ messages.error(request, "Can't access a seller that is not yours!")
653
+ return HttpResponseRedirect(reverse('bom:home'))
654
+
655
+ seller_parts = SellerPart.objects.filter(seller=seller).order_by('manufacturer_part__part__number_class',
656
+ 'manufacturer_part__part__number_item',
657
+ 'manufacturer_part__part__number_variation',
658
+ 'manufacturer_part__manufacturer__name',
659
+ 'manufacturer_part__manufacturer_part_number',
660
+ 'seller__name',
661
+ 'minimum_order_quantity')
662
+ return TemplateResponse(request, 'bom/seller-info.html', locals())
663
+
664
+
665
+ @login_required(login_url=BOM_LOGIN_URL)
666
+ def seller_edit(request, seller_id):
667
+ user = request.user
668
+ profile = user.bom_profile()
669
+ organization = profile.organization
670
+
671
+ seller = get_object_or_404(Seller, pk=seller_id)
672
+ title = 'Edit Seller'
673
+ action = reverse('bom:seller-edit', kwargs={'seller_id': seller_id})
674
+
675
+ if request.method == 'POST':
676
+ form = SellerForm(request.POST, instance=seller)
677
+ if form.is_valid():
678
+ form.save()
679
+ return HttpResponseRedirect(reverse('bom:seller-info', kwargs={'seller_id': seller_id}))
680
+ else:
681
+ form = SellerForm(instance=seller)
682
+
683
+ return TemplateResponse(request, 'bom/bom-form.html', locals())
684
+
685
+
686
+ @login_required(login_url=BOM_LOGIN_URL)
687
+ @organization_admin
688
+ def seller_delete(request, seller_id):
689
+ seller = get_object_or_404(Seller, pk=seller_id)
690
+ seller.delete()
691
+ return HttpResponseRedirect(reverse('bom:sellers'))
692
+
693
+ @login_required(login_url=BOM_LOGIN_URL)
694
+ def user_meta_edit(request, user_meta_id):
695
+ user = request.user
696
+ profile = user.bom_profile()
697
+ organization = profile.organization
698
+
699
+ user_meta = get_object_or_404(UserMeta, pk=user_meta_id)
700
+ user_meta_user = get_object_or_404(User, pk=user_meta.user.id)
701
+ title = 'Edit User {}'.format(user_meta.user.__str__())
702
+
703
+ if request.method == 'POST':
704
+ user_meta_user_form = UserForm(request.POST, instance=user_meta_user)
705
+ if user_meta_user_form.is_valid():
706
+ user_meta_form = UserMetaForm(request.POST, instance=user_meta, organization=organization)
707
+ if user_meta_form.is_valid():
708
+ user_meta_user_form.save()
709
+ user_meta_form.save()
710
+ return HttpResponseRedirect(reverse('bom:settings', kwargs={'tab_anchor': 'organization'}))
711
+
712
+ return TemplateResponse(request, 'bom/edit-user-meta.html', locals())
713
+
714
+ else:
715
+ user_meta_user_form = UserForm(instance=user_meta_user)
716
+ user_meta_form = UserMetaForm(instance=user_meta, organization=organization)
717
+
718
+ return TemplateResponse(request, 'bom/edit-user-meta.html', locals())
719
+
720
+
721
+ @login_required(login_url=BOM_LOGIN_URL)
722
+ def part_info(request, part_id, part_revision_id=None):
723
+ tab_anchor = request.GET.get('tab_anchor', None)
724
+
725
+ user = request.user
726
+ profile = user.bom_profile()
727
+ organization = profile.organization
728
+
729
+ part = get_object_or_404(Part, pk=part_id)
730
+
731
+ part_revision = None
732
+ if part_revision_id is None:
733
+ part_revision = part.latest()
734
+ else:
735
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
736
+
737
+ try:
738
+ selected_rev_is_latest = part_revision.revision == part.latest().revision
739
+ except AttributeError:
740
+ selected_rev_is_latest = False
741
+
742
+ revisions = PartRevision.objects.filter(part=part_id).order_by('-id')
743
+
744
+ if part.organization != organization:
745
+ messages.error(request, "Can't access a part that is not yours!")
746
+ return HttpResponseRedirect(reverse('bom:home'))
747
+
748
+ qty_cache_key = str(part_id) + '_qty'
749
+ qty = cache.get(qty_cache_key, 100)
750
+ part_info_form = PartInfoForm(initial={'quantity': qty})
751
+ upload_file_to_part_form = FileForm()
752
+
753
+ if request.method == 'POST':
754
+ part_info_form = PartInfoForm(request.POST)
755
+ if part_info_form.is_valid():
756
+ qty = request.POST.get('quantity', 100)
757
+
758
+ try:
759
+ qty = int(qty)
760
+ except ValueError:
761
+ qty = 100
762
+
763
+ cache.set(qty_cache_key, qty, timeout=None)
764
+
765
+ try:
766
+ indented_bom = part_revision.indented(top_level_quantity=qty)
767
+ except (RuntimeError, RecursionError):
768
+ messages.error(request, "Error: infinite recursion in part relationship. Contact info@indabom.com to resolve.")
769
+ indented_bom = []
770
+ except AttributeError as err:
771
+ # No part revision found, that's OK
772
+ indented_bom = []
773
+
774
+ try:
775
+ flat_bom = part_revision.flat(top_level_quantity=qty)
776
+ except (RuntimeError, RecursionError):
777
+ messages.error(request, "Error: infinite recursion in part relationship. Contact info@indabom.com to resolve.")
778
+ flat_bom = []
779
+ except AttributeError as err:
780
+ # No part revision found, that's OK
781
+ flat_bom = []
782
+
783
+ try:
784
+ where_used = part_revision.where_used()
785
+ except AttributeError:
786
+ where_used = []
787
+
788
+ try:
789
+ mouser_parts = len(flat_bom.mouser_parts().keys()) > 0
790
+ except AttributeError:
791
+ mouser_parts = False
792
+
793
+ where_used_part = part.where_used()
794
+ seller_parts = part.seller_parts()
795
+ return TemplateResponse(request, 'bom/part-info.html', locals())
796
+
797
+
798
+ @login_required(login_url=BOM_LOGIN_URL)
799
+ def part_export_bom(request, part_id=None, part_revision_id=None, flat=False, sourcing=False, sourcing_detailed=False):
800
+ user = request.user
801
+ profile = user.bom_profile()
802
+ organization = profile.organization
803
+
804
+ if part_id is not None:
805
+ part = get_object_or_404(Part, pk=part_id)
806
+ part_revision = part.latest()
807
+ elif part_revision_id is not None:
808
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
809
+ part = part_revision.part
810
+ else:
811
+ messages.error(request, "View requires part or part revision.")
812
+ return HttpResponseRedirect(request.META.get('HTTP_REFERER'), '/')
813
+
814
+ if part.organization != organization:
815
+ messages.error(request, "Cant export a part that is not yours!")
816
+ return HttpResponseRedirect(request.META.get('HTTP_REFERER'), '/')
817
+
818
+ response = HttpResponse(content_type='text/csv')
819
+ filename = f'indabom_export_{part.full_part_number()}_{"flat" if flat else "indented"}'
820
+ response['Content-Disposition'] = f'attachment; filename="{filename}.csv'
821
+
822
+ qty_cache_key = str(part_id) + '_qty'
823
+ qty = cache.get(qty_cache_key, 1000)
824
+
825
+ try:
826
+ if flat:
827
+ bom = part_revision.flat(top_level_quantity=qty)
828
+ else:
829
+ bom = part_revision.indented(top_level_quantity=qty)
830
+ except (RuntimeError, RecursionError):
831
+ messages.error(request, "Error: infinite recursion in part relationship. Contact info@indabom.com to resolve.")
832
+ bom = []
833
+ except AttributeError as err:
834
+ messages.error(request, err)
835
+ bom = []
836
+
837
+ if flat:
838
+ csv_headers = BOMFlatCSVHeaders()
839
+ else:
840
+ csv_headers = BOMIndentedCSVHeaders()
841
+
842
+ csv_headers_raw = csv_headers.get_default_all()
843
+ csv_rows = []
844
+ for _, item in bom.parts.items():
845
+ mapped_row = {}
846
+ raw_row = {k: smart_str(v) for k, v in item.as_dict_for_export().items()}
847
+ for kx, vx in raw_row.items():
848
+ if csv_headers.get_default(kx) is None: print ("NONE", kx)
849
+ mapped_row.update({csv_headers.get_default(kx): vx})
850
+
851
+ if sourcing_detailed:
852
+ for idx, sp in enumerate(item.seller_parts_for_export()):
853
+ if f'{ManufacturerPartCSVHeaders.all_headers_defns[0]}_{idx + 1}' not in csv_headers_raw:
854
+ csv_headers_raw.extend([f'{h}_{idx + 1}' for h in ManufacturerPartCSVHeaders.all_headers_defns])
855
+ csv_headers_raw.extend([f'{h}_{idx + 1}' for h in SellerPartCSVHeaders.all_headers_defns])
856
+ mapped_row.update({f'{k}_{idx + 1}': smart_str(v) for k, v in sp.items()})
857
+ elif sourcing:
858
+ for idx, mp in enumerate(item.manufacturer_parts_for_export()):
859
+ if f'{ManufacturerPartCSVHeaders.all_headers_defns[0]}_{idx + 1}' not in csv_headers_raw:
860
+ csv_headers_raw.extend([f'{h}_{idx + 1}' for h in ManufacturerPartCSVHeaders.all_headers_defns])
861
+ mapped_row.update({f'{k}_{idx + 1}': smart_str(v) for k, v in mp.items()})
862
+
863
+ csv_rows.append(mapped_row)
864
+
865
+ writer = csv.DictWriter(response, fieldnames=csv_headers_raw)
866
+ writer.writeheader()
867
+ writer.writerows(csv_rows)
868
+
869
+ return response
870
+
871
+ # @login_required
872
+ # def part_export_bom_flat(request, part_revision_id):
873
+ # user = request.user
874
+ # profile = user.bom_profile()
875
+ # organization = profile.organization
876
+ #
877
+ # part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
878
+ #
879
+ # if part_revision.part.organization != organization:
880
+ # messages.error(request, "Cant export a part that is not yours!")
881
+ # return HttpResponseRedirect(request.META.get('HTTP_REFERER'), '/')
882
+ #
883
+ # response = HttpResponse(content_type='text/csv')
884
+ # response['Content-Disposition'] = 'attachment; filename="{}_indabom_parts_flat.csv"'.format(
885
+ # part_revision.part.full_part_number())
886
+ #
887
+ # # As compared to indented bom, show all references for a subpart as a single item and
888
+ # # don't show do_not_load status at all because it won't be clear as to which subpart reference
889
+ # # the do_not_load refers to.
890
+ # qty_cache_key = str(part_revision.part.id) + '_qty'
891
+ # qty = cache.get(qty_cache_key, 1000)
892
+ #
893
+ # try:
894
+ # bom = part_revision.flat(top_level_quantity=qty)
895
+ # except (RuntimeError, RecursionError):
896
+ # messages.error(request, "Error: infinite recursion in part relationship. Contact info@indabom.com to resolve.")
897
+ # bom = []
898
+ # except AttributeError as err:
899
+ # messages.error(request, err)
900
+ # bom = []
901
+ #
902
+ # csv_headers = BOMFlatCSVHeaders()
903
+ # writer = csv.DictWriter(response, fieldnames=csv_headers.get_default_all())
904
+ # writer.writeheader()
905
+ #
906
+ # for _, item in bom.parts.items():
907
+ # mapped_row = {}
908
+ # raw_row = {k: smart_str(v) for k, v in item.as_dict_for_export().items()}
909
+ # for kx, vx in raw_row.items():
910
+ # if csv_headers.get_default(kx) is None: print ("NONE", kx)
911
+ # mapped_row.update({csv_headers.get_default(kx): vx})
912
+ # writer.writerow({k: smart_str(v) for k, v in mapped_row.items()})
913
+ #
914
+ # return response
915
+
916
+
917
+ @login_required(login_url=BOM_LOGIN_URL)
918
+ def upload_bom(request):
919
+ user = request.user
920
+ profile = user.bom_profile()
921
+ organization = profile.organization
922
+ title = 'Upload Bill of Materials'
923
+
924
+ if request.method == 'POST' and 'file' in request.FILES and request.FILES['file'] is not None:
925
+ upload_bom_form = UploadBOMForm(request.POST, organization=organization)
926
+ if upload_bom_form.is_valid():
927
+ bom_csv_form = BOMCSVForm(request.POST, request.FILES, parent_part=upload_bom_form.parent_part, organization=organization)
928
+ if bom_csv_form.is_valid():
929
+ for success in bom_csv_form.successes:
930
+ messages.info(request, success)
931
+ for warning in bom_csv_form.warnings:
932
+ messages.info(request, warning)
933
+ else:
934
+ messages.error(request, bom_csv_form.errors)
935
+ else:
936
+ messages.error(request, upload_bom_form.errors)
937
+ else:
938
+ upload_bom_form = UploadBOMForm(initial={'organization': organization})
939
+ bom_csv_form = BOMCSVForm()
940
+
941
+ return TemplateResponse(request, 'bom/upload-bom.html', locals())
942
+
943
+
944
+ @login_required(login_url=BOM_LOGIN_URL)
945
+ def part_upload_bom(request, part_id):
946
+ user = request.user
947
+ profile = user.bom_profile()
948
+ organization = profile.organization
949
+
950
+ try:
951
+ parent_part = Part.objects.get(id=part_id)
952
+ except Part.DoesNotExist:
953
+ messages.error(request, "No part found with given part_id {}.".format(part_id))
954
+ return HttpResponseRedirect(request.META.get('HTTP_REFERER'), '/')
955
+
956
+ if request.method == 'POST' and request.FILES['file'] is not None:
957
+ bom_csv_form = BOMCSVForm(request.POST, request.FILES, parent_part=parent_part, organization=organization)
958
+ if bom_csv_form.is_valid():
959
+ for success in bom_csv_form.successes:
960
+ messages.info(request, success)
961
+ for warning in bom_csv_form.warnings:
962
+ messages.info(request, warning)
963
+ else:
964
+ messages.error(request, bom_csv_form.errors)
965
+ else:
966
+ upload_bom_form = UploadBOMForm(initial={'organization': organization})
967
+ bom_csv_form = BOMCSVForm()
968
+
969
+ return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('bom:home')), locals())
970
+
971
+
972
+ @login_required(login_url=BOM_LOGIN_URL)
973
+ def upload_parts_help(request):
974
+ return TemplateResponse(request, 'bom/upload-parts-help.html', locals())
975
+
976
+
977
+ @login_required(login_url=BOM_LOGIN_URL)
978
+ def upload_parts(request):
979
+ user = request.user
980
+ profile = user.bom_profile()
981
+ organization = profile.organization
982
+ title = 'Upload Parts'
983
+
984
+ if request.method == 'POST' and request.FILES['file'] is not None:
985
+ form = PartCSVForm(request.POST, request.FILES, organization=organization)
986
+ if form.is_valid():
987
+ for success in form.successes:
988
+ messages.info(request, success)
989
+ for warning in form.warnings:
990
+ messages.warning(request, warning)
991
+ else:
992
+ messages.error(request, form.errors)
993
+ else:
994
+ form = FileForm()
995
+ if organization.number_scheme == constants.NUMBER_SCHEME_SEMI_INTELLIGENT and organization.partclass_set.count() <= 0:
996
+ messages.warning(request, f'!! Warning !! Before you upload parts, you must create any part classes. You can do this in Settings > Indabom.')
997
+ return TemplateResponse(request, 'bom/upload-parts.html', locals())
998
+
999
+ return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('bom:home')))
1000
+
1001
+
1002
+ @login_required(login_url=BOM_LOGIN_URL)
1003
+ def export_part_list(request):
1004
+ user = request.user
1005
+ profile = user.bom_profile()
1006
+ organization = profile.organization
1007
+
1008
+ response = HttpResponse(content_type='text/csv')
1009
+ response['Content-Disposition'] = 'attachment; filename="indabom_parts.csv"'
1010
+
1011
+ parts = Part.objects.filter(
1012
+ organization=organization).order_by(
1013
+ 'number_class__code',
1014
+ 'number_item',
1015
+ 'number_variation')
1016
+
1017
+ fieldnames = [
1018
+ 'part_number',
1019
+ 'part_synopsis',
1020
+ 'part_revision',
1021
+ 'part_manufacturer',
1022
+ 'part_manufacturer_part_number',
1023
+ ]
1024
+
1025
+ csv_headers = organization.part_list_csv_headers()
1026
+ writer = csv.DictWriter(response, fieldnames=fieldnames)
1027
+ writer.writeheader()
1028
+ for item in parts:
1029
+ try:
1030
+ row = {
1031
+ csv_headers.get_default('part_number'): item.full_part_number(),
1032
+ csv_headers.get_default('part_revision'): item.latest().revision,
1033
+ csv_headers.get_default('part_manufacturer'): item.primary_manufacturer_part.manufacturer.name if item.primary_manufacturer_part is not None and item.primary_manufacturer_part.manufacturer is not None else '',
1034
+ csv_headers.get_default('part_manufacturer_part_number'): item.primary_manufacturer_part.manufacturer_part_number if item.primary_manufacturer_part is not None and item.primary_manufacturer_part.manufacturer is not None else '',
1035
+ }
1036
+ for field_name in csv_headers.get_default_all():
1037
+ if field_name not in csv_headers.get_defaults_list(
1038
+ ['part_number', 'part_category', 'part_synopsis', 'part_revision', 'part_manufacturer',
1039
+ 'part_manufacturer_part_number', ]):
1040
+ attr = getattr(item, field_name)
1041
+ row.update({csv_headers.get_default(field_name): attr if attr is not None else ''})
1042
+ writer.writerow({k: smart_str(v) for k, v in row.items()})
1043
+
1044
+ except AttributeError as e:
1045
+ messages.warning(request, "No change history for part: {}. Can't export.".format(item.full_part_number()))
1046
+
1047
+ return response
1048
+
1049
+
1050
+ @login_required(login_url=BOM_LOGIN_URL)
1051
+ def create_part(request):
1052
+ user = request.user
1053
+ profile = user.bom_profile()
1054
+ organization = profile.organization
1055
+
1056
+ title = 'Create New Part'
1057
+
1058
+ PartForm = part_form_from_organization(organization)
1059
+
1060
+ if organization.number_scheme == constants.NUMBER_SCHEME_SEMI_INTELLIGENT and PartClass.objects.count() == 0:
1061
+ messages.info(request, f'Welcome to IndaBOM! Before you create your first part, you must create your first part class. '
1062
+ f'<a href="{reverse("bom:help")}#part-numbering" target="_blank">What is a part class?</a>')
1063
+ return HttpResponseRedirect(reverse('bom:settings', kwargs={'tab_anchor': 'indabom'}))
1064
+
1065
+ if request.method == 'POST':
1066
+ part_form = PartForm(request.POST, organization=organization)
1067
+ manufacturer_form = ManufacturerForm(request.POST)
1068
+ manufacturer_part_form = ManufacturerPartForm(request.POST, organization=organization)
1069
+ part_revision_form = PartRevisionForm(request.POST)
1070
+ # Checking if part form is valid checks for number uniqueness
1071
+ if part_form.is_valid() and manufacturer_form.is_valid() and manufacturer_part_form.is_valid():
1072
+ mpn = manufacturer_part_form.cleaned_data['manufacturer_part_number']
1073
+ old_manufacturer = manufacturer_part_form.cleaned_data['manufacturer']
1074
+ new_manufacturer_name = manufacturer_form.cleaned_data['name']
1075
+
1076
+ manufacturer = None
1077
+ if mpn:
1078
+ if old_manufacturer and new_manufacturer_name == '':
1079
+ manufacturer = old_manufacturer
1080
+ elif new_manufacturer_name and new_manufacturer_name != '' and not old_manufacturer:
1081
+ manufacturer, created = Manufacturer.objects.get_or_create(name__iexact=new_manufacturer_name, organization=organization, defaults={'name': new_manufacturer_name})
1082
+ else:
1083
+ messages.error(request, "Either create a new manufacturer, or select an existing manufacturer.")
1084
+ return TemplateResponse(request, 'bom/create-part.html', locals())
1085
+ elif old_manufacturer or new_manufacturer_name != '':
1086
+ messages.warning(request, "No manufacturer was selected or created, no manufacturer part number was assigned.")
1087
+ new_part = part_form.save(commit=False)
1088
+ new_part.organization = organization
1089
+
1090
+ if organization.number_scheme == constants.NUMBER_SCHEME_INTELLIGENT:
1091
+ new_part.number_class = None
1092
+ new_part.number_variation = None
1093
+
1094
+ if part_revision_form.is_valid():
1095
+ # Save the Part before the PartRevision, as this will again check for part
1096
+ # number uniqueness. This way if someone else(s) working concurrently is also
1097
+ # using the same part number, then only one person will succeed.
1098
+ try:
1099
+ new_part.save() # Database checks that the part number is still unique
1100
+ pr = part_revision_form.save(commit=False)
1101
+ pr.part = new_part # Associate PartRevision with Part
1102
+ pr.save()
1103
+ except IntegrityError as err:
1104
+ messages.error(request, "Error! Already created a part with part number {0}-{1}-{3}}".format(new_part.number_class.code, new_part.number_item, new_part.number_variation))
1105
+ return TemplateResponse(request, 'bom/create-part.html', locals())
1106
+ else:
1107
+ messages.error(request, part_revision_form.errors)
1108
+ return TemplateResponse(request, 'bom/create-part.html', locals())
1109
+
1110
+ manufacturer_part = None
1111
+ if manufacturer is not None:
1112
+ manufacturer_part, created = ManufacturerPart.objects.get_or_create(
1113
+ part=new_part,
1114
+ manufacturer_part_number='' if mpn == '' else mpn,
1115
+ manufacturer=manufacturer)
1116
+
1117
+ new_part.primary_manufacturer_part = manufacturer_part
1118
+ new_part.save()
1119
+
1120
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': str(new_part.id)}))
1121
+ else:
1122
+ # Initialize organization in the form's model and in the form itself:
1123
+ part_form = PartForm(initial={'organization': organization}, organization=organization)
1124
+ part_revision_form = PartRevisionForm(initial={'revision': 1, 'organization': organization})
1125
+ manufacturer_form = ManufacturerForm(initial={'organization': organization})
1126
+ manufacturer_part_form = ManufacturerPartForm(organization=organization)
1127
+
1128
+ return TemplateResponse(request, 'bom/create-part.html', locals())
1129
+
1130
+
1131
+ @login_required(login_url=BOM_LOGIN_URL)
1132
+ def part_edit(request, part_id):
1133
+ user = request.user
1134
+ profile = user.bom_profile()
1135
+ organization = profile.organization
1136
+
1137
+ part = get_object_or_404(Part, pk=part_id)
1138
+ title = 'Edit Part {}'.format(part.full_part_number())
1139
+
1140
+ action = reverse('bom:part-edit', kwargs={'part_id': part_id})
1141
+
1142
+ PartForm = part_form_from_organization(organization)
1143
+
1144
+ if request.method == 'POST':
1145
+ form = PartForm(request.POST, instance=part, organization=organization)
1146
+ if form.is_valid():
1147
+ form.save()
1148
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': part_id}))
1149
+ else:
1150
+ form = PartForm(instance=part, organization=organization)
1151
+
1152
+ return TemplateResponse(request, 'bom/bom-form.html', locals())
1153
+
1154
+
1155
+ @login_required(login_url=BOM_LOGIN_URL)
1156
+ def manage_bom(request, part_id, part_revision_id):
1157
+ user = request.user
1158
+ profile = user.bom_profile()
1159
+ organization = profile.organization
1160
+
1161
+ part = get_object_or_404(Part, pk=part_id)
1162
+
1163
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
1164
+
1165
+ title = 'Manage BOM for ' + part.full_part_number()
1166
+
1167
+ if part.organization != organization:
1168
+ messages.error(request, "Cant access a part that is not yours!")
1169
+ return HttpResponseRedirect(request.META.get('HTTP_REFERER'), '/')
1170
+
1171
+ add_subpart_form = AddSubpartForm(initial={'count': 1, }, organization=organization, part_id=part_id)
1172
+ upload_subparts_csv_form = FileForm()
1173
+
1174
+ qty_cache_key = str(part_id) + '_qty'
1175
+ qty = cache.get(qty_cache_key, 100)
1176
+
1177
+ try:
1178
+ indented_bom = part_revision.indented(top_level_quantity=qty)
1179
+ except (RuntimeError, RecursionError):
1180
+ messages.error(request, "Error: infinite recursion in part relationship. Contact info@indabom.com to resolve.")
1181
+ indented_bom = []
1182
+ except AttributeError as err:
1183
+ messages.error(request, err)
1184
+ indented_bom = []
1185
+
1186
+ references_seen = set()
1187
+ duplicate_references = set()
1188
+ for sp in part_revision.assembly.subparts.all():
1189
+ check_references_for_duplicates(sp.reference, references_seen, duplicate_references)
1190
+
1191
+ if len(duplicate_references) > 0:
1192
+ sorted_duplicate_references = sorted(duplicate_references, key=prep_for_sorting_nicely)
1193
+ messages.warning(request, "Warning: The following BOM references are associated with multiple parts: " + str(sorted_duplicate_references))
1194
+
1195
+ return TemplateResponse(request, 'bom/part-revision-manage-bom.html', locals())
1196
+
1197
+
1198
+ @login_required(login_url=BOM_LOGIN_URL)
1199
+ @organization_admin
1200
+ def part_delete(request, part_id):
1201
+ part = get_object_or_404(Part, pk=part_id)
1202
+ part.delete()
1203
+ return HttpResponseRedirect(reverse('bom:home'))
1204
+
1205
+
1206
+ @login_required(login_url=BOM_LOGIN_URL)
1207
+ def add_subpart(request, part_id, part_revision_id):
1208
+ user = request.user
1209
+ profile = user.bom_profile()
1210
+ organization = profile.organization
1211
+
1212
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
1213
+
1214
+ if request.method == 'POST':
1215
+ add_subpart_form = AddSubpartForm(request.POST, organization=organization, part_id=part_id)
1216
+ if add_subpart_form.is_valid():
1217
+ subpart_part = add_subpart_form.subpart_part
1218
+ reference = add_subpart_form.cleaned_data['reference']
1219
+ dnl = add_subpart_form.cleaned_data['do_not_load']
1220
+ count = add_subpart_form.cleaned_data['count']
1221
+
1222
+ first_level_bom = part_revision.assembly.subparts.filter(part_revision=subpart_part, do_not_load=dnl)
1223
+
1224
+ if first_level_bom.count() > 0:
1225
+ new_part = first_level_bom[0]
1226
+ new_part.count += count
1227
+ if reference:
1228
+ new_part.reference = new_part.reference + ', ' + reference
1229
+ new_part.save()
1230
+ else:
1231
+ new_part = Subpart.objects.create(
1232
+ part_revision=subpart_part,
1233
+ count=count,
1234
+ reference=reference,
1235
+ do_not_load=dnl)
1236
+
1237
+ if part_revision.assembly is None:
1238
+ part_revision.assembly = Assembly.objects.create()
1239
+ part_revision.save()
1240
+
1241
+ AssemblySubparts.objects.create(assembly=part_revision.assembly, subpart=new_part)
1242
+
1243
+ info_msg = "Added subpart "
1244
+ if reference:
1245
+ info_msg += ' ' + reference
1246
+ info_msg += " {} to part {}".format(subpart_part, part_revision)
1247
+ messages.info(request, info_msg)
1248
+
1249
+ else:
1250
+ messages.error(request, add_subpart_form.errors)
1251
+
1252
+ return HttpResponseRedirect(reverse('bom:part-manage-bom', kwargs={'part_id': part_id, 'part_revision_id': part_revision_id}))
1253
+
1254
+
1255
+ @login_required(login_url=BOM_LOGIN_URL)
1256
+ @organization_admin
1257
+ def remove_subpart(request, part_id, part_revision_id, subpart_id):
1258
+ subpart = get_object_or_404(Subpart, pk=subpart_id)
1259
+ subpart.delete()
1260
+ return HttpResponseRedirect(
1261
+ reverse('bom:part-manage-bom', kwargs={'part_id': part_id, 'part_revision_id': part_revision_id}))
1262
+
1263
+
1264
+ @login_required(login_url=BOM_LOGIN_URL)
1265
+ def part_class_edit(request, part_class_id):
1266
+ user = request.user
1267
+ profile = user.bom_profile()
1268
+ organization = profile.organization
1269
+
1270
+ part_class = get_object_or_404(PartClass, pk=part_class_id)
1271
+ title = 'Edit Part Class {}'.format(part_class.__str__())
1272
+
1273
+ if request.method == 'POST':
1274
+ part_class_form = PartClassForm(request.POST, instance=part_class, organization=organization)
1275
+ if part_class_form.is_valid():
1276
+ part_class_form.save()
1277
+ return HttpResponseRedirect(reverse('bom:settings', kwargs={'tab_anchor': 'indabom'}))
1278
+
1279
+ else:
1280
+ return TemplateResponse(request, 'bom/edit-part-class.html', locals())
1281
+
1282
+ else:
1283
+ part_class_form = PartClassForm(instance=part_class, organization=organization)
1284
+
1285
+ return TemplateResponse(request, 'bom/edit-part-class.html', locals())
1286
+
1287
+
1288
+ @login_required(login_url=BOM_LOGIN_URL)
1289
+ def edit_subpart(request, part_id, part_revision_id, subpart_id):
1290
+ user = request.user
1291
+ profile = user.bom_profile()
1292
+ organization = profile.organization
1293
+ action = reverse('bom:part-edit-subpart', kwargs={'part_id': part_id, 'subpart_id': subpart_id, 'part_revision_id': part_revision_id})
1294
+
1295
+ part = get_object_or_404(Part, pk=part_id)
1296
+ subpart = get_object_or_404(Subpart, pk=subpart_id)
1297
+ title = "Edit Subpart"
1298
+ h1 = "{} {}".format(subpart.part_revision.part.full_part_number(), subpart.part_revision.synopsis())
1299
+
1300
+ if request.method == 'POST':
1301
+ form = SubpartForm(request.POST, instance=subpart, organization=organization, part_id=subpart.part_revision.part.id)
1302
+ if form.is_valid():
1303
+ reference_list = listify_string(form.cleaned_data['reference'])
1304
+ count = form.cleaned_data['count']
1305
+ form.save()
1306
+ return HttpResponseRedirect(reverse('bom:part-manage-bom', kwargs={'part_id': part_id, 'part_revision_id': part_revision_id}))
1307
+ else:
1308
+ return TemplateResponse(request, 'bom/bom-form.html', locals())
1309
+
1310
+ else:
1311
+ form = SubpartForm(instance=subpart, organization=organization, part_id=subpart.part_revision.part.id)
1312
+
1313
+ return TemplateResponse(request, 'bom/bom-form.html', locals())
1314
+
1315
+
1316
+ @login_required(login_url=BOM_LOGIN_URL)
1317
+ @organization_admin
1318
+ def remove_all_subparts(request, part_id, part_revision_id):
1319
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
1320
+ part_revision.assembly.subparts.all().delete()
1321
+ return HttpResponseRedirect(reverse('bom:part-manage-bom', kwargs={'part_id': part_id, 'part_revision_id': part_revision_id}))
1322
+
1323
+
1324
+ @login_required(login_url=BOM_LOGIN_URL)
1325
+ def add_sellerpart(request, manufacturer_part_id):
1326
+ user = request.user
1327
+ profile = user.bom_profile()
1328
+ organization = profile.organization
1329
+ title = 'Add Seller Part'
1330
+
1331
+ manufacturer_part = get_object_or_404(ManufacturerPart, pk=manufacturer_part_id)
1332
+ title = "Add Seller Part to {}".format(manufacturer_part)
1333
+
1334
+ if request.method == 'POST':
1335
+ form = SellerPartForm(request.POST, manufacturer_part=manufacturer_part, organization=organization)
1336
+ if form.is_valid():
1337
+ form.save()
1338
+ return HttpResponseRedirect(
1339
+ reverse('bom:part-info', kwargs={'part_id': manufacturer_part.part.id}) + '?tab_anchor=sourcing')
1340
+ else:
1341
+ form = SellerPartForm(organization=organization)
1342
+
1343
+ return TemplateResponse(request, 'bom/bom-form.html', locals())
1344
+
1345
+
1346
+ @login_required(login_url=BOM_LOGIN_URL)
1347
+ def add_manufacturer_part(request, part_id):
1348
+ user = request.user
1349
+ profile = user.bom_profile()
1350
+ organization = profile.organization
1351
+
1352
+ part = get_object_or_404(Part, pk=part_id)
1353
+ title = 'Add Manufacturer Part to {}'.format(part.full_part_number())
1354
+
1355
+ if request.method == 'POST':
1356
+ manufacturer_form = ManufacturerForm(request.POST)
1357
+ manufacturer_part_form = ManufacturerPartForm(request.POST, organization=organization)
1358
+ if manufacturer_form.is_valid() and manufacturer_part_form.is_valid():
1359
+ manufacturer_part_number = manufacturer_part_form.cleaned_data['manufacturer_part_number']
1360
+ manufacturer = manufacturer_part_form.cleaned_data['manufacturer']
1361
+ new_manufacturer_name = manufacturer_form.cleaned_data['name']
1362
+ if manufacturer is None and new_manufacturer_name == '':
1363
+ messages.error(request, "Must either select an existing manufacturer, or enter a new manufacturer name.")
1364
+ return TemplateResponse(request, 'bom/add-manufacturer-part.html', locals())
1365
+
1366
+ if new_manufacturer_name != '' and new_manufacturer_name is not None:
1367
+ manufacturer, created = Manufacturer.objects.get_or_create(name__iexact=new_manufacturer_name, organization=organization, defaults={'name': new_manufacturer_name})
1368
+ manufacturer_part_form.cleaned_data['manufacturer'] = manufacturer
1369
+
1370
+ manufacturer_part, created = ManufacturerPart.objects.get_or_create(part=part, manufacturer_part_number=manufacturer_part_number, manufacturer=manufacturer)
1371
+
1372
+ if part.primary_manufacturer_part is None and manufacturer_part is not None:
1373
+ part.primary_manufacturer_part = manufacturer_part
1374
+ part.save()
1375
+
1376
+ return HttpResponseRedirect(
1377
+ reverse('bom:part-info', kwargs={'part_id': str(part.id)}) + '?tab_anchor=sourcing')
1378
+ else:
1379
+ messages.error(request, "{}".format(manufacturer_form.is_valid()))
1380
+ messages.error(request, "{}".format(manufacturer_part_form.is_valid()))
1381
+ else:
1382
+ default_mfg = Manufacturer.objects.filter(organization=organization, name__iexact=organization.name).first()
1383
+ manufacturer_form = ManufacturerForm(initial={'organization': organization})
1384
+ manufacturer_part_form = ManufacturerPartForm(organization=organization, initial={'manufacturer_part_number': part.full_part_number(), 'manufacturer': default_mfg})
1385
+
1386
+ return TemplateResponse(request, 'bom/add-manufacturer-part.html', locals())
1387
+
1388
+
1389
+ @login_required(login_url=BOM_LOGIN_URL)
1390
+ def manufacturer_part_edit(request, manufacturer_part_id):
1391
+ user = request.user
1392
+ profile = user.bom_profile()
1393
+ organization = profile.organization
1394
+
1395
+ title = 'Edit Manufacturer Part'
1396
+
1397
+ manufacturer_part = get_object_or_404(ManufacturerPart, pk=manufacturer_part_id)
1398
+ part = manufacturer_part.part
1399
+
1400
+ if request.method == 'POST':
1401
+ manufacturer_part_form = ManufacturerPartForm(request.POST, instance=manufacturer_part, organization=organization)
1402
+ manufacturer_form = ManufacturerForm(request.POST, instance=manufacturer_part.manufacturer)
1403
+ if manufacturer_part_form.is_valid() and manufacturer_form.is_valid():
1404
+ manufacturer_part_number = manufacturer_part_form.cleaned_data.get('manufacturer_part_number')
1405
+ manufacturer = manufacturer_part_form.cleaned_data.get('manufacturer', None)
1406
+ new_manufacturer_name = manufacturer_form.cleaned_data.get('name', '')
1407
+
1408
+ if manufacturer is None and new_manufacturer_name == '':
1409
+ messages.error(request, "Must either select an existing manufacturer, or enter a new manufacturer name.")
1410
+ return TemplateResponse(request, 'bom/edit-manufacturer-part.html', locals())
1411
+
1412
+ new_manufacturer = None
1413
+ if new_manufacturer_name != '' and new_manufacturer_name is not None:
1414
+ new_manufacturer, created = Manufacturer.objects.get_or_create(name__iexact=new_manufacturer_name, organization=organization, defaults={'name': new_manufacturer_name})
1415
+ manufacturer_part = manufacturer_part_form.save(commit=False)
1416
+ manufacturer_part.manufacturer = new_manufacturer
1417
+ manufacturer_part.save()
1418
+ else:
1419
+ manufacturer_part = manufacturer_part_form.save()
1420
+
1421
+ if part.primary_manufacturer_part is None and manufacturer_part is not None:
1422
+ part.primary_manufacturer_part = manufacturer_part
1423
+ part.save()
1424
+ return HttpResponseRedirect(
1425
+ reverse('bom:part-info', kwargs={'part_id': manufacturer_part.part.id}) + '?tab_anchor=sourcing')
1426
+ else:
1427
+ messages.error(request, manufacturer_part_form.errors)
1428
+ messages.error(request, manufacturer_form.errors)
1429
+ else:
1430
+ if manufacturer_part.manufacturer is None:
1431
+ manufacturer_form = ManufacturerForm(instance=manufacturer_part.manufacturer, initial={'organization': organization})
1432
+ else:
1433
+ manufacturer_form = ManufacturerForm(initial={'organization': organization})
1434
+
1435
+ manufacturer_part_form = ManufacturerPartForm(instance=manufacturer_part, organization=organization, )
1436
+
1437
+ return TemplateResponse(request, 'bom/edit-manufacturer-part.html', locals())
1438
+
1439
+
1440
+ @login_required(login_url=BOM_LOGIN_URL)
1441
+ @organization_admin
1442
+ def manufacturer_part_delete(request, manufacturer_part_id):
1443
+ manufacturer_part = get_object_or_404(ManufacturerPart, pk=manufacturer_part_id)
1444
+ part = manufacturer_part.part
1445
+ manufacturer_part.delete()
1446
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': part.id}) + '?tab_anchor=sourcing')
1447
+
1448
+
1449
+ @login_required(login_url=BOM_LOGIN_URL)
1450
+ def sellerpart_edit(request, sellerpart_id):
1451
+ user = request.user
1452
+ profile = user.bom_profile()
1453
+ organization = profile.organization
1454
+
1455
+ title = "Edit Seller Part"
1456
+ action = reverse('bom:sellerpart-edit', kwargs={'sellerpart_id': sellerpart_id})
1457
+ sellerpart = get_object_or_404(SellerPart, pk=sellerpart_id)
1458
+
1459
+ if request.method == 'POST':
1460
+ form = SellerPartForm(request.POST, instance=sellerpart, organization=organization)
1461
+ if form.is_valid():
1462
+ form.save()
1463
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': sellerpart.manufacturer_part.part.id}) + '?tab_anchor=sourcing')
1464
+ else:
1465
+ form = SellerPartForm(instance=sellerpart, organization=organization)
1466
+
1467
+ return TemplateResponse(request, 'bom/bom-form.html', locals())
1468
+
1469
+
1470
+ @login_required(login_url=BOM_LOGIN_URL)
1471
+ @organization_admin
1472
+ def sellerpart_delete(request, sellerpart_id):
1473
+ sellerpart = get_object_or_404(SellerPart, pk=sellerpart_id)
1474
+ part = sellerpart.manufacturer_part.part
1475
+ sellerpart.delete()
1476
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': part.id}) + '?tab_anchor=sourcing')
1477
+
1478
+
1479
+ @login_required(login_url=BOM_LOGIN_URL)
1480
+ def part_revision_release(request, part_id, part_revision_id):
1481
+ user = request.user
1482
+ profile = user.bom_profile()
1483
+ organization = profile.organization
1484
+
1485
+ part = get_object_or_404(Part, pk=part_id)
1486
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
1487
+ action = reverse('bom:part-revision-release', kwargs={'part_id': part.id, 'part_revision_id': part_revision.id})
1488
+ title = 'Promote {} Rev {} {} from <b>Working</b> to <b>Released</b>?'.format(part.full_part_number(), part_revision.revision, part_revision.synopsis())
1489
+
1490
+ subparts = part_revision.assembly.subparts.filter(part_revision__configuration="W")
1491
+ release_warning = subparts.count() > 0
1492
+
1493
+ if request.method == 'POST':
1494
+ part_revision.configuration = 'R'
1495
+ part_revision.save()
1496
+ return HttpResponseRedirect(reverse('bom:part-info-history', kwargs={'part_id': part.id, 'part_revision_id': part_revision.id}))
1497
+
1498
+ return TemplateResponse(request, 'bom/part-revision-release.html', locals())
1499
+
1500
+
1501
+ @login_required(login_url=BOM_LOGIN_URL)
1502
+ def part_revision_revert(request, part_id, part_revision_id):
1503
+ user = request.user
1504
+ profile = user.bom_profile()
1505
+ organization = profile.organization
1506
+
1507
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
1508
+ part_revision.configuration = 'W'
1509
+ part_revision.save()
1510
+ return HttpResponseRedirect(reverse('bom:part-info-history', kwargs={'part_id': part_id, 'part_revision_id': part_revision_id}))
1511
+
1512
+
1513
+ @login_required(login_url=BOM_LOGIN_URL)
1514
+ def part_revision_new(request, part_id):
1515
+ user = request.user
1516
+ profile = user.bom_profile()
1517
+ organization = profile.organization
1518
+
1519
+ part = get_object_or_404(Part, pk=part_id)
1520
+ title = 'New Revision for {}'.format(part.full_part_number())
1521
+ action = reverse('bom:part-revision-new', kwargs={'part_id': part_id})
1522
+
1523
+ latest_revision = part.latest()
1524
+ next_revision_number = latest_revision.next_revision() if latest_revision else None
1525
+
1526
+ all_part_revisions = part.revisions()
1527
+ all_used_part_revisions = PartRevision.objects.filter(part=part)
1528
+ used_in_subparts = Subpart.objects.filter(part_revision__in=all_used_part_revisions)
1529
+ used_in_assembly_ids = AssemblySubparts.objects.filter(subpart__in=used_in_subparts).values_list('assembly', flat=True)
1530
+ all_used_in_prs = PartRevision.objects.filter(assembly__in=used_in_assembly_ids)
1531
+ used_part_revisions = all_used_in_prs.filter(configuration='W')
1532
+
1533
+ if request.method == 'POST':
1534
+ part_revision_new_form = PartRevisionNewForm(request.POST, part=part, revision=next_revision_number, assembly=latest_revision.assembly)
1535
+ if part_revision_new_form.is_valid():
1536
+ new_part_revision = part_revision_new_form.save()
1537
+
1538
+ revisions_to_roll = request.POST.getlist('roll')
1539
+ # TODO: could optimize this, but probably shouldn't get too crazy so may be fine...
1540
+ for r_id in revisions_to_roll:
1541
+ subparts = PartRevision.objects.get(id=r_id).assembly.subparts \
1542
+ .filter(part_revision__in=all_part_revisions)
1543
+ subparts.update(part_revision=new_part_revision)
1544
+
1545
+ if part_revision_new_form.cleaned_data['copy_assembly']:
1546
+ old_subparts = latest_revision.assembly.subparts.all() if latest_revision.assembly is not None else None
1547
+ new_assembly = latest_revision.assembly if latest_revision.assembly is not None else Assembly()
1548
+ new_assembly.pk = None
1549
+ new_assembly.save()
1550
+
1551
+ part_revision_new_form.cleaned_data['assembly'] = new_assembly
1552
+
1553
+ new_part_revision.assembly = new_assembly
1554
+ new_part_revision.save()
1555
+ for sp in old_subparts:
1556
+ new_sp = sp
1557
+ new_sp.pk = None
1558
+ new_sp.save()
1559
+ AssemblySubparts.objects.create(assembly=new_assembly, subpart=new_sp)
1560
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': part_id}))
1561
+
1562
+ else:
1563
+ if latest_revision:
1564
+ messages.info(request, 'New revision automatically incremented to `{}` from your last revision `{}`.'.format(next_revision_number, latest_revision.revision))
1565
+ latest_revision.revision = next_revision_number # use updated object to populate form but don't save changes
1566
+ part_revision_new_form = PartRevisionNewForm(instance=latest_revision)
1567
+ else:
1568
+ part_revision_new_form = PartRevisionNewForm()
1569
+
1570
+ return TemplateResponse(request, 'bom/part-revision-new.html', locals())
1571
+
1572
+
1573
+ @login_required(login_url=BOM_LOGIN_URL)
1574
+ def part_revision_edit(request, part_id, part_revision_id):
1575
+ user = request.user
1576
+ profile = user.bom_profile()
1577
+ organization = profile.organization
1578
+
1579
+ part = get_object_or_404(Part, pk=part_id)
1580
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
1581
+ title = 'Edit {} Rev {}'.format(part.full_part_number(), part_revision.revision)
1582
+
1583
+ action = reverse('bom:part-revision-edit', kwargs={'part_id': part_id, 'part_revision_id': part_revision_id})
1584
+
1585
+ if request.method == 'POST':
1586
+ form = PartRevisionForm(request.POST, instance=part_revision)
1587
+ if form.is_valid():
1588
+ form.save()
1589
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': part_id}))
1590
+ else:
1591
+ form = PartRevisionForm(instance=part_revision)
1592
+
1593
+ return TemplateResponse(request, 'bom/part-revision-edit.html', locals())
1594
+
1595
+
1596
+ @login_required(login_url=BOM_LOGIN_URL)
1597
+ @organization_admin
1598
+ def part_revision_delete(request, part_id, part_revision_id):
1599
+ part_revision = get_object_or_404(PartRevision, pk=part_revision_id)
1600
+ part = part_revision.part
1601
+ part_revision.delete()
1602
+
1603
+ if part.revisions().count() == 0:
1604
+ part.delete()
1605
+ messages.info(request, 'Deleted {}.'.format(part.full_part_number()))
1606
+ return HttpResponseRedirect(reverse('bom:home'))
1607
+
1608
+ messages.info(request, 'Deleted {} Rev {}.'.format(part.full_part_number(), part_revision.revision))
1609
+
1610
+ return HttpResponseRedirect(reverse('bom:part-info', kwargs={'part_id': part.id}))
1611
+
1612
+
1613
+ class Help(TemplateView):
1614
+ name = 'help'
1615
+ template_name = f'bom/{name}.html'
1616
+
1617
+ def get_context_data(self, *args, **kwargs):
1618
+ context = super(Help, self).get_context_data(**kwargs)
1619
+ context['name'] = self.name
1620
+ return context