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/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, validate_pct
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('Organization', on_delete=models.CASCADE, db_index=True)
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 Organization(models.Model):
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), MaxValueValidator(NUMBER_CLASS_CODE_LEN_MAX)])
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), MaxValueValidator(NUMBER_ITEM_LEN_MAX)])
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), MaxValueValidator(NUMBER_VARIATION_LEN_MAX)])
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
- class Meta:
49
- permissions = (
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
- return PartsListCSVHeaders()
117
+ headers = PartsListCSVHeaders()
71
118
  else:
72
- return PartsListCSVHeadersSemiIntelligent()
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(Organization, self).save()
80
- SellerPart.objects.filter(seller__organization=self).update(unit_cost_currency=self.currency, nre_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
- class UserMeta(models.Model):
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(Organization, blank=True, null=True, on_delete=models.CASCADE)
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
- organization, created = Organization.objects.get_or_create(owner=self.user, defaults={'name': org_name, 'subscription': 'F'})
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
- def _user_meta(self, organization=None):
113
- return UserMeta.objects.get_or_create(user=self, defaults={'organization': organization})[0]
190
+ class Meta:
191
+ abstract = True
114
192
 
115
- User.add_to_class('bom_profile', _user_meta)
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.optimal_seller():
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
- tolerance = self.tolerance.replace('%', '') if self.tolerance else ''
446
- s += verbosify(tolerance, post='%', post_whitespace=False)
447
- s += verbosify(self.attribute)
448
- s += verbosify(self.package if make_searchable else self.get_package_display())
449
- s += verbosify(self.pin_count, post='pins')
450
- s += verbosify(self.frequency, units=self.frequency_units if make_searchable else self.get_frequency_units_display())
451
- s += verbosify(self.wavelength, units=self.wavelength_units if make_searchable else self.get_wavelength_units_display())
452
- s += verbosify(self.memory, units=self.memory_units if make_searchable else self.get_memory_units_display())
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
- assy = Assembly.objects.create()
476
- self.assembly = assy
477
- # if self.id:
478
- # previous_configuration = PartRevision.objects.get(id=self.id).configuration
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
- super(PartRevision, self).save(*args, **kwargs)
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
@@ -23,6 +23,8 @@ BOM_CONFIG_DEFAULT = {
23
23
  'page_size': 50,
24
24
  }
25
25
  }
26
+ BOM_ORGANIZATION_MODEL = 'bom.Organization'
27
+ BOM_USER_META_MODEL = 'bom.UserMeta'
26
28
 
27
29
  # Apply custom settings over defaults
28
30
  bom_config_new = BOM_CONFIG_DEFAULT.copy()
@@ -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
+ }