247 lines
11 KiB
Python
247 lines
11 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 _
|
|
|
|
|
|
# ── Choices ───────────────────────────────────────────────────────────────────
|
|
|
|
class ShootingPosition(models.TextChoices):
|
|
PRONE = 'PRONE', _('Prone')
|
|
STANDING = 'STANDING', _('Standing')
|
|
SITTING = 'SITTING', _('Sitting')
|
|
KNEELING = 'KNEELING', _('Kneeling')
|
|
BARRICADE = 'BARRICADE', _('Barricade')
|
|
UNSUPPORTED = 'UNSUPPORTED', _('Unsupported')
|
|
OTHER = 'OTHER', _('Other')
|
|
|
|
|
|
class CorrectionUnit(models.TextChoices):
|
|
MOA = 'MOA', _('MOA')
|
|
MRAD = 'MRAD', _('MRAD')
|
|
CLICK = 'CLICK', _('Clicks')
|
|
|
|
|
|
# ── Abstract base ─────────────────────────────────────────────────────────────
|
|
|
|
class AbstractSession(models.Model):
|
|
"""
|
|
Shared fields inherited by all concrete session types.
|
|
Each subclass gets its own DB table (no cross-table joins).
|
|
"""
|
|
# Use '+' to suppress reverse accessors — query via concrete model managers
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='+',
|
|
verbose_name=_('user'),
|
|
)
|
|
name = models.CharField(_('name'), max_length=255, blank=True)
|
|
date = models.DateField(_('date'))
|
|
location = models.CharField(_('location'), max_length=255, blank=True)
|
|
|
|
# Intentional cross-app FKs (string refs avoid circular imports)
|
|
analysis = models.ForeignKey(
|
|
'tools.ChronographAnalysis',
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='+',
|
|
verbose_name=_('chronograph analysis'),
|
|
)
|
|
rig = models.ForeignKey(
|
|
'gears.Rig',
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='+',
|
|
verbose_name=_('rig'),
|
|
)
|
|
ammo = models.ForeignKey(
|
|
'gears.Ammo',
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='+',
|
|
verbose_name=_('factory ammo'),
|
|
)
|
|
reloaded_batch = models.ForeignKey(
|
|
'gears.ReloadedAmmoBatch',
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='+',
|
|
verbose_name=_('reloaded batch'),
|
|
)
|
|
|
|
# Weather
|
|
temperature_c = models.DecimalField(_('temperature (°C)'), max_digits=5, decimal_places=1, null=True, blank=True)
|
|
wind_speed_ms = models.DecimalField(_('wind speed (m/s)'), max_digits=5, decimal_places=1, null=True, blank=True)
|
|
wind_direction_deg = models.PositiveSmallIntegerField(_('wind direction (°)'), null=True, blank=True)
|
|
humidity_pct = models.PositiveSmallIntegerField(_('humidity (%)'), null=True, blank=True)
|
|
pressure_hpa = models.DecimalField(_('pressure (hPa)'), max_digits=6, decimal_places=1, null=True, blank=True)
|
|
weather_notes = models.TextField(_('weather notes'), blank=True)
|
|
|
|
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:
|
|
abstract = True
|
|
ordering = ['-date', '-created_at']
|
|
|
|
def clean(self):
|
|
if self.ammo_id and self.reloaded_batch_id:
|
|
raise ValidationError(
|
|
_('A session may use factory ammo or a reloaded batch, not both.')
|
|
)
|
|
if self.rig_id:
|
|
if self.rig.user_id != self.user_id:
|
|
raise ValidationError({'rig': _('This rig does not belong to you.')})
|
|
if self.wind_direction_deg is not None and not (0 <= self.wind_direction_deg <= 359):
|
|
raise ValidationError(
|
|
{'wind_direction_deg': _('Wind direction must be between 0 and 359 degrees.')}
|
|
)
|
|
if self.humidity_pct is not None and not (0 <= self.humidity_pct <= 100):
|
|
raise ValidationError(
|
|
{'humidity_pct': _('Humidity must be between 0 and 100.')}
|
|
)
|
|
|
|
|
|
# ── PRS session ───────────────────────────────────────────────────────────────
|
|
|
|
class PRSSession(AbstractSession):
|
|
"""
|
|
A Precision Rifle Series session.
|
|
Two-phase workflow: preparation (stages defined upfront) →
|
|
execution (weather entered, corrections computed, results recorded).
|
|
"""
|
|
competition_name = models.CharField(_('competition name'), max_length=255, blank=True)
|
|
category = models.CharField(_('category'), max_length=100, blank=True)
|
|
|
|
class Meta(AbstractSession.Meta):
|
|
verbose_name = _('PRS session')
|
|
verbose_name_plural = _('PRS sessions')
|
|
|
|
def __str__(self):
|
|
label = self.competition_name or self.name or _('PRS session')
|
|
return f"{label} — {self.date}"
|
|
|
|
|
|
class PRSStage(models.Model):
|
|
"""
|
|
One stage within a PRSSession.
|
|
Fields are grouped by lifecycle phase: prep, execution, results.
|
|
"""
|
|
session = models.ForeignKey(
|
|
PRSSession,
|
|
on_delete=models.CASCADE,
|
|
related_name='stages',
|
|
verbose_name=_('session'),
|
|
)
|
|
|
|
# ── Prep phase ────────────────────────────────────────────────────────────
|
|
order = models.PositiveSmallIntegerField(_('order'))
|
|
position = models.CharField(
|
|
_('shooting position'), max_length=20,
|
|
choices=ShootingPosition.choices, default=ShootingPosition.PRONE,
|
|
)
|
|
distance_m = models.PositiveSmallIntegerField(_('distance (m)'))
|
|
target_width_cm = models.DecimalField(
|
|
_('target width (cm)'), max_digits=6, decimal_places=1, null=True, blank=True,
|
|
)
|
|
target_height_cm = models.DecimalField(
|
|
_('target height (cm)'), max_digits=6, decimal_places=1, null=True, blank=True,
|
|
)
|
|
max_time_s = models.PositiveSmallIntegerField(_('max time (s)'), null=True, blank=True)
|
|
shots_count = models.PositiveSmallIntegerField(_('shots count'), default=1)
|
|
notes_prep = models.TextField(_('prep notes'), blank=True)
|
|
|
|
# ── Execution phase ───────────────────────────────────────────────────────
|
|
# computed_* are set by the ballistic engine (read-only for clients)
|
|
computed_elevation = models.DecimalField(
|
|
_('computed elevation'), max_digits=6, decimal_places=2, null=True, blank=True,
|
|
)
|
|
computed_windage = models.DecimalField(
|
|
_('computed windage'), max_digits=6, decimal_places=2, null=True, blank=True,
|
|
)
|
|
correction_unit = models.CharField(
|
|
_('correction unit'), max_length=10,
|
|
choices=CorrectionUnit.choices, blank=True,
|
|
)
|
|
# actual_* are editable by the shooter
|
|
actual_elevation = models.DecimalField(
|
|
_('actual elevation'), max_digits=6, decimal_places=2, null=True, blank=True,
|
|
)
|
|
actual_windage = models.DecimalField(
|
|
_('actual windage'), max_digits=6, decimal_places=2, null=True, blank=True,
|
|
)
|
|
|
|
# ── Results phase ─────────────────────────────────────────────────────────
|
|
hits = models.PositiveSmallIntegerField(_('hits'), null=True, blank=True)
|
|
score = models.PositiveSmallIntegerField(_('score'), null=True, blank=True)
|
|
time_taken_s = models.DecimalField(
|
|
_('time taken (s)'), max_digits=6, decimal_places=2, null=True, blank=True,
|
|
)
|
|
# Optional link to chronograph/shot data
|
|
shot_group = models.ForeignKey(
|
|
'tools.ShotGroup',
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='prs_stages',
|
|
verbose_name=_('shot group'),
|
|
)
|
|
notes_post = models.TextField(_('post notes'), blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = _('PRS stage')
|
|
verbose_name_plural = _('PRS stages')
|
|
ordering = ['order']
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['session', 'order'], name='unique_prs_stage_order')
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Stage {self.order} — {self.distance_m}m {self.get_position_display()}"
|
|
|
|
def clean(self):
|
|
if self.hits is not None and self.hits > self.shots_count:
|
|
raise ValidationError(
|
|
{'hits': _('Hits cannot exceed the number of shots for this stage.')}
|
|
)
|
|
if self.score is not None and self.hits is not None and self.score > self.hits:
|
|
raise ValidationError(
|
|
{'score': _('Score cannot exceed the number of hits.')}
|
|
)
|
|
|
|
|
|
# ── Free Practice session ─────────────────────────────────────────────────────
|
|
|
|
class FreePracticeSession(AbstractSession):
|
|
"""A free-form practice session at a fixed distance."""
|
|
distance_m = models.PositiveSmallIntegerField(_('distance (m)'), null=True, blank=True)
|
|
target_description = models.CharField(_('target description'), max_length=255, blank=True)
|
|
rounds_fired = models.PositiveSmallIntegerField(_('rounds fired'), null=True, blank=True)
|
|
|
|
class Meta(AbstractSession.Meta):
|
|
verbose_name = _('free practice session')
|
|
verbose_name_plural = _('free practice sessions')
|
|
|
|
def __str__(self):
|
|
label = self.name or _('Free practice')
|
|
dist = f' — {self.distance_m}m' if self.distance_m else ''
|
|
return f"{label}{dist} ({self.date})"
|
|
|
|
|
|
# ── Speed Shooting session ────────────────────────────────────────────────────
|
|
|
|
class SpeedShootingSession(AbstractSession):
|
|
"""A speed shooting session (IPSC, IDPA, Steel Challenge, …). Minimal placeholder."""
|
|
format = models.CharField(_('format'), max_length=100, blank=True)
|
|
rounds_fired = models.PositiveSmallIntegerField(_('rounds fired'), null=True, blank=True)
|
|
|
|
class Meta(AbstractSession.Meta):
|
|
verbose_name = _('speed shooting session')
|
|
verbose_name_plural = _('speed shooting sessions')
|
|
|
|
def __str__(self):
|
|
label = self.name or self.format or _('Speed shooting')
|
|
return f"{label} ({self.date})"
|