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"