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

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}"