from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ # ── ChronographAnalysis ─────────────────────────────────────────────────────── class ChronographAnalysis(models.Model): """ A velocity recording session composed of one or more ShotGroups. Can be used anonymously or by an authenticated user. """ user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='chronograph_analyses', verbose_name=_('user'), ) name = models.CharField(_('name'), max_length=150) date = models.DateField(_('date')) 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 = _('chronograph analysis') verbose_name_plural = _('chronograph analyses') ordering = ['-date', '-created_at'] def __str__(self): owner = self.user.email if self.user_id else _('anonymous') return f"{self.name} ({owner})" def clean(self): if not self.name or not self.name.strip(): raise ValidationError({'name': _('Name may not be blank.')}) # ── ShotGroup ───────────────────────────────────────────────────────────────── class ShotGroup(models.Model): """ A named group of shots. Can be nested under a ChronographAnalysis or exist as a standalone group (analysis=None). """ # Optional link to a chronograph session; SET_NULL keeps the group alive # when an analysis is deleted. analysis = models.ForeignKey( ChronographAnalysis, null=True, blank=True, on_delete=models.SET_NULL, related_name='shot_groups', verbose_name=_('analysis'), ) # Owner for standalone groups (groups nested under an analysis inherit # ownership via analysis.user). user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='shot_groups', verbose_name=_('user'), ) label = models.CharField(_('label'), max_length=100) distance_m = models.DecimalField( _('distance (m)'), max_digits=7, decimal_places=1, null=True, blank=True, ) order = models.PositiveSmallIntegerField(_('order'), default=0) notes = models.TextField(_('notes'), blank=True) # Intentional cross-app FKs: tools → gears (string refs avoid circular imports) ammo_batch = models.ForeignKey( 'gears.ReloadedAmmoBatch', null=True, blank=True, on_delete=models.SET_NULL, related_name='shot_groups', verbose_name=_('reloaded ammo batch'), ) ammo = models.ForeignKey( 'gears.Ammo', null=True, blank=True, on_delete=models.SET_NULL, related_name='shot_groups', verbose_name=_('factory ammo'), ) class Meta: verbose_name = _('shot group') verbose_name_plural = _('shot groups') ordering = ['order', 'id'] def __str__(self): prefix = self.analysis.name if self.analysis_id else _('Standalone') return f"{prefix} / {self.label}" def clean(self): if self.distance_m is not None and self.distance_m <= 0: raise ValidationError( {'distance_m': _('Distance must be a positive value.')} ) # ── Shot ────────────────────────────────────────────────────────────────────── class Shot(models.Model): """A single bullet velocity reading within a ShotGroup.""" group = models.ForeignKey( ShotGroup, on_delete=models.CASCADE, related_name='shots', verbose_name=_('group'), ) shot_number = models.PositiveSmallIntegerField( _('shot number'), editable=False, ) velocity_fps = models.DecimalField( _('velocity (fps)'), max_digits=6, decimal_places=1, ) notes = models.CharField(_('notes'), max_length=255, blank=True) class Meta: verbose_name = _('shot') verbose_name_plural = _('shots') ordering = ['shot_number'] constraints = [ models.UniqueConstraint( fields=['group', 'shot_number'], name='unique_shot_number_per_group', ) ] def __str__(self): return f"Shot #{self.shot_number} — {self.velocity_fps} fps" def clean(self): if self.velocity_fps is not None and self.velocity_fps <= 0: raise ValidationError( {'velocity_fps': _('Velocity must be a positive value.')} ) def save(self, *args, **kwargs): if not self.pk: last = ( Shot.objects .filter(group=self.group) .order_by('-shot_number') .values_list('shot_number', flat=True) .first() ) self.shot_number = (last or 0) + 1 super().save(*args, **kwargs)