django-bom 1.243__py3-none-any.whl → 1.257__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bom/admin.py +60 -14
- bom/auth_backends.py +3 -1
- bom/constants.py +7 -0
- bom/csv_headers.py +22 -44
- bom/forms.py +971 -1043
- bom/helpers.py +79 -4
- bom/migrations/0051_alter_manufacturer_organization_and_more.py +41 -0
- bom/migrations/0052_remove_partrevision_attribute_and_more.py +576 -0
- bom/models.py +253 -99
- bom/settings.py +2 -0
- bom/static/bom/css/style.css +19 -0
- bom/static/bom/js/formset-handler.js +65 -0
- bom/templates/bom/edit-part-class.html +98 -11
- bom/templates/bom/edit-quantity-of-measure.html +119 -0
- bom/templates/bom/edit-user-meta.html +58 -26
- bom/templates/bom/organization-create.html +6 -3
- bom/templates/bom/part-info.html +10 -1
- bom/templates/bom/part-revision-display.html +22 -159
- bom/templates/bom/settings.html +142 -15
- bom/tests.py +117 -31
- bom/urls.py +6 -0
- bom/views/json_views.py +2 -2
- bom/views/views.py +194 -46
- {django_bom-1.243.dist-info → django_bom-1.257.dist-info}/METADATA +1 -1
- {django_bom-1.243.dist-info → django_bom-1.257.dist-info}/RECORD +28 -24
- {django_bom-1.243.dist-info → django_bom-1.257.dist-info}/WHEEL +0 -0
- {django_bom-1.243.dist-info → django_bom-1.257.dist-info}/licenses/LICENSE +0 -0
- {django_bom-1.243.dist-info → django_bom-1.257.dist-info}/top_level.txt +0 -0
bom/models.py
CHANGED
|
@@ -1,54 +1,101 @@
|
|
|
1
1
|
from __future__ import unicode_literals
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
4
5
|
|
|
6
|
+
from django.apps import apps
|
|
5
7
|
from django.conf import settings
|
|
6
8
|
from django.contrib.auth import get_user_model
|
|
7
9
|
from django.core.cache import cache
|
|
10
|
+
from django.core.exceptions import ValidationError
|
|
8
11
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
9
12
|
from django.db import models
|
|
10
13
|
from django.utils import timezone
|
|
14
|
+
from django.utils.text import slugify
|
|
11
15
|
from djmoney.models.fields import CURRENCY_CHOICES, CurrencyField, MoneyField
|
|
12
16
|
from math import ceil
|
|
13
17
|
from social_django.models import UserSocialAuth
|
|
14
18
|
|
|
15
19
|
from .base_classes import AsDictModel
|
|
16
20
|
from .constants import *
|
|
17
|
-
from .csv_headers import PartsListCSVHeaders, PartsListCSVHeadersSemiIntelligent
|
|
21
|
+
from .csv_headers import PartsListCSVHeaders, PartsListCSVHeadersSemiIntelligent, BOMIndentedCSVHeaders, \
|
|
22
|
+
BOMFlatCSVHeaders
|
|
18
23
|
from .part_bom import PartBom, PartBomItem, PartIndentedBomItem
|
|
19
24
|
from .utils import increment_str, listify_string, prep_for_sorting_nicely, stringify_list, strip_trailing_zeros
|
|
20
|
-
from .validators import alphanumeric
|
|
25
|
+
from .validators import alphanumeric
|
|
21
26
|
|
|
22
27
|
logger = logging.getLogger(__name__)
|
|
23
28
|
User = get_user_model()
|
|
24
29
|
|
|
25
30
|
|
|
31
|
+
def get_user_meta_model():
|
|
32
|
+
from django.apps import apps
|
|
33
|
+
from django.conf import settings
|
|
34
|
+
return apps.get_model(settings.BOM_USER_META_MODEL)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_organization_model():
|
|
38
|
+
from django.apps import apps
|
|
39
|
+
from django.conf import settings
|
|
40
|
+
return apps.get_model(settings.BOM_ORGANIZATION_MODEL)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _user_meta(self, organization=None):
|
|
44
|
+
from django.apps import apps
|
|
45
|
+
from django.conf import settings
|
|
46
|
+
UserMetaModel = apps.get_model(settings.BOM_USER_META_MODEL)
|
|
47
|
+
meta, created = UserMetaModel.objects.get_or_create(
|
|
48
|
+
user=self,
|
|
49
|
+
defaults={'organization': organization}
|
|
50
|
+
)
|
|
51
|
+
return meta
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OrganizationManager(models.Manager):
|
|
55
|
+
def available_to(self, organization):
|
|
56
|
+
return self.get_queryset().filter(
|
|
57
|
+
models.Q(organization=organization) |
|
|
58
|
+
models.Q(organization__isnull=True)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
26
62
|
class OrganizationScopedModel(models.Model):
|
|
27
|
-
organization = models.ForeignKey(
|
|
63
|
+
organization = models.ForeignKey(settings.BOM_ORGANIZATION_MODEL, on_delete=models.CASCADE, db_index=True)
|
|
64
|
+
|
|
65
|
+
objects = OrganizationManager()
|
|
28
66
|
|
|
29
67
|
class Meta:
|
|
30
68
|
abstract = True
|
|
31
69
|
|
|
32
70
|
|
|
33
|
-
class
|
|
71
|
+
class OrganizationOptionalModel(models.Model):
|
|
72
|
+
organization = models.ForeignKey(settings.BOM_ORGANIZATION_MODEL, on_delete=models.CASCADE, db_index=True,
|
|
73
|
+
null=True, blank=True, help_text="Leave empty for a Global/System record.")
|
|
74
|
+
|
|
75
|
+
objects = OrganizationManager()
|
|
76
|
+
|
|
77
|
+
class Meta:
|
|
78
|
+
abstract = True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AbstractOrganization(models.Model):
|
|
34
82
|
name = models.CharField(max_length=255, default=None)
|
|
35
|
-
subscription = models.CharField(max_length=1, choices=SUBSCRIPTION_TYPES)
|
|
36
|
-
subscription_quantity = models.IntegerField(default=0)
|
|
37
83
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
|
38
84
|
number_scheme = models.CharField(max_length=1, choices=NUMBER_SCHEMES, default=NUMBER_SCHEME_SEMI_INTELLIGENT)
|
|
39
85
|
number_class_code_len = models.PositiveIntegerField(default=NUMBER_CLASS_CODE_LEN_DEFAULT,
|
|
40
|
-
validators=[MinValueValidator(NUMBER_CLASS_CODE_LEN_MIN),
|
|
86
|
+
validators=[MinValueValidator(NUMBER_CLASS_CODE_LEN_MIN),
|
|
87
|
+
MaxValueValidator(NUMBER_CLASS_CODE_LEN_MAX)])
|
|
41
88
|
number_item_len = models.PositiveIntegerField(default=NUMBER_ITEM_LEN_DEFAULT,
|
|
42
|
-
validators=[MinValueValidator(NUMBER_ITEM_LEN_MIN),
|
|
89
|
+
validators=[MinValueValidator(NUMBER_ITEM_LEN_MIN),
|
|
90
|
+
MaxValueValidator(NUMBER_ITEM_LEN_MAX)])
|
|
43
91
|
number_variation_len = models.PositiveIntegerField(default=NUMBER_VARIATION_LEN_DEFAULT,
|
|
44
|
-
validators=[MinValueValidator(NUMBER_VARIATION_LEN_MIN),
|
|
92
|
+
validators=[MinValueValidator(NUMBER_VARIATION_LEN_MIN),
|
|
93
|
+
MaxValueValidator(NUMBER_VARIATION_LEN_MAX)])
|
|
45
94
|
google_drive_parent = models.CharField(max_length=128, blank=True, default=None, null=True)
|
|
46
95
|
currency = CurrencyField(max_length=3, choices=CURRENCY_CHOICES, default='USD')
|
|
47
96
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
("manage_members", "Can manage organization members"),
|
|
51
|
-
)
|
|
97
|
+
subscription = models.CharField(max_length=1, choices=SUBSCRIPTION_TYPES)
|
|
98
|
+
subscription_quantity = models.IntegerField(default=0)
|
|
52
99
|
|
|
53
100
|
def number_cs(self):
|
|
54
101
|
return "C" * self.number_class_code_len
|
|
@@ -67,22 +114,51 @@ class Organization(models.Model):
|
|
|
67
114
|
|
|
68
115
|
def part_list_csv_headers(self):
|
|
69
116
|
if self.number_scheme == NUMBER_SCHEME_INTELLIGENT:
|
|
70
|
-
|
|
117
|
+
headers = PartsListCSVHeaders()
|
|
71
118
|
else:
|
|
72
|
-
|
|
119
|
+
headers = PartsListCSVHeadersSemiIntelligent()
|
|
120
|
+
|
|
121
|
+
# Add dynamic headers
|
|
122
|
+
definitions = PartRevisionPropertyDefinition.objects.available_to(self).all()
|
|
123
|
+
headers.add_dynamic_headers(definitions)
|
|
124
|
+
return headers
|
|
125
|
+
|
|
126
|
+
def bom_indented_csv_headers(self):
|
|
127
|
+
headers = BOMIndentedCSVHeaders()
|
|
128
|
+
definitions = PartRevisionPropertyDefinition.objects.available_to(self).all()
|
|
129
|
+
headers.add_dynamic_headers(definitions)
|
|
130
|
+
return headers
|
|
131
|
+
|
|
132
|
+
def bom_flat_csv_headers(self):
|
|
133
|
+
headers = BOMFlatCSVHeaders()
|
|
134
|
+
definitions = PartRevisionPropertyDefinition.objects.available_to(self).all()
|
|
135
|
+
headers.add_dynamic_headers(definitions)
|
|
136
|
+
return headers
|
|
73
137
|
|
|
74
138
|
@property
|
|
75
139
|
def email(self):
|
|
76
140
|
return self.owner.email
|
|
77
141
|
|
|
78
142
|
def save(self, *args, **kwargs):
|
|
79
|
-
super(
|
|
80
|
-
SellerPart.objects.filter(seller__organization=self).update(unit_cost_currency=self.currency,
|
|
143
|
+
super(AbstractOrganization, self).save()
|
|
144
|
+
SellerPart.objects.filter(seller__organization=self).update(unit_cost_currency=self.currency,
|
|
145
|
+
nre_cost_currency=self.currency)
|
|
81
146
|
|
|
147
|
+
class Meta:
|
|
148
|
+
abstract = True
|
|
82
149
|
|
|
83
|
-
|
|
150
|
+
|
|
151
|
+
class Organization(AbstractOrganization):
|
|
152
|
+
class Meta:
|
|
153
|
+
swappable = 'BOM_ORGANIZATION_MODEL'
|
|
154
|
+
permissions = (
|
|
155
|
+
("manage_members", "Can manage organization members"),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AbstractUserMeta(models.Model):
|
|
84
160
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE)
|
|
85
|
-
organization = models.ForeignKey(
|
|
161
|
+
organization = models.ForeignKey(settings.BOM_ORGANIZATION_MODEL, blank=True, null=True, on_delete=models.CASCADE)
|
|
86
162
|
role = models.CharField(max_length=1, choices=ROLE_TYPES)
|
|
87
163
|
|
|
88
164
|
def get_or_create_organization(self):
|
|
@@ -92,7 +168,9 @@ class UserMeta(models.Model):
|
|
|
92
168
|
else:
|
|
93
169
|
org_name = self.user.first_name + ' ' + self.user.last_name
|
|
94
170
|
|
|
95
|
-
|
|
171
|
+
OrganizationModel = apps.get_model(settings.BOM_ORGANIZATION_MODEL)
|
|
172
|
+
organization, created = OrganizationModel.objects.get_or_create(owner=self.user, defaults={'name': org_name,
|
|
173
|
+
'subscription': 'F'})
|
|
96
174
|
|
|
97
175
|
self.organization = organization
|
|
98
176
|
self.role = 'A'
|
|
@@ -109,10 +187,13 @@ class UserMeta(models.Model):
|
|
|
109
187
|
def is_organization_owner(self) -> bool:
|
|
110
188
|
return self.organization.owner == self.user if self.organization else False
|
|
111
189
|
|
|
112
|
-
|
|
113
|
-
|
|
190
|
+
class Meta:
|
|
191
|
+
abstract = True
|
|
114
192
|
|
|
115
|
-
|
|
193
|
+
|
|
194
|
+
class UserMeta(AbstractUserMeta):
|
|
195
|
+
class Meta:
|
|
196
|
+
swappable = 'BOM_USER_META_MODEL'
|
|
116
197
|
|
|
117
198
|
|
|
118
199
|
class PartClass(OrganizationScopedModel):
|
|
@@ -120,6 +201,8 @@ class PartClass(OrganizationScopedModel):
|
|
|
120
201
|
name = models.CharField(max_length=255, default=None)
|
|
121
202
|
comment = models.CharField(max_length=255, default='', blank=True)
|
|
122
203
|
mouser_enabled = models.BooleanField(default=False)
|
|
204
|
+
property_definitions = models.ManyToManyField('PartRevisionPropertyDefinition', blank=True,
|
|
205
|
+
related_name='part_classes')
|
|
123
206
|
|
|
124
207
|
class Meta(OrganizationScopedModel.Meta):
|
|
125
208
|
unique_together = [['code', 'organization', ], ]
|
|
@@ -289,7 +372,7 @@ class Part(OrganizationScopedModel):
|
|
|
289
372
|
|
|
290
373
|
def manufacturer_parts(self, exclude_primary=False):
|
|
291
374
|
q = ManufacturerPart.objects.filter(part=self).select_related('manufacturer')
|
|
292
|
-
if exclude_primary and self.primary_manufacturer_part is not None and self.primary_manufacturer_part.
|
|
375
|
+
if exclude_primary and self.primary_manufacturer_part is not None and self.primary_manufacturer_part.id is not None:
|
|
293
376
|
return q.exclude(id=self.primary_manufacturer_part.id)
|
|
294
377
|
return q
|
|
295
378
|
|
|
@@ -366,6 +449,68 @@ class Part(OrganizationScopedModel):
|
|
|
366
449
|
return u'%s' % (self.full_part_number())
|
|
367
450
|
|
|
368
451
|
|
|
452
|
+
class QuantityOfMeasure(OrganizationOptionalModel):
|
|
453
|
+
"""
|
|
454
|
+
Defines the physical dimension (e.g., Length, Voltage, Mass).
|
|
455
|
+
Acts as the 'bucket' for compatible units.
|
|
456
|
+
"""
|
|
457
|
+
name = models.CharField(max_length=64, help_text="e.g. Voltage")
|
|
458
|
+
|
|
459
|
+
def get_base_unit(self):
|
|
460
|
+
return self.units.filter(base_multiplier=1.0).first()
|
|
461
|
+
|
|
462
|
+
class Meta:
|
|
463
|
+
unique_together = (('organization', 'name',),)
|
|
464
|
+
|
|
465
|
+
def __str__(self):
|
|
466
|
+
return self.name
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class UnitDefinition(OrganizationOptionalModel):
|
|
470
|
+
"""
|
|
471
|
+
Defines valid units.
|
|
472
|
+
"""
|
|
473
|
+
name = models.CharField(max_length=64) # e.g. Millivolt
|
|
474
|
+
symbol = models.CharField(max_length=16) # e.g. mV
|
|
475
|
+
quantity_of_measure = models.ForeignKey(QuantityOfMeasure, on_delete=models.CASCADE, related_name='units')
|
|
476
|
+
base_multiplier = models.DecimalField(default=Decimal('1.0'), max_digits=40, decimal_places=20)
|
|
477
|
+
|
|
478
|
+
class Meta:
|
|
479
|
+
unique_together = (('organization', 'quantity_of_measure', 'symbol'),)
|
|
480
|
+
ordering = ['base_multiplier']
|
|
481
|
+
|
|
482
|
+
def __str__(self):
|
|
483
|
+
return f"{self.symbol}"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class PartRevisionPropertyDefinition(OrganizationOptionalModel):
|
|
487
|
+
code = models.CharField(max_length=64, blank=True) # The internal slug (e.g., max_operating_temp).
|
|
488
|
+
name = models.CharField(max_length=64) # The user-friendly text displayed in the UI
|
|
489
|
+
type = models.CharField(max_length=1, choices=PART_REVISION_PROPERTY_TYPES)
|
|
490
|
+
required = models.BooleanField(default=False)
|
|
491
|
+
quantity_of_measure = models.ForeignKey(QuantityOfMeasure, on_delete=models.SET_NULL, null=True, blank=True)
|
|
492
|
+
|
|
493
|
+
class Meta:
|
|
494
|
+
unique_together = ('organization', 'code',)
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def form_field_name(self):
|
|
498
|
+
return f'property_{self.code}'
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def form_unit_field_name(self):
|
|
502
|
+
return f'{self.form_field_name}_unit'
|
|
503
|
+
|
|
504
|
+
def __str__(self):
|
|
505
|
+
return self.name
|
|
506
|
+
|
|
507
|
+
def save(self, *args, **kwargs):
|
|
508
|
+
if not self.code:
|
|
509
|
+
self.code = slugify(self.name)
|
|
510
|
+
|
|
511
|
+
super(PartRevisionPropertyDefinition, self).save(*args, **kwargs)
|
|
512
|
+
|
|
513
|
+
|
|
369
514
|
# Below are attributes of a part that can be changed, but it's important to trace the change over time
|
|
370
515
|
class PartRevision(models.Model):
|
|
371
516
|
part = models.ForeignKey(Part, on_delete=models.CASCADE, db_index=True)
|
|
@@ -375,55 +520,12 @@ class PartRevision(models.Model):
|
|
|
375
520
|
assembly = models.ForeignKey('Assembly', default=None, null=True, on_delete=models.CASCADE, db_index=True)
|
|
376
521
|
displayable_synopsis = models.CharField(editable=False, default="", null=True, blank=True, max_length=255, db_index=True)
|
|
377
522
|
searchable_synopsis = models.CharField(editable=False, default="", null=True, blank=True, max_length=255, db_index=True)
|
|
523
|
+
description = models.CharField(max_length=255, default="", null=True, blank=True)
|
|
378
524
|
|
|
379
525
|
class Meta:
|
|
380
526
|
unique_together = (('part', 'revision'),)
|
|
381
527
|
ordering = ['part']
|
|
382
528
|
|
|
383
|
-
# Part Revision Specification Properties:
|
|
384
|
-
|
|
385
|
-
description = models.CharField(max_length=255, default="", null=True, blank=True)
|
|
386
|
-
|
|
387
|
-
# By convention for IndaBOM, for part revision properties below, if a property value has
|
|
388
|
-
# an associated units of measure, and if the property value field name is 'vvv' then the
|
|
389
|
-
# associated units of measure field name must be 'vvv_units'.
|
|
390
|
-
value_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VALUE_UNITS)
|
|
391
|
-
value = models.CharField(max_length=255, default=None, null=True, blank=True)
|
|
392
|
-
attribute = models.CharField(max_length=255, default=None, null=True, blank=True)
|
|
393
|
-
pin_count = models.DecimalField(max_digits=3, decimal_places=0, default=None, null=True, blank=True)
|
|
394
|
-
tolerance = models.CharField(max_length=6, validators=[validate_pct], default=None, null=True, blank=True)
|
|
395
|
-
package = models.CharField(max_length=16, default=None, null=True, blank=True, choices=PACKAGE_TYPES)
|
|
396
|
-
material = models.CharField(max_length=32, default=None, null=True, blank=True)
|
|
397
|
-
finish = models.CharField(max_length=32, default=None, null=True, blank=True)
|
|
398
|
-
color = models.CharField(max_length=32, default=None, null=True, blank=True)
|
|
399
|
-
length_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
|
|
400
|
-
length = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
401
|
-
width_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
|
|
402
|
-
width = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
403
|
-
height_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
|
|
404
|
-
height = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
405
|
-
weight_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=WEIGHT_UNITS)
|
|
406
|
-
weight = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
407
|
-
temperature_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=TEMPERATURE_UNITS)
|
|
408
|
-
temperature_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
409
|
-
temperature_rating_range_max = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
410
|
-
temperature_rating_range_min = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
411
|
-
wavelength_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=WAVELENGTH_UNITS)
|
|
412
|
-
wavelength = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
413
|
-
frequency_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=FREQUENCY_UNITS)
|
|
414
|
-
frequency = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
415
|
-
memory_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=MEMORY_UNITS)
|
|
416
|
-
memory = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
417
|
-
interface = models.CharField(max_length=12, default=None, null=True, blank=True, choices=INTERFACE_TYPES)
|
|
418
|
-
power_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=POWER_UNITS)
|
|
419
|
-
power_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
420
|
-
supply_voltage_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VOLTAGE_UNITS)
|
|
421
|
-
supply_voltage = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
422
|
-
voltage_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VOLTAGE_UNITS)
|
|
423
|
-
voltage_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
424
|
-
current_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=CURRENT_UNITS)
|
|
425
|
-
current_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
426
|
-
|
|
427
529
|
def generate_synopsis(self, make_searchable=False):
|
|
428
530
|
def verbosify(val, units=None, pre=None, pre_whitespace=True, post=None, post_whitespace=True):
|
|
429
531
|
elaborated = ""
|
|
@@ -440,47 +542,52 @@ class PartRevision(models.Model):
|
|
|
440
542
|
return elaborated
|
|
441
543
|
|
|
442
544
|
s = ""
|
|
443
|
-
s += verbosify(self.value, units=self.value_units if make_searchable else self.get_value_units_display())
|
|
444
545
|
s += verbosify(self.description)
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
s += verbosify(self.interface if make_searchable else self.get_interface_display())
|
|
454
|
-
s += verbosify(self.supply_voltage, units=self.supply_voltage_units if make_searchable else self.get_supply_voltage_units_display(), post='supply')
|
|
455
|
-
s += verbosify(self.temperature_rating, units=self.temperature_rating_units if make_searchable else self.get_temperature_rating_units_display(), post='rating')
|
|
456
|
-
s += verbosify(self.power_rating, units=self.power_rating_units if make_searchable else self.get_power_rating_units_display(), post='rating')
|
|
457
|
-
s += verbosify(self.voltage_rating, units=self.voltage_rating_units if make_searchable else self.get_voltage_rating_units_display(), post='rating')
|
|
458
|
-
s += verbosify(self.current_rating, units=self.current_rating_units if make_searchable else self.get_current_rating_units_display(), post='rating')
|
|
459
|
-
s += verbosify(self.material)
|
|
460
|
-
s += verbosify(self.color)
|
|
461
|
-
s += verbosify(self.finish)
|
|
462
|
-
s += verbosify(self.length, units=self.length_units if make_searchable else self.get_length_units_display(), pre='L')
|
|
463
|
-
s += verbosify(self.width, units=self.width_units if make_searchable else self.get_width_units_display(), pre='W')
|
|
464
|
-
s += verbosify(self.height, units=self.height_units if make_searchable else self.get_height_units_display(), pre='H')
|
|
465
|
-
s += verbosify(self.weight, units=self.weight_units if make_searchable else self.get_weight_units_display())
|
|
546
|
+
|
|
547
|
+
# TODO: We no longer order these, in the future we will add a template / inheritance type pattern like
|
|
548
|
+
# Capacitor: {Capacitance} {Units}, {Voltage}, {Package}
|
|
549
|
+
for prop in self.properties.all().select_related('property_definition', 'unit_definition'):
|
|
550
|
+
val = prop.value_raw
|
|
551
|
+
units = prop.unit_definition.symbol if prop.unit_definition else None
|
|
552
|
+
s += verbosify(val, units=units)
|
|
553
|
+
|
|
466
554
|
return s[:255]
|
|
467
555
|
|
|
468
556
|
def synopsis(self, return_displayable=True):
|
|
469
557
|
return self.displayable_synopsis if return_displayable else self.searchable_synopsis
|
|
470
558
|
|
|
559
|
+
def update_synopsis(self):
|
|
560
|
+
self.searchable_synopsis = self.generate_synopsis(True)
|
|
561
|
+
self.displayable_synopsis = self.generate_synopsis(False)
|
|
562
|
+
# Use update() to avoid triggering save() again and potentially recursion
|
|
563
|
+
PartRevision.objects.filter(id=self.id).update(
|
|
564
|
+
searchable_synopsis=self.searchable_synopsis,
|
|
565
|
+
displayable_synopsis=self.displayable_synopsis
|
|
566
|
+
)
|
|
567
|
+
|
|
471
568
|
def save(self, *args, **kwargs):
|
|
472
|
-
if self.tolerance:
|
|
473
|
-
self.tolerance = self.tolerance.replace('%', '')
|
|
474
569
|
if self.assembly is None:
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
# if self.configuration != previous_configuration:
|
|
480
|
-
# self.timestamp = timezone.now()
|
|
570
|
+
self.assembly = Assembly.objects.create()
|
|
571
|
+
|
|
572
|
+
super(PartRevision, self).save(*args, **kwargs)
|
|
573
|
+
|
|
481
574
|
self.searchable_synopsis = self.generate_synopsis(True)
|
|
482
575
|
self.displayable_synopsis = self.generate_synopsis(False)
|
|
483
|
-
|
|
576
|
+
|
|
577
|
+
def get_field_value(self, field_name):
|
|
578
|
+
if hasattr(self, field_name):
|
|
579
|
+
return getattr(self, field_name)
|
|
580
|
+
|
|
581
|
+
is_unit = field_name.endswith('_units')
|
|
582
|
+
prop_name = field_name[:-6] if is_unit else field_name
|
|
583
|
+
|
|
584
|
+
for prop in self.properties.all():
|
|
585
|
+
if prop.property_definition.name == prop_name:
|
|
586
|
+
if is_unit:
|
|
587
|
+
return prop.unit_definition.symbol if prop.unit_definition else ''
|
|
588
|
+
else:
|
|
589
|
+
return prop.value_raw
|
|
590
|
+
return None
|
|
484
591
|
|
|
485
592
|
def indented(self, top_level_quantity=100):
|
|
486
593
|
def indented_given_bom(bom, part_revision, parent_id=None, parent=None, qty=1, parent_qty=1, indent_level=0, subpart=None, reference='', do_not_load=False):
|
|
@@ -604,6 +711,50 @@ class PartRevision(models.Model):
|
|
|
604
711
|
return u'{}, Rev {}'.format(self.part.full_part_number(), self.revision)
|
|
605
712
|
|
|
606
713
|
|
|
714
|
+
class PartRevisionProperty(models.Model):
|
|
715
|
+
part_revision = models.ForeignKey(PartRevision, on_delete=models.CASCADE, related_name='properties')
|
|
716
|
+
property_definition = models.ForeignKey(PartRevisionPropertyDefinition, on_delete=models.CASCADE)
|
|
717
|
+
value_raw = models.CharField(max_length=255) # Base unit value, e.g. 0.01 (to describe 10mV)
|
|
718
|
+
unit_definition = models.ForeignKey(UnitDefinition, null=True, blank=True, on_delete=models.SET_NULL)
|
|
719
|
+
value_normalized = models.DecimalField(null=True, blank=True, max_digits=40, decimal_places=20)
|
|
720
|
+
|
|
721
|
+
def clean(self):
|
|
722
|
+
super().clean()
|
|
723
|
+
|
|
724
|
+
if self.unit_definition and self.property_definition.quantity_of_measure:
|
|
725
|
+
if self.unit_definition.quantity_of_measure != self.property_definition.quantity_of_measure:
|
|
726
|
+
raise ValidationError(
|
|
727
|
+
f"Unit '{self.unit_definition}' matches {self.unit_definition.quantity_of_measure}, but property requires {self.property_definition.quantity_of_measure}")
|
|
728
|
+
|
|
729
|
+
# Validate property is allowed for this part class
|
|
730
|
+
part = self.part_revision.part
|
|
731
|
+
if part.number_class:
|
|
732
|
+
allowed_definitions = part.number_class.property_definitions.all()
|
|
733
|
+
if not allowed_definitions.filter(id=self.property_definition.id).exists():
|
|
734
|
+
raise ValidationError(
|
|
735
|
+
f"The property '{self.property_definition.name}' is not valid for the Part Class '{part.number_class.name}'."
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
def save(self, *args, **kwargs):
|
|
739
|
+
if self.unit_definition and self.value_raw:
|
|
740
|
+
try:
|
|
741
|
+
val = Decimal(str(self.value_raw))
|
|
742
|
+
multiplier = Decimal(str(self.unit_definition.base_multiplier))
|
|
743
|
+
self.value_normalized = val * multiplier
|
|
744
|
+
except (ValueError, InvalidOperation):
|
|
745
|
+
self.value_normalized = None
|
|
746
|
+
else:
|
|
747
|
+
self.value_normalized = None
|
|
748
|
+
super(PartRevisionProperty, self).save(*args, **kwargs)
|
|
749
|
+
|
|
750
|
+
if self.part_revision:
|
|
751
|
+
self.part_revision.update_synopsis()
|
|
752
|
+
|
|
753
|
+
def __str__(self):
|
|
754
|
+
unit_sym = self.unit_definition.symbol if self.unit_definition else ""
|
|
755
|
+
return f"{self.property_definition.name}: {self.value_raw} {unit_sym}"
|
|
756
|
+
|
|
757
|
+
|
|
607
758
|
class AssemblySubparts(models.Model):
|
|
608
759
|
assembly = models.ForeignKey('Assembly', models.CASCADE)
|
|
609
760
|
subpart = models.ForeignKey('Subpart', models.CASCADE)
|
|
@@ -730,3 +881,6 @@ class SellerPart(models.Model, AsDictModel):
|
|
|
730
881
|
|
|
731
882
|
def __str__(self):
|
|
732
883
|
return u'%s' % (self.manufacturer_part.part.full_part_number() + ' ' + self.seller.name)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
User.add_to_class('bom_profile', _user_meta)
|
bom/settings.py
CHANGED
bom/static/bom/css/style.css
CHANGED
|
@@ -89,6 +89,25 @@ td, tr {
|
|
|
89
89
|
white-space: nowrap;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
.table-action {
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
vertical-align: middle;
|
|
96
|
+
font-size: 0.9rem;
|
|
97
|
+
font-weight: 500;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* 2. Tighten icons so they don't stretch the row height */
|
|
101
|
+
.table-action .material-icons {
|
|
102
|
+
font-size: 1.1rem !important;
|
|
103
|
+
margin-right: 4px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* 3. Give consecutive actions a little breathing room */
|
|
107
|
+
.table-action + .table-action {
|
|
108
|
+
margin-left: 12px;
|
|
109
|
+
}
|
|
110
|
+
|
|
92
111
|
.responsive-table-wrapper {
|
|
93
112
|
overflow-x: auto;
|
|
94
113
|
padding-bottom: 16px;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function initFormset(options) {
|
|
2
|
+
const {
|
|
3
|
+
prefix,
|
|
4
|
+
addBtnId,
|
|
5
|
+
formContainerId,
|
|
6
|
+
emptyFormTemplateId,
|
|
7
|
+
rowSelector,
|
|
8
|
+
onRowAdd
|
|
9
|
+
} = options;
|
|
10
|
+
|
|
11
|
+
const totalForms = document.getElementById(`id_${prefix}-TOTAL_FORMS`);
|
|
12
|
+
const formContainer = document.getElementById(formContainerId);
|
|
13
|
+
const emptyFormTemplate = document.getElementById(emptyFormTemplateId).innerHTML;
|
|
14
|
+
const addBtn = document.getElementById(addBtnId);
|
|
15
|
+
|
|
16
|
+
if (addBtn) {
|
|
17
|
+
addBtn.addEventListener('click', function (e) {
|
|
18
|
+
if (e) e.preventDefault();
|
|
19
|
+
const currentFormCount = parseInt(totalForms.value);
|
|
20
|
+
const newRowHtml = emptyFormTemplate.replace(/__prefix__/g, currentFormCount);
|
|
21
|
+
|
|
22
|
+
formContainer.insertAdjacentHTML('beforeend', newRowHtml);
|
|
23
|
+
totalForms.value = currentFormCount + 1;
|
|
24
|
+
|
|
25
|
+
const newRow = formContainer.lastElementChild;
|
|
26
|
+
|
|
27
|
+
// Re-initialize Materialize components
|
|
28
|
+
if (typeof M !== 'undefined') {
|
|
29
|
+
const selects = newRow.querySelectorAll('select');
|
|
30
|
+
if (selects.length > 0) {
|
|
31
|
+
M.FormSelect.init(selects);
|
|
32
|
+
}
|
|
33
|
+
M.updateTextFields();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (onRowAdd) {
|
|
37
|
+
onRowAdd(newRow);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
formContainer.addEventListener('click', function (e) {
|
|
43
|
+
// REMOVE NEW ROW (Client-side only)
|
|
44
|
+
const removeBtn = e.target.closest('.remove-new-row');
|
|
45
|
+
if (removeBtn) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
const row = removeBtn.closest(rowSelector);
|
|
48
|
+
row.remove();
|
|
49
|
+
// We don't necessarily decrement totalForms.value here as Django handles missing indexes,
|
|
50
|
+
// but some implementations do. Given the original code didn't, we won't either unless needed.
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// DELETE EXISTING ROW (Server-side logic via DELETE checkbox)
|
|
54
|
+
const deleteBtn = e.target.closest('.delete-existing-row');
|
|
55
|
+
if (deleteBtn) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
const row = deleteBtn.closest(rowSelector);
|
|
58
|
+
const checkbox = row.querySelector('input[type="checkbox"][name$="-DELETE"]');
|
|
59
|
+
if (checkbox) {
|
|
60
|
+
checkbox.checked = true;
|
|
61
|
+
row.style.display = 'none';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|