Files
ShooterHub/apps/tools/models.py
2026-04-02 11:24:30 +02:00

158 lines
5.5 KiB
Python

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)