First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
727
apps/gears/models.py
Normal file
727
apps/gears/models.py
Normal file
@@ -0,0 +1,727 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# ── Choices ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class GearStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', _('Pending Verification')
|
||||
VERIFIED = 'VERIFIED', _('Verified')
|
||||
REJECTED = 'REJECTED', _('Rejected')
|
||||
|
||||
|
||||
class GearType(models.TextChoices):
|
||||
FIREARM = 'FIREARM', _('Firearm')
|
||||
SCOPE = 'SCOPE', _('Scope')
|
||||
SUPPRESSOR = 'SUPPRESSOR', _('Suppressor')
|
||||
BIPOD = 'BIPOD', _('Bipod')
|
||||
MAGAZINE = 'MAGAZINE', _('Magazine')
|
||||
|
||||
|
||||
class FirearmType(models.TextChoices):
|
||||
RIFLE = 'RIFLE', _('Rifle')
|
||||
PISTOL = 'PISTOL', _('Pistol')
|
||||
SHOTGUN = 'SHOTGUN', _('Shotgun')
|
||||
REVOLVER = 'REVOLVER', _('Revolver')
|
||||
CARBINE = 'CARBINE', _('Carbine')
|
||||
|
||||
|
||||
|
||||
class ReticleType(models.TextChoices):
|
||||
DUPLEX = 'DUPLEX', _('Duplex')
|
||||
MILDOT = 'MILDOT', _('Mil-Dot')
|
||||
BDC = 'BDC', _('BDC')
|
||||
ILLUMINATED = 'ILLUMINATED', _('Illuminated')
|
||||
ETCHED = 'ETCHED', _('Etched Glass')
|
||||
|
||||
|
||||
class AdjustmentUnit(models.TextChoices):
|
||||
MOA = 'MOA', _('MOA (Minute of Angle)')
|
||||
MRAD = 'MRAD', _('MRAD (Milliradian)')
|
||||
|
||||
|
||||
class FocalPlane(models.TextChoices):
|
||||
FFP = 'FFP', _('First Focal Plane (FFP)')
|
||||
SFP = 'SFP', _('Second Focal Plane (SFP)')
|
||||
|
||||
|
||||
class AttachmentType(models.TextChoices):
|
||||
PICATINNY = 'PICATINNY', _('Picatinny Rail')
|
||||
SLING_STUD = 'SLING_STUD', _('Sling Stud')
|
||||
ARCA_SWISS = 'ARCA_SWISS', _('Arca-Swiss')
|
||||
M_LOK = 'M_LOK', _('M-LOK')
|
||||
KEYMOD = 'KEYMOD', _('KeyMod')
|
||||
|
||||
|
||||
class RigRole(models.TextChoices):
|
||||
PRIMARY = 'PRIMARY', _('Primary Firearm')
|
||||
OPTIC = 'OPTIC', _('Optic / Scope')
|
||||
SUPPRESSOR = 'SUPPRESSOR', _('Suppressor')
|
||||
BIPOD = 'BIPOD', _('Bipod')
|
||||
MAGAZINE = 'MAGAZINE', _('Magazine')
|
||||
OTHER = 'OTHER', _('Other Accessory')
|
||||
|
||||
|
||||
class BulletType(models.TextChoices):
|
||||
FMJ = 'FMJ', _('Full Metal Jacket')
|
||||
HP = 'HP', _('Hollow Point')
|
||||
BTHP = 'BTHP', _('Boat Tail Hollow Point')
|
||||
SP = 'SP', _('Soft Point')
|
||||
HPBT = 'HPBT', _('Hollow Point Boat Tail')
|
||||
SMK = 'SMK', _('Sierra MatchKing')
|
||||
A_TIP = 'A_TIP', _('Hornady A-Tip')
|
||||
MONO = 'MONO', _('Monolithic / Solid')
|
||||
|
||||
|
||||
class PrimerSize(models.TextChoices):
|
||||
SMALL_PISTOL = 'SP', _('Small Pistol')
|
||||
LARGE_PISTOL = 'LP', _('Large Pistol')
|
||||
SMALL_RIFLE = 'SR', _('Small Rifle')
|
||||
LARGE_RIFLE = 'LR', _('Large Rifle')
|
||||
LARGE_RIFLE_MAG = 'LRM', _('Large Rifle Magnum')
|
||||
|
||||
|
||||
class CaseMaterial(models.TextChoices):
|
||||
BRASS = 'BRASS', _('Brass')
|
||||
STEEL = 'STEEL', _('Steel')
|
||||
ALUMINUM = 'ALUMINUM', _('Aluminum')
|
||||
NICKEL_PLATED = 'NICKEL', _('Nickel-Plated Brass')
|
||||
|
||||
|
||||
class PowderType(models.TextChoices):
|
||||
BALL = 'BALL', _('Ball / Spherical')
|
||||
EXTRUDED = 'EXTRUDED', _('Extruded / Stick')
|
||||
FLAKE = 'FLAKE', _('Flake')
|
||||
|
||||
|
||||
class CrimpType(models.TextChoices):
|
||||
NONE = 'NONE', _('No Crimp')
|
||||
TAPER = 'TAPER', _('Taper Crimp')
|
||||
ROLL = 'ROLL', _('Roll Crimp')
|
||||
|
||||
|
||||
# ── Gear catalog (MTI) ────────────────────────────────────────────────────────
|
||||
|
||||
class Gear(models.Model):
|
||||
"""
|
||||
Base catalog entry shared by all gear types.
|
||||
Concrete entries live in child tables via multi-table inheritance.
|
||||
gear_type acts as a discriminator and is set automatically by each subclass.
|
||||
"""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
model_name = models.CharField(_('model name'), max_length=150)
|
||||
description = models.TextField(_('description'), blank=True)
|
||||
gear_type = models.CharField(
|
||||
_('gear type'), max_length=20, choices=GearType.choices, editable=False
|
||||
)
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.PENDING
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='submitted_gears',
|
||||
verbose_name=_('submitted by'),
|
||||
)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='reviewed_gears',
|
||||
verbose_name=_('reviewed by'),
|
||||
)
|
||||
reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('gear')
|
||||
verbose_name_plural = _('gears')
|
||||
ordering = ['brand', 'model_name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'model_name'], name='unique_gear_brand_model'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.model_name}"
|
||||
|
||||
def verify(self, reviewed_by):
|
||||
self.status = GearStatus.VERIFIED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
def reject(self, reviewed_by):
|
||||
self.status = GearStatus.REJECTED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
|
||||
class Firearm(Gear):
|
||||
firearm_type = models.CharField(_('firearm type'), max_length=10, choices=FirearmType.choices)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
barrel_length_mm = models.DecimalField(
|
||||
_('barrel length (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
magazine_capacity = models.PositiveSmallIntegerField(
|
||||
_('magazine capacity'), null=True, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.FIREARM
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('firearm')
|
||||
verbose_name_plural = _('firearms')
|
||||
|
||||
|
||||
class Scope(Gear):
|
||||
magnification_min = models.DecimalField(_('min magnification'), max_digits=5, decimal_places=1)
|
||||
magnification_max = models.DecimalField(_('max magnification'), max_digits=5, decimal_places=1)
|
||||
objective_diameter_mm = models.DecimalField(
|
||||
_('objective diameter (mm)'), max_digits=5, decimal_places=1
|
||||
)
|
||||
tube_diameter_mm = models.DecimalField(
|
||||
_('tube diameter (mm)'), max_digits=5, decimal_places=1, default=30
|
||||
)
|
||||
reticle_type = models.CharField(
|
||||
_('reticle type'), max_length=20, choices=ReticleType.choices, blank=True
|
||||
)
|
||||
adjustment_unit = models.CharField(
|
||||
_('adjustment unit'), max_length=4, choices=AdjustmentUnit.choices, blank=True
|
||||
)
|
||||
focal_plane = models.CharField(
|
||||
_('focal plane'), max_length=3, choices=FocalPlane.choices, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.SCOPE
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('scope')
|
||||
verbose_name_plural = _('scopes')
|
||||
|
||||
|
||||
class Suppressor(Gear):
|
||||
max_caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('max caliber'),
|
||||
)
|
||||
thread_pitch = models.CharField(_('thread pitch'), max_length=20, blank=True)
|
||||
length_mm = models.DecimalField(
|
||||
_('length (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
weight_g = models.DecimalField(
|
||||
_('weight (g)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.SUPPRESSOR
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('suppressor')
|
||||
verbose_name_plural = _('suppressors')
|
||||
|
||||
|
||||
class Bipod(Gear):
|
||||
min_height_mm = models.DecimalField(
|
||||
_('min height (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
max_height_mm = models.DecimalField(
|
||||
_('max height (mm)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
attachment_type = models.CharField(
|
||||
_('attachment type'), max_length=20, choices=AttachmentType.choices, blank=True
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.BIPOD
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('bipod')
|
||||
verbose_name_plural = _('bipods')
|
||||
|
||||
|
||||
class Magazine(Gear):
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
capacity = models.PositiveSmallIntegerField(_('capacity'))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.gear_type = GearType.MAGAZINE
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('magazine')
|
||||
verbose_name_plural = _('magazines')
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_GEAR_TYPE_ATTR = {
|
||||
GearType.FIREARM: 'firearm',
|
||||
GearType.SCOPE: 'scope',
|
||||
GearType.SUPPRESSOR: 'suppressor',
|
||||
GearType.BIPOD: 'bipod',
|
||||
GearType.MAGAZINE: 'magazine',
|
||||
}
|
||||
|
||||
|
||||
def get_concrete_gear(gear):
|
||||
"""Return the concrete MTI subclass instance for a base Gear instance."""
|
||||
attr = _GEAR_TYPE_ATTR.get(gear.gear_type)
|
||||
if attr:
|
||||
try:
|
||||
return getattr(gear, attr)
|
||||
except Exception:
|
||||
pass
|
||||
return gear
|
||||
|
||||
|
||||
# ── User inventory ────────────────────────────────────────────────────────────
|
||||
|
||||
class UserGear(models.Model):
|
||||
"""A user's personal instance of a catalog Gear entry."""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name='inventory', verbose_name=_('user'),
|
||||
)
|
||||
gear = models.ForeignKey(
|
||||
Gear, on_delete=models.CASCADE,
|
||||
related_name='user_instances', verbose_name=_('gear'),
|
||||
)
|
||||
nickname = models.CharField(_('nickname'), max_length=100, blank=True)
|
||||
serial_number = models.CharField(_('serial number'), max_length=100, blank=True)
|
||||
purchase_date = models.DateField(_('purchase date'), null=True, blank=True)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
added_at = models.DateTimeField(_('added at'), auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('owned gear')
|
||||
verbose_name_plural = _('owned gears')
|
||||
ordering = ['-added_at']
|
||||
|
||||
def __str__(self):
|
||||
label = self.nickname or str(self.gear)
|
||||
return f"{self.user.email} — {label}"
|
||||
|
||||
|
||||
# ── Rigs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Rig(models.Model):
|
||||
"""A named loadout: a collection of UserGear items with optional roles."""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name='rigs', verbose_name=_('user'),
|
||||
)
|
||||
name = models.CharField(_('name'), max_length=100)
|
||||
description = models.TextField(_('description'), blank=True)
|
||||
is_public = models.BooleanField(_('public'), default=False)
|
||||
photo = models.ForeignKey(
|
||||
'photos.Photo',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='rig',
|
||||
verbose_name=_('photo'),
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
UserGear, through='RigItem', related_name='rigs',
|
||||
verbose_name=_('items'),
|
||||
)
|
||||
# Ballistic computation inputs
|
||||
zero_distance_m = models.PositiveSmallIntegerField(
|
||||
_('zero distance (m)'), null=True, blank=True,
|
||||
)
|
||||
scope_height_mm = models.DecimalField(
|
||||
_('scope height above bore (mm)'), max_digits=5, decimal_places=1,
|
||||
null=True, blank=True,
|
||||
)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('rig')
|
||||
verbose_name_plural = _('rigs')
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'name'], name='unique_rig_per_user')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} — {self.name}"
|
||||
|
||||
|
||||
class RigItem(models.Model):
|
||||
"""Through table linking a UserGear to a Rig with an optional role label."""
|
||||
rig = models.ForeignKey(
|
||||
Rig, on_delete=models.CASCADE,
|
||||
related_name='rig_items', verbose_name=_('rig'),
|
||||
)
|
||||
user_gear = models.ForeignKey(
|
||||
UserGear, on_delete=models.CASCADE,
|
||||
related_name='rig_items', verbose_name=_('gear'),
|
||||
)
|
||||
role = models.CharField(
|
||||
_('role'), max_length=20, choices=RigRole.choices, default=RigRole.OTHER
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('rig item')
|
||||
verbose_name_plural = _('rig items')
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['rig', 'user_gear'], name='unique_gear_per_rig'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rig.name} / {self.user_gear} [{self.role}]"
|
||||
|
||||
def clean(self):
|
||||
if self.user_gear.user_id != self.rig.user_id:
|
||||
raise ValidationError(
|
||||
{'user_gear': _('This gear does not belong to the rig owner.')}
|
||||
)
|
||||
|
||||
if self.role == RigRole.PRIMARY:
|
||||
if self.user_gear.gear.gear_type != GearType.FIREARM:
|
||||
raise ValidationError(
|
||||
{'role': _('The PRIMARY slot must contain a Firearm.')}
|
||||
)
|
||||
qs = RigItem.objects.filter(rig=self.rig, role=RigRole.PRIMARY)
|
||||
if self.pk:
|
||||
qs = qs.exclude(pk=self.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{'role': _('A rig can only have one primary firearm.')}
|
||||
)
|
||||
|
||||
|
||||
# ── Ammo catalog ──────────────────────────────────────────────────────────────
|
||||
|
||||
class Ammo(models.Model):
|
||||
"""
|
||||
Commercial/factory ammunition catalog entry.
|
||||
Independent of the Gear MTI hierarchy.
|
||||
Same PENDING/VERIFIED/REJECTED moderation workflow as Gear.
|
||||
"""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
name = models.CharField(_('name'), max_length=150)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
bullet_weight_gr = models.DecimalField(
|
||||
_('bullet weight (gr)'), max_digits=6, decimal_places=1
|
||||
)
|
||||
bullet_type = models.CharField(
|
||||
_('bullet type'), max_length=5, choices=BulletType.choices
|
||||
)
|
||||
primer_size = models.CharField(
|
||||
_('primer size'), max_length=3, choices=PrimerSize.choices, blank=True
|
||||
)
|
||||
case_material = models.CharField(
|
||||
_('case material'), max_length=10, choices=CaseMaterial.choices,
|
||||
default=CaseMaterial.BRASS
|
||||
)
|
||||
muzzle_velocity_fps = models.DecimalField(
|
||||
_('muzzle velocity (fps)'), max_digits=6, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
muzzle_energy_ftlb = models.DecimalField(
|
||||
_('muzzle energy (ft·lb)'), max_digits=7, decimal_places=1, null=True, blank=True
|
||||
)
|
||||
box_count = models.PositiveSmallIntegerField(_('box count'), null=True, blank=True)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.PENDING
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='submitted_ammo',
|
||||
verbose_name=_('submitted by'),
|
||||
)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='reviewed_ammo',
|
||||
verbose_name=_('reviewed by'),
|
||||
)
|
||||
reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('ammo')
|
||||
verbose_name_plural = _('ammo')
|
||||
ordering = ['brand', 'name', 'caliber__name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'name', 'caliber'],
|
||||
name='unique_ammo_brand_name_caliber'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
caliber_str = self.caliber.name if self.caliber_id else '?'
|
||||
return f"{self.brand} {self.name} ({caliber_str})"
|
||||
|
||||
def verify(self, reviewed_by):
|
||||
self.status = GearStatus.VERIFIED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
def reject(self, reviewed_by):
|
||||
self.status = GearStatus.REJECTED
|
||||
self.reviewed_by = reviewed_by
|
||||
self.reviewed_at = timezone.now()
|
||||
self.save(update_fields=['status', 'reviewed_by', 'reviewed_at'])
|
||||
|
||||
|
||||
# ── Reloading components ───────────────────────────────────────────────────────
|
||||
|
||||
class ComponentMixin(models.Model):
|
||||
"""
|
||||
Shared moderation fields for user-submitted reload components.
|
||||
Existing catalog entries default to VERIFIED for backward compatibility.
|
||||
"""
|
||||
status = models.CharField(
|
||||
_('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.VERIFIED
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='submitted_%(class)ss',
|
||||
verbose_name=_('submitted by'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def verify(self, reviewed_by=None):
|
||||
self.status = GearStatus.VERIFIED
|
||||
self.save(update_fields=['status'])
|
||||
|
||||
def reject(self, reviewed_by=None):
|
||||
self.status = GearStatus.REJECTED
|
||||
self.save(update_fields=['status'])
|
||||
|
||||
|
||||
class Primer(ComponentMixin):
|
||||
"""Primer reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
name = models.CharField(_('name'), max_length=100)
|
||||
size = models.CharField(_('size'), max_length=3, choices=PrimerSize.choices)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('primer')
|
||||
verbose_name_plural = _('primers')
|
||||
ordering = ['brand', 'name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['brand', 'name'], name='unique_primer_brand_name')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.name} ({self.get_size_display()})"
|
||||
|
||||
|
||||
class Brass(ComponentMixin):
|
||||
"""Brass/case reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
primer_pocket = models.CharField(
|
||||
_('primer pocket'), max_length=3, choices=PrimerSize.choices, blank=True
|
||||
)
|
||||
trim_length_mm = models.DecimalField(
|
||||
_('trim-to length (mm)'), max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('brass')
|
||||
verbose_name_plural = _('brass')
|
||||
ordering = ['brand', 'caliber__name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'caliber'], name='unique_brass_brand_caliber'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
caliber_str = self.caliber.name if self.caliber_id else '?'
|
||||
return f"{self.brand} {caliber_str}"
|
||||
|
||||
|
||||
class Bullet(ComponentMixin):
|
||||
"""Bullet/projectile reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
model_name = models.CharField(_('model name'), max_length=150)
|
||||
weight_gr = models.DecimalField(_('weight (gr)'), max_digits=6, decimal_places=1)
|
||||
bullet_type = models.CharField(_('bullet type'), max_length=5, choices=BulletType.choices)
|
||||
diameter_mm = models.DecimalField(
|
||||
_('diameter (mm)'), max_digits=5, decimal_places=3, null=True, blank=True
|
||||
)
|
||||
length_mm = models.DecimalField(
|
||||
_('length (mm)'), max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
bc_g1 = models.DecimalField(
|
||||
_('BC (G1)'), max_digits=5, decimal_places=4, null=True, blank=True
|
||||
)
|
||||
bc_g7 = models.DecimalField(
|
||||
_('BC (G7)'), max_digits=5, decimal_places=4, null=True, blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('bullet')
|
||||
verbose_name_plural = _('bullets')
|
||||
ordering = ['brand', 'model_name', 'weight_gr']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'model_name', 'weight_gr'],
|
||||
name='unique_bullet_brand_model_weight'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.model_name} {self.weight_gr}gr"
|
||||
|
||||
|
||||
class Powder(ComponentMixin):
|
||||
"""Propellant powder reference — can be user-submitted (PENDING) or admin-verified."""
|
||||
brand = models.CharField(_('brand'), max_length=100)
|
||||
name = models.CharField(_('name'), max_length=100)
|
||||
powder_type = models.CharField(
|
||||
_('powder type'), max_length=10, choices=PowderType.choices, blank=True
|
||||
)
|
||||
burn_rate_index = models.PositiveSmallIntegerField(
|
||||
_('burn rate index'), null=True, blank=True,
|
||||
help_text=_('Lower = faster burning. Used for relative ordering only.'),
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('powder')
|
||||
verbose_name_plural = _('powders')
|
||||
ordering = ['burn_rate_index', 'brand', 'name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['brand', 'name'], name='unique_powder_brand_name'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.brand} {self.name}"
|
||||
|
||||
|
||||
# ── Reload development ────────────────────────────────────────────────────────
|
||||
|
||||
class ReloadRecipe(models.Model):
|
||||
"""
|
||||
A reloading recipe: a fixed combination of primer, brass, and bullet
|
||||
owned by one user. Batches (different powder charges) hang off this.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name='reload_recipes', verbose_name=_('user'),
|
||||
)
|
||||
name = models.CharField(_('name'), max_length=150)
|
||||
caliber = models.ForeignKey(
|
||||
'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL,
|
||||
related_name='+', verbose_name=_('caliber'),
|
||||
)
|
||||
primer = models.ForeignKey(
|
||||
Primer, on_delete=models.PROTECT,
|
||||
related_name='recipes', verbose_name=_('primer'),
|
||||
)
|
||||
brass = models.ForeignKey(
|
||||
Brass, on_delete=models.PROTECT,
|
||||
related_name='recipes', verbose_name=_('brass'),
|
||||
)
|
||||
bullet = models.ForeignKey(
|
||||
Bullet, on_delete=models.PROTECT,
|
||||
related_name='recipes', verbose_name=_('bullet'),
|
||||
)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
is_public = models.BooleanField(_('public'), default=False)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('reload recipe')
|
||||
verbose_name_plural = _('reload recipes')
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['user', 'name'], name='unique_recipe_name_per_user'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
caliber_str = self.caliber.name if self.caliber_id else '?'
|
||||
return f"{self.user.email} — {self.name} ({caliber_str})"
|
||||
|
||||
|
||||
class ReloadedAmmoBatch(models.Model):
|
||||
"""
|
||||
A specific powder charge variant within a ReloadRecipe.
|
||||
Multiple batches under one recipe represent the powder charge development workflow.
|
||||
ShotGroups in apps.tools can link to a batch to track performance per charge.
|
||||
"""
|
||||
recipe = models.ForeignKey(
|
||||
ReloadRecipe, on_delete=models.CASCADE,
|
||||
related_name='batches', verbose_name=_('recipe'),
|
||||
)
|
||||
powder = models.ForeignKey(
|
||||
Powder, on_delete=models.PROTECT,
|
||||
related_name='batches', verbose_name=_('powder'),
|
||||
)
|
||||
powder_charge_gr = models.DecimalField(
|
||||
_('powder charge (gr)'), max_digits=5, decimal_places=2
|
||||
)
|
||||
quantity = models.PositiveSmallIntegerField(
|
||||
_('quantity loaded'), null=True, blank=True
|
||||
)
|
||||
oal_mm = models.DecimalField(
|
||||
_('overall length (mm)'), max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
coal_mm = models.DecimalField(
|
||||
_('cartridge overall length to ogive (mm)'), max_digits=6, decimal_places=2,
|
||||
null=True, blank=True
|
||||
)
|
||||
crimp = models.CharField(
|
||||
_('crimp'), max_length=6, choices=CrimpType.choices, default=CrimpType.NONE
|
||||
)
|
||||
case_prep_notes = models.TextField(_('case prep notes'), blank=True)
|
||||
notes = models.TextField(_('notes'), blank=True)
|
||||
loaded_at = models.DateField(_('loaded at'), null=True, blank=True)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('reloaded ammo batch')
|
||||
verbose_name_plural = _('reloaded ammo batches')
|
||||
ordering = ['recipe', 'powder_charge_gr']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['recipe', 'powder', 'powder_charge_gr'],
|
||||
name='unique_batch_charge_per_recipe_powder'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.recipe.name} / {self.powder} {self.powder_charge_gr}gr"
|
||||
Reference in New Issue
Block a user