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