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.

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