django-bom 1.252__py3-none-any.whl → 1.258__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 +52 -12
- bom/constants.py +7 -0
- bom/csv_headers.py +22 -44
- bom/forms.py +966 -1019
- bom/helpers.py +79 -4
- bom/migrations/0052_remove_partrevision_attribute_and_more.py +584 -0
- bom/models.py +191 -80
- 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/part-info.html +10 -1
- bom/templates/bom/part-revision-display.html +22 -159
- bom/templates/bom/settings.html +138 -11
- bom/tests.py +117 -31
- bom/urls.py +6 -0
- bom/views/views.py +179 -42
- {django_bom-1.252.dist-info → django_bom-1.258.dist-info}/METADATA +1 -1
- {django_bom-1.252.dist-info → django_bom-1.258.dist-info}/RECORD +23 -20
- {django_bom-1.252.dist-info → django_bom-1.258.dist-info}/WHEEL +0 -0
- {django_bom-1.252.dist-info → django_bom-1.258.dist-info}/licenses/LICENSE +0 -0
- {django_bom-1.252.dist-info → django_bom-1.258.dist-info}/top_level.txt +0 -0
bom/models.py
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
from __future__ import unicode_literals
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
4
5
|
|
|
5
6
|
from django.apps import apps
|
|
6
7
|
from django.conf import settings
|
|
7
8
|
from django.contrib.auth import get_user_model
|
|
8
9
|
from django.core.cache import cache
|
|
10
|
+
from django.core.exceptions import ValidationError
|
|
9
11
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
10
12
|
from django.db import models
|
|
11
13
|
from django.utils import timezone
|
|
14
|
+
from django.utils.text import slugify
|
|
12
15
|
from djmoney.models.fields import CURRENCY_CHOICES, CurrencyField, MoneyField
|
|
13
16
|
from math import ceil
|
|
14
17
|
from social_django.models import UserSocialAuth
|
|
15
18
|
|
|
16
19
|
from .base_classes import AsDictModel
|
|
17
20
|
from .constants import *
|
|
18
|
-
from .csv_headers import PartsListCSVHeaders, PartsListCSVHeadersSemiIntelligent
|
|
21
|
+
from .csv_headers import PartsListCSVHeaders, PartsListCSVHeadersSemiIntelligent, BOMIndentedCSVHeaders, \
|
|
22
|
+
BOMFlatCSVHeaders
|
|
19
23
|
from .part_bom import PartBom, PartBomItem, PartIndentedBomItem
|
|
20
24
|
from .utils import increment_str, listify_string, prep_for_sorting_nicely, stringify_list, strip_trailing_zeros
|
|
21
|
-
from .validators import alphanumeric
|
|
25
|
+
from .validators import alphanumeric
|
|
22
26
|
|
|
23
27
|
logger = logging.getLogger(__name__)
|
|
24
28
|
User = get_user_model()
|
|
@@ -47,9 +51,29 @@ def _user_meta(self, organization=None):
|
|
|
47
51
|
return meta
|
|
48
52
|
|
|
49
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
|
+
|
|
50
62
|
class OrganizationScopedModel(models.Model):
|
|
51
63
|
organization = models.ForeignKey(settings.BOM_ORGANIZATION_MODEL, on_delete=models.CASCADE, db_index=True)
|
|
52
64
|
|
|
65
|
+
objects = OrganizationManager()
|
|
66
|
+
|
|
67
|
+
class Meta:
|
|
68
|
+
abstract = True
|
|
69
|
+
|
|
70
|
+
|
|
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
|
+
|
|
53
77
|
class Meta:
|
|
54
78
|
abstract = True
|
|
55
79
|
|
|
@@ -90,9 +114,26 @@ class AbstractOrganization(models.Model):
|
|
|
90
114
|
|
|
91
115
|
def part_list_csv_headers(self):
|
|
92
116
|
if self.number_scheme == NUMBER_SCHEME_INTELLIGENT:
|
|
93
|
-
|
|
117
|
+
headers = PartsListCSVHeaders()
|
|
94
118
|
else:
|
|
95
|
-
|
|
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
|
|
96
137
|
|
|
97
138
|
@property
|
|
98
139
|
def email(self):
|
|
@@ -160,6 +201,8 @@ class PartClass(OrganizationScopedModel):
|
|
|
160
201
|
name = models.CharField(max_length=255, default=None)
|
|
161
202
|
comment = models.CharField(max_length=255, default='', blank=True)
|
|
162
203
|
mouser_enabled = models.BooleanField(default=False)
|
|
204
|
+
property_definitions = models.ManyToManyField('PartRevisionPropertyDefinition', blank=True,
|
|
205
|
+
related_name='part_classes')
|
|
163
206
|
|
|
164
207
|
class Meta(OrganizationScopedModel.Meta):
|
|
165
208
|
unique_together = [['code', 'organization', ], ]
|
|
@@ -329,7 +372,7 @@ class Part(OrganizationScopedModel):
|
|
|
329
372
|
|
|
330
373
|
def manufacturer_parts(self, exclude_primary=False):
|
|
331
374
|
q = ManufacturerPart.objects.filter(part=self).select_related('manufacturer')
|
|
332
|
-
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:
|
|
333
376
|
return q.exclude(id=self.primary_manufacturer_part.id)
|
|
334
377
|
return q
|
|
335
378
|
|
|
@@ -406,6 +449,68 @@ class Part(OrganizationScopedModel):
|
|
|
406
449
|
return u'%s' % (self.full_part_number())
|
|
407
450
|
|
|
408
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
|
+
|
|
409
514
|
# Below are attributes of a part that can be changed, but it's important to trace the change over time
|
|
410
515
|
class PartRevision(models.Model):
|
|
411
516
|
part = models.ForeignKey(Part, on_delete=models.CASCADE, db_index=True)
|
|
@@ -415,55 +520,12 @@ class PartRevision(models.Model):
|
|
|
415
520
|
assembly = models.ForeignKey('Assembly', default=None, null=True, on_delete=models.CASCADE, db_index=True)
|
|
416
521
|
displayable_synopsis = models.CharField(editable=False, default="", null=True, blank=True, max_length=255, db_index=True)
|
|
417
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)
|
|
418
524
|
|
|
419
525
|
class Meta:
|
|
420
526
|
unique_together = (('part', 'revision'),)
|
|
421
527
|
ordering = ['part']
|
|
422
528
|
|
|
423
|
-
# Part Revision Specification Properties:
|
|
424
|
-
|
|
425
|
-
description = models.CharField(max_length=255, default="", null=True, blank=True)
|
|
426
|
-
|
|
427
|
-
# By convention for IndaBOM, for part revision properties below, if a property value has
|
|
428
|
-
# an associated units of measure, and if the property value field name is 'vvv' then the
|
|
429
|
-
# associated units of measure field name must be 'vvv_units'.
|
|
430
|
-
value_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VALUE_UNITS)
|
|
431
|
-
value = models.CharField(max_length=255, default=None, null=True, blank=True)
|
|
432
|
-
attribute = models.CharField(max_length=255, default=None, null=True, blank=True)
|
|
433
|
-
pin_count = models.DecimalField(max_digits=3, decimal_places=0, default=None, null=True, blank=True)
|
|
434
|
-
tolerance = models.CharField(max_length=6, validators=[validate_pct], default=None, null=True, blank=True)
|
|
435
|
-
package = models.CharField(max_length=16, default=None, null=True, blank=True, choices=PACKAGE_TYPES)
|
|
436
|
-
material = models.CharField(max_length=32, default=None, null=True, blank=True)
|
|
437
|
-
finish = models.CharField(max_length=32, default=None, null=True, blank=True)
|
|
438
|
-
color = models.CharField(max_length=32, default=None, null=True, blank=True)
|
|
439
|
-
length_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
|
|
440
|
-
length = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
441
|
-
width_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
|
|
442
|
-
width = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
443
|
-
height_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=DISTANCE_UNITS)
|
|
444
|
-
height = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
445
|
-
weight_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=WEIGHT_UNITS)
|
|
446
|
-
weight = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
447
|
-
temperature_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=TEMPERATURE_UNITS)
|
|
448
|
-
temperature_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
449
|
-
temperature_rating_range_max = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
450
|
-
temperature_rating_range_min = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
451
|
-
wavelength_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=WAVELENGTH_UNITS)
|
|
452
|
-
wavelength = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
453
|
-
frequency_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=FREQUENCY_UNITS)
|
|
454
|
-
frequency = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
455
|
-
memory_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=MEMORY_UNITS)
|
|
456
|
-
memory = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
457
|
-
interface = models.CharField(max_length=12, default=None, null=True, blank=True, choices=INTERFACE_TYPES)
|
|
458
|
-
power_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=POWER_UNITS)
|
|
459
|
-
power_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
460
|
-
supply_voltage_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VOLTAGE_UNITS)
|
|
461
|
-
supply_voltage = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
462
|
-
voltage_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=VOLTAGE_UNITS)
|
|
463
|
-
voltage_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
464
|
-
current_rating_units = models.CharField(max_length=5, default=None, null=True, blank=True, choices=CURRENT_UNITS)
|
|
465
|
-
current_rating = models.DecimalField(max_digits=7, decimal_places=3, default=None, null=True, blank=True)
|
|
466
|
-
|
|
467
529
|
def generate_synopsis(self, make_searchable=False):
|
|
468
530
|
def verbosify(val, units=None, pre=None, pre_whitespace=True, post=None, post_whitespace=True):
|
|
469
531
|
elaborated = ""
|
|
@@ -480,47 +542,52 @@ class PartRevision(models.Model):
|
|
|
480
542
|
return elaborated
|
|
481
543
|
|
|
482
544
|
s = ""
|
|
483
|
-
s += verbosify(self.value, units=self.value_units if make_searchable else self.get_value_units_display())
|
|
484
545
|
s += verbosify(self.description)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
s += verbosify(self.interface if make_searchable else self.get_interface_display())
|
|
494
|
-
s += verbosify(self.supply_voltage, units=self.supply_voltage_units if make_searchable else self.get_supply_voltage_units_display(), post='supply')
|
|
495
|
-
s += verbosify(self.temperature_rating, units=self.temperature_rating_units if make_searchable else self.get_temperature_rating_units_display(), post='rating')
|
|
496
|
-
s += verbosify(self.power_rating, units=self.power_rating_units if make_searchable else self.get_power_rating_units_display(), post='rating')
|
|
497
|
-
s += verbosify(self.voltage_rating, units=self.voltage_rating_units if make_searchable else self.get_voltage_rating_units_display(), post='rating')
|
|
498
|
-
s += verbosify(self.current_rating, units=self.current_rating_units if make_searchable else self.get_current_rating_units_display(), post='rating')
|
|
499
|
-
s += verbosify(self.material)
|
|
500
|
-
s += verbosify(self.color)
|
|
501
|
-
s += verbosify(self.finish)
|
|
502
|
-
s += verbosify(self.length, units=self.length_units if make_searchable else self.get_length_units_display(), pre='L')
|
|
503
|
-
s += verbosify(self.width, units=self.width_units if make_searchable else self.get_width_units_display(), pre='W')
|
|
504
|
-
s += verbosify(self.height, units=self.height_units if make_searchable else self.get_height_units_display(), pre='H')
|
|
505
|
-
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
|
+
|
|
506
554
|
return s[:255]
|
|
507
555
|
|
|
508
556
|
def synopsis(self, return_displayable=True):
|
|
509
557
|
return self.displayable_synopsis if return_displayable else self.searchable_synopsis
|
|
510
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
|
+
|
|
511
568
|
def save(self, *args, **kwargs):
|
|
512
|
-
if self.tolerance:
|
|
513
|
-
self.tolerance = self.tolerance.replace('%', '')
|
|
514
569
|
if self.assembly is None:
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
# if self.configuration != previous_configuration:
|
|
520
|
-
# self.timestamp = timezone.now()
|
|
570
|
+
self.assembly = Assembly.objects.create()
|
|
571
|
+
|
|
572
|
+
super(PartRevision, self).save(*args, **kwargs)
|
|
573
|
+
|
|
521
574
|
self.searchable_synopsis = self.generate_synopsis(True)
|
|
522
575
|
self.displayable_synopsis = self.generate_synopsis(False)
|
|
523
|
-
|
|
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
|
|
524
591
|
|
|
525
592
|
def indented(self, top_level_quantity=100):
|
|
526
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):
|
|
@@ -644,6 +711,50 @@ class PartRevision(models.Model):
|
|
|
644
711
|
return u'{}, Rev {}'.format(self.part.full_part_number(), self.revision)
|
|
645
712
|
|
|
646
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
|
+
|
|
647
758
|
class AssemblySubparts(models.Model):
|
|
648
759
|
assembly = models.ForeignKey('Assembly', models.CASCADE)
|
|
649
760
|
subpart = models.ForeignKey('Subpart', models.CASCADE)
|
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
|
+
}
|
|
@@ -1,26 +1,89 @@
|
|
|
1
1
|
{% extends 'bom/bom-base.html' %}
|
|
2
|
-
|
|
3
2
|
{% load static %}
|
|
4
3
|
{% load materializecss %}
|
|
5
4
|
|
|
6
5
|
{% block head-title %}{{ title }}{% endblock %}
|
|
7
6
|
|
|
8
7
|
{% block content %}
|
|
9
|
-
<div class="container-
|
|
8
|
+
<div class="container" style="padding-top: 40px; max-width: 1000px;">
|
|
10
9
|
{% if profile.role == 'A' %}
|
|
11
10
|
<div class="row">
|
|
12
|
-
<form action="{% url 'bom:part-class-edit' part_class_id=part_class.id %}" method="post"
|
|
11
|
+
<form action="{% url 'bom:part-class-edit' part_class_id=part_class.id %}" method="post"
|
|
12
|
+
class="col s12">
|
|
13
13
|
{% csrf_token %}
|
|
14
|
-
|
|
15
|
-
{{ part_class_form.code|materializecss:'s12 m2' }}
|
|
16
|
-
{{ part_class_form.name|materializecss:'s12 m6' }}
|
|
17
|
-
{{ part_class_form.comment|materializecss:'s12 m12' }}
|
|
14
|
+
|
|
18
15
|
<div class="row">
|
|
19
|
-
<div class="col
|
|
20
|
-
|
|
16
|
+
<div class="col s12 m3">
|
|
17
|
+
{{ part_class_form.code|materializecss }}
|
|
18
|
+
</div>
|
|
19
|
+
<div class="col s12 m9">
|
|
20
|
+
{{ part_class_form.name|materializecss }}
|
|
21
|
+
</div>
|
|
22
|
+
<div class="col s12">
|
|
23
|
+
{{ part_class_form.comment|materializecss }}
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="row" style="margin-top: 40px; margin-bottom: 20px;">
|
|
28
|
+
<div class="col s12">
|
|
29
|
+
<h5>Property Definitions</h5>
|
|
30
|
+
<p class="grey-text text-darken-1">Define the custom fields available to parts in this
|
|
31
|
+
class.</p>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div id="property-definitions-wrapper">
|
|
36
|
+
{{ property_definitions_formset.management_form }}
|
|
37
|
+
|
|
38
|
+
<table class="highlight" style="border-bottom: 1px solid #e0e0e0;">
|
|
39
|
+
<thead>
|
|
40
|
+
<tr class="grey-text">
|
|
41
|
+
<th style="width: 90%;">Property Definition</th>
|
|
42
|
+
<th style="width: 10%;" class="center-align">Action</th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody id="property-definitions-body">
|
|
46
|
+
{% for form in property_definitions_formset %}
|
|
47
|
+
<tr class="property-definition-row">
|
|
48
|
+
{% for hidden in form.hidden_fields %}
|
|
49
|
+
{{ hidden }}
|
|
50
|
+
{% endfor %}
|
|
51
|
+
|
|
52
|
+
<td>{{ form.property_definition|materializecss }}</td>
|
|
53
|
+
<td class="center-align">
|
|
54
|
+
{% if forloop.counter0 < property_definitions_formset.initial_form_count %}
|
|
55
|
+
<div style="display:none;">{{ form.DELETE }}</div>
|
|
56
|
+
<a href="#!" class="btn-flat btn-small red-text delete-existing-row">
|
|
57
|
+
<i class="material-icons">delete</i>
|
|
58
|
+
</a>
|
|
59
|
+
{% else %}
|
|
60
|
+
<a href="#!" class="btn-flat btn-small red-text remove-new-row">
|
|
61
|
+
<i class="material-icons">close</i>
|
|
62
|
+
</a>
|
|
63
|
+
{% endif %}
|
|
64
|
+
</td>
|
|
65
|
+
</tr>
|
|
66
|
+
{% endfor %}
|
|
67
|
+
</tbody>
|
|
68
|
+
</table>
|
|
69
|
+
|
|
70
|
+
<div class="row" style="margin-top: 15px;">
|
|
71
|
+
<div class="col s12">
|
|
72
|
+
<button type="button" class="btn-flat waves-effect"
|
|
73
|
+
id="add-property-definition" style="padding-left: 0;">
|
|
74
|
+
<i class="material-icons left">add</i> Add Property
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
21
77
|
</div>
|
|
22
|
-
|
|
23
|
-
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="row" style="margin-top: 50px;">
|
|
81
|
+
<div class="col s12 right-align">
|
|
82
|
+
<a href="{% url 'bom:settings' %}indabom"
|
|
83
|
+
class="btn btn-flat waves-effect"
|
|
84
|
+
style="margin-right: 10px;">Cancel</a>
|
|
85
|
+
<button class="waves-effect waves-light btn btn-primary" type="submit" name="action">
|
|
86
|
+
Save
|
|
24
87
|
</button>
|
|
25
88
|
</div>
|
|
26
89
|
</div>
|
|
@@ -30,4 +93,28 @@
|
|
|
30
93
|
{% include 'bom/nothing-to-see.html' with required_privilege='Admin' %}
|
|
31
94
|
{% endif %}
|
|
32
95
|
</div>
|
|
96
|
+
|
|
97
|
+
<script type="text/template" id="empty-form-template">
|
|
98
|
+
<tr class="property-definition-row">
|
|
99
|
+
<td>{{ property_definitions_formset.empty_form.property_definition|materializecss }}</td>
|
|
100
|
+
<td class="center-align">
|
|
101
|
+
<a href="#!" class="btn-flat btn-small red-text remove-new-row">
|
|
102
|
+
<i class="material-icons">close</i>
|
|
103
|
+
</a>
|
|
104
|
+
</td>
|
|
105
|
+
</tr>
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<script src="{% static 'bom/js/formset-handler.js' %}"></script>
|
|
109
|
+
<script>
|
|
110
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
111
|
+
initFormset({
|
|
112
|
+
prefix: '{{ property_definitions_formset.prefix }}',
|
|
113
|
+
addBtnId: 'add-property-definition',
|
|
114
|
+
formContainerId: 'property-definitions-body',
|
|
115
|
+
emptyFormTemplateId: 'empty-form-template',
|
|
116
|
+
rowSelector: '.property-definition-row'
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
</script>
|
|
33
120
|
{% endblock %}
|