173 lines
6.3 KiB
Python
173 lines
6.3 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 _
|
|
|
|
|
|
class Photo(models.Model):
|
|
"""
|
|
Generic DB-backed image. Raw bytes are stored in PostgreSQL (bytea).
|
|
Served via GET /api/photos/{id}/data/ — no filesystem or S3 required.
|
|
"""
|
|
data = models.BinaryField(_('data'))
|
|
content_type = models.CharField(_('content type'), max_length=50) # e.g. 'image/jpeg'
|
|
size = models.PositiveIntegerField(_('size (bytes)'))
|
|
width = models.PositiveSmallIntegerField(_('width (px)'), null=True, blank=True)
|
|
height = models.PositiveSmallIntegerField(_('height (px)'), null=True, blank=True)
|
|
uploaded_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='photos',
|
|
verbose_name=_('uploaded by'),
|
|
)
|
|
uploaded_at = models.DateTimeField(_('uploaded at'), auto_now_add=True)
|
|
description = models.CharField(_('description'), max_length=255, blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = _('photo')
|
|
verbose_name_plural = _('photos')
|
|
ordering = ['-uploaded_at']
|
|
|
|
def __str__(self):
|
|
owner = self.uploaded_by.email if self.uploaded_by_id else _('anonymous')
|
|
return f"Photo #{self.pk} ({self.content_type}, {owner})"
|
|
|
|
|
|
class GroupPhoto(models.Model):
|
|
"""
|
|
Links a Photo to a ShotGroup. A single group can have multiple photos
|
|
(e.g. different distances or targets at the same session).
|
|
"""
|
|
photo = models.OneToOneField(
|
|
Photo,
|
|
on_delete=models.CASCADE,
|
|
related_name='group_photo',
|
|
verbose_name=_('photo'),
|
|
)
|
|
shot_group = models.ForeignKey(
|
|
'tools.ShotGroup',
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='group_photos',
|
|
verbose_name=_('shot group'),
|
|
)
|
|
caption = models.CharField(_('caption'), max_length=255, blank=True)
|
|
order = models.PositiveSmallIntegerField(_('order'), default=0)
|
|
is_public = models.BooleanField(_('public'), default=False)
|
|
|
|
class Meta:
|
|
verbose_name = _('group photo')
|
|
verbose_name_plural = _('group photos')
|
|
ordering = ['order', 'id']
|
|
|
|
def __str__(self):
|
|
target = self.shot_group or _('unlinked')
|
|
return f"GroupPhoto #{self.pk} → {target}"
|
|
|
|
|
|
class GroupPhotoAnalysis(models.Model):
|
|
"""
|
|
Ballistic overlay data for a GroupPhoto: group size, point-of-impact
|
|
offsets, and mean radius — all in millimetres and MOA.
|
|
"""
|
|
group_photo = models.OneToOneField(
|
|
GroupPhoto,
|
|
on_delete=models.CASCADE,
|
|
related_name='analysis',
|
|
verbose_name=_('group photo'),
|
|
)
|
|
group_size_mm = models.DecimalField(
|
|
_('group size (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
|
)
|
|
group_size_moa = models.DecimalField(
|
|
_('group size (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
|
)
|
|
elevation_offset_mm = models.DecimalField(
|
|
_('elevation offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
|
)
|
|
elevation_offset_moa = models.DecimalField(
|
|
_('elevation offset (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
|
)
|
|
windage_offset_mm = models.DecimalField(
|
|
_('windage offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
|
)
|
|
windage_offset_moa = models.DecimalField(
|
|
_('windage offset (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
|
)
|
|
mean_radius_mm = models.DecimalField(
|
|
_('mean radius (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
|
)
|
|
mean_radius_moa = models.DecimalField(
|
|
_('mean radius (MOA)'), max_digits=7, decimal_places=3, null=True, blank=True,
|
|
)
|
|
notes = models.TextField(_('notes'), blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = _('group photo analysis')
|
|
verbose_name_plural = _('group photo analyses')
|
|
|
|
def __str__(self):
|
|
return f"Analysis for {self.group_photo}"
|
|
|
|
def clean(self):
|
|
errors = {}
|
|
for field in ('group_size_mm', 'group_size_moa', 'mean_radius_mm', 'mean_radius_moa'):
|
|
value = getattr(self, field)
|
|
if value is not None and value < 0:
|
|
errors[field] = _('This measurement cannot be negative.')
|
|
if errors:
|
|
raise ValidationError(errors)
|
|
|
|
|
|
class PointOfImpact(models.Model):
|
|
"""
|
|
An individual bullet-hole marker on a GroupPhoto.
|
|
|
|
Pixel coordinates (x_px, y_px) allow UI overlays.
|
|
Real-world coordinates (x_mm, y_mm) use the point-of-aim as origin,
|
|
with + = right and + = up (standard ballistic convention).
|
|
Optionally linked to a Shot from the chronograph for combined analysis.
|
|
"""
|
|
group_photo = models.ForeignKey(
|
|
GroupPhoto,
|
|
on_delete=models.CASCADE,
|
|
related_name='points_of_impact',
|
|
verbose_name=_('group photo'),
|
|
)
|
|
# Optional link to the matching Shot record from a ChronographAnalysis
|
|
shot = models.OneToOneField(
|
|
'tools.Shot',
|
|
null=True, blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name='point_of_impact',
|
|
verbose_name=_('shot'),
|
|
)
|
|
order = models.PositiveSmallIntegerField(
|
|
_('order'), default=0,
|
|
help_text=_('1-based sequence; used when shot FK is absent.'),
|
|
)
|
|
# Pixel position on the photo (for overlay rendering)
|
|
x_px = models.PositiveSmallIntegerField(_('x (px)'))
|
|
y_px = models.PositiveSmallIntegerField(_('y (px)'))
|
|
# Real-world offsets from point-of-aim (millimetres)
|
|
x_mm = models.DecimalField(
|
|
_('x offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
|
)
|
|
y_mm = models.DecimalField(
|
|
_('y offset (mm)'), max_digits=7, decimal_places=2, null=True, blank=True,
|
|
)
|
|
# Radius of the bullet hole (for rendering)
|
|
radius_mm = models.DecimalField(
|
|
_('bullet hole radius (mm)'), max_digits=6, decimal_places=2, null=True, blank=True,
|
|
)
|
|
notes = models.CharField(_('notes'), max_length=255, blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = _('point of impact')
|
|
verbose_name_plural = _('points of impact')
|
|
ordering = ['order', 'id']
|
|
|
|
def __str__(self):
|
|
return f"POI #{self.order or self.pk} on {self.group_photo}"
|